snapshot of https://github.com/livekit/livekit.git @ 08c5433449c106943231037d0b892895f6e92d3b (2026-09-15)
This commit is contained in:
258
pkg/agent/agent_test.go
Normal file
258
pkg/agent/agent_test.go
Normal file
@@ -0,0 +1,258 @@
|
||||
package agent_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/agent"
|
||||
"github.com/livekit/livekit-server/pkg/agent/testutils"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
testAgentName := "test_agent"
|
||||
t.Run("dispatched jobs are assigned to a worker", func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register(testAgentName, livekit.JobType_JT_ROOM)
|
||||
jobAssignments := worker.JobAssignments.Observe()
|
||||
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
DispatchId: guid.New(guid.AgentDispatchPrefix),
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{},
|
||||
AgentName: testAgentName,
|
||||
}
|
||||
_, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job)
|
||||
require.NoError(t, err)
|
||||
|
||||
select {
|
||||
case a := <-jobAssignments.Events():
|
||||
require.EqualValues(t, job.Id, a.Job.Id)
|
||||
v, err := auth.ParseAPIToken(a.Token)
|
||||
require.NoError(t, err)
|
||||
_, claims, err := v.Verify(server.TestAPISecret)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, testAgentName, claims.Attributes[agent.AgentNameAttributeKey])
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func testBatchJobRequest(t require.TestingT, batchSize int, totalJobs int, client rpc.AgentInternalClient, workers []*testutils.AgentWorker) <-chan struct{} {
|
||||
var assigned atomic.Uint32
|
||||
done := make(chan struct{})
|
||||
for _, w := range workers {
|
||||
assignments := w.JobAssignments.Observe()
|
||||
go func() {
|
||||
defer assignments.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
case <-assignments.Events():
|
||||
if assigned.Inc() == uint32(totalJobs) {
|
||||
close(done)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// wait for agent registration
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < totalJobs; i += batchSize {
|
||||
wg.Add(1)
|
||||
go func(start int) {
|
||||
defer wg.Done()
|
||||
for j := start; j < start+batchSize && j < totalJobs; j++ {
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
DispatchId: guid.New(guid.AgentDispatchPrefix),
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{},
|
||||
AgentName: "test",
|
||||
}
|
||||
_, err := client.JobRequest(context.Background(), "test", agent.RoomAgentTopic, job)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return done
|
||||
}
|
||||
|
||||
func TestAgentLoadBalancing(t *testing.T) {
|
||||
t.Run("jobs are distributed normally with baseline worker load", func(t *testing.T) {
|
||||
totalWorkers := 5
|
||||
totalJobs := 100
|
||||
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
t.Cleanup(client.Close)
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
agents := make([]*testutils.AgentWorker, totalWorkers)
|
||||
for i := range totalWorkers {
|
||||
agents[i] = server.SimulateAgentWorker(
|
||||
testutils.WithLabel(fmt.Sprintf("agent-%d", i)),
|
||||
testutils.WithJobLoad(testutils.NewStableJobLoad(0.01)),
|
||||
)
|
||||
agents[i].Register("test", livekit.JobType_JT_ROOM)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testBatchJobRequest(t, 10, totalJobs, client, agents):
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
|
||||
jobCount := make(map[string]int)
|
||||
for _, w := range agents {
|
||||
jobCount[w.Label] = len(w.Jobs())
|
||||
}
|
||||
|
||||
// check that jobs are distributed normally
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
require.GreaterOrEqual(t, jobCount[label], 0)
|
||||
require.Less(t, jobCount[label], 35) // three std deviations from the mean is 32
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jobs are distributed with variable and overloaded worker load", func(t *testing.T) {
|
||||
totalWorkers := 4
|
||||
totalJobs := 15
|
||||
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
|
||||
client := must.Get(rpc.NewAgentInternalClient(bus))
|
||||
t.Cleanup(client.Close)
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
agents := make([]*testutils.AgentWorker, totalWorkers)
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
if i%2 == 0 {
|
||||
// make sure we have some workers that can accept jobs
|
||||
agents[i] = server.SimulateAgentWorker(testutils.WithLabel(label))
|
||||
} else {
|
||||
agents[i] = server.SimulateAgentWorker(testutils.WithLabel(label), testutils.WithDefaultWorkerLoad(0.9))
|
||||
}
|
||||
agents[i].Register("test", livekit.JobType_JT_ROOM)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-testBatchJobRequest(t, 1, totalJobs, client, agents):
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "job assignment timeout")
|
||||
}
|
||||
|
||||
jobCount := make(map[string]int)
|
||||
for _, w := range agents {
|
||||
jobCount[w.Label] = len(w.Jobs())
|
||||
}
|
||||
|
||||
for i := range totalWorkers {
|
||||
label := fmt.Sprintf("agent-%d", i)
|
||||
|
||||
if i%2 == 0 {
|
||||
require.GreaterOrEqual(t, jobCount[label], 2)
|
||||
} else {
|
||||
require.Equal(t, 0, jobCount[label])
|
||||
}
|
||||
require.GreaterOrEqual(t, jobCount[label], 0)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestConnectionClosedOnDispatchError(t *testing.T) {
|
||||
t.Run("connection closed when unknown message type received", func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
// register agent
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register("test_agent", livekit.JobType_JT_ROOM)
|
||||
responses := worker.RegisterWorkerResponses.Observe()
|
||||
select {
|
||||
case <-responses.Events():
|
||||
// registered
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "registration timeout")
|
||||
}
|
||||
responses.Stop()
|
||||
|
||||
// send invalid message (nil Message field triggers ErrUnknownWorkerSignal)
|
||||
worker.SendMessage(&livekit.WorkerMessage{Message: nil})
|
||||
|
||||
select {
|
||||
case <-worker.Closed():
|
||||
// connection closed
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "connection should have been closed after dispatch error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrainConnectionsDoesNotDeadlock(t *testing.T) {
|
||||
for _, force := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("force=%v", force), func(t *testing.T) {
|
||||
bus := psrpc.NewLocalMessageBus()
|
||||
server := testutils.NewTestServer(bus)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
worker := server.SimulateAgentWorker()
|
||||
worker.Register("drain_agent", livekit.JobType_JT_ROOM)
|
||||
responses := worker.RegisterWorkerResponses.Observe()
|
||||
select {
|
||||
case <-responses.Events():
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "registration timeout")
|
||||
}
|
||||
responses.Stop()
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
server.DrainConnections(time.Millisecond, force)
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
require.Fail(t, "DrainConnections deadlocked while closing workers")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-worker.Closed():
|
||||
case <-time.After(time.Second):
|
||||
require.Fail(t, "worker should be closed after drain")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
349
pkg/agent/client.go
Normal file
349
pkg/agent/client.go
Normal file
@@ -0,0 +1,349 @@
|
||||
// Copyright 2024 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 agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gammazero/workerpool"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
serverutils "github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
const (
|
||||
EnabledCacheTTL = 1 * time.Minute
|
||||
RoomAgentTopic = "room"
|
||||
PublisherAgentTopic = "publisher"
|
||||
ParticipantAgentTopic = "participant"
|
||||
DefaultHandlerNamespace = ""
|
||||
|
||||
CheckEnabledTimeout = 5 * time.Second
|
||||
)
|
||||
|
||||
var jobTypeTopics = map[livekit.JobType]string{
|
||||
livekit.JobType_JT_ROOM: RoomAgentTopic,
|
||||
livekit.JobType_JT_PUBLISHER: PublisherAgentTopic,
|
||||
livekit.JobType_JT_PARTICIPANT: ParticipantAgentTopic,
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
// LaunchJob starts a room or participant job on an agent.
|
||||
// it will launch a job once for each worker in each namespace
|
||||
LaunchJob(ctx context.Context, desc *JobRequest) *serverutils.IncrementalDispatcher[*livekit.Job]
|
||||
TerminateJob(ctx context.Context, jobID string, reason rpc.JobTerminateReason) (*livekit.JobState, error)
|
||||
Stop() error
|
||||
}
|
||||
|
||||
type JobRequest struct {
|
||||
DispatchId string
|
||||
JobType livekit.JobType
|
||||
Room *livekit.Room
|
||||
// only set for participant jobs
|
||||
Participant *livekit.ParticipantInfo
|
||||
Metadata string
|
||||
AgentName string
|
||||
Deployment string
|
||||
Attributes map[string]string
|
||||
}
|
||||
|
||||
type agentClient struct {
|
||||
client rpc.AgentInternalClient
|
||||
config Config
|
||||
|
||||
mu sync.RWMutex
|
||||
|
||||
// cache response to avoid constantly checking with controllers
|
||||
// cache is invalidated with AgentRegistered updates
|
||||
roomNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
publisherNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
participantNamespaces *serverutils.IncrementalDispatcher[string] // deprecated
|
||||
roomAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
publisherAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
participantAgentNames *serverutils.IncrementalDispatcher[string]
|
||||
|
||||
enabledExpiresAt time.Time
|
||||
|
||||
workers *workerpool.WorkerPool
|
||||
|
||||
invalidateSub psrpc.Subscription[*emptypb.Empty]
|
||||
subDone chan struct{}
|
||||
}
|
||||
|
||||
func NewAgentClient(bus psrpc.MessageBus, config Config) (Client, error) {
|
||||
client, err := rpc.NewAgentInternalClient(bus)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &agentClient{
|
||||
client: client,
|
||||
config: config,
|
||||
workers: workerpool.New(50),
|
||||
subDone: make(chan struct{}),
|
||||
}
|
||||
|
||||
sub, err := c.client.SubscribeWorkerRegistered(context.Background(), DefaultHandlerNamespace)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c.invalidateSub = sub
|
||||
|
||||
go func() {
|
||||
// invalidate cache
|
||||
for range sub.Channel() {
|
||||
c.mu.Lock()
|
||||
c.roomNamespaces = nil
|
||||
c.publisherNamespaces = nil
|
||||
c.participantNamespaces = nil
|
||||
c.roomAgentNames = nil
|
||||
c.publisherAgentNames = nil
|
||||
c.participantAgentNames = nil
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
c.subDone <- struct{}{}
|
||||
}()
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *agentClient) LaunchJob(ctx context.Context, desc *JobRequest) *serverutils.IncrementalDispatcher[*livekit.Job] {
|
||||
var wg sync.WaitGroup
|
||||
ret := serverutils.NewIncrementalDispatcher[*livekit.Job]()
|
||||
defer func() {
|
||||
c.workers.Submit(func() {
|
||||
wg.Wait()
|
||||
ret.Done()
|
||||
})
|
||||
}()
|
||||
|
||||
jobTypeTopic, ok := jobTypeTopics[desc.JobType]
|
||||
if !ok {
|
||||
return ret
|
||||
}
|
||||
|
||||
dispatcher := c.getDispatcher(desc.AgentName, desc.JobType)
|
||||
|
||||
if dispatcher == nil {
|
||||
logger.Infow("not dispatching agent job since no worker is available",
|
||||
"agentName", desc.AgentName,
|
||||
"jobType", desc.JobType,
|
||||
"room", desc.Room.Name,
|
||||
"roomID", desc.Room.Sid)
|
||||
return ret
|
||||
}
|
||||
|
||||
dispatcher.ForEach(func(curNs string) {
|
||||
topic := GetAgentTopic(desc.AgentName, curNs, desc.Deployment)
|
||||
|
||||
wg.Add(1)
|
||||
c.workers.Submit(func() {
|
||||
defer wg.Done()
|
||||
// The cached agent parameters do not provide the exact combination of available job type/agent name/namespace, so some of the JobRequest RPC may not trigger any worker
|
||||
job := &livekit.Job{
|
||||
Id: utils.NewGuid(utils.AgentJobPrefix),
|
||||
DispatchId: desc.DispatchId,
|
||||
Type: desc.JobType,
|
||||
Room: desc.Room,
|
||||
Participant: desc.Participant,
|
||||
Namespace: curNs,
|
||||
AgentName: desc.AgentName,
|
||||
Metadata: desc.Metadata,
|
||||
EnableRecording: c.config.EnableUserDataRecording,
|
||||
EnableRedaction: c.config.EnableUserDataRedaction,
|
||||
Deployment: desc.Deployment,
|
||||
Attributes: desc.Attributes,
|
||||
}
|
||||
resp, err := c.client.JobRequest(context.Background(), topic, jobTypeTopic, job)
|
||||
if err != nil {
|
||||
logger.Infow("failed to send job request", "error", err, "namespace", curNs, "jobType", desc.JobType, "agentName", desc.AgentName)
|
||||
return
|
||||
}
|
||||
job.State = resp.State
|
||||
ret.Add(job)
|
||||
})
|
||||
})
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func (c *agentClient) TerminateJob(ctx context.Context, jobID string, reason rpc.JobTerminateReason) (*livekit.JobState, error) {
|
||||
resp, err := c.client.JobTerminate(context.Background(), jobID, &rpc.JobTerminateRequest{
|
||||
JobId: jobID,
|
||||
Reason: reason,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Infow("failed to send job request", "error", err, "jobID", jobID)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return resp.State, nil
|
||||
}
|
||||
|
||||
func (c *agentClient) getDispatcher(agName string, jobType livekit.JobType) *serverutils.IncrementalDispatcher[string] {
|
||||
c.mu.Lock()
|
||||
|
||||
if time.Since(c.enabledExpiresAt) > EnabledCacheTTL || c.roomNamespaces == nil ||
|
||||
c.publisherNamespaces == nil || c.participantNamespaces == nil || c.roomAgentNames == nil || c.publisherAgentNames == nil || c.participantAgentNames == nil {
|
||||
c.enabledExpiresAt = time.Now()
|
||||
c.roomNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.publisherNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.participantNamespaces = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.roomAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.publisherAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
c.participantAgentNames = serverutils.NewIncrementalDispatcher[string]()
|
||||
|
||||
go c.checkEnabled(c.roomNamespaces, c.publisherNamespaces, c.participantNamespaces, c.roomAgentNames, c.publisherAgentNames, c.participantAgentNames)
|
||||
}
|
||||
|
||||
var target *serverutils.IncrementalDispatcher[string]
|
||||
var agentNames *serverutils.IncrementalDispatcher[string]
|
||||
switch jobType {
|
||||
case livekit.JobType_JT_ROOM:
|
||||
target = c.roomNamespaces
|
||||
agentNames = c.roomAgentNames
|
||||
case livekit.JobType_JT_PUBLISHER:
|
||||
target = c.publisherNamespaces
|
||||
agentNames = c.publisherAgentNames
|
||||
case livekit.JobType_JT_PARTICIPANT:
|
||||
target = c.participantNamespaces
|
||||
agentNames = c.participantAgentNames
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
if agName == "" {
|
||||
// if no agent name is given, we would need to dispatch backwards compatible mode
|
||||
// which means dispatching to each of the namespaces
|
||||
return target
|
||||
}
|
||||
|
||||
done := make(chan *serverutils.IncrementalDispatcher[string], 1)
|
||||
c.workers.Submit(func() {
|
||||
agentNames.ForEach(func(ag string) {
|
||||
if ag == agName {
|
||||
select {
|
||||
case done <- target:
|
||||
default:
|
||||
}
|
||||
}
|
||||
})
|
||||
select {
|
||||
case done <- nil:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
return <-done
|
||||
}
|
||||
|
||||
func (c *agentClient) checkEnabled(roomNamespaces, publisherNamespaces, participantNamespaces, roomAgentNames, publisherAgentNames, participantAgentNames *serverutils.IncrementalDispatcher[string]) {
|
||||
defer roomNamespaces.Done()
|
||||
defer publisherNamespaces.Done()
|
||||
defer participantNamespaces.Done()
|
||||
defer roomAgentNames.Done()
|
||||
defer publisherAgentNames.Done()
|
||||
defer participantAgentNames.Done()
|
||||
|
||||
resChan, err := c.client.CheckEnabled(context.Background(), &rpc.CheckEnabledRequest{}, psrpc.WithRequestTimeout(CheckEnabledTimeout))
|
||||
if err != nil {
|
||||
logger.Errorw("failed to check enabled", err)
|
||||
return
|
||||
}
|
||||
|
||||
roomNSMap := make(map[string]bool)
|
||||
publisherNSMap := make(map[string]bool)
|
||||
participantNSMap := make(map[string]bool)
|
||||
roomAgMap := make(map[string]bool)
|
||||
publisherAgMap := make(map[string]bool)
|
||||
participantAgMap := make(map[string]bool)
|
||||
|
||||
for r := range resChan {
|
||||
if r.Result.GetRoomEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := roomNSMap[ns]; !ok {
|
||||
roomNamespaces.Add(ns)
|
||||
roomNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := roomAgMap[ag]; !ok {
|
||||
roomAgentNames.Add(ag)
|
||||
roomAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Result.GetPublisherEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := publisherNSMap[ns]; !ok {
|
||||
publisherNamespaces.Add(ns)
|
||||
publisherNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := publisherAgMap[ag]; !ok {
|
||||
publisherAgentNames.Add(ag)
|
||||
publisherAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if r.Result.GetParticipantEnabled() {
|
||||
for _, ns := range r.Result.GetNamespaces() {
|
||||
if _, ok := participantNSMap[ns]; !ok {
|
||||
participantNamespaces.Add(ns)
|
||||
participantNSMap[ns] = true
|
||||
}
|
||||
}
|
||||
for _, ag := range r.Result.GetAgentNames() {
|
||||
if _, ok := participantAgMap[ag]; !ok {
|
||||
participantAgentNames.Add(ag)
|
||||
participantAgMap[ag] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *agentClient) Stop() error {
|
||||
_ = c.invalidateSub.Close()
|
||||
<-c.subDone
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetAgentTopic(agentName, namespace, deployment string) string {
|
||||
var topic string
|
||||
if agentName == "" {
|
||||
// Backward compatibility
|
||||
topic = namespace
|
||||
} else if namespace == "" {
|
||||
// Forward compatibility once the namespace field is removed from the worker SDK
|
||||
topic = agentName
|
||||
} else {
|
||||
topic = fmt.Sprintf("%s_%s", agentName, namespace)
|
||||
}
|
||||
if deployment != "" {
|
||||
topic += "_" + deployment
|
||||
}
|
||||
return topic
|
||||
}
|
||||
9
pkg/agent/config.go
Normal file
9
pkg/agent/config.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package agent
|
||||
|
||||
const DefaultTargetLoad = 0.7
|
||||
|
||||
type Config struct {
|
||||
EnableUserDataRecording bool `yaml:"enable_user_data_recording"`
|
||||
EnableUserDataRedaction bool `yaml:"enable_user_data_redaction"`
|
||||
TargetLoad float32 `yaml:"target_load,omitempty"`
|
||||
}
|
||||
500
pkg/agent/testutils/server.go
Normal file
500
pkg/agent/testutils/server.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/gammazero/deque"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/agent"
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/service"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/events"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
"github.com/livekit/protocol/utils/options"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
type AgentService interface {
|
||||
HandleConnection(context.Context, agent.SignalConn, agent.WorkerRegistration)
|
||||
DrainConnections(time.Duration, bool)
|
||||
}
|
||||
|
||||
type TestServer struct {
|
||||
AgentService
|
||||
TestAPIKey string
|
||||
TestAPISecret string
|
||||
}
|
||||
|
||||
func NewTestServer(bus psrpc.MessageBus) *TestServer {
|
||||
localNode, _ := routing.NewLocalNode(nil)
|
||||
return NewTestServerWithService(must.Get(service.NewAgentService(
|
||||
&config.Config{
|
||||
Region: "test",
|
||||
Agents: agent.Config{
|
||||
TargetLoad: agent.DefaultTargetLoad,
|
||||
},
|
||||
},
|
||||
localNode,
|
||||
bus,
|
||||
auth.NewSimpleKeyProvider("test", "verysecretsecret"),
|
||||
)))
|
||||
}
|
||||
|
||||
func NewTestServerWithService(s AgentService) *TestServer {
|
||||
return &TestServer{s, "test", "verysecretsecret"}
|
||||
}
|
||||
|
||||
type SimulatedWorkerOptions struct {
|
||||
Context context.Context
|
||||
Label string
|
||||
SupportResume bool
|
||||
DefaultJobLoad float32
|
||||
JobLoadThreshold float32
|
||||
DefaultWorkerLoad float32
|
||||
HandleAvailability func(AgentJobRequest)
|
||||
HandleAssignment func(*livekit.Job) JobLoad
|
||||
}
|
||||
|
||||
type SimulatedWorkerOption func(*SimulatedWorkerOptions)
|
||||
|
||||
func WithContext(ctx context.Context) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.Context = ctx
|
||||
}
|
||||
}
|
||||
|
||||
func WithLabel(label string) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.Label = label
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobAvailabilityHandler(h func(AgentJobRequest)) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.HandleAvailability = h
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobAssignmentHandler(h func(*livekit.Job) JobLoad) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.HandleAssignment = h
|
||||
}
|
||||
}
|
||||
|
||||
func WithJobLoad(l JobLoad) SimulatedWorkerOption {
|
||||
return WithJobAssignmentHandler(func(j *livekit.Job) JobLoad { return l })
|
||||
}
|
||||
|
||||
func WithDefaultWorkerLoad(load float32) SimulatedWorkerOption {
|
||||
return func(o *SimulatedWorkerOptions) {
|
||||
o.DefaultWorkerLoad = load
|
||||
}
|
||||
}
|
||||
|
||||
func (h *TestServer) SimulateAgentWorker(opts ...SimulatedWorkerOption) *AgentWorker {
|
||||
o := &SimulatedWorkerOptions{
|
||||
Context: context.Background(),
|
||||
Label: guid.New("TEST_AGENT_"),
|
||||
DefaultJobLoad: 0.1,
|
||||
JobLoadThreshold: 0.8,
|
||||
DefaultWorkerLoad: 0.0,
|
||||
HandleAvailability: func(r AgentJobRequest) { r.Accept() },
|
||||
HandleAssignment: func(j *livekit.Job) JobLoad { return nil },
|
||||
}
|
||||
options.Apply(o, opts)
|
||||
|
||||
w := &AgentWorker{
|
||||
workerMessages: make(chan *livekit.WorkerMessage, 1),
|
||||
jobs: map[string]*AgentJob{},
|
||||
SimulatedWorkerOptions: o,
|
||||
|
||||
RegisterWorkerResponses: events.NewObserverList[*livekit.RegisterWorkerResponse](),
|
||||
AvailabilityRequests: events.NewObserverList[*livekit.AvailabilityRequest](),
|
||||
JobAssignments: events.NewObserverList[*livekit.JobAssignment](),
|
||||
JobTerminations: events.NewObserverList[*livekit.JobTermination](),
|
||||
WorkerPongs: events.NewObserverList[*livekit.WorkerPong](),
|
||||
}
|
||||
w.ctx, w.cancel = context.WithCancel(context.Background())
|
||||
|
||||
if o.DefaultWorkerLoad > 0.0 {
|
||||
w.sendStatus()
|
||||
}
|
||||
|
||||
ctx := service.WithAPIKey(o.Context, &auth.ClaimGrants{}, "test")
|
||||
go h.HandleConnection(ctx, w, agent.MakeWorkerRegistration())
|
||||
|
||||
return w
|
||||
}
|
||||
|
||||
func (h *TestServer) Close() {
|
||||
h.DrainConnections(1, false)
|
||||
}
|
||||
|
||||
var _ agent.SignalConn = (*AgentWorker)(nil)
|
||||
|
||||
type JobLoad interface {
|
||||
Load() float32
|
||||
}
|
||||
|
||||
type AgentJob struct {
|
||||
*livekit.Job
|
||||
JobLoad
|
||||
}
|
||||
|
||||
type AgentJobRequest struct {
|
||||
w *AgentWorker
|
||||
*livekit.AvailabilityRequest
|
||||
}
|
||||
|
||||
func (r AgentJobRequest) Accept() {
|
||||
identity := guid.New("PI_")
|
||||
r.w.SendAvailability(&livekit.AvailabilityResponse{
|
||||
JobId: r.Job.Id,
|
||||
Available: true,
|
||||
SupportsResume: r.w.SupportResume,
|
||||
ParticipantName: identity,
|
||||
ParticipantIdentity: identity,
|
||||
})
|
||||
}
|
||||
|
||||
func (r AgentJobRequest) Reject() {
|
||||
r.w.SendAvailability(&livekit.AvailabilityResponse{
|
||||
JobId: r.Job.Id,
|
||||
Available: false,
|
||||
})
|
||||
}
|
||||
|
||||
type AgentWorker struct {
|
||||
*SimulatedWorkerOptions
|
||||
|
||||
fuse core.Fuse
|
||||
mu sync.Mutex
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
workerMessages chan *livekit.WorkerMessage
|
||||
serverMessages deque.Deque[*livekit.ServerMessage]
|
||||
jobs map[string]*AgentJob
|
||||
|
||||
RegisterWorkerResponses *events.ObserverList[*livekit.RegisterWorkerResponse]
|
||||
AvailabilityRequests *events.ObserverList[*livekit.AvailabilityRequest]
|
||||
JobAssignments *events.ObserverList[*livekit.JobAssignment]
|
||||
JobTerminations *events.ObserverList[*livekit.JobTermination]
|
||||
WorkerPongs *events.ObserverList[*livekit.WorkerPong]
|
||||
}
|
||||
|
||||
func (w *AgentWorker) statusWorker() {
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
for !w.fuse.IsBroken() {
|
||||
w.sendStatus()
|
||||
<-t.C
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.fuse.Break()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) CloseWithReason(_ string) error {
|
||||
return w.Close()
|
||||
}
|
||||
|
||||
// Closed returns a channel that is closed when the connection is closed by the server
|
||||
func (w *AgentWorker) Closed() <-chan struct{} {
|
||||
return w.fuse.Watch()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SetReadDeadline(t time.Time) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if !w.fuse.IsBroken() {
|
||||
cancel := w.cancel
|
||||
if t.IsZero() {
|
||||
w.ctx, w.cancel = context.WithCancel(context.Background())
|
||||
} else {
|
||||
w.ctx, w.cancel = context.WithDeadline(context.Background(), t)
|
||||
}
|
||||
cancel()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) {
|
||||
for {
|
||||
w.mu.Lock()
|
||||
ctx := w.ctx
|
||||
w.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-w.fuse.Watch():
|
||||
return nil, 0, io.EOF
|
||||
case <-ctx.Done():
|
||||
if err := ctx.Err(); errors.Is(err, context.DeadlineExceeded) {
|
||||
return nil, 0, err
|
||||
}
|
||||
case m := <-w.workerMessages:
|
||||
return m, 0, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) WriteServerMessage(m *livekit.ServerMessage) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.serverMessages.PushBack(m)
|
||||
if w.serverMessages.Len() == 1 {
|
||||
go w.handleServerMessages()
|
||||
}
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleServerMessages() {
|
||||
w.mu.Lock()
|
||||
for w.serverMessages.Len() != 0 {
|
||||
m := w.serverMessages.Front()
|
||||
w.mu.Unlock()
|
||||
|
||||
switch m := m.Message.(type) {
|
||||
case *livekit.ServerMessage_Register:
|
||||
w.handleRegister(m.Register)
|
||||
case *livekit.ServerMessage_Availability:
|
||||
w.handleAvailability(m.Availability)
|
||||
case *livekit.ServerMessage_Assignment:
|
||||
w.handleAssignment(m.Assignment)
|
||||
case *livekit.ServerMessage_Termination:
|
||||
w.handleTermination(m.Termination)
|
||||
case *livekit.ServerMessage_Pong:
|
||||
w.handlePong(m.Pong)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
w.serverMessages.PopFront()
|
||||
}
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleRegister(m *livekit.RegisterWorkerResponse) {
|
||||
w.RegisterWorkerResponses.Emit(m)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleAvailability(m *livekit.AvailabilityRequest) {
|
||||
w.AvailabilityRequests.Emit(m)
|
||||
if w.HandleAvailability != nil {
|
||||
w.HandleAvailability(AgentJobRequest{w, m})
|
||||
} else {
|
||||
AgentJobRequest{w, m}.Accept()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleAssignment(m *livekit.JobAssignment) {
|
||||
w.JobAssignments.Emit(m)
|
||||
|
||||
var load JobLoad
|
||||
if w.HandleAssignment != nil {
|
||||
load = w.HandleAssignment(m.Job)
|
||||
}
|
||||
|
||||
if load == nil {
|
||||
load = NewStableJobLoad(w.DefaultJobLoad)
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.jobs[m.Job.Id] = &AgentJob{m.Job, load}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handleTermination(m *livekit.JobTermination) {
|
||||
w.JobTerminations.Emit(m)
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
delete(w.jobs, m.JobId)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) handlePong(m *livekit.WorkerPong) {
|
||||
w.WorkerPongs.Emit(m)
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendMessage(m *livekit.WorkerMessage) {
|
||||
select {
|
||||
case <-w.fuse.Watch():
|
||||
case w.workerMessages <- m:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendRegister(m *livekit.RegisterWorkerRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Register{
|
||||
Register: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendAvailability(m *livekit.AvailabilityResponse) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Availability{
|
||||
Availability: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendUpdateWorker(m *livekit.UpdateWorkerStatus) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_UpdateWorker{
|
||||
UpdateWorker: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendUpdateJob(m *livekit.UpdateJobStatus) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_UpdateJob{
|
||||
UpdateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendPing(m *livekit.WorkerPing) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_Ping{
|
||||
Ping: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendSimulateJob(m *livekit.SimulateJobRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_SimulateJob{
|
||||
SimulateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SendMigrateJob(m *livekit.MigrateJobRequest) {
|
||||
w.SendMessage(&livekit.WorkerMessage{Message: &livekit.WorkerMessage_MigrateJob{
|
||||
MigrateJob: m,
|
||||
}})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) sendStatus() {
|
||||
w.mu.Lock()
|
||||
var load float32
|
||||
jobCount := len(w.jobs)
|
||||
|
||||
if len(w.jobs) == 0 {
|
||||
load = w.DefaultWorkerLoad
|
||||
} else {
|
||||
for _, j := range w.jobs {
|
||||
load += j.Load()
|
||||
}
|
||||
}
|
||||
w.mu.Unlock()
|
||||
|
||||
status := livekit.WorkerStatus_WS_AVAILABLE
|
||||
if load > w.JobLoadThreshold {
|
||||
status = livekit.WorkerStatus_WS_FULL
|
||||
}
|
||||
|
||||
w.SendUpdateWorker(&livekit.UpdateWorkerStatus{
|
||||
Status: &status,
|
||||
Load: load,
|
||||
JobCount: uint32(jobCount),
|
||||
})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Register(agentName string, jobType livekit.JobType) {
|
||||
w.SendRegister(&livekit.RegisterWorkerRequest{
|
||||
Type: jobType,
|
||||
AgentName: agentName,
|
||||
})
|
||||
go w.statusWorker()
|
||||
}
|
||||
|
||||
func (w *AgentWorker) SimulateRoomJob(roomName string) {
|
||||
w.SendSimulateJob(&livekit.SimulateJobRequest{
|
||||
Type: livekit.JobType_JT_ROOM,
|
||||
Room: &livekit.Room{
|
||||
Sid: guid.New(guid.RoomPrefix),
|
||||
Name: roomName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (w *AgentWorker) Jobs() []*AgentJob {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return slices.Collect(maps.Values(w.jobs))
|
||||
}
|
||||
|
||||
type stableJobLoad struct {
|
||||
load float32
|
||||
}
|
||||
|
||||
func NewStableJobLoad(load float32) JobLoad {
|
||||
return stableJobLoad{load}
|
||||
}
|
||||
|
||||
func (s stableJobLoad) Load() float32 {
|
||||
return s.load
|
||||
}
|
||||
|
||||
type periodicJobLoad struct {
|
||||
amplitude float64
|
||||
period time.Duration
|
||||
epoch time.Time
|
||||
}
|
||||
|
||||
func NewPeriodicJobLoad(max float32, period time.Duration) JobLoad {
|
||||
return periodicJobLoad{
|
||||
amplitude: float64(max / 2),
|
||||
period: period,
|
||||
epoch: time.Now().Add(-time.Duration(rand.Int64N(int64(period)))),
|
||||
}
|
||||
}
|
||||
|
||||
func (s periodicJobLoad) Load() float32 {
|
||||
a := math.Sin(time.Since(s.epoch).Seconds() / s.period.Seconds() * math.Pi * 2)
|
||||
return float32(s.amplitude + a*s.amplitude)
|
||||
}
|
||||
|
||||
type uniformRandomJobLoad struct {
|
||||
min, max float32
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
func NewUniformRandomJobLoad(min, max float32) JobLoad {
|
||||
return uniformRandomJobLoad{min, max, rand.Float64}
|
||||
}
|
||||
|
||||
func NewUniformRandomJobLoadWithRNG(min, max float32, rng *rand.Rand) JobLoad {
|
||||
return uniformRandomJobLoad{min, max, rng.Float64}
|
||||
}
|
||||
|
||||
func (s uniformRandomJobLoad) Load() float32 {
|
||||
return rand.Float32()*(s.max-s.min) + s.min
|
||||
}
|
||||
|
||||
type normalRandomJobLoad struct {
|
||||
mean, stddev float64
|
||||
rng func() float64
|
||||
}
|
||||
|
||||
func NewNormalRandomJobLoad(mean, stddev float64) JobLoad {
|
||||
return normalRandomJobLoad{mean, stddev, rand.Float64}
|
||||
}
|
||||
|
||||
func NewNormalRandomJobLoadWithRNG(mean, stddev float64, rng *rand.Rand) JobLoad {
|
||||
return normalRandomJobLoad{mean, stddev, rng.Float64}
|
||||
}
|
||||
|
||||
func (s normalRandomJobLoad) Load() float32 {
|
||||
u := 1 - s.rng()
|
||||
v := s.rng()
|
||||
z := math.Sqrt(-2*math.Log(u)) * math.Cos(2*math.Pi*v)
|
||||
return float32(max(0, z*s.stddev+s.mean))
|
||||
}
|
||||
604
pkg/agent/worker.go
Normal file
604
pkg/agent/worker.go
Normal file
@@ -0,0 +1,604 @@
|
||||
// Copyright 2024 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 agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
protoagent "github.com/livekit/protocol/agent"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnimplementedWrorkerSignal = errors.New("unimplemented worker signal")
|
||||
ErrUnknownWorkerSignal = errors.New("unknown worker signal")
|
||||
ErrUnknownJobType = errors.New("unknown job type")
|
||||
ErrJobNotFound = psrpc.NewErrorf(psrpc.NotFound, "no running job for given jobID")
|
||||
ErrWorkerClosed = errors.New("worker closed")
|
||||
ErrWorkerNotAvailable = errors.New("worker not available")
|
||||
ErrAvailabilityTimeout = errors.New("agent worker availability timeout")
|
||||
ErrDuplicateJobAssignment = errors.New("duplicate job assignment")
|
||||
)
|
||||
|
||||
const AgentNameAttributeKey = "lk.agent_name"
|
||||
|
||||
type WorkerProtocolVersion int
|
||||
|
||||
const CurrentProtocol = 1
|
||||
|
||||
const (
|
||||
RegisterTimeout = 10 * time.Second
|
||||
AssignJobTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
type SignalConn interface {
|
||||
WriteServerMessage(msg *livekit.ServerMessage) (int, error)
|
||||
ReadWorkerMessage() (*livekit.WorkerMessage, int, error)
|
||||
SetReadDeadline(time.Time) error
|
||||
Close() error
|
||||
CloseWithReason(reason string) error
|
||||
}
|
||||
|
||||
func JobStatusIsEnded(s livekit.JobStatus) bool {
|
||||
return s == livekit.JobStatus_JS_SUCCESS || s == livekit.JobStatus_JS_FAILED
|
||||
}
|
||||
|
||||
type AssignmentHook func(next func(*livekit.JobAssignment) error) func(*livekit.JobAssignment) error
|
||||
|
||||
type WorkerSignalHandler interface {
|
||||
HandleRegister(*livekit.RegisterWorkerRequest) error
|
||||
HandleAvailability(*livekit.AvailabilityResponse) error
|
||||
HandleUpdateJob(*livekit.UpdateJobStatus) error
|
||||
HandleSimulateJob(*livekit.SimulateJobRequest) error
|
||||
HandlePing(*livekit.WorkerPing) error
|
||||
HandleUpdateWorker(*livekit.UpdateWorkerStatus) error
|
||||
HandleMigrateJob(*livekit.MigrateJobRequest) error
|
||||
}
|
||||
|
||||
func DispatchWorkerSignal(req *livekit.WorkerMessage, h WorkerSignalHandler) error {
|
||||
switch m := req.Message.(type) {
|
||||
case *livekit.WorkerMessage_Register:
|
||||
return h.HandleRegister(m.Register)
|
||||
case *livekit.WorkerMessage_Availability:
|
||||
return h.HandleAvailability(m.Availability)
|
||||
case *livekit.WorkerMessage_UpdateJob:
|
||||
return h.HandleUpdateJob(m.UpdateJob)
|
||||
case *livekit.WorkerMessage_SimulateJob:
|
||||
return h.HandleSimulateJob(m.SimulateJob)
|
||||
case *livekit.WorkerMessage_Ping:
|
||||
return h.HandlePing(m.Ping)
|
||||
case *livekit.WorkerMessage_UpdateWorker:
|
||||
return h.HandleUpdateWorker(m.UpdateWorker)
|
||||
case *livekit.WorkerMessage_MigrateJob:
|
||||
return h.HandleMigrateJob(m.MigrateJob)
|
||||
default:
|
||||
return ErrUnknownWorkerSignal
|
||||
}
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*UnimplementedWorkerSignalHandler)(nil)
|
||||
|
||||
type UnimplementedWorkerSignalHandler struct{}
|
||||
|
||||
func (UnimplementedWorkerSignalHandler) HandleRegister(*livekit.RegisterWorkerRequest) error {
|
||||
return fmt.Errorf("%w: Register", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleAvailability(*livekit.AvailabilityResponse) error {
|
||||
return fmt.Errorf("%w: Availability", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleUpdateJob(*livekit.UpdateJobStatus) error {
|
||||
return fmt.Errorf("%w: UpdateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleSimulateJob(*livekit.SimulateJobRequest) error {
|
||||
return fmt.Errorf("%w: SimulateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandlePing(*livekit.WorkerPing) error {
|
||||
return fmt.Errorf("%w: Ping", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleUpdateWorker(*livekit.UpdateWorkerStatus) error {
|
||||
return fmt.Errorf("%w: UpdateWorker", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
func (UnimplementedWorkerSignalHandler) HandleMigrateJob(*livekit.MigrateJobRequest) error {
|
||||
return fmt.Errorf("%w: MigrateJob", ErrUnimplementedWrorkerSignal)
|
||||
}
|
||||
|
||||
type WorkerPingHandler struct {
|
||||
UnimplementedWorkerSignalHandler
|
||||
conn SignalConn
|
||||
}
|
||||
|
||||
func (h WorkerPingHandler) HandlePing(ping *livekit.WorkerPing) error {
|
||||
_, err := h.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Pong{
|
||||
Pong: &livekit.WorkerPong{
|
||||
LastTimestamp: ping.Timestamp,
|
||||
Timestamp: time.Now().UnixMilli(),
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
type WorkerRegistration struct {
|
||||
Protocol WorkerProtocolVersion
|
||||
ID string
|
||||
Version string
|
||||
AgentID string
|
||||
AgentName string
|
||||
Namespace string
|
||||
JobType livekit.JobType
|
||||
Permissions *livekit.ParticipantPermission
|
||||
ClientIP string
|
||||
Deployment string
|
||||
// KindDetails, when set by the server, are stamped onto the participant
|
||||
// join token minted for every job assigned to this worker (e.g. marking
|
||||
// hosted/cloud agents so they can be distinguished from self-hosted ones in
|
||||
// observability). This is server-controlled and never populated from the
|
||||
// worker's register request.
|
||||
KindDetails []livekit.ParticipantInfo_KindDetail
|
||||
}
|
||||
|
||||
func MakeWorkerRegistration() WorkerRegistration {
|
||||
return WorkerRegistration{
|
||||
ID: guid.New(guid.AgentWorkerPrefix),
|
||||
Protocol: CurrentProtocol,
|
||||
}
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*WorkerRegisterer)(nil)
|
||||
|
||||
type WorkerRegisterer struct {
|
||||
WorkerPingHandler
|
||||
serverInfo *livekit.ServerInfo
|
||||
deadline time.Time
|
||||
|
||||
registration WorkerRegistration
|
||||
registered bool
|
||||
}
|
||||
|
||||
func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration) *WorkerRegisterer {
|
||||
return &WorkerRegisterer{
|
||||
WorkerPingHandler: WorkerPingHandler{conn: conn},
|
||||
serverInfo: serverInfo,
|
||||
registration: base,
|
||||
deadline: time.Now().Add(RegisterTimeout),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Deadline() time.Time {
|
||||
return h.deadline
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Registration() WorkerRegistration {
|
||||
return h.registration
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) Registered() bool {
|
||||
return h.registered
|
||||
}
|
||||
|
||||
func (h *WorkerRegisterer) HandleRegister(req *livekit.RegisterWorkerRequest) error {
|
||||
if !livekit.IsJobType(req.GetType()) {
|
||||
return ErrUnknownJobType
|
||||
}
|
||||
|
||||
if err := protoagent.ValidateDeployment(req.GetDeployment()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
permissions := req.AllowedPermissions
|
||||
if permissions == nil {
|
||||
permissions = &livekit.ParticipantPermission{
|
||||
CanSubscribe: true,
|
||||
CanPublish: true,
|
||||
CanPublishData: true,
|
||||
CanUpdateMetadata: true,
|
||||
}
|
||||
}
|
||||
|
||||
h.registration.Version = req.Version
|
||||
h.registration.AgentName = req.AgentName
|
||||
h.registration.Namespace = req.GetNamespace()
|
||||
h.registration.JobType = req.GetType()
|
||||
h.registration.Permissions = permissions
|
||||
h.registration.Deployment = req.GetDeployment()
|
||||
h.registered = true
|
||||
|
||||
_, err := h.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Register{
|
||||
Register: &livekit.RegisterWorkerResponse{
|
||||
WorkerId: h.registration.ID,
|
||||
ServerInfo: h.serverInfo,
|
||||
},
|
||||
},
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
var _ WorkerSignalHandler = (*Worker)(nil)
|
||||
|
||||
type Worker struct {
|
||||
WorkerPingHandler
|
||||
WorkerRegistration
|
||||
|
||||
apiKey string
|
||||
apiSecret string
|
||||
logger logger.Logger
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
closed chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
load float32
|
||||
status livekit.WorkerStatus
|
||||
|
||||
runningJobs map[livekit.JobID]*livekit.Job
|
||||
availability map[livekit.JobID]chan *livekit.AvailabilityResponse
|
||||
}
|
||||
|
||||
func NewWorker(
|
||||
registration WorkerRegistration,
|
||||
apiKey string,
|
||||
apiSecret string,
|
||||
conn SignalConn,
|
||||
logger logger.Logger,
|
||||
) *Worker {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Worker{
|
||||
WorkerPingHandler: WorkerPingHandler{conn: conn},
|
||||
WorkerRegistration: registration,
|
||||
apiKey: apiKey,
|
||||
apiSecret: apiSecret,
|
||||
logger: logger.WithValues(
|
||||
"workerID", registration.ID,
|
||||
"agentName", registration.AgentName,
|
||||
"deployment", registration.Deployment,
|
||||
"agentID", registration.AgentID,
|
||||
"version", registration.Version,
|
||||
"jobType", registration.JobType.String(),
|
||||
),
|
||||
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
closed: make(chan struct{}),
|
||||
|
||||
runningJobs: make(map[livekit.JobID]*livekit.Job),
|
||||
availability: make(map[livekit.JobID]chan *livekit.AvailabilityResponse),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) APIKey() string {
|
||||
return w.apiKey
|
||||
}
|
||||
|
||||
func (w *Worker) Status() livekit.WorkerStatus {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.status
|
||||
}
|
||||
|
||||
func (w *Worker) Load() float32 {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return w.load
|
||||
}
|
||||
|
||||
func (w *Worker) Logger() logger.Logger {
|
||||
return w.logger
|
||||
}
|
||||
|
||||
func (w *Worker) RunningJobs() map[livekit.JobID]*livekit.Job {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return maps.Clone(w.runningJobs)
|
||||
}
|
||||
|
||||
func (w *Worker) RunningJobCount() int {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
return len(w.runningJobs)
|
||||
}
|
||||
|
||||
func (w *Worker) GetJobState(jobID livekit.JobID) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
j, ok := w.runningJobs[jobID]
|
||||
if !ok {
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
return utils.CloneProto(j.State), nil
|
||||
}
|
||||
|
||||
func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook AssignmentHook) (*livekit.JobState, error) {
|
||||
availCh := make(chan *livekit.AvailabilityResponse, 1)
|
||||
job = utils.CloneProto(job)
|
||||
jobID := livekit.JobID(job.Id)
|
||||
|
||||
w.mu.Lock()
|
||||
if _, ok := w.availability[jobID]; ok {
|
||||
w.mu.Unlock()
|
||||
return nil, ErrDuplicateJobAssignment
|
||||
}
|
||||
|
||||
w.availability[jobID] = availCh
|
||||
w.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.mu.Lock()
|
||||
delete(w.availability, jobID)
|
||||
w.mu.Unlock()
|
||||
}()
|
||||
|
||||
if job.State == nil {
|
||||
job.State = &livekit.JobState{}
|
||||
}
|
||||
now := time.Now()
|
||||
job.State.WorkerId = w.ID
|
||||
job.State.AgentId = w.AgentID
|
||||
job.State.UpdatedAt = now.UnixNano()
|
||||
job.State.StartedAt = now.UnixNano()
|
||||
job.State.Status = livekit.JobStatus_JS_RUNNING
|
||||
|
||||
if _, err := w.conn.WriteServerMessage(&livekit.ServerMessage{Message: &livekit.ServerMessage_Availability{
|
||||
Availability: &livekit.AvailabilityRequest{Job: job},
|
||||
}}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeout := time.NewTimer(AssignJobTimeout)
|
||||
defer timeout.Stop()
|
||||
|
||||
// See handleAvailability for the response
|
||||
select {
|
||||
case res := <-availCh:
|
||||
if res.Terminate {
|
||||
job.State.EndedAt = now.UnixNano()
|
||||
job.State.Status = livekit.JobStatus_JS_SUCCESS
|
||||
return job.State, nil
|
||||
}
|
||||
|
||||
if !res.Available {
|
||||
return nil, ErrWorkerNotAvailable
|
||||
}
|
||||
|
||||
job.State.ParticipantIdentity = res.ParticipantIdentity
|
||||
attributes := res.ParticipantAttributes
|
||||
if attributes == nil {
|
||||
attributes = make(map[string]string)
|
||||
}
|
||||
attributes[AgentNameAttributeKey] = w.AgentName
|
||||
|
||||
token, err := protoagent.BuildAgentToken(
|
||||
w.apiKey,
|
||||
w.apiSecret,
|
||||
job.Room.Name,
|
||||
res.ParticipantIdentity,
|
||||
res.ParticipantName,
|
||||
res.ParticipantMetadata,
|
||||
attributes,
|
||||
w.Permissions,
|
||||
w.KindDetails...,
|
||||
)
|
||||
if err != nil {
|
||||
w.logger.Errorw("failed to build agent token", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
send := func(a *livekit.JobAssignment) error {
|
||||
_, err := w.conn.WriteServerMessage(&livekit.ServerMessage{
|
||||
Message: &livekit.ServerMessage_Assignment{Assignment: a},
|
||||
})
|
||||
return err
|
||||
}
|
||||
if hook != nil {
|
||||
send = hook(send)
|
||||
}
|
||||
if err := send(&livekit.JobAssignment{Job: job, Token: token}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := utils.CloneProto(job.State)
|
||||
|
||||
w.mu.Lock()
|
||||
w.runningJobs[jobID] = job
|
||||
w.mu.Unlock()
|
||||
|
||||
// TODO sweep jobs that are never started. We can't do this until all SDKs actually update the the JOB state
|
||||
|
||||
return state, nil
|
||||
case <-timeout.C:
|
||||
return nil, ErrAvailabilityTimeout
|
||||
case <-w.ctx.Done():
|
||||
return nil, ErrWorkerClosed
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) TerminateJob(jobID livekit.JobID, reason rpc.JobTerminateReason) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
_, ok := w.runningJobs[jobID]
|
||||
w.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return nil, ErrJobNotFound
|
||||
}
|
||||
|
||||
_, writeErr := w.conn.WriteServerMessage(&livekit.ServerMessage{Message: &livekit.ServerMessage_Termination{
|
||||
Termination: &livekit.JobTermination{
|
||||
JobId: string(jobID),
|
||||
},
|
||||
}})
|
||||
|
||||
status := livekit.JobStatus_JS_SUCCESS
|
||||
errorStr := ""
|
||||
if reason == rpc.JobTerminateReason_AGENT_LEFT_ROOM {
|
||||
status = livekit.JobStatus_JS_FAILED
|
||||
errorStr = "agent worker left the room"
|
||||
}
|
||||
|
||||
state, updateErr := w.UpdateJobStatus(&livekit.UpdateJobStatus{
|
||||
JobId: string(jobID),
|
||||
Status: status,
|
||||
Error: errorStr,
|
||||
})
|
||||
return state, multierr.Combine(writeErr, updateErr)
|
||||
}
|
||||
|
||||
func (w *Worker) UpdateMetadata(metadata string) {
|
||||
w.logger.Debugw("worker metadata updated", nil, "metadata", metadata)
|
||||
}
|
||||
|
||||
func (w *Worker) IsClosed() bool {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Worker) Close() {
|
||||
w.mu.Lock()
|
||||
if w.IsClosed() {
|
||||
w.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
w.logger.Infow("closing worker", "workerID", w.ID, "jobType", w.JobType, "agentName", w.AgentName)
|
||||
|
||||
close(w.closed)
|
||||
w.cancel()
|
||||
_ = w.conn.Close()
|
||||
w.mu.Unlock()
|
||||
}
|
||||
|
||||
func (w *Worker) HandleAvailability(res *livekit.AvailabilityResponse) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
jobID := livekit.JobID(res.JobId)
|
||||
availCh, ok := w.availability[jobID]
|
||||
if !ok {
|
||||
w.logger.Warnw("received availability response for unknown job", nil, "jobID", jobID)
|
||||
return nil
|
||||
}
|
||||
|
||||
availCh <- res
|
||||
delete(w.availability, jobID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleUpdateJob(update *livekit.UpdateJobStatus) error {
|
||||
_, err := w.UpdateJobStatus(update)
|
||||
if err != nil {
|
||||
// treating this as a debug message only
|
||||
// this can happen if the Room closes first, which would delete the agent dispatch
|
||||
// that would mark the job as successful. subsequent updates from the same worker
|
||||
// would not be able to find the same jobID.
|
||||
w.logger.Debugw("received job update for unknown job", "jobID", update.JobId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) UpdateJobStatus(update *livekit.UpdateJobStatus) (*livekit.JobState, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
jobID := livekit.JobID(update.JobId)
|
||||
job, ok := w.runningJobs[jobID]
|
||||
if !ok {
|
||||
return nil, psrpc.NewErrorf(psrpc.NotFound, "received job update for unknown job")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
job.State.UpdatedAt = now.UnixNano()
|
||||
|
||||
if job.State.Status == livekit.JobStatus_JS_PENDING && update.Status != livekit.JobStatus_JS_PENDING {
|
||||
job.State.StartedAt = now.UnixNano()
|
||||
}
|
||||
|
||||
job.State.Status = update.Status
|
||||
job.State.Error = update.Error
|
||||
|
||||
if JobStatusIsEnded(update.Status) {
|
||||
job.State.EndedAt = now.UnixNano()
|
||||
delete(w.runningJobs, jobID)
|
||||
|
||||
w.logger.Infow("job ended", "jobID", update.JobId, "status", update.Status, "error", update.Error)
|
||||
}
|
||||
|
||||
return proto.Clone(job.State).(*livekit.JobState), nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleSimulateJob(simulate *livekit.SimulateJobRequest) error {
|
||||
jobType := livekit.JobType_JT_ROOM
|
||||
if simulate.Participant != nil {
|
||||
jobType = livekit.JobType_JT_PUBLISHER
|
||||
}
|
||||
|
||||
job := &livekit.Job{
|
||||
Id: guid.New(guid.AgentJobPrefix),
|
||||
Type: jobType,
|
||||
Room: simulate.Room,
|
||||
Participant: simulate.Participant,
|
||||
Namespace: w.Namespace,
|
||||
AgentName: w.AgentName,
|
||||
}
|
||||
|
||||
go func() {
|
||||
_, err := w.AssignJob(w.ctx, job, nil)
|
||||
if err != nil {
|
||||
w.logger.Errorw("unable to simulate job", err, "jobID", job.Id)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleUpdateWorker(update *livekit.UpdateWorkerStatus) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if status := update.Status; status != nil && w.status != *status {
|
||||
w.status = *status
|
||||
w.Logger().Debugw("worker status changed", "status", w.status)
|
||||
}
|
||||
w.load = update.GetLoad()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *Worker) HandleMigrateJob(req *livekit.MigrateJobRequest) error {
|
||||
// TODO(theomonnom): On OSS this is not implemented
|
||||
// We could maybe just move a specific job to another worker
|
||||
return nil
|
||||
}
|
||||
64
pkg/clientconfiguration/conf.go
Normal file
64
pkg/clientconfiguration/conf.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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 clientconfiguration
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
)
|
||||
|
||||
// StaticConfigurations list specific device-side limitations that should be disabled at a global level
|
||||
var StaticConfigurations = []ConfigurationItem{
|
||||
// {
|
||||
// Match: must.Get(NewScriptMatch(`c.protocol <= 5 || c.browser == "firefox"`)),
|
||||
// Configuration: &livekit.ClientConfiguration{ResumeConnection: livekit.ClientConfigSetting_DISABLED},
|
||||
// Merge: false,
|
||||
// },
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.browser == "safari"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Codecs: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeAV1.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.browser == "safari" && c.browser_version > "18.3"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Publish: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeVP9.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`(c.device_model == "xiaomi 2201117ti" && c.os == "android") ||
|
||||
((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
DisabledCodecs: &livekit.DisabledCodecs{
|
||||
Publish: []*livekit.Codec{
|
||||
{Mime: mime.MimeTypeH264.String()},
|
||||
},
|
||||
},
|
||||
},
|
||||
Merge: false,
|
||||
},
|
||||
}
|
||||
134
pkg/clientconfiguration/conf_test.go
Normal file
134
pkg/clientconfiguration/conf_test.go
Normal file
@@ -0,0 +1,134 @@
|
||||
// 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 clientconfiguration
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils/must"
|
||||
)
|
||||
|
||||
func TestScriptMatchConfiguration(t *testing.T) {
|
||||
t.Run("no merge", func(t *testing.T) {
|
||||
confs := []ConfigurationItem{
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.protocol > 5 && c.browser != "firefox"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
ResumeConnection: livekit.ClientConfigSetting_ENABLED,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cm := NewStaticClientConfigurationManager(confs)
|
||||
|
||||
conf := cm.GetConfiguration(&livekit.ClientInfo{Protocol: 4})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "firefox"})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "chrome"})
|
||||
require.Equal(t, conf.ResumeConnection, livekit.ClientConfigSetting_ENABLED)
|
||||
})
|
||||
|
||||
t.Run("merge", func(t *testing.T) {
|
||||
confs := []ConfigurationItem{
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.protocol > 5 && c.browser != "firefox"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
ResumeConnection: livekit.ClientConfigSetting_ENABLED,
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
{
|
||||
Match: must.Get(NewScriptMatch(`c.sdk == "android"`)),
|
||||
Configuration: &livekit.ClientConfiguration{
|
||||
Video: &livekit.VideoConfiguration{
|
||||
HardwareEncoder: livekit.ClientConfigSetting_DISABLED,
|
||||
},
|
||||
},
|
||||
Merge: true,
|
||||
},
|
||||
}
|
||||
|
||||
cm := NewStaticClientConfigurationManager(confs)
|
||||
|
||||
conf := cm.GetConfiguration(&livekit.ClientInfo{Protocol: 4})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "firefox"})
|
||||
require.Nil(t, conf)
|
||||
|
||||
conf = cm.GetConfiguration(&livekit.ClientInfo{Protocol: 6, Browser: "chrome", Sdk: 3})
|
||||
require.Equal(t, conf.ResumeConnection, livekit.ClientConfigSetting_ENABLED)
|
||||
require.Equal(t, conf.Video.HardwareEncoder, livekit.ClientConfigSetting_DISABLED)
|
||||
})
|
||||
}
|
||||
|
||||
func TestScriptMatch(t *testing.T) {
|
||||
client := &livekit.ClientInfo{
|
||||
Protocol: 6,
|
||||
Browser: "chrome",
|
||||
Sdk: 3, // android
|
||||
DeviceModel: "12345",
|
||||
BrowserVersion: "13.2",
|
||||
Version: "2.17.1",
|
||||
}
|
||||
|
||||
type testcase struct {
|
||||
name string
|
||||
expr string
|
||||
result bool
|
||||
err bool
|
||||
}
|
||||
|
||||
cases := []testcase{
|
||||
{name: "simple match", expr: `c.protocol > 5`, result: true},
|
||||
{name: "invalid expr", expr: `cc.protocol > 5`, err: true},
|
||||
{name: "unexist field", expr: `c.protocols > 5`, err: true},
|
||||
{name: "combined condition", expr: `c.protocol > 5 && (c.sdk=="android" || c.sdk=="ios")`, result: true},
|
||||
{name: "combined condition2", expr: `(c.device_model == "xiaomi 2201117ti" && c.os == "android") || ((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`, result: false},
|
||||
{name: "string lesser", expr: `c.browser_version < "11.3"`, result: false},
|
||||
{name: "string lesser eq", expr: `c.browser_version <= "13.2"`, result: true},
|
||||
{name: "string greater", expr: `c.browser_version > "11.3"`, result: true},
|
||||
{name: "string greater eq", expr: `c.browser_version >= "13.2"`, result: true},
|
||||
{name: "semantic lesser", expr: `c.version < "2.16.10"`, result: false},
|
||||
{name: "semantic lesser eq", expr: `c.version <= "2.17.1"`, result: true},
|
||||
{name: "semantic greater", expr: `c.version > "2.16.10"`, result: true},
|
||||
{name: "semantic greater eq", expr: `c.version >= "2.17.1"`, result: true},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
match, err := NewScriptMatch(c.expr)
|
||||
if err != nil {
|
||||
if !c.err {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
m, err := match.Match(client)
|
||||
if c.err {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.Equal(t, c.result, m)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
177
pkg/clientconfiguration/match.go
Normal file
177
pkg/clientconfiguration/match.go
Normal file
@@ -0,0 +1,177 @@
|
||||
// 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 clientconfiguration
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/d5/tengo/v2"
|
||||
"github.com/d5/tengo/v2/token"
|
||||
"golang.org/x/mod/semver"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type Match interface {
|
||||
Match(clientInfo *livekit.ClientInfo) (bool, error)
|
||||
}
|
||||
|
||||
type ScriptMatch struct {
|
||||
compiled *tengo.Compiled
|
||||
}
|
||||
|
||||
func NewScriptMatch(expr string) (*ScriptMatch, error) {
|
||||
script := tengo.NewScript(fmt.Appendf(nil, "__res__ := (%s)", expr))
|
||||
if err := script.Add("c", &clientObject{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
compiled, err := script.Compile()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScriptMatch{compiled}, nil
|
||||
}
|
||||
|
||||
// use result of eval script expression for match.
|
||||
// expression examples:
|
||||
// protocol bigger than 5 : c.protocol > 5
|
||||
// browser if firefox: c.browser == "firefox"
|
||||
// combined rule : c.protocol > 5 && c.browser == "firefox"
|
||||
func (m *ScriptMatch) Match(clientInfo *livekit.ClientInfo) (bool, error) {
|
||||
clone := m.compiled.Clone()
|
||||
if err := clone.Set("c", &clientObject{info: clientInfo}); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := clone.Run(); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
res := clone.Get("__res__").Value()
|
||||
if val, ok := res.(bool); ok {
|
||||
return val, nil
|
||||
}
|
||||
return false, errors.New("invalid match expression result")
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type clientObject struct {
|
||||
tengo.ObjectImpl
|
||||
info *livekit.ClientInfo
|
||||
}
|
||||
|
||||
func (c *clientObject) TypeName() string {
|
||||
return "clientObject"
|
||||
}
|
||||
|
||||
func (c *clientObject) String() string {
|
||||
return c.info.String()
|
||||
}
|
||||
|
||||
func (c *clientObject) IndexGet(index tengo.Object) (res tengo.Object, err error) {
|
||||
field, ok := index.(*tengo.String)
|
||||
if !ok {
|
||||
return nil, tengo.ErrInvalidIndexType
|
||||
}
|
||||
|
||||
switch field.Value {
|
||||
case "sdk":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Sdk.String())}, nil
|
||||
case "version":
|
||||
return &ruleSdkVersion{sdkVersion: c.info.Version}, nil
|
||||
case "protocol":
|
||||
return &tengo.Int{Value: int64(c.info.Protocol)}, nil
|
||||
case "os":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Os)}, nil
|
||||
case "os_version":
|
||||
return &tengo.String{Value: c.info.OsVersion}, nil
|
||||
case "device_model":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.DeviceModel)}, nil
|
||||
case "browser":
|
||||
return &tengo.String{Value: strings.ToLower(c.info.Browser)}, nil
|
||||
case "browser_version":
|
||||
return &ruleSdkVersion{sdkVersion: c.info.BrowserVersion}, nil
|
||||
case "address":
|
||||
return &tengo.String{Value: c.info.Address}, nil
|
||||
}
|
||||
return &tengo.Undefined{}, nil
|
||||
}
|
||||
|
||||
// ------------------------------------------
|
||||
|
||||
type ruleSdkVersion struct {
|
||||
tengo.ObjectImpl
|
||||
sdkVersion string
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) TypeName() string {
|
||||
return "sdkVersion"
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) String() string {
|
||||
return r.sdkVersion
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) BinaryOp(op token.Token, rhs tengo.Object) (tengo.Object, error) {
|
||||
if rhs, ok := rhs.(*tengo.String); ok {
|
||||
cmp := r.compare(rhs.Value)
|
||||
|
||||
isMatch := false
|
||||
switch op {
|
||||
case token.Greater:
|
||||
isMatch = cmp > 0
|
||||
case token.GreaterEq:
|
||||
isMatch = cmp >= 0
|
||||
case token.Less:
|
||||
isMatch = cmp < 0
|
||||
case token.LessEq:
|
||||
isMatch = cmp <= 0
|
||||
default:
|
||||
return nil, tengo.ErrInvalidOperator
|
||||
}
|
||||
|
||||
if isMatch {
|
||||
return tengo.TrueValue, nil
|
||||
}
|
||||
return tengo.FalseValue, nil
|
||||
}
|
||||
|
||||
return nil, tengo.ErrInvalidOperator
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) Equals(rhs tengo.Object) bool {
|
||||
if rhs, ok := rhs.(*tengo.String); ok {
|
||||
return r.compare(rhs.Value) == 0
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *ruleSdkVersion) compare(rhsSdkVersion string) int {
|
||||
if !semver.IsValid("v"+r.sdkVersion) || !semver.IsValid("v"+rhsSdkVersion) {
|
||||
// if not valid semver, do string compare
|
||||
switch {
|
||||
case r.sdkVersion < rhsSdkVersion:
|
||||
return -1
|
||||
case r.sdkVersion > rhsSdkVersion:
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
return semver.Compare("v"+r.sdkVersion, "v"+rhsSdkVersion)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
71
pkg/clientconfiguration/staticconfiguration.go
Normal file
71
pkg/clientconfiguration/staticconfiguration.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 clientconfiguration
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
protoutils "github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type ConfigurationItem struct {
|
||||
Match
|
||||
Configuration *livekit.ClientConfiguration
|
||||
Merge bool
|
||||
}
|
||||
|
||||
type StaticClientConfigurationManager struct {
|
||||
confs []ConfigurationItem
|
||||
}
|
||||
|
||||
func NewStaticClientConfigurationManager(confs []ConfigurationItem) *StaticClientConfigurationManager {
|
||||
return &StaticClientConfigurationManager{confs: confs}
|
||||
}
|
||||
|
||||
func (s *StaticClientConfigurationManager) GetConfiguration(clientInfo *livekit.ClientInfo) *livekit.ClientConfiguration {
|
||||
var matchedConf []*livekit.ClientConfiguration
|
||||
for _, c := range s.confs {
|
||||
matched, err := c.Match.Match(clientInfo)
|
||||
if err != nil {
|
||||
logger.Errorw("matchrule failed", err,
|
||||
"clientInfo", logger.Proto(utils.ClientInfoWithoutAddress(clientInfo)),
|
||||
)
|
||||
continue
|
||||
}
|
||||
if !matched {
|
||||
continue
|
||||
}
|
||||
if !c.Merge {
|
||||
return c.Configuration
|
||||
}
|
||||
matchedConf = append(matchedConf, c.Configuration)
|
||||
}
|
||||
|
||||
var conf *livekit.ClientConfiguration
|
||||
for k, v := range matchedConf {
|
||||
if k == 0 {
|
||||
conf = protoutils.CloneProto(matchedConf[0])
|
||||
} else {
|
||||
// TODO : there is a problem use protobuf merge, we don't have flag to indicate 'no value',
|
||||
// don't override default behavior or other configuration's field. So a bool value = false or
|
||||
// a int value = 0 will override same field in other configuration
|
||||
proto.Merge(conf, v)
|
||||
}
|
||||
}
|
||||
return conf
|
||||
}
|
||||
23
pkg/clientconfiguration/types.go
Normal file
23
pkg/clientconfiguration/types.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// 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 clientconfiguration
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type ClientConfigurationManager interface {
|
||||
GetConfiguration(clientInfo *livekit.ClientInfo) *livekit.ClientConfiguration
|
||||
}
|
||||
1113
pkg/config/config.go
Normal file
1113
pkg/config/config.go
Normal file
File diff suppressed because it is too large
Load Diff
249
pkg/config/config_test.go
Normal file
249
pkg/config/config_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
// 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 config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/urfave/cli/v3"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config/configtest"
|
||||
)
|
||||
|
||||
func TestConfig_UnmarshalKeys(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, conf.unmarshalKeys("key1: secret1"))
|
||||
require.Equal(t, "secret1", conf.Keys["key1"])
|
||||
}
|
||||
|
||||
func TestConfig_DefaultsKept(t *testing.T) {
|
||||
const content = `room:
|
||||
empty_timeout: 10`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, conf.Room.AutoCreate)
|
||||
require.Equal(t, uint32(10), conf.Room.EmptyTimeout)
|
||||
}
|
||||
|
||||
func TestConfig_SignalMessageSizeLimitDefaults(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
// both default to 2 MiB when not specified
|
||||
require.Equal(t, int64(2<<20), conf.Limit.SignalMessageSizeLimit)
|
||||
require.Equal(t, int64(2<<20), conf.Limit.AgentSignalMessageSizeLimit)
|
||||
}
|
||||
|
||||
func TestConfig_SignalMessageSizeLimitOverride(t *testing.T) {
|
||||
const content = `limit:
|
||||
signal_message_size_limit: 1024
|
||||
agent_signal_message_size_limit: 0`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1024), conf.Limit.SignalMessageSizeLimit)
|
||||
// 0 explicitly disables the limit
|
||||
require.Equal(t, int64(0), conf.Limit.AgentSignalMessageSizeLimit)
|
||||
}
|
||||
|
||||
func TestConfig_PSRPCCompressionDefaults(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, conf.PSRPC.Compression.Quality)
|
||||
require.Equal(t, 1024, conf.PSRPC.Compression.Threshold)
|
||||
require.Equal(t, 0, conf.PSRPC.Compression.MaxDecompressedSize)
|
||||
}
|
||||
|
||||
func TestConfig_PSRPCCompressionOverride(t *testing.T) {
|
||||
const content = `psrpc:
|
||||
compression:
|
||||
quality: 6
|
||||
max_decompressed_size: 4096`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 6, conf.PSRPC.Compression.Quality)
|
||||
require.Equal(t, 4096, conf.PSRPC.Compression.MaxDecompressedSize)
|
||||
require.Equal(t, 1024, conf.PSRPC.Compression.Threshold)
|
||||
require.Equal(t, 3, conf.PSRPC.MaxAttempts)
|
||||
}
|
||||
|
||||
func TestConfig_UnknownKeys(t *testing.T) {
|
||||
const content = `unknown: 10
|
||||
room:
|
||||
empty_timeout: 10`
|
||||
_, err := NewConfig(content, true, nil, nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGeneratedFlags(t *testing.T) {
|
||||
generatedFlags, err := GenerateCLIFlags(nil, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := &cli.Command{}
|
||||
c.Name = "test"
|
||||
c.Flags = append(c.Flags, generatedFlags...)
|
||||
|
||||
c.Set("rtc.use_ice_lite", "true")
|
||||
c.Set("redis.address", "localhost:6379")
|
||||
c.Set("prometheus.port", "9999")
|
||||
c.Set("rtc.allow_tcp_fallback", "true")
|
||||
c.Set("rtc.reconnect_on_publication_error", "true")
|
||||
c.Set("rtc.reconnect_on_subscription_error", "false")
|
||||
|
||||
conf, err := NewConfig("", true, c, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, conf.RTC.UseICELite)
|
||||
require.Equal(t, "localhost:6379", conf.Redis.Address)
|
||||
require.Equal(t, uint32(9999), conf.Prometheus.Port)
|
||||
|
||||
require.NotNil(t, conf.RTC.AllowTCPFallback)
|
||||
require.True(t, *conf.RTC.AllowTCPFallback)
|
||||
|
||||
require.NotNil(t, conf.RTC.ReconnectOnPublicationError)
|
||||
require.True(t, *conf.RTC.ReconnectOnPublicationError)
|
||||
|
||||
require.NotNil(t, conf.RTC.ReconnectOnSubscriptionError)
|
||||
require.False(t, *conf.RTC.ReconnectOnSubscriptionError)
|
||||
}
|
||||
|
||||
func TestYAMLTag(t *testing.T) {
|
||||
require.NoError(t, configtest.CheckYAMLTags(Config{}))
|
||||
}
|
||||
|
||||
func TestClampTURNTTLSeconds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in int
|
||||
want int
|
||||
changed bool
|
||||
}{
|
||||
{"negative falls back to default", -1, DefaultTURNTTLSeconds, true},
|
||||
{"large negative falls back to default", -1 << 40, DefaultTURNTTLSeconds, true},
|
||||
{"zero falls back to default", 0, DefaultTURNTTLSeconds, true},
|
||||
{"in range preserved", 600, 600, false},
|
||||
{"max preserved", TURNMaxTTLSeconds, TURNMaxTTLSeconds, false},
|
||||
{"over max clamps to max", TURNMaxTTLSeconds + 1, TURNMaxTTLSeconds, true},
|
||||
{"overflowing value clamps to max", 1<<62 + 1, TURNMaxTTLSeconds, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, changed := ClampTURNTTLSeconds(c.in)
|
||||
require.Equal(t, c.want, got)
|
||||
require.Equal(t, c.changed, changed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeTURNTTLs(t *testing.T) {
|
||||
conf, err := NewConfig("", true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
conf.TURN.TTLSeconds = -5
|
||||
conf.RTC.TURNServers = []TURNServer{
|
||||
{Host: "over", TTL: TURNMaxTTLSeconds + 100},
|
||||
{Host: "negative", TTL: -1},
|
||||
{Host: "default", TTL: 0},
|
||||
{Host: "ok", TTL: 600},
|
||||
}
|
||||
|
||||
conf.NormalizeTURNTTLs()
|
||||
|
||||
// embedded TTL <= 0 falls back to the 5m default
|
||||
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
|
||||
// external TTLs: only the upper bound is capped at load; 0/negative keep their
|
||||
// "use the default" meaning and are resolved when credentials are generated
|
||||
require.Equal(t, TURNMaxTTLSeconds, conf.RTC.TURNServers[0].TTL)
|
||||
require.Equal(t, -1, conf.RTC.TURNServers[1].TTL)
|
||||
require.Equal(t, 0, conf.RTC.TURNServers[2].TTL)
|
||||
require.Equal(t, 600, conf.RTC.TURNServers[3].TTL)
|
||||
}
|
||||
|
||||
func TestNewConfigNormalizesTURNTTL(t *testing.T) {
|
||||
const content = `turn:
|
||||
ttl_seconds: -10`
|
||||
conf, err := NewConfig(content, true, nil, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds)
|
||||
}
|
||||
|
||||
func writeSecretFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "secret")
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func TestLoadTURNSecrets(t *testing.T) {
|
||||
t.Run("empty secret file is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, "")}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
|
||||
})
|
||||
|
||||
t.Run("whitespace-only secret file is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " \n\t ")}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNSecretEmpty)
|
||||
})
|
||||
|
||||
t.Run("valid secret file is loaded and trimmed", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", SecretFile: writeSecretFile(t, " topsecret\n")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "topsecret", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
|
||||
t.Run("inline whitespace secret without static creds is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " "}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("no credentials at all is rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h"}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("static credentials are accepted", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u", Credential: "c"}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
})
|
||||
|
||||
t.Run("partial static credentials are rejected", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Username: "u"}}
|
||||
require.ErrorIs(t, conf.LoadTURNSecrets(), ErrTURNServerNoCredentials)
|
||||
})
|
||||
|
||||
t.Run("inline secret takes precedence and is trimmed", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " inline ", SecretFile: writeSecretFile(t, "fromfile")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "inline", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
|
||||
t.Run("blank inline secret falls back to secret file", func(t *testing.T) {
|
||||
conf := &Config{}
|
||||
conf.RTC.TURNServers = []TURNServer{{Host: "h", Secret: " ", SecretFile: writeSecretFile(t, "fromfile")}}
|
||||
require.NoError(t, conf.LoadTURNSecrets())
|
||||
require.Equal(t, "fromfile", conf.RTC.TURNServers[0].Secret)
|
||||
})
|
||||
}
|
||||
69
pkg/config/configtest/checkyamltag.go
Normal file
69
pkg/config/configtest/checkyamltag.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package configtest
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/multierr"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var protoMessageType = reflect.TypeOf((*proto.Message)(nil)).Elem()
|
||||
|
||||
func checkYAMLTags(t reflect.Type, seen map[reflect.Type]struct{}) error {
|
||||
if _, ok := seen[t]; ok {
|
||||
return nil
|
||||
}
|
||||
seen[t] = struct{}{}
|
||||
|
||||
switch t.Kind() {
|
||||
case reflect.Array, reflect.Map, reflect.Slice, reflect.Pointer:
|
||||
return checkYAMLTags(t.Elem(), seen)
|
||||
case reflect.Struct:
|
||||
if reflect.PointerTo(t).Implements(protoMessageType) {
|
||||
// ignore protobuf messages
|
||||
return nil
|
||||
}
|
||||
|
||||
var errs error
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
|
||||
if !field.IsExported() {
|
||||
// ignore unexported fields
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Type.Kind() == reflect.Bool {
|
||||
// ignore boolean fields
|
||||
continue
|
||||
}
|
||||
|
||||
if field.Tag.Get("config") == "allowempty" {
|
||||
// ignore configured exceptions
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.Split(field.Tag.Get("yaml"), ",")
|
||||
if parts[0] == "-" {
|
||||
// ignore unparsed fields
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(parts, "omitempty") && !slices.Contains(parts, "inline") {
|
||||
errs = multierr.Append(errs, fmt.Errorf("%s/%s.%s missing omitempty tag", t.PkgPath(), t.Name(), field.Name))
|
||||
}
|
||||
|
||||
errs = multierr.Append(errs, checkYAMLTags(field.Type, seen))
|
||||
}
|
||||
return errs
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func CheckYAMLTags(config any) error {
|
||||
return checkYAMLTags(reflect.TypeOf(config), map[reflect.Type]struct{}{})
|
||||
}
|
||||
31
pkg/metric/metric_config.go
Normal file
31
pkg/metric/metric_config.go
Normal file
@@ -0,0 +1,31 @@
|
||||
// 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 metric
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricConfig struct {
|
||||
Timestamper MetricTimestamperConfig `yaml:"timestamper_config,omitempty"`
|
||||
Collector MetricsCollectorConfig `yaml:"collector,omitempty"`
|
||||
Reporter MetricsReporterConfig `yaml:"reporter,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricConfig = MetricConfig{
|
||||
Timestamper: DefaultMetricTimestamperConfig,
|
||||
Collector: DefaultMetricsCollectorConfig,
|
||||
Reporter: DefaultMetricsReporterConfig,
|
||||
}
|
||||
)
|
||||
119
pkg/metric/metric_timestamper.go
Normal file
119
pkg/metric/metric_timestamper.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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 metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/mediatransportutil/pkg/latency"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricTimestamperConfig struct {
|
||||
OneWayDelayEstimatorMinInterval time.Duration `yaml:"one_way_delay_estimator_min_interval,omitempty"`
|
||||
OneWayDelayEstimatorMaxBatch int `yaml:"one_way_delay_estimator_max_batch,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricTimestamperConfig = MetricTimestamperConfig{
|
||||
OneWayDelayEstimatorMinInterval: 5 * time.Second,
|
||||
OneWayDelayEstimatorMaxBatch: 100,
|
||||
}
|
||||
)
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type MetricTimestamperParams struct {
|
||||
Config MetricTimestamperConfig
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricTimestamper struct {
|
||||
params MetricTimestamperParams
|
||||
lock sync.Mutex
|
||||
owdEstimator *latency.OWDEstimator
|
||||
lastOWDEstimatorRunAt time.Time
|
||||
batchesSinceLastOWDEstimatorRun int
|
||||
}
|
||||
|
||||
func NewMetricTimestamper(params MetricTimestamperParams) *MetricTimestamper {
|
||||
return &MetricTimestamper{
|
||||
params: params,
|
||||
owdEstimator: latency.NewOWDEstimator(latency.OWDEstimatorParamsDefault),
|
||||
lastOWDEstimatorRunAt: time.Now().Add(-params.Config.OneWayDelayEstimatorMinInterval),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MetricTimestamper) Process(batch *livekit.MetricsBatch) {
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// run OWD estimation periodically
|
||||
estimatedOWDNanos := m.maybeRunOWDEstimator(batch)
|
||||
|
||||
// normalize all time stamps and add estimated OWD
|
||||
// NOTE: all timestamps will be re-mapped. If the time series or event happened some time
|
||||
// in the past and the OWD estimation has changed since, those samples will get the updated
|
||||
// OWD estimation applied. So, they may have more uncertainty in addition to the uncertainty
|
||||
// of OWD estimation process.
|
||||
batch.NormalizedTimestamp = timestamppb.New(time.Unix(0, batch.TimestampMs*1e6+estimatedOWDNanos))
|
||||
|
||||
for _, ts := range batch.TimeSeries {
|
||||
for _, sample := range ts.Samples {
|
||||
sample.NormalizedTimestamp = timestamppb.New(time.Unix(0, sample.TimestampMs*1e6+estimatedOWDNanos))
|
||||
}
|
||||
}
|
||||
|
||||
for _, ev := range batch.Events {
|
||||
ev.NormalizedStartTimestamp = timestamppb.New(time.Unix(0, ev.StartTimestampMs*1e6+estimatedOWDNanos))
|
||||
|
||||
endTimestampMs := ev.GetEndTimestampMs()
|
||||
if endTimestampMs != 0 {
|
||||
ev.NormalizedEndTimestamp = timestamppb.New(time.Unix(0, endTimestampMs*1e6+estimatedOWDNanos))
|
||||
}
|
||||
}
|
||||
|
||||
m.params.Logger.Debugw("timestamped metrics batch", "batch", logger.Proto(batch))
|
||||
}
|
||||
|
||||
func (m *MetricTimestamper) maybeRunOWDEstimator(batch *livekit.MetricsBatch) int64 {
|
||||
m.lock.Lock()
|
||||
defer m.lock.Unlock()
|
||||
|
||||
if time.Since(m.lastOWDEstimatorRunAt) < m.params.Config.OneWayDelayEstimatorMinInterval &&
|
||||
m.batchesSinceLastOWDEstimatorRun < m.params.Config.OneWayDelayEstimatorMaxBatch {
|
||||
m.batchesSinceLastOWDEstimatorRun++
|
||||
return m.owdEstimator.EstimatedPropagationDelay()
|
||||
}
|
||||
|
||||
senderClockTime := batch.GetTimestampMs()
|
||||
if senderClockTime == 0 {
|
||||
m.batchesSinceLastOWDEstimatorRun++
|
||||
return m.owdEstimator.EstimatedPropagationDelay()
|
||||
}
|
||||
|
||||
m.lastOWDEstimatorRunAt = time.Now()
|
||||
m.batchesSinceLastOWDEstimatorRun = 1
|
||||
|
||||
estimatedOWDNs, _ := m.owdEstimator.Update(senderClockTime*1e6, mono.UnixNano())
|
||||
return estimatedOWDNs
|
||||
}
|
||||
225
pkg/metric/metrics_collector.go
Normal file
225
pkg/metric/metrics_collector.go
Normal file
@@ -0,0 +1,225 @@
|
||||
// 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 metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type MetricsCollectorProvider interface {
|
||||
MetricsCollectorTimeToCollectMetrics()
|
||||
MetricsCollectorBatchReady(mb *livekit.MetricsBatch)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsCollectorConfig struct {
|
||||
SamplingIntervalMs uint32 `yaml:"sampling_interval_ms,omitempty" json:"sampling_interval_ms,omitempty"`
|
||||
BatchIntervalMs uint32 `yaml:"batch_interval_ms,omitempty" json:"batch_interval_ms,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricsCollectorConfig = MetricsCollectorConfig{
|
||||
SamplingIntervalMs: 3 * 1000,
|
||||
BatchIntervalMs: 10 * 1000,
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsCollectorParams struct {
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
Config MetricsCollectorConfig
|
||||
Provider MetricsCollectorProvider
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricsCollector struct {
|
||||
params MetricsCollectorParams
|
||||
|
||||
lock sync.RWMutex
|
||||
mbb *utils.MetricsBatchBuilder
|
||||
publisherRTTMetricId map[livekit.ParticipantIdentity]int
|
||||
subscriberRTTMetricId int
|
||||
relayRTTMetricId map[livekit.ParticipantIdentity]int
|
||||
|
||||
stop core.Fuse
|
||||
}
|
||||
|
||||
func NewMetricsCollector(params MetricsCollectorParams) *MetricsCollector {
|
||||
mc := &MetricsCollector{
|
||||
params: params,
|
||||
}
|
||||
mc.reset()
|
||||
|
||||
go mc.worker()
|
||||
return mc
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) Stop() {
|
||||
if mc != nil {
|
||||
mc.stop.Break()
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddPublisherRTT(participantIdentity livekit.ParticipantIdentity, rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
metricId, ok := mc.publisherRTTMetricId[participantIdentity]
|
||||
if !ok {
|
||||
var err error
|
||||
metricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_PUBLISHER_RTT, participantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for publisher RTT", err)
|
||||
return
|
||||
}
|
||||
|
||||
mc.publisherRTTMetricId[participantIdentity] = metricId
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(metricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddSubscriberRTT(rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
if mc.subscriberRTTMetricId == utils.MetricsBatchBuilderInvalidTimeSeriesMetricId {
|
||||
var err error
|
||||
mc.subscriberRTTMetricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_SUBSCRIBER_RTT, mc.params.ParticipantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for publisher RTT", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(mc.subscriberRTTMetricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) AddRelayRTT(participantIdentity livekit.ParticipantIdentity, rtt float32) {
|
||||
mc.lock.Lock()
|
||||
defer mc.lock.Unlock()
|
||||
|
||||
metricId, ok := mc.relayRTTMetricId[participantIdentity]
|
||||
if !ok {
|
||||
var err error
|
||||
metricId, err = mc.createTimeSeriesMetric(livekit.MetricLabel_SERVER_MESH_RTT, participantIdentity)
|
||||
if err != nil {
|
||||
mc.params.Logger.Warnw("could not add time series metric for server mesh RTT", err)
|
||||
return
|
||||
}
|
||||
|
||||
mc.relayRTTMetricId[participantIdentity] = metricId
|
||||
}
|
||||
|
||||
mc.addTimeSeriesMetricSample(metricId, rtt)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) getMetricsBatchAndReset() *livekit.MetricsBatch {
|
||||
mc.lock.Lock()
|
||||
mbb := mc.mbb
|
||||
|
||||
mc.reset()
|
||||
mc.lock.Unlock()
|
||||
|
||||
if mbb.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := mono.Now()
|
||||
mbb.SetTime(now, now)
|
||||
return mbb.ToProto()
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) reset() {
|
||||
mc.mbb = utils.NewMetricsBatchBuilder()
|
||||
mc.mbb.SetRestrictedLabels(utils.MetricRestrictedLabels{
|
||||
LabelRanges: []utils.MetricLabelRange{
|
||||
{
|
||||
StartInclusive: livekit.MetricLabel_CLIENT_VIDEO_SUBSCRIBER_FREEZE_COUNT,
|
||||
EndInclusive: livekit.MetricLabel_CLIENT_VIDEO_PUBLISHER_QUALITY_LIMITATION_DURATION_OTHER,
|
||||
},
|
||||
},
|
||||
ParticipantIdentity: mc.params.ParticipantIdentity,
|
||||
})
|
||||
|
||||
mc.publisherRTTMetricId = make(map[livekit.ParticipantIdentity]int)
|
||||
mc.subscriberRTTMetricId = utils.MetricsBatchBuilderInvalidTimeSeriesMetricId
|
||||
mc.relayRTTMetricId = make(map[livekit.ParticipantIdentity]int)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) createTimeSeriesMetric(
|
||||
label livekit.MetricLabel,
|
||||
participantIdentity livekit.ParticipantIdentity,
|
||||
) (int, error) {
|
||||
return mc.mbb.AddTimeSeriesMetric(utils.TimeSeriesMetric{
|
||||
MetricLabel: label,
|
||||
ParticipantIdentity: participantIdentity,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) addTimeSeriesMetricSample(metricId int, value float32) {
|
||||
now := mono.Now()
|
||||
if err := mc.mbb.AddMetricSamplesToTimeSeriesMetric(metricId, []utils.MetricSample{
|
||||
{
|
||||
At: now,
|
||||
NormalizedAt: now,
|
||||
Value: value,
|
||||
},
|
||||
}); err != nil {
|
||||
mc.params.Logger.Warnw("could not add metric sample", err, "metricId", metricId)
|
||||
}
|
||||
}
|
||||
|
||||
func (mc *MetricsCollector) worker() {
|
||||
samplingIntervalMs := mc.params.Config.SamplingIntervalMs
|
||||
if samplingIntervalMs == 0 {
|
||||
samplingIntervalMs = DefaultMetricsCollectorConfig.SamplingIntervalMs
|
||||
}
|
||||
samplingTicker := time.NewTicker(time.Duration(samplingIntervalMs) * time.Millisecond)
|
||||
defer samplingTicker.Stop()
|
||||
|
||||
batchIntervalMs := mc.params.Config.BatchIntervalMs
|
||||
if batchIntervalMs < samplingIntervalMs {
|
||||
batchIntervalMs = samplingIntervalMs
|
||||
}
|
||||
batchTicker := time.NewTicker(time.Duration(batchIntervalMs) * time.Millisecond)
|
||||
defer batchTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-samplingTicker.C:
|
||||
mc.params.Provider.MetricsCollectorTimeToCollectMetrics()
|
||||
|
||||
case <-batchTicker.C:
|
||||
if mb := mc.getMetricsBatchAndReset(); mb != nil {
|
||||
mc.params.Provider.MetricsCollectorBatchReady(mb)
|
||||
}
|
||||
|
||||
case <-mc.stop.Watch():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
138
pkg/metric/metrics_reporter.go
Normal file
138
pkg/metric/metrics_reporter.go
Normal file
@@ -0,0 +1,138 @@
|
||||
// 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 metric
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type MetricsReporterConsumer interface {
|
||||
MetricsReporterBatchReady(mb *livekit.MetricsBatch)
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsReporterConfig struct {
|
||||
ReportingIntervalMs uint32 `yaml:"reporting_interval_ms,omitempty" json:"reporting_interval_ms,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
DefaultMetricsReporterConfig = MetricsReporterConfig{
|
||||
ReportingIntervalMs: 10 * 1000,
|
||||
}
|
||||
)
|
||||
|
||||
// --------------------------------------------------------
|
||||
|
||||
type MetricsReporterParams struct {
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
Config MetricsReporterConfig
|
||||
Consumer MetricsReporterConsumer
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MetricsReporter struct {
|
||||
params MetricsReporterParams
|
||||
|
||||
lock sync.RWMutex
|
||||
mbb *utils.MetricsBatchBuilder
|
||||
|
||||
stop core.Fuse
|
||||
}
|
||||
|
||||
func NewMetricsReporter(params MetricsReporterParams) *MetricsReporter {
|
||||
mr := &MetricsReporter{
|
||||
params: params,
|
||||
}
|
||||
mr.reset()
|
||||
|
||||
go mr.worker()
|
||||
return mr
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) Stop() {
|
||||
if mr != nil {
|
||||
mr.stop.Break()
|
||||
}
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) Merge(other *livekit.MetricsBatch) {
|
||||
if mr == nil {
|
||||
return
|
||||
}
|
||||
|
||||
mr.lock.Lock()
|
||||
defer mr.lock.Unlock()
|
||||
|
||||
mr.mbb.Merge(other)
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) getMetricsBatchAndReset() *livekit.MetricsBatch {
|
||||
mr.lock.Lock()
|
||||
mbb := mr.mbb
|
||||
|
||||
mr.reset()
|
||||
mr.lock.Unlock()
|
||||
|
||||
if mbb.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := mono.Now()
|
||||
mbb.SetTime(now, now)
|
||||
return mbb.ToProto()
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) reset() {
|
||||
mr.mbb = utils.NewMetricsBatchBuilder()
|
||||
mr.mbb.SetRestrictedLabels(utils.MetricRestrictedLabels{
|
||||
LabelRanges: []utils.MetricLabelRange{
|
||||
{
|
||||
StartInclusive: livekit.MetricLabel_CLIENT_VIDEO_SUBSCRIBER_FREEZE_COUNT,
|
||||
EndInclusive: livekit.MetricLabel_CLIENT_VIDEO_PUBLISHER_QUALITY_LIMITATION_DURATION_OTHER,
|
||||
},
|
||||
},
|
||||
ParticipantIdentity: mr.params.ParticipantIdentity,
|
||||
})
|
||||
}
|
||||
|
||||
func (mr *MetricsReporter) worker() {
|
||||
reportingIntervalMs := mr.params.Config.ReportingIntervalMs
|
||||
if reportingIntervalMs == 0 {
|
||||
reportingIntervalMs = DefaultMetricsReporterConfig.ReportingIntervalMs
|
||||
}
|
||||
reportingTicker := time.NewTicker(time.Duration(reportingIntervalMs) * time.Millisecond)
|
||||
defer reportingTicker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-reportingTicker.C:
|
||||
if mb := mr.getMetricsBatchAndReset(); mb != nil {
|
||||
mr.params.Consumer.MetricsReporterBatchReady(mb)
|
||||
}
|
||||
|
||||
case <-mr.stop.Watch():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
36
pkg/routing/errors.go
Normal file
36
pkg/routing/errors.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("could not find object")
|
||||
ErrIPNotSet = errors.New("ip address is required and not set")
|
||||
ErrHandlerNotDefined = errors.New("handler not defined")
|
||||
ErrIncorrectRTCNode = errors.New("current node isn't the RTC node for the room")
|
||||
ErrNodeNotFound = errors.New("could not locate the node")
|
||||
ErrNodeLimitReached = errors.New("reached configured limit for node")
|
||||
ErrInvalidRouterMessage = errors.New("invalid router message")
|
||||
ErrChannelClosed = errors.New("channel closed")
|
||||
ErrChannelFull = errors.New("channel is full")
|
||||
|
||||
// errors when starting signal connection
|
||||
ErrRequestChannelClosed = errors.New("request channel closed")
|
||||
ErrCouldNotMigrateParticipant = errors.New("could not migrate participant")
|
||||
ErrClientInfoNotSet = errors.New("client info not set")
|
||||
)
|
||||
334
pkg/routing/interfaces.go
Normal file
334
pkg/routing/interfaces.go
Normal file
@@ -0,0 +1,334 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/atomic"
|
||||
"go.uber.org/zap/zapcore"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/auth"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
)
|
||||
|
||||
//go:generate go run github.com/maxbrunsfeld/counterfeiter/v6 -generate
|
||||
|
||||
// MessageSink is an abstraction for writing protobuf messages and having them read by a MessageSource,
|
||||
// potentially on a different node via a transport
|
||||
//
|
||||
//counterfeiter:generate . MessageSink
|
||||
type MessageSink interface {
|
||||
WriteMessage(msg proto.Message) error
|
||||
IsClosed() bool
|
||||
Close()
|
||||
ConnectionID() livekit.ConnectionID
|
||||
}
|
||||
|
||||
// ----------
|
||||
|
||||
type NullMessageSink struct {
|
||||
connID livekit.ConnectionID
|
||||
isClosed atomic.Bool
|
||||
}
|
||||
|
||||
func NewNullMessageSink(connID livekit.ConnectionID) *NullMessageSink {
|
||||
return &NullMessageSink{
|
||||
connID: connID,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) WriteMessage(_msg proto.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) IsClosed() bool {
|
||||
return n.isClosed.Load()
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) Close() {
|
||||
n.isClosed.Store(true)
|
||||
}
|
||||
|
||||
func (n *NullMessageSink) ConnectionID() livekit.ConnectionID {
|
||||
return n.connID
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
//counterfeiter:generate . MessageSource
|
||||
type MessageSource interface {
|
||||
// ReadChan exposes a one way channel to make it easier to use with select
|
||||
ReadChan() <-chan proto.Message
|
||||
IsClosed() bool
|
||||
Close()
|
||||
ConnectionID() livekit.ConnectionID
|
||||
}
|
||||
|
||||
// ----------
|
||||
|
||||
type NullMessageSource struct {
|
||||
connID livekit.ConnectionID
|
||||
msgChan chan proto.Message
|
||||
isClosed atomic.Bool
|
||||
}
|
||||
|
||||
func NewNullMessageSource(connID livekit.ConnectionID) *NullMessageSource {
|
||||
return &NullMessageSource{
|
||||
connID: connID,
|
||||
msgChan: make(chan proto.Message),
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) ReadChan() <-chan proto.Message {
|
||||
return n.msgChan
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) IsClosed() bool {
|
||||
return n.isClosed.Load()
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) Close() {
|
||||
if !n.isClosed.Swap(true) {
|
||||
close(n.msgChan)
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NullMessageSource) ConnectionID() livekit.ConnectionID {
|
||||
return n.connID
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
// Router allows multiple nodes to coordinate the participant session
|
||||
//
|
||||
//counterfeiter:generate . Router
|
||||
type Router interface {
|
||||
MessageRouter
|
||||
|
||||
RegisterNode() error
|
||||
UnregisterNode() error
|
||||
RemoveDeadNodes() error
|
||||
|
||||
ListNodes() ([]*livekit.Node, error)
|
||||
|
||||
GetNodeForRoom(ctx context.Context, roomName livekit.RoomName) (*livekit.Node, error)
|
||||
SetNodeForRoom(ctx context.Context, roomName livekit.RoomName, nodeId livekit.NodeID) error
|
||||
ClearRoomState(ctx context.Context, roomName livekit.RoomName) error
|
||||
|
||||
GetRegion() string
|
||||
|
||||
Start() error
|
||||
Drain()
|
||||
Stop()
|
||||
}
|
||||
|
||||
type StartParticipantSignalResults struct {
|
||||
ConnectionID livekit.ConnectionID
|
||||
RequestSink MessageSink
|
||||
ResponseSource MessageSource
|
||||
NodeID livekit.NodeID
|
||||
NodeSelectionReason string
|
||||
}
|
||||
|
||||
type MessageRouter interface {
|
||||
// CreateRoom starts an rtc room
|
||||
CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error)
|
||||
|
||||
// StartParticipantSignal participant signal connection is ready to start
|
||||
StartParticipantSignal(
|
||||
ctx context.Context,
|
||||
roomName livekit.RoomName,
|
||||
pi ParticipantInit,
|
||||
) (res StartParticipantSignalResults, err error)
|
||||
}
|
||||
|
||||
func CreateRouter(
|
||||
rc redis.UniversalClient,
|
||||
node LocalNode,
|
||||
signalClient SignalClient,
|
||||
roomManagerClient RoomManagerClient,
|
||||
kps rpc.KeepalivePubSub,
|
||||
nodeStatsConfig config.NodeStatsConfig,
|
||||
) Router {
|
||||
lr := NewLocalRouter(node, signalClient, roomManagerClient, nodeStatsConfig)
|
||||
|
||||
if rc != nil {
|
||||
return NewRedisRouter(lr, rc, kps)
|
||||
}
|
||||
|
||||
// local routing and store
|
||||
logger.Infow("using single-node routing")
|
||||
return lr
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type ParticipantInit struct {
|
||||
Identity livekit.ParticipantIdentity
|
||||
Name livekit.ParticipantName
|
||||
Reconnect bool
|
||||
ReconnectReason livekit.ReconnectReason
|
||||
AutoSubscribe bool
|
||||
AutoSubscribeDataTrack *bool
|
||||
Client *livekit.ClientInfo
|
||||
Grants *auth.ClaimGrants
|
||||
TokenExpiresAt time.Time
|
||||
Region string
|
||||
AdaptiveStream bool
|
||||
ID livekit.ParticipantID
|
||||
SubscriberAllowPause *bool
|
||||
DisableICELite bool
|
||||
CreateRoom *livekit.CreateRoomRequest
|
||||
AddTrackRequests []*livekit.AddTrackRequest
|
||||
PublisherOffer *livekit.SessionDescription
|
||||
SyncState *livekit.SyncState
|
||||
UseSinglePeerConnection bool
|
||||
}
|
||||
|
||||
func (pi *ParticipantInit) MarshalLogObject(e zapcore.ObjectEncoder) error {
|
||||
if pi == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
logBoolPtr := func(prop string, val *bool) {
|
||||
if val == nil {
|
||||
e.AddString(prop, "not-set")
|
||||
} else {
|
||||
e.AddBool(prop, *val)
|
||||
}
|
||||
}
|
||||
|
||||
e.AddString("Identity", string(pi.Identity))
|
||||
logBoolPtr("Reconnect", &pi.Reconnect)
|
||||
e.AddString("ReconnectReason", pi.ReconnectReason.String())
|
||||
logBoolPtr("AutoSubscribe", &pi.AutoSubscribe)
|
||||
logBoolPtr("AutoSubscribeDataTrack", pi.AutoSubscribeDataTrack)
|
||||
e.AddObject("Client", logger.Proto(utils.ClientInfoWithoutAddress(pi.Client)))
|
||||
e.AddObject("Grants", pi.Grants)
|
||||
if !pi.TokenExpiresAt.IsZero() {
|
||||
e.AddTime("TokenExpiresAt", pi.TokenExpiresAt)
|
||||
}
|
||||
e.AddString("Region", pi.Region)
|
||||
logBoolPtr("AdaptiveStream", &pi.AdaptiveStream)
|
||||
e.AddString("ID", string(pi.ID))
|
||||
logBoolPtr("SubscriberAllowPause", pi.SubscriberAllowPause)
|
||||
logBoolPtr("DisableICELite", &pi.DisableICELite)
|
||||
e.AddObject("CreateRoom", logger.Proto(pi.CreateRoom))
|
||||
e.AddArray("AddTrackRequests", logger.ProtoSlice(pi.AddTrackRequests))
|
||||
e.AddObject("PublisherOffer", logger.Proto(pi.PublisherOffer))
|
||||
e.AddObject("SyncState", logger.Proto(pi.SyncState))
|
||||
logBoolPtr("UseSinglePeerConnection", &pi.UseSinglePeerConnection)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (pi *ParticipantInit) ToStartSession(roomName livekit.RoomName, connectionID livekit.ConnectionID) (*livekit.StartSession, error) {
|
||||
claims, err := json.Marshal(pi.Grants)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var tokenExpiresAt int64
|
||||
if !pi.TokenExpiresAt.IsZero() {
|
||||
tokenExpiresAt = pi.TokenExpiresAt.Unix()
|
||||
}
|
||||
|
||||
ss := &livekit.StartSession{
|
||||
RoomName: string(roomName),
|
||||
Identity: string(pi.Identity),
|
||||
Name: string(pi.Name),
|
||||
ConnectionId: string(connectionID),
|
||||
Reconnect: pi.Reconnect,
|
||||
ReconnectReason: pi.ReconnectReason,
|
||||
AutoSubscribe: pi.AutoSubscribe,
|
||||
Client: pi.Client,
|
||||
GrantsJson: string(claims),
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
AdaptiveStream: pi.AdaptiveStream,
|
||||
ParticipantId: string(pi.ID),
|
||||
DisableIceLite: pi.DisableICELite,
|
||||
CreateRoom: pi.CreateRoom,
|
||||
AddTrackRequests: pi.AddTrackRequests,
|
||||
PublisherOffer: pi.PublisherOffer,
|
||||
SyncState: pi.SyncState,
|
||||
UseSinglePeerConnection: pi.UseSinglePeerConnection,
|
||||
}
|
||||
if pi.AutoSubscribeDataTrack != nil {
|
||||
autoSubscribeDataTrack := *pi.AutoSubscribeDataTrack
|
||||
ss.AutoSubscribeDataTrack = &autoSubscribeDataTrack
|
||||
}
|
||||
if pi.SubscriberAllowPause != nil {
|
||||
subscriberAllowPause := *pi.SubscriberAllowPause
|
||||
ss.SubscriberAllowPause = &subscriberAllowPause
|
||||
}
|
||||
|
||||
return ss, nil
|
||||
}
|
||||
|
||||
func ParticipantInitFromStartSession(ss *livekit.StartSession, region string) (*ParticipantInit, error) {
|
||||
claims := &auth.ClaimGrants{}
|
||||
if err := json.Unmarshal([]byte(ss.GrantsJson), claims); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var tokenExpiresAt time.Time
|
||||
if ss.TokenExpiresAt > 0 {
|
||||
tokenExpiresAt = time.Unix(ss.TokenExpiresAt, 0)
|
||||
}
|
||||
|
||||
pi := &ParticipantInit{
|
||||
Identity: livekit.ParticipantIdentity(ss.Identity),
|
||||
Name: livekit.ParticipantName(ss.Name),
|
||||
Reconnect: ss.Reconnect,
|
||||
ReconnectReason: ss.ReconnectReason,
|
||||
Client: ss.Client,
|
||||
AutoSubscribe: ss.AutoSubscribe,
|
||||
Grants: claims,
|
||||
TokenExpiresAt: tokenExpiresAt,
|
||||
Region: region,
|
||||
AdaptiveStream: ss.AdaptiveStream,
|
||||
ID: livekit.ParticipantID(ss.ParticipantId),
|
||||
DisableICELite: ss.DisableIceLite,
|
||||
CreateRoom: ss.CreateRoom,
|
||||
AddTrackRequests: ss.AddTrackRequests,
|
||||
PublisherOffer: ss.PublisherOffer,
|
||||
SyncState: ss.SyncState,
|
||||
UseSinglePeerConnection: ss.UseSinglePeerConnection,
|
||||
}
|
||||
if ss.AutoSubscribeDataTrack != nil {
|
||||
autoSubscribeDataTrack := *ss.AutoSubscribeDataTrack
|
||||
pi.AutoSubscribeDataTrack = &autoSubscribeDataTrack
|
||||
}
|
||||
if ss.SubscriberAllowPause != nil {
|
||||
subscriberAllowPause := *ss.SubscriberAllowPause
|
||||
pi.SubscriberAllowPause = &subscriberAllowPause
|
||||
}
|
||||
|
||||
// TODO: clean up after 1.7 eol
|
||||
if pi.CreateRoom == nil {
|
||||
pi.CreateRoom = &livekit.CreateRoomRequest{
|
||||
Name: ss.RoomName,
|
||||
}
|
||||
}
|
||||
|
||||
return pi, nil
|
||||
}
|
||||
173
pkg/routing/localrouter.go
Normal file
173
pkg/routing/localrouter.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ Router = (*LocalRouter)(nil)
|
||||
|
||||
// a router of messages on the same node, basic implementation for local testing
|
||||
type LocalRouter struct {
|
||||
currentNode LocalNode
|
||||
signalClient SignalClient
|
||||
roomManagerClient RoomManagerClient
|
||||
nodeStatsConfig config.NodeStatsConfig
|
||||
|
||||
// channels for each participant
|
||||
requestChannels map[string]*MessageChannel
|
||||
responseChannels map[string]*MessageChannel
|
||||
isStarted atomic.Bool
|
||||
}
|
||||
|
||||
func NewLocalRouter(
|
||||
currentNode LocalNode,
|
||||
signalClient SignalClient,
|
||||
roomManagerClient RoomManagerClient,
|
||||
nodeStatsConfig config.NodeStatsConfig,
|
||||
) *LocalRouter {
|
||||
return &LocalRouter{
|
||||
currentNode: currentNode,
|
||||
signalClient: signalClient,
|
||||
roomManagerClient: roomManagerClient,
|
||||
nodeStatsConfig: nodeStatsConfig,
|
||||
requestChannels: make(map[string]*MessageChannel),
|
||||
responseChannels: make(map[string]*MessageChannel),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *LocalRouter) GetNodeForRoom(_ context.Context, _ livekit.RoomName) (*livekit.Node, error) {
|
||||
return r.currentNode.Clone(), nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) SetNodeForRoom(_ context.Context, _ livekit.RoomName, _ livekit.NodeID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) ClearRoomState(_ context.Context, _ livekit.RoomName) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) RegisterNode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) UnregisterNode() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) RemoveDeadNodes() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) GetNode(nodeID livekit.NodeID) (*livekit.Node, error) {
|
||||
if nodeID == r.currentNode.NodeID() {
|
||||
return r.currentNode.Clone(), nil
|
||||
}
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
func (r *LocalRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
return []*livekit.Node{
|
||||
r.currentNode.Clone(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error) {
|
||||
return r.CreateRoomWithNodeID(ctx, req, r.currentNode.NodeID())
|
||||
}
|
||||
|
||||
func (r *LocalRouter) CreateRoomWithNodeID(ctx context.Context, req *livekit.CreateRoomRequest, nodeID livekit.NodeID) (res *livekit.Room, err error) {
|
||||
return r.roomManagerClient.CreateRoom(ctx, nodeID, req)
|
||||
}
|
||||
|
||||
func (r *LocalRouter) StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit) (res StartParticipantSignalResults, err error) {
|
||||
return r.StartParticipantSignalWithNodeID(ctx, roomName, pi, r.currentNode.NodeID())
|
||||
}
|
||||
|
||||
func (r *LocalRouter) StartParticipantSignalWithNodeID(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit, nodeID livekit.NodeID) (res StartParticipantSignalResults, err error) {
|
||||
connectionID, reqSink, resSource, err := r.signalClient.StartParticipantSignal(ctx, roomName, pi, nodeID)
|
||||
if err != nil {
|
||||
logger.Errorw(
|
||||
"could not handle new participant", err,
|
||||
"room", roomName,
|
||||
"participant", pi.Identity,
|
||||
"connID", connectionID,
|
||||
)
|
||||
} else {
|
||||
return StartParticipantSignalResults{
|
||||
ConnectionID: connectionID,
|
||||
RequestSink: reqSink,
|
||||
ResponseSource: resSource,
|
||||
NodeID: nodeID,
|
||||
}, nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Start() error {
|
||||
if r.isStarted.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
go r.statsWorker()
|
||||
// go r.memStatsWorker()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Drain() {
|
||||
r.currentNode.SetState(livekit.NodeState_SHUTTING_DOWN)
|
||||
}
|
||||
|
||||
func (r *LocalRouter) Stop() {}
|
||||
|
||||
func (r *LocalRouter) GetRegion() string {
|
||||
return r.currentNode.Region()
|
||||
}
|
||||
|
||||
func (r *LocalRouter) statsWorker() {
|
||||
for {
|
||||
if !r.isStarted.Load() {
|
||||
return
|
||||
}
|
||||
<-time.After(r.nodeStatsConfig.StatsUpdateInterval)
|
||||
r.currentNode.UpdateNodeStats()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
func (r *LocalRouter) memStatsWorker() {
|
||||
ticker := time.NewTicker(time.Second * 30)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
<-ticker.C
|
||||
|
||||
var m runtime.MemStats
|
||||
runtime.ReadMemStats(&m)
|
||||
logger.Infow("memstats",
|
||||
"mallocs", m.Mallocs, "frees", m.Frees, "m-f", m.Mallocs-m.Frees,
|
||||
"hinuse", m.HeapInuse, "halloc", m.HeapAlloc, "frag", m.HeapInuse-m.HeapAlloc,
|
||||
)
|
||||
}
|
||||
}
|
||||
*/
|
||||
94
pkg/routing/messagechannel.go
Normal file
94
pkg/routing/messagechannel.go
Normal file
@@ -0,0 +1,94 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
const DefaultMessageChannelSize = 200
|
||||
|
||||
type MessageChannel struct {
|
||||
connectionID livekit.ConnectionID
|
||||
msgChan chan proto.Message
|
||||
onClose func()
|
||||
isClosed bool
|
||||
lock sync.RWMutex
|
||||
}
|
||||
|
||||
func NewDefaultMessageChannel(connectionID livekit.ConnectionID) *MessageChannel {
|
||||
return NewMessageChannel(connectionID, DefaultMessageChannelSize)
|
||||
}
|
||||
|
||||
func NewMessageChannel(connectionID livekit.ConnectionID, size int) *MessageChannel {
|
||||
return &MessageChannel{
|
||||
connectionID: connectionID,
|
||||
// allow some buffer to avoid blocked writes
|
||||
msgChan: make(chan proto.Message, size),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) OnClose(f func()) {
|
||||
m.onClose = f
|
||||
}
|
||||
|
||||
func (m *MessageChannel) IsClosed() bool {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
return m.isClosed
|
||||
}
|
||||
|
||||
func (m *MessageChannel) WriteMessage(msg proto.Message) error {
|
||||
m.lock.RLock()
|
||||
defer m.lock.RUnlock()
|
||||
if m.isClosed {
|
||||
return ErrChannelClosed
|
||||
}
|
||||
|
||||
select {
|
||||
case m.msgChan <- msg:
|
||||
// published
|
||||
return nil
|
||||
default:
|
||||
// channel is full
|
||||
return ErrChannelFull
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) ReadChan() <-chan proto.Message {
|
||||
return m.msgChan
|
||||
}
|
||||
|
||||
func (m *MessageChannel) Close() {
|
||||
m.lock.Lock()
|
||||
if m.isClosed {
|
||||
m.lock.Unlock()
|
||||
return
|
||||
}
|
||||
m.isClosed = true
|
||||
close(m.msgChan)
|
||||
m.lock.Unlock()
|
||||
|
||||
if m.onClose != nil {
|
||||
m.onClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MessageChannel) ConnectionID() livekit.ConnectionID {
|
||||
return m.connectionID
|
||||
}
|
||||
50
pkg/routing/messagechannel_test.go
Normal file
50
pkg/routing/messagechannel_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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 routing_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
)
|
||||
|
||||
func TestMessageChannel_WriteMessageClosed(t *testing.T) {
|
||||
// ensure it doesn't panic when written to after closing
|
||||
m := routing.NewMessageChannel(livekit.ConnectionID("test"), routing.DefaultMessageChannelSize)
|
||||
go func() {
|
||||
for msg := range m.ReadChan() {
|
||||
if msg == nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range 100 {
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
}
|
||||
}()
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
m.Close()
|
||||
_ = m.WriteMessage(&livekit.SignalRequest{})
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
158
pkg/routing/node.go
Normal file
158
pkg/routing/node.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
type LocalNode interface {
|
||||
Clone() *livekit.Node
|
||||
SetNodeID(nodeID livekit.NodeID)
|
||||
NodeID() livekit.NodeID
|
||||
NodeType() livekit.NodeType
|
||||
NodeIP() string
|
||||
Region() string
|
||||
SetState(state livekit.NodeState)
|
||||
SetStats(stats *livekit.NodeStats)
|
||||
UpdateNodeStats() bool
|
||||
SecondsSinceNodeStatsUpdate() float64
|
||||
}
|
||||
|
||||
type LocalNodeImpl struct {
|
||||
lock sync.RWMutex
|
||||
node *livekit.Node
|
||||
|
||||
nodeStats *NodeStats
|
||||
}
|
||||
|
||||
func NewLocalNode(conf *config.Config) (*LocalNodeImpl, error) {
|
||||
nodeID := guid.New(utils.NodePrefix)
|
||||
if conf != nil && conf.RTC.NodeIP.IsEmpty() {
|
||||
return nil, ErrIPNotSet
|
||||
}
|
||||
nowUnix := time.Now().Unix()
|
||||
l := &LocalNodeImpl{
|
||||
node: &livekit.Node{
|
||||
Id: nodeID,
|
||||
NumCpus: uint32(runtime.NumCPU()),
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
StartedAt: nowUnix,
|
||||
UpdatedAt: nowUnix,
|
||||
},
|
||||
},
|
||||
}
|
||||
var nsc *config.NodeStatsConfig
|
||||
if conf != nil {
|
||||
l.node.Ip = conf.RTC.NodeIP.PrimaryIP()
|
||||
l.node.Region = conf.Region
|
||||
|
||||
nsc = &conf.NodeStats
|
||||
}
|
||||
l.nodeStats = NewNodeStats(nsc, nowUnix)
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func NewLocalNodeFromNodeProto(node *livekit.Node) (*LocalNodeImpl, error) {
|
||||
return &LocalNodeImpl{node: utils.CloneProto(node)}, nil
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) Clone() *livekit.Node {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return utils.CloneProto(l.node)
|
||||
}
|
||||
|
||||
// for testing only
|
||||
func (l *LocalNodeImpl) SetNodeID(nodeID livekit.NodeID) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.Id = string(nodeID)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeID() livekit.NodeID {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return livekit.NodeID(l.node.Id)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeType() livekit.NodeType {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Type
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) NodeIP() string {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Ip
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) Region() string {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return l.node.Region
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) SetState(state livekit.NodeState) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.State = state
|
||||
}
|
||||
|
||||
// for testing only
|
||||
func (l *LocalNodeImpl) SetStats(stats *livekit.NodeStats) {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
l.node.Stats = utils.CloneProto(stats)
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) UpdateNodeStats() bool {
|
||||
l.lock.Lock()
|
||||
defer l.lock.Unlock()
|
||||
|
||||
stats, err := l.nodeStats.UpdateAndGetNodeStats()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
l.node.Stats = stats
|
||||
return true
|
||||
}
|
||||
|
||||
func (l *LocalNodeImpl) SecondsSinceNodeStatsUpdate() float64 {
|
||||
l.lock.RLock()
|
||||
defer l.lock.RUnlock()
|
||||
|
||||
return time.Since(time.Unix(l.node.Stats.UpdatedAt, 0)).Seconds()
|
||||
}
|
||||
91
pkg/routing/nodestats.go
Normal file
91
pkg/routing/nodestats.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
)
|
||||
|
||||
type NodeStats struct {
|
||||
config config.NodeStatsConfig
|
||||
startedAt int64
|
||||
|
||||
lock sync.RWMutex
|
||||
statsHistory []*livekit.NodeStats
|
||||
statsHistoryWritePtr int
|
||||
}
|
||||
|
||||
func NewNodeStats(conf *config.NodeStatsConfig, startedAt int64) *NodeStats {
|
||||
n := &NodeStats{
|
||||
startedAt: startedAt,
|
||||
}
|
||||
n.UpdateConfig(conf)
|
||||
return n
|
||||
}
|
||||
|
||||
func (n *NodeStats) UpdateConfig(conf *config.NodeStatsConfig) {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
|
||||
if conf == nil {
|
||||
conf = &config.DefaultNodeStatsConfig
|
||||
}
|
||||
n.config = *conf
|
||||
|
||||
// set up stats history to be able to measure different rate windows
|
||||
var maxInterval time.Duration
|
||||
for _, rateInterval := range conf.StatsRateMeasurementIntervals {
|
||||
if rateInterval > maxInterval {
|
||||
maxInterval = rateInterval
|
||||
}
|
||||
}
|
||||
n.statsHistory = make([]*livekit.NodeStats, (maxInterval+conf.StatsUpdateInterval-1)/conf.StatsUpdateInterval)
|
||||
n.statsHistoryWritePtr = 0
|
||||
}
|
||||
|
||||
func (n *NodeStats) UpdateAndGetNodeStats() (*livekit.NodeStats, error) {
|
||||
n.lock.Lock()
|
||||
defer n.lock.Unlock()
|
||||
|
||||
stats, err := prometheus.GetNodeStats(
|
||||
n.startedAt,
|
||||
append(n.statsHistory[n.statsHistoryWritePtr:], n.statsHistory[0:n.statsHistoryWritePtr]...),
|
||||
n.config.StatsRateMeasurementIntervals,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Errorw("could not update node stats", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.statsHistory[n.statsHistoryWritePtr] = stats
|
||||
n.statsHistoryWritePtr = (n.statsHistoryWritePtr + 1) % len(n.statsHistory)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (n *NodeStats) GetLatestNodeStats() *livekit.NodeStats {
|
||||
n.lock.RLock()
|
||||
defer n.lock.RUnlock()
|
||||
|
||||
latestPtr := (n.statsHistoryWritePtr - 1 + len(n.statsHistory)) % len(n.statsHistory)
|
||||
return utils.CloneProto(n.statsHistory[latestPtr])
|
||||
}
|
||||
252
pkg/routing/redisrouter.go
Normal file
252
pkg/routing/redisrouter.go
Normal file
@@ -0,0 +1,252 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"runtime/pprof"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
// hash of node_id => Node proto
|
||||
NodesKey = "nodes"
|
||||
|
||||
// hash of room_name => node_id
|
||||
NodeRoomKey = "room_node_map"
|
||||
)
|
||||
|
||||
var _ Router = (*RedisRouter)(nil)
|
||||
|
||||
// RedisRouter uses Redis pub/sub to route signaling messages across different nodes
|
||||
// It relies on the RTC node to be the primary driver of the participant connection.
|
||||
// Because
|
||||
type RedisRouter struct {
|
||||
*LocalRouter
|
||||
|
||||
rc redis.UniversalClient
|
||||
kps rpc.KeepalivePubSub
|
||||
ctx context.Context
|
||||
isStarted atomic.Bool
|
||||
|
||||
cancel func()
|
||||
}
|
||||
|
||||
func NewRedisRouter(lr *LocalRouter, rc redis.UniversalClient, kps rpc.KeepalivePubSub) *RedisRouter {
|
||||
rr := &RedisRouter{
|
||||
LocalRouter: lr,
|
||||
rc: rc,
|
||||
kps: kps,
|
||||
}
|
||||
rr.ctx, rr.cancel = context.WithCancel(context.Background())
|
||||
return rr
|
||||
}
|
||||
|
||||
func (r *RedisRouter) RegisterNode() error {
|
||||
data, err := proto.Marshal(r.currentNode.Clone())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := r.rc.HSet(r.ctx, NodesKey, string(r.currentNode.NodeID()), data).Err(); err != nil {
|
||||
return errors.Wrap(err, "could not register node")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) UnregisterNode() error {
|
||||
// could be called after Stop(), so we'd want to use an unrelated context
|
||||
return r.rc.HDel(context.Background(), NodesKey, string(r.currentNode.NodeID())).Err()
|
||||
}
|
||||
|
||||
func (r *RedisRouter) RemoveDeadNodes() error {
|
||||
nodes, err := r.ListNodes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if !selector.IsAvailable(n) {
|
||||
if err := r.rc.HDel(context.Background(), NodesKey, n.Id).Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetNodeForRoom finds the node where the room is hosted at
|
||||
func (r *RedisRouter) GetNodeForRoom(_ context.Context, roomName livekit.RoomName) (*livekit.Node, error) {
|
||||
nodeID, err := r.rc.HGet(r.ctx, NodeRoomKey, string(roomName)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, errors.Wrap(err, "could not get node for room")
|
||||
}
|
||||
|
||||
return r.GetNode(livekit.NodeID(nodeID))
|
||||
}
|
||||
|
||||
func (r *RedisRouter) SetNodeForRoom(_ context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error {
|
||||
return r.rc.HSet(r.ctx, NodeRoomKey, string(roomName), string(nodeID)).Err()
|
||||
}
|
||||
|
||||
func (r *RedisRouter) ClearRoomState(_ context.Context, roomName livekit.RoomName) error {
|
||||
if err := r.rc.HDel(context.Background(), NodeRoomKey, string(roomName)).Err(); err != nil {
|
||||
return errors.Wrap(err, "could not clear room state")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) GetNode(nodeID livekit.NodeID) (*livekit.Node, error) {
|
||||
data, err := r.rc.HGet(r.ctx, NodesKey, string(nodeID)).Result()
|
||||
if err == redis.Nil {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := livekit.Node{}
|
||||
if err = proto.Unmarshal([]byte(data), &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
items, err := r.rc.HVals(r.ctx, NodesKey).Result()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not list nodes")
|
||||
}
|
||||
nodes := make([]*livekit.Node, 0, len(items))
|
||||
for _, item := range items {
|
||||
n := livekit.Node{}
|
||||
if err := proto.Unmarshal([]byte(item), &n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, &n)
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (r *RedisRouter) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (res *livekit.Room, err error) {
|
||||
rtcNode, err := r.GetNodeForRoom(ctx, livekit.RoomName(req.Name))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.CreateRoomWithNodeID(ctx, req, livekit.NodeID(rtcNode.Id))
|
||||
}
|
||||
|
||||
// StartParticipantSignal signal connection sets up paths to the RTC node, and starts to route messages to that message queue
|
||||
func (r *RedisRouter) StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit) (res StartParticipantSignalResults, err error) {
|
||||
rtcNode, err := r.GetNodeForRoom(ctx, roomName)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
return r.StartParticipantSignalWithNodeID(ctx, roomName, pi, livekit.NodeID(rtcNode.Id))
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Start() error {
|
||||
if r.isStarted.Swap(true) {
|
||||
return nil
|
||||
}
|
||||
|
||||
workerStarted := make(chan error)
|
||||
go r.statsWorker()
|
||||
go r.keepaliveWorker(workerStarted)
|
||||
|
||||
// wait until worker is running
|
||||
return <-workerStarted
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Drain() {
|
||||
r.currentNode.SetState(livekit.NodeState_SHUTTING_DOWN)
|
||||
if err := r.RegisterNode(); err != nil {
|
||||
logger.Errorw("failed to mark as draining", err, "nodeID", r.currentNode.NodeID())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RedisRouter) Stop() {
|
||||
if !r.isStarted.Swap(false) {
|
||||
return
|
||||
}
|
||||
logger.Debugw("stopping RedisRouter")
|
||||
_ = r.UnregisterNode()
|
||||
r.cancel()
|
||||
}
|
||||
|
||||
// update node stats and cleanup
|
||||
func (r *RedisRouter) statsWorker() {
|
||||
goroutineDumped := false
|
||||
for r.ctx.Err() == nil {
|
||||
// update periodically
|
||||
select {
|
||||
case <-time.After(r.nodeStatsConfig.StatsUpdateInterval):
|
||||
r.kps.PublishPing(r.ctx, r.currentNode.NodeID(), &rpc.KeepalivePing{Timestamp: time.Now().Unix()})
|
||||
|
||||
delaySeconds := r.currentNode.SecondsSinceNodeStatsUpdate()
|
||||
if delaySeconds > r.nodeStatsConfig.StatsMaxDelay.Seconds() {
|
||||
if !goroutineDumped {
|
||||
goroutineDumped = true
|
||||
buf := bytes.NewBuffer(nil)
|
||||
_ = pprof.Lookup("goroutine").WriteTo(buf, 2)
|
||||
logger.Errorw("status update delayed, possible deadlock", nil,
|
||||
"delay", delaySeconds,
|
||||
"goroutines", buf.String())
|
||||
}
|
||||
} else {
|
||||
goroutineDumped = false
|
||||
}
|
||||
case <-r.ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RedisRouter) keepaliveWorker(startedChan chan error) {
|
||||
pings, err := r.kps.SubscribePing(r.ctx, r.currentNode.NodeID())
|
||||
if err != nil {
|
||||
startedChan <- err
|
||||
return
|
||||
}
|
||||
close(startedChan)
|
||||
|
||||
for ping := range pings.Channel() {
|
||||
if time.Since(time.Unix(ping.Timestamp, 0)) > r.nodeStatsConfig.StatsUpdateInterval {
|
||||
logger.Infow("keep alive too old, skipping", "timestamp", ping.Timestamp)
|
||||
continue
|
||||
}
|
||||
|
||||
if !r.currentNode.UpdateNodeStats() {
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: check stats against config.Limit values
|
||||
if err := r.RegisterNode(); err != nil {
|
||||
logger.Errorw("could not update node", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
63
pkg/routing/roommanager.go
Normal file
63
pkg/routing/roommanager.go
Normal file
@@ -0,0 +1,63 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/psrpc"
|
||||
"github.com/livekit/psrpc/pkg/middleware"
|
||||
)
|
||||
|
||||
//counterfeiter:generate . RoomManagerClient
|
||||
type RoomManagerClient interface {
|
||||
rpc.TypedRoomManagerClient
|
||||
}
|
||||
|
||||
type roomManagerClient struct {
|
||||
config config.RoomConfig
|
||||
client rpc.TypedRoomManagerClient
|
||||
}
|
||||
|
||||
func NewRoomManagerClient(clientParams rpc.ClientParams, config config.RoomConfig) (RoomManagerClient, error) {
|
||||
c, err := rpc.NewTypedRoomManagerClient(
|
||||
clientParams.Bus,
|
||||
psrpc.WithClientChannelSize(clientParams.BufferSize),
|
||||
middleware.WithClientMetrics(clientParams.Observer),
|
||||
rpc.WithClientLogger(clientParams.Logger),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &roomManagerClient{
|
||||
config: config,
|
||||
client: c,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *roomManagerClient) CreateRoom(ctx context.Context, nodeID livekit.NodeID, req *livekit.CreateRoomRequest, opts ...psrpc.RequestOption) (*livekit.Room, error) {
|
||||
return c.client.CreateRoom(ctx, nodeID, req, append(opts, psrpc.WithRequestInterceptors(middleware.NewRPCRetryInterceptor(middleware.RetryOptions{
|
||||
MaxAttempts: c.config.CreateRoomAttempts,
|
||||
Timeout: c.config.CreateRoomTimeout,
|
||||
})))...)
|
||||
}
|
||||
|
||||
func (c *roomManagerClient) Close() {
|
||||
c.client.Close()
|
||||
}
|
||||
265
pkg/routing/routingfakes/fake_message_sink.go
Normal file
265
pkg/routing/routingfakes/fake_message_sink.go
Normal file
@@ -0,0 +1,265 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FakeMessageSink struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
ConnectionIDStub func() livekit.ConnectionID
|
||||
connectionIDMutex sync.RWMutex
|
||||
connectionIDArgsForCall []struct {
|
||||
}
|
||||
connectionIDReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
connectionIDReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
IsClosedStub func() bool
|
||||
isClosedMutex sync.RWMutex
|
||||
isClosedArgsForCall []struct {
|
||||
}
|
||||
isClosedReturns struct {
|
||||
result1 bool
|
||||
}
|
||||
isClosedReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
WriteMessageStub func(proto.Message) error
|
||||
writeMessageMutex sync.RWMutex
|
||||
writeMessageArgsForCall []struct {
|
||||
arg1 proto.Message
|
||||
}
|
||||
writeMessageReturns struct {
|
||||
result1 error
|
||||
}
|
||||
writeMessageReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionID() livekit.ConnectionID {
|
||||
fake.connectionIDMutex.Lock()
|
||||
ret, specificReturn := fake.connectionIDReturnsOnCall[len(fake.connectionIDArgsForCall)]
|
||||
fake.connectionIDArgsForCall = append(fake.connectionIDArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ConnectionIDStub
|
||||
fakeReturns := fake.connectionIDReturns
|
||||
fake.recordInvocation("ConnectionID", []interface{}{})
|
||||
fake.connectionIDMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDCallCount() int {
|
||||
fake.connectionIDMutex.RLock()
|
||||
defer fake.connectionIDMutex.RUnlock()
|
||||
return len(fake.connectionIDArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDCalls(stub func() livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDReturns(result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
fake.connectionIDReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) ConnectionIDReturnsOnCall(i int, result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
if fake.connectionIDReturnsOnCall == nil {
|
||||
fake.connectionIDReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
})
|
||||
}
|
||||
fake.connectionIDReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosed() bool {
|
||||
fake.isClosedMutex.Lock()
|
||||
ret, specificReturn := fake.isClosedReturnsOnCall[len(fake.isClosedArgsForCall)]
|
||||
fake.isClosedArgsForCall = append(fake.isClosedArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.IsClosedStub
|
||||
fakeReturns := fake.isClosedReturns
|
||||
fake.recordInvocation("IsClosed", []interface{}{})
|
||||
fake.isClosedMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedCallCount() int {
|
||||
fake.isClosedMutex.RLock()
|
||||
defer fake.isClosedMutex.RUnlock()
|
||||
return len(fake.isClosedArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedCalls(stub func() bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedReturns(result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
fake.isClosedReturns = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) IsClosedReturnsOnCall(i int, result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
if fake.isClosedReturnsOnCall == nil {
|
||||
fake.isClosedReturnsOnCall = make(map[int]struct {
|
||||
result1 bool
|
||||
})
|
||||
}
|
||||
fake.isClosedReturnsOnCall[i] = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessage(arg1 proto.Message) error {
|
||||
fake.writeMessageMutex.Lock()
|
||||
ret, specificReturn := fake.writeMessageReturnsOnCall[len(fake.writeMessageArgsForCall)]
|
||||
fake.writeMessageArgsForCall = append(fake.writeMessageArgsForCall, struct {
|
||||
arg1 proto.Message
|
||||
}{arg1})
|
||||
stub := fake.WriteMessageStub
|
||||
fakeReturns := fake.writeMessageReturns
|
||||
fake.recordInvocation("WriteMessage", []interface{}{arg1})
|
||||
fake.writeMessageMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageCallCount() int {
|
||||
fake.writeMessageMutex.RLock()
|
||||
defer fake.writeMessageMutex.RUnlock()
|
||||
return len(fake.writeMessageArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageCalls(stub func(proto.Message) error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageArgsForCall(i int) proto.Message {
|
||||
fake.writeMessageMutex.RLock()
|
||||
defer fake.writeMessageMutex.RUnlock()
|
||||
argsForCall := fake.writeMessageArgsForCall[i]
|
||||
return argsForCall.arg1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageReturns(result1 error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = nil
|
||||
fake.writeMessageReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) WriteMessageReturnsOnCall(i int, result1 error) {
|
||||
fake.writeMessageMutex.Lock()
|
||||
defer fake.writeMessageMutex.Unlock()
|
||||
fake.WriteMessageStub = nil
|
||||
if fake.writeMessageReturnsOnCall == nil {
|
||||
fake.writeMessageReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.writeMessageReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSink) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.MessageSink = new(FakeMessageSink)
|
||||
256
pkg/routing/routingfakes/fake_message_source.go
Normal file
256
pkg/routing/routingfakes/fake_message_source.go
Normal file
@@ -0,0 +1,256 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type FakeMessageSource struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
ConnectionIDStub func() livekit.ConnectionID
|
||||
connectionIDMutex sync.RWMutex
|
||||
connectionIDArgsForCall []struct {
|
||||
}
|
||||
connectionIDReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
connectionIDReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
}
|
||||
IsClosedStub func() bool
|
||||
isClosedMutex sync.RWMutex
|
||||
isClosedArgsForCall []struct {
|
||||
}
|
||||
isClosedReturns struct {
|
||||
result1 bool
|
||||
}
|
||||
isClosedReturnsOnCall map[int]struct {
|
||||
result1 bool
|
||||
}
|
||||
ReadChanStub func() <-chan proto.Message
|
||||
readChanMutex sync.RWMutex
|
||||
readChanArgsForCall []struct {
|
||||
}
|
||||
readChanReturns struct {
|
||||
result1 <-chan proto.Message
|
||||
}
|
||||
readChanReturnsOnCall map[int]struct {
|
||||
result1 <-chan proto.Message
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionID() livekit.ConnectionID {
|
||||
fake.connectionIDMutex.Lock()
|
||||
ret, specificReturn := fake.connectionIDReturnsOnCall[len(fake.connectionIDArgsForCall)]
|
||||
fake.connectionIDArgsForCall = append(fake.connectionIDArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ConnectionIDStub
|
||||
fakeReturns := fake.connectionIDReturns
|
||||
fake.recordInvocation("ConnectionID", []interface{}{})
|
||||
fake.connectionIDMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDCallCount() int {
|
||||
fake.connectionIDMutex.RLock()
|
||||
defer fake.connectionIDMutex.RUnlock()
|
||||
return len(fake.connectionIDArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDCalls(stub func() livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDReturns(result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
fake.connectionIDReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ConnectionIDReturnsOnCall(i int, result1 livekit.ConnectionID) {
|
||||
fake.connectionIDMutex.Lock()
|
||||
defer fake.connectionIDMutex.Unlock()
|
||||
fake.ConnectionIDStub = nil
|
||||
if fake.connectionIDReturnsOnCall == nil {
|
||||
fake.connectionIDReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
})
|
||||
}
|
||||
fake.connectionIDReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosed() bool {
|
||||
fake.isClosedMutex.Lock()
|
||||
ret, specificReturn := fake.isClosedReturnsOnCall[len(fake.isClosedArgsForCall)]
|
||||
fake.isClosedArgsForCall = append(fake.isClosedArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.IsClosedStub
|
||||
fakeReturns := fake.isClosedReturns
|
||||
fake.recordInvocation("IsClosed", []interface{}{})
|
||||
fake.isClosedMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedCallCount() int {
|
||||
fake.isClosedMutex.RLock()
|
||||
defer fake.isClosedMutex.RUnlock()
|
||||
return len(fake.isClosedArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedCalls(stub func() bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedReturns(result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
fake.isClosedReturns = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) IsClosedReturnsOnCall(i int, result1 bool) {
|
||||
fake.isClosedMutex.Lock()
|
||||
defer fake.isClosedMutex.Unlock()
|
||||
fake.IsClosedStub = nil
|
||||
if fake.isClosedReturnsOnCall == nil {
|
||||
fake.isClosedReturnsOnCall = make(map[int]struct {
|
||||
result1 bool
|
||||
})
|
||||
}
|
||||
fake.isClosedReturnsOnCall[i] = struct {
|
||||
result1 bool
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChan() <-chan proto.Message {
|
||||
fake.readChanMutex.Lock()
|
||||
ret, specificReturn := fake.readChanReturnsOnCall[len(fake.readChanArgsForCall)]
|
||||
fake.readChanArgsForCall = append(fake.readChanArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ReadChanStub
|
||||
fakeReturns := fake.readChanReturns
|
||||
fake.recordInvocation("ReadChan", []interface{}{})
|
||||
fake.readChanMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanCallCount() int {
|
||||
fake.readChanMutex.RLock()
|
||||
defer fake.readChanMutex.RUnlock()
|
||||
return len(fake.readChanArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanCalls(stub func() <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanReturns(result1 <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = nil
|
||||
fake.readChanReturns = struct {
|
||||
result1 <-chan proto.Message
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) ReadChanReturnsOnCall(i int, result1 <-chan proto.Message) {
|
||||
fake.readChanMutex.Lock()
|
||||
defer fake.readChanMutex.Unlock()
|
||||
fake.ReadChanStub = nil
|
||||
if fake.readChanReturnsOnCall == nil {
|
||||
fake.readChanReturnsOnCall = make(map[int]struct {
|
||||
result1 <-chan proto.Message
|
||||
})
|
||||
}
|
||||
fake.readChanReturnsOnCall[i] = struct {
|
||||
result1 <-chan proto.Message
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeMessageSource) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.MessageSource = new(FakeMessageSource)
|
||||
151
pkg/routing/routingfakes/fake_room_manager_client.go
Normal file
151
pkg/routing/routingfakes/fake_room_manager_client.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/psrpc"
|
||||
)
|
||||
|
||||
type FakeRoomManagerClient struct {
|
||||
CloseStub func()
|
||||
closeMutex sync.RWMutex
|
||||
closeArgsForCall []struct {
|
||||
}
|
||||
CreateRoomStub func(context.Context, livekit.NodeID, *livekit.CreateRoomRequest, ...psrpc.RequestOption) (*livekit.Room, error)
|
||||
createRoomMutex sync.RWMutex
|
||||
createRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.NodeID
|
||||
arg3 *livekit.CreateRoomRequest
|
||||
arg4 []psrpc.RequestOption
|
||||
}
|
||||
createRoomReturns struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
createRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) Close() {
|
||||
fake.closeMutex.Lock()
|
||||
fake.closeArgsForCall = append(fake.closeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.CloseStub
|
||||
fake.recordInvocation("Close", []interface{}{})
|
||||
fake.closeMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.CloseStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CloseCallCount() int {
|
||||
fake.closeMutex.RLock()
|
||||
defer fake.closeMutex.RUnlock()
|
||||
return len(fake.closeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CloseCalls(stub func()) {
|
||||
fake.closeMutex.Lock()
|
||||
defer fake.closeMutex.Unlock()
|
||||
fake.CloseStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoom(arg1 context.Context, arg2 livekit.NodeID, arg3 *livekit.CreateRoomRequest, arg4 ...psrpc.RequestOption) (*livekit.Room, error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)]
|
||||
fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.NodeID
|
||||
arg3 *livekit.CreateRoomRequest
|
||||
arg4 []psrpc.RequestOption
|
||||
}{arg1, arg2, arg3, arg4})
|
||||
stub := fake.CreateRoomStub
|
||||
fakeReturns := fake.createRoomReturns
|
||||
fake.recordInvocation("CreateRoom", []interface{}{arg1, arg2, arg3, arg4})
|
||||
fake.createRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3, arg4...)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomCallCount() int {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
return len(fake.createRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomCalls(stub func(context.Context, livekit.NodeID, *livekit.CreateRoomRequest, ...psrpc.RequestOption) (*livekit.Room, error)) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomArgsForCall(i int) (context.Context, livekit.NodeID, *livekit.CreateRoomRequest, []psrpc.RequestOption) {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
argsForCall := fake.createRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomReturns(result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
fake.createRoomReturns = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
if fake.createRoomReturnsOnCall == nil {
|
||||
fake.createRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.createRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeRoomManagerClient) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.RoomManagerClient = new(FakeRoomManagerClient)
|
||||
867
pkg/routing/routingfakes/fake_router.go
Normal file
867
pkg/routing/routingfakes/fake_router.go
Normal file
@@ -0,0 +1,867 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type FakeRouter struct {
|
||||
ClearRoomStateStub func(context.Context, livekit.RoomName) error
|
||||
clearRoomStateMutex sync.RWMutex
|
||||
clearRoomStateArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}
|
||||
clearRoomStateReturns struct {
|
||||
result1 error
|
||||
}
|
||||
clearRoomStateReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
CreateRoomStub func(context.Context, *livekit.CreateRoomRequest) (*livekit.Room, error)
|
||||
createRoomMutex sync.RWMutex
|
||||
createRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 *livekit.CreateRoomRequest
|
||||
}
|
||||
createRoomReturns struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
createRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}
|
||||
DrainStub func()
|
||||
drainMutex sync.RWMutex
|
||||
drainArgsForCall []struct {
|
||||
}
|
||||
GetNodeForRoomStub func(context.Context, livekit.RoomName) (*livekit.Node, error)
|
||||
getNodeForRoomMutex sync.RWMutex
|
||||
getNodeForRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}
|
||||
getNodeForRoomReturns struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}
|
||||
getNodeForRoomReturnsOnCall map[int]struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}
|
||||
GetRegionStub func() string
|
||||
getRegionMutex sync.RWMutex
|
||||
getRegionArgsForCall []struct {
|
||||
}
|
||||
getRegionReturns struct {
|
||||
result1 string
|
||||
}
|
||||
getRegionReturnsOnCall map[int]struct {
|
||||
result1 string
|
||||
}
|
||||
ListNodesStub func() ([]*livekit.Node, error)
|
||||
listNodesMutex sync.RWMutex
|
||||
listNodesArgsForCall []struct {
|
||||
}
|
||||
listNodesReturns struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}
|
||||
listNodesReturnsOnCall map[int]struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}
|
||||
RegisterNodeStub func() error
|
||||
registerNodeMutex sync.RWMutex
|
||||
registerNodeArgsForCall []struct {
|
||||
}
|
||||
registerNodeReturns struct {
|
||||
result1 error
|
||||
}
|
||||
registerNodeReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
RemoveDeadNodesStub func() error
|
||||
removeDeadNodesMutex sync.RWMutex
|
||||
removeDeadNodesArgsForCall []struct {
|
||||
}
|
||||
removeDeadNodesReturns struct {
|
||||
result1 error
|
||||
}
|
||||
removeDeadNodesReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
SetNodeForRoomStub func(context.Context, livekit.RoomName, livekit.NodeID) error
|
||||
setNodeForRoomMutex sync.RWMutex
|
||||
setNodeForRoomArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 livekit.NodeID
|
||||
}
|
||||
setNodeForRoomReturns struct {
|
||||
result1 error
|
||||
}
|
||||
setNodeForRoomReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
StartStub func() error
|
||||
startMutex sync.RWMutex
|
||||
startArgsForCall []struct {
|
||||
}
|
||||
startReturns struct {
|
||||
result1 error
|
||||
}
|
||||
startReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
StartParticipantSignalStub func(context.Context, livekit.RoomName, routing.ParticipantInit) (routing.StartParticipantSignalResults, error)
|
||||
startParticipantSignalMutex sync.RWMutex
|
||||
startParticipantSignalArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
}
|
||||
startParticipantSignalReturns struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}
|
||||
startParticipantSignalReturnsOnCall map[int]struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}
|
||||
StopStub func()
|
||||
stopMutex sync.RWMutex
|
||||
stopArgsForCall []struct {
|
||||
}
|
||||
UnregisterNodeStub func() error
|
||||
unregisterNodeMutex sync.RWMutex
|
||||
unregisterNodeArgsForCall []struct {
|
||||
}
|
||||
unregisterNodeReturns struct {
|
||||
result1 error
|
||||
}
|
||||
unregisterNodeReturnsOnCall map[int]struct {
|
||||
result1 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomState(arg1 context.Context, arg2 livekit.RoomName) error {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
ret, specificReturn := fake.clearRoomStateReturnsOnCall[len(fake.clearRoomStateArgsForCall)]
|
||||
fake.clearRoomStateArgsForCall = append(fake.clearRoomStateArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}{arg1, arg2})
|
||||
stub := fake.ClearRoomStateStub
|
||||
fakeReturns := fake.clearRoomStateReturns
|
||||
fake.recordInvocation("ClearRoomState", []interface{}{arg1, arg2})
|
||||
fake.clearRoomStateMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateCallCount() int {
|
||||
fake.clearRoomStateMutex.RLock()
|
||||
defer fake.clearRoomStateMutex.RUnlock()
|
||||
return len(fake.clearRoomStateArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateCalls(stub func(context.Context, livekit.RoomName) error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateArgsForCall(i int) (context.Context, livekit.RoomName) {
|
||||
fake.clearRoomStateMutex.RLock()
|
||||
defer fake.clearRoomStateMutex.RUnlock()
|
||||
argsForCall := fake.clearRoomStateArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateReturns(result1 error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = nil
|
||||
fake.clearRoomStateReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ClearRoomStateReturnsOnCall(i int, result1 error) {
|
||||
fake.clearRoomStateMutex.Lock()
|
||||
defer fake.clearRoomStateMutex.Unlock()
|
||||
fake.ClearRoomStateStub = nil
|
||||
if fake.clearRoomStateReturnsOnCall == nil {
|
||||
fake.clearRoomStateReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.clearRoomStateReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest) (*livekit.Room, error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)]
|
||||
fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 *livekit.CreateRoomRequest
|
||||
}{arg1, arg2})
|
||||
stub := fake.CreateRoomStub
|
||||
fakeReturns := fake.createRoomReturns
|
||||
fake.recordInvocation("CreateRoom", []interface{}{arg1, arg2})
|
||||
fake.createRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomCallCount() int {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
return len(fake.createRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest) (*livekit.Room, error)) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomArgsForCall(i int) (context.Context, *livekit.CreateRoomRequest) {
|
||||
fake.createRoomMutex.RLock()
|
||||
defer fake.createRoomMutex.RUnlock()
|
||||
argsForCall := fake.createRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomReturns(result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
fake.createRoomReturns = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 error) {
|
||||
fake.createRoomMutex.Lock()
|
||||
defer fake.createRoomMutex.Unlock()
|
||||
fake.CreateRoomStub = nil
|
||||
if fake.createRoomReturnsOnCall == nil {
|
||||
fake.createRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.createRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Room
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Drain() {
|
||||
fake.drainMutex.Lock()
|
||||
fake.drainArgsForCall = append(fake.drainArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.DrainStub
|
||||
fake.recordInvocation("Drain", []interface{}{})
|
||||
fake.drainMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.DrainStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) DrainCallCount() int {
|
||||
fake.drainMutex.RLock()
|
||||
defer fake.drainMutex.RUnlock()
|
||||
return len(fake.drainArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) DrainCalls(stub func()) {
|
||||
fake.drainMutex.Lock()
|
||||
defer fake.drainMutex.Unlock()
|
||||
fake.DrainStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoom(arg1 context.Context, arg2 livekit.RoomName) (*livekit.Node, error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
ret, specificReturn := fake.getNodeForRoomReturnsOnCall[len(fake.getNodeForRoomArgsForCall)]
|
||||
fake.getNodeForRoomArgsForCall = append(fake.getNodeForRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
}{arg1, arg2})
|
||||
stub := fake.GetNodeForRoomStub
|
||||
fakeReturns := fake.getNodeForRoomReturns
|
||||
fake.recordInvocation("GetNodeForRoom", []interface{}{arg1, arg2})
|
||||
fake.getNodeForRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomCallCount() int {
|
||||
fake.getNodeForRoomMutex.RLock()
|
||||
defer fake.getNodeForRoomMutex.RUnlock()
|
||||
return len(fake.getNodeForRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomCalls(stub func(context.Context, livekit.RoomName) (*livekit.Node, error)) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomArgsForCall(i int) (context.Context, livekit.RoomName) {
|
||||
fake.getNodeForRoomMutex.RLock()
|
||||
defer fake.getNodeForRoomMutex.RUnlock()
|
||||
argsForCall := fake.getNodeForRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomReturns(result1 *livekit.Node, result2 error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = nil
|
||||
fake.getNodeForRoomReturns = struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetNodeForRoomReturnsOnCall(i int, result1 *livekit.Node, result2 error) {
|
||||
fake.getNodeForRoomMutex.Lock()
|
||||
defer fake.getNodeForRoomMutex.Unlock()
|
||||
fake.GetNodeForRoomStub = nil
|
||||
if fake.getNodeForRoomReturnsOnCall == nil {
|
||||
fake.getNodeForRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.getNodeForRoomReturnsOnCall[i] = struct {
|
||||
result1 *livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegion() string {
|
||||
fake.getRegionMutex.Lock()
|
||||
ret, specificReturn := fake.getRegionReturnsOnCall[len(fake.getRegionArgsForCall)]
|
||||
fake.getRegionArgsForCall = append(fake.getRegionArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.GetRegionStub
|
||||
fakeReturns := fake.getRegionReturns
|
||||
fake.recordInvocation("GetRegion", []interface{}{})
|
||||
fake.getRegionMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionCallCount() int {
|
||||
fake.getRegionMutex.RLock()
|
||||
defer fake.getRegionMutex.RUnlock()
|
||||
return len(fake.getRegionArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionCalls(stub func() string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionReturns(result1 string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = nil
|
||||
fake.getRegionReturns = struct {
|
||||
result1 string
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) GetRegionReturnsOnCall(i int, result1 string) {
|
||||
fake.getRegionMutex.Lock()
|
||||
defer fake.getRegionMutex.Unlock()
|
||||
fake.GetRegionStub = nil
|
||||
if fake.getRegionReturnsOnCall == nil {
|
||||
fake.getRegionReturnsOnCall = make(map[int]struct {
|
||||
result1 string
|
||||
})
|
||||
}
|
||||
fake.getRegionReturnsOnCall[i] = struct {
|
||||
result1 string
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodes() ([]*livekit.Node, error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
ret, specificReturn := fake.listNodesReturnsOnCall[len(fake.listNodesArgsForCall)]
|
||||
fake.listNodesArgsForCall = append(fake.listNodesArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ListNodesStub
|
||||
fakeReturns := fake.listNodesReturns
|
||||
fake.recordInvocation("ListNodes", []interface{}{})
|
||||
fake.listNodesMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesCallCount() int {
|
||||
fake.listNodesMutex.RLock()
|
||||
defer fake.listNodesMutex.RUnlock()
|
||||
return len(fake.listNodesArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesCalls(stub func() ([]*livekit.Node, error)) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesReturns(result1 []*livekit.Node, result2 error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = nil
|
||||
fake.listNodesReturns = struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) ListNodesReturnsOnCall(i int, result1 []*livekit.Node, result2 error) {
|
||||
fake.listNodesMutex.Lock()
|
||||
defer fake.listNodesMutex.Unlock()
|
||||
fake.ListNodesStub = nil
|
||||
if fake.listNodesReturnsOnCall == nil {
|
||||
fake.listNodesReturnsOnCall = make(map[int]struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.listNodesReturnsOnCall[i] = struct {
|
||||
result1 []*livekit.Node
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNode() error {
|
||||
fake.registerNodeMutex.Lock()
|
||||
ret, specificReturn := fake.registerNodeReturnsOnCall[len(fake.registerNodeArgsForCall)]
|
||||
fake.registerNodeArgsForCall = append(fake.registerNodeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.RegisterNodeStub
|
||||
fakeReturns := fake.registerNodeReturns
|
||||
fake.recordInvocation("RegisterNode", []interface{}{})
|
||||
fake.registerNodeMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeCallCount() int {
|
||||
fake.registerNodeMutex.RLock()
|
||||
defer fake.registerNodeMutex.RUnlock()
|
||||
return len(fake.registerNodeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeCalls(stub func() error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeReturns(result1 error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = nil
|
||||
fake.registerNodeReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RegisterNodeReturnsOnCall(i int, result1 error) {
|
||||
fake.registerNodeMutex.Lock()
|
||||
defer fake.registerNodeMutex.Unlock()
|
||||
fake.RegisterNodeStub = nil
|
||||
if fake.registerNodeReturnsOnCall == nil {
|
||||
fake.registerNodeReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.registerNodeReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodes() error {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
ret, specificReturn := fake.removeDeadNodesReturnsOnCall[len(fake.removeDeadNodesArgsForCall)]
|
||||
fake.removeDeadNodesArgsForCall = append(fake.removeDeadNodesArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.RemoveDeadNodesStub
|
||||
fakeReturns := fake.removeDeadNodesReturns
|
||||
fake.recordInvocation("RemoveDeadNodes", []interface{}{})
|
||||
fake.removeDeadNodesMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesCallCount() int {
|
||||
fake.removeDeadNodesMutex.RLock()
|
||||
defer fake.removeDeadNodesMutex.RUnlock()
|
||||
return len(fake.removeDeadNodesArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesCalls(stub func() error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesReturns(result1 error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = nil
|
||||
fake.removeDeadNodesReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) RemoveDeadNodesReturnsOnCall(i int, result1 error) {
|
||||
fake.removeDeadNodesMutex.Lock()
|
||||
defer fake.removeDeadNodesMutex.Unlock()
|
||||
fake.RemoveDeadNodesStub = nil
|
||||
if fake.removeDeadNodesReturnsOnCall == nil {
|
||||
fake.removeDeadNodesReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.removeDeadNodesReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoom(arg1 context.Context, arg2 livekit.RoomName, arg3 livekit.NodeID) error {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
ret, specificReturn := fake.setNodeForRoomReturnsOnCall[len(fake.setNodeForRoomArgsForCall)]
|
||||
fake.setNodeForRoomArgsForCall = append(fake.setNodeForRoomArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 livekit.NodeID
|
||||
}{arg1, arg2, arg3})
|
||||
stub := fake.SetNodeForRoomStub
|
||||
fakeReturns := fake.setNodeForRoomReturns
|
||||
fake.recordInvocation("SetNodeForRoom", []interface{}{arg1, arg2, arg3})
|
||||
fake.setNodeForRoomMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomCallCount() int {
|
||||
fake.setNodeForRoomMutex.RLock()
|
||||
defer fake.setNodeForRoomMutex.RUnlock()
|
||||
return len(fake.setNodeForRoomArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomCalls(stub func(context.Context, livekit.RoomName, livekit.NodeID) error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomArgsForCall(i int) (context.Context, livekit.RoomName, livekit.NodeID) {
|
||||
fake.setNodeForRoomMutex.RLock()
|
||||
defer fake.setNodeForRoomMutex.RUnlock()
|
||||
argsForCall := fake.setNodeForRoomArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomReturns(result1 error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = nil
|
||||
fake.setNodeForRoomReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) SetNodeForRoomReturnsOnCall(i int, result1 error) {
|
||||
fake.setNodeForRoomMutex.Lock()
|
||||
defer fake.setNodeForRoomMutex.Unlock()
|
||||
fake.SetNodeForRoomStub = nil
|
||||
if fake.setNodeForRoomReturnsOnCall == nil {
|
||||
fake.setNodeForRoomReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.setNodeForRoomReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Start() error {
|
||||
fake.startMutex.Lock()
|
||||
ret, specificReturn := fake.startReturnsOnCall[len(fake.startArgsForCall)]
|
||||
fake.startArgsForCall = append(fake.startArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.StartStub
|
||||
fakeReturns := fake.startReturns
|
||||
fake.recordInvocation("Start", []interface{}{})
|
||||
fake.startMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartCallCount() int {
|
||||
fake.startMutex.RLock()
|
||||
defer fake.startMutex.RUnlock()
|
||||
return len(fake.startArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartCalls(stub func() error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartReturns(result1 error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = nil
|
||||
fake.startReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartReturnsOnCall(i int, result1 error) {
|
||||
fake.startMutex.Lock()
|
||||
defer fake.startMutex.Unlock()
|
||||
fake.StartStub = nil
|
||||
if fake.startReturnsOnCall == nil {
|
||||
fake.startReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.startReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignal(arg1 context.Context, arg2 livekit.RoomName, arg3 routing.ParticipantInit) (routing.StartParticipantSignalResults, error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
ret, specificReturn := fake.startParticipantSignalReturnsOnCall[len(fake.startParticipantSignalArgsForCall)]
|
||||
fake.startParticipantSignalArgsForCall = append(fake.startParticipantSignalArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
}{arg1, arg2, arg3})
|
||||
stub := fake.StartParticipantSignalStub
|
||||
fakeReturns := fake.startParticipantSignalReturns
|
||||
fake.recordInvocation("StartParticipantSignal", []interface{}{arg1, arg2, arg3})
|
||||
fake.startParticipantSignalMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalCallCount() int {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
return len(fake.startParticipantSignalArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalCalls(stub func(context.Context, livekit.RoomName, routing.ParticipantInit) (routing.StartParticipantSignalResults, error)) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalArgsForCall(i int) (context.Context, livekit.RoomName, routing.ParticipantInit) {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
argsForCall := fake.startParticipantSignalArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalReturns(result1 routing.StartParticipantSignalResults, result2 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
fake.startParticipantSignalReturns = struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StartParticipantSignalReturnsOnCall(i int, result1 routing.StartParticipantSignalResults, result2 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
if fake.startParticipantSignalReturnsOnCall == nil {
|
||||
fake.startParticipantSignalReturnsOnCall = make(map[int]struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
})
|
||||
}
|
||||
fake.startParticipantSignalReturnsOnCall[i] = struct {
|
||||
result1 routing.StartParticipantSignalResults
|
||||
result2 error
|
||||
}{result1, result2}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Stop() {
|
||||
fake.stopMutex.Lock()
|
||||
fake.stopArgsForCall = append(fake.stopArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.StopStub
|
||||
fake.recordInvocation("Stop", []interface{}{})
|
||||
fake.stopMutex.Unlock()
|
||||
if stub != nil {
|
||||
fake.StopStub()
|
||||
}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StopCallCount() int {
|
||||
fake.stopMutex.RLock()
|
||||
defer fake.stopMutex.RUnlock()
|
||||
return len(fake.stopArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) StopCalls(stub func()) {
|
||||
fake.stopMutex.Lock()
|
||||
defer fake.stopMutex.Unlock()
|
||||
fake.StopStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNode() error {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
ret, specificReturn := fake.unregisterNodeReturnsOnCall[len(fake.unregisterNodeArgsForCall)]
|
||||
fake.unregisterNodeArgsForCall = append(fake.unregisterNodeArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.UnregisterNodeStub
|
||||
fakeReturns := fake.unregisterNodeReturns
|
||||
fake.recordInvocation("UnregisterNode", []interface{}{})
|
||||
fake.unregisterNodeMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeCallCount() int {
|
||||
fake.unregisterNodeMutex.RLock()
|
||||
defer fake.unregisterNodeMutex.RUnlock()
|
||||
return len(fake.unregisterNodeArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeCalls(stub func() error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeReturns(result1 error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = nil
|
||||
fake.unregisterNodeReturns = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) UnregisterNodeReturnsOnCall(i int, result1 error) {
|
||||
fake.unregisterNodeMutex.Lock()
|
||||
defer fake.unregisterNodeMutex.Unlock()
|
||||
fake.UnregisterNodeStub = nil
|
||||
if fake.unregisterNodeReturnsOnCall == nil {
|
||||
fake.unregisterNodeReturnsOnCall = make(map[int]struct {
|
||||
result1 error
|
||||
})
|
||||
}
|
||||
fake.unregisterNodeReturnsOnCall[i] = struct {
|
||||
result1 error
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeRouter) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.Router = new(FakeRouter)
|
||||
195
pkg/routing/routingfakes/fake_signal_client.go
Normal file
195
pkg/routing/routingfakes/fake_signal_client.go
Normal file
@@ -0,0 +1,195 @@
|
||||
// Code generated by counterfeiter. DO NOT EDIT.
|
||||
package routingfakes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type FakeSignalClient struct {
|
||||
ActiveCountStub func() int
|
||||
activeCountMutex sync.RWMutex
|
||||
activeCountArgsForCall []struct {
|
||||
}
|
||||
activeCountReturns struct {
|
||||
result1 int
|
||||
}
|
||||
activeCountReturnsOnCall map[int]struct {
|
||||
result1 int
|
||||
}
|
||||
StartParticipantSignalStub func(context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error)
|
||||
startParticipantSignalMutex sync.RWMutex
|
||||
startParticipantSignalArgsForCall []struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
arg4 livekit.NodeID
|
||||
}
|
||||
startParticipantSignalReturns struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}
|
||||
startParticipantSignalReturnsOnCall map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}
|
||||
invocations map[string][][]interface{}
|
||||
invocationsMutex sync.RWMutex
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCount() int {
|
||||
fake.activeCountMutex.Lock()
|
||||
ret, specificReturn := fake.activeCountReturnsOnCall[len(fake.activeCountArgsForCall)]
|
||||
fake.activeCountArgsForCall = append(fake.activeCountArgsForCall, struct {
|
||||
}{})
|
||||
stub := fake.ActiveCountStub
|
||||
fakeReturns := fake.activeCountReturns
|
||||
fake.recordInvocation("ActiveCount", []interface{}{})
|
||||
fake.activeCountMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub()
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1
|
||||
}
|
||||
return fakeReturns.result1
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountCallCount() int {
|
||||
fake.activeCountMutex.RLock()
|
||||
defer fake.activeCountMutex.RUnlock()
|
||||
return len(fake.activeCountArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountCalls(stub func() int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountReturns(result1 int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = nil
|
||||
fake.activeCountReturns = struct {
|
||||
result1 int
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) ActiveCountReturnsOnCall(i int, result1 int) {
|
||||
fake.activeCountMutex.Lock()
|
||||
defer fake.activeCountMutex.Unlock()
|
||||
fake.ActiveCountStub = nil
|
||||
if fake.activeCountReturnsOnCall == nil {
|
||||
fake.activeCountReturnsOnCall = make(map[int]struct {
|
||||
result1 int
|
||||
})
|
||||
}
|
||||
fake.activeCountReturnsOnCall[i] = struct {
|
||||
result1 int
|
||||
}{result1}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignal(arg1 context.Context, arg2 livekit.RoomName, arg3 routing.ParticipantInit, arg4 livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
ret, specificReturn := fake.startParticipantSignalReturnsOnCall[len(fake.startParticipantSignalArgsForCall)]
|
||||
fake.startParticipantSignalArgsForCall = append(fake.startParticipantSignalArgsForCall, struct {
|
||||
arg1 context.Context
|
||||
arg2 livekit.RoomName
|
||||
arg3 routing.ParticipantInit
|
||||
arg4 livekit.NodeID
|
||||
}{arg1, arg2, arg3, arg4})
|
||||
stub := fake.StartParticipantSignalStub
|
||||
fakeReturns := fake.startParticipantSignalReturns
|
||||
fake.recordInvocation("StartParticipantSignal", []interface{}{arg1, arg2, arg3, arg4})
|
||||
fake.startParticipantSignalMutex.Unlock()
|
||||
if stub != nil {
|
||||
return stub(arg1, arg2, arg3, arg4)
|
||||
}
|
||||
if specificReturn {
|
||||
return ret.result1, ret.result2, ret.result3, ret.result4
|
||||
}
|
||||
return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalCallCount() int {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
return len(fake.startParticipantSignalArgsForCall)
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalCalls(stub func(context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) (livekit.ConnectionID, routing.MessageSink, routing.MessageSource, error)) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = stub
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalArgsForCall(i int) (context.Context, livekit.RoomName, routing.ParticipantInit, livekit.NodeID) {
|
||||
fake.startParticipantSignalMutex.RLock()
|
||||
defer fake.startParticipantSignalMutex.RUnlock()
|
||||
argsForCall := fake.startParticipantSignalArgsForCall[i]
|
||||
return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalReturns(result1 livekit.ConnectionID, result2 routing.MessageSink, result3 routing.MessageSource, result4 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
fake.startParticipantSignalReturns = struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}{result1, result2, result3, result4}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) StartParticipantSignalReturnsOnCall(i int, result1 livekit.ConnectionID, result2 routing.MessageSink, result3 routing.MessageSource, result4 error) {
|
||||
fake.startParticipantSignalMutex.Lock()
|
||||
defer fake.startParticipantSignalMutex.Unlock()
|
||||
fake.StartParticipantSignalStub = nil
|
||||
if fake.startParticipantSignalReturnsOnCall == nil {
|
||||
fake.startParticipantSignalReturnsOnCall = make(map[int]struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
})
|
||||
}
|
||||
fake.startParticipantSignalReturnsOnCall[i] = struct {
|
||||
result1 livekit.ConnectionID
|
||||
result2 routing.MessageSink
|
||||
result3 routing.MessageSource
|
||||
result4 error
|
||||
}{result1, result2, result3, result4}
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) Invocations() map[string][][]interface{} {
|
||||
fake.invocationsMutex.RLock()
|
||||
defer fake.invocationsMutex.RUnlock()
|
||||
copiedInvocations := map[string][][]interface{}{}
|
||||
for key, value := range fake.invocations {
|
||||
copiedInvocations[key] = value
|
||||
}
|
||||
return copiedInvocations
|
||||
}
|
||||
|
||||
func (fake *FakeSignalClient) recordInvocation(key string, args []interface{}) {
|
||||
fake.invocationsMutex.Lock()
|
||||
defer fake.invocationsMutex.Unlock()
|
||||
if fake.invocations == nil {
|
||||
fake.invocations = map[string][][]interface{}{}
|
||||
}
|
||||
if fake.invocations[key] == nil {
|
||||
fake.invocations[key] = [][]interface{}{}
|
||||
}
|
||||
fake.invocations[key] = append(fake.invocations[key], args)
|
||||
}
|
||||
|
||||
var _ routing.SignalClient = new(FakeSignalClient)
|
||||
34
pkg/routing/selector/any.go
Normal file
34
pkg/routing/selector/any.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// AnySelector selects any available node with no limitations
|
||||
type AnySelector struct {
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *AnySelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes = GetAvailableNodes(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
287
pkg/routing/selector/any_test.go
Normal file
287
pkg/routing/selector/any_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func createTestNode(id string, cpuLoad float32, numRooms int32, numClients int32, state livekit.NodeState) *livekit.Node {
|
||||
return &livekit.Node{
|
||||
Id: id,
|
||||
State: state,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 1, // Recent update to be considered available
|
||||
CpuLoad: cpuLoad,
|
||||
NumRooms: numRooms,
|
||||
NumClients: numClients,
|
||||
NumCpus: 4,
|
||||
LoadAvgLast1Min: cpuLoad * 4, // Simulate system load
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_TwoChoice(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sortBy string
|
||||
algorithm string
|
||||
nodes []*livekit.Node
|
||||
wantErr string
|
||||
expected string
|
||||
notExpected string
|
||||
}{
|
||||
{
|
||||
name: "successful selection with cpuload sorting",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.8, 5, 10, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.3, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 3, 8, livekit.NodeState_SERVING),
|
||||
createTestNode("node4", 0.9, 6, 12, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node4", // Node with highest load should not be selected
|
||||
},
|
||||
{
|
||||
name: "successful selection with rooms sorting",
|
||||
sortBy: "rooms",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 8, 15, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.4, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 12, 20, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node3", // Node with highest room count should not be selected
|
||||
},
|
||||
{
|
||||
name: "successful selection with clients sorting",
|
||||
sortBy: "clients",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 25, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.4, 2, 5, livekit.NodeState_SERVING),
|
||||
createTestNode("node3", 0.6, 4, 30, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "", // Not deterministic selection, so no specific expected node
|
||||
notExpected: "node3", // Node with highest clients should not be selected
|
||||
},
|
||||
{
|
||||
name: "empty nodes list",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "no available nodes - all unavailable",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
{
|
||||
Id: "node1",
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 10, // Too old
|
||||
CpuLoad: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "no available nodes - not serving",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
{
|
||||
Id: "node1",
|
||||
State: livekit.NodeState_SHUTTING_DOWN,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 1,
|
||||
CpuLoad: 0.3,
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: "could not find any available nodes",
|
||||
},
|
||||
{
|
||||
name: "single available node",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 10, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "node1", // Should select the only available node
|
||||
notExpected: "", // No other nodes to compare against
|
||||
},
|
||||
{
|
||||
name: "two available nodes",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "twochoice",
|
||||
nodes: []*livekit.Node{
|
||||
createTestNode("node1", 0.8, 5, 15, livekit.NodeState_SERVING),
|
||||
createTestNode("node2", 0.3, 2, 5, livekit.NodeState_SERVING),
|
||||
},
|
||||
wantErr: "",
|
||||
expected: "node2", // Should select the node with lower load
|
||||
notExpected: "node1", // Should not select the node with higher load
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := &AnySelector{
|
||||
SortBy: tt.sortBy,
|
||||
Algorithm: tt.algorithm,
|
||||
}
|
||||
|
||||
node, err := selector.SelectNode(tt.nodes)
|
||||
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
require.Nil(t, node)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
require.NotEmpty(t, node.Id)
|
||||
|
||||
// Verify the selected node is one of the available nodes
|
||||
found := false
|
||||
availableNodes := GetAvailableNodes(tt.nodes)
|
||||
for _, availableNode := range availableNodes {
|
||||
if availableNode.Id == node.Id {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "Selected node should be one of the available nodes")
|
||||
|
||||
if tt.expected != "" {
|
||||
require.Equal(t, tt.expected, node.Id, "Selected node should match expected")
|
||||
}
|
||||
if tt.notExpected != "" {
|
||||
require.NotEqual(t, tt.notExpected, node.Id, "Selected node should not match not expected")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_TwoChoice_Probabilistic_Behavior(t *testing.T) {
|
||||
// Test that two-choice algorithm favors nodes with lower metrics
|
||||
// This test runs multiple iterations to increase confidence in the probabilistic behavior
|
||||
selector := &AnySelector{
|
||||
SortBy: "cpuload",
|
||||
Algorithm: "twochoice",
|
||||
}
|
||||
|
||||
// Create nodes where node2 has significantly lower CPU load
|
||||
nodes := []*livekit.Node{
|
||||
createTestNode("node1", 0.95, 10, 20, livekit.NodeState_SERVING), // Very high load
|
||||
createTestNode("node2", 0.1, 1, 2, livekit.NodeState_SERVING), // Low load
|
||||
createTestNode("node3", 0.5, 9, 18, livekit.NodeState_SERVING), // Medium load
|
||||
createTestNode("node4", 0.85, 8, 16, livekit.NodeState_SERVING), // High load
|
||||
}
|
||||
|
||||
// Run multiple selections and count how often the low-load node is selected
|
||||
iterations := 1000
|
||||
lowLoadSelections := 0
|
||||
higestLoadSelections := 0
|
||||
|
||||
for range iterations {
|
||||
node, err := selector.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
|
||||
if node.Id == "node2" {
|
||||
lowLoadSelections++
|
||||
}
|
||||
if node.Id == "node1" {
|
||||
higestLoadSelections++
|
||||
}
|
||||
}
|
||||
|
||||
// The low-load node should be selected more often than pure random (25%)
|
||||
// Due to the two-choice algorithm favoring the better node
|
||||
selectionRate := float64(lowLoadSelections) / float64(iterations)
|
||||
require.Greater(t, selectionRate, 0.4, "Two-choice algorithm should favor the low-load node more than random selection")
|
||||
require.Equal(t, higestLoadSelections, 0, "Two-choice algorithm should never favor the highest load node")
|
||||
}
|
||||
|
||||
func TestAnySelector_SelectNode_InvalidParameters(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
createTestNode("node1", 0.5, 3, 10, livekit.NodeState_SERVING),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sortBy string
|
||||
algorithm string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "empty sortBy",
|
||||
sortBy: "",
|
||||
algorithm: "twochoice",
|
||||
wantErr: "sort by option cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "empty algorithm",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "",
|
||||
wantErr: "node selector algorithm option cannot be blank",
|
||||
},
|
||||
{
|
||||
name: "unknown sortBy",
|
||||
sortBy: "invalid",
|
||||
algorithm: "twochoice",
|
||||
wantErr: "unknown sort by option",
|
||||
},
|
||||
{
|
||||
name: "unknown algorithm",
|
||||
sortBy: "cpuload",
|
||||
algorithm: "invalid",
|
||||
wantErr: "unknown node selector algorithm option",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
selector := &AnySelector{
|
||||
SortBy: tt.sortBy,
|
||||
Algorithm: tt.algorithm,
|
||||
}
|
||||
|
||||
node, err := selector.SelectNode(nodes)
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), tt.wantErr)
|
||||
require.Nil(t, node)
|
||||
})
|
||||
}
|
||||
}
|
||||
47
pkg/routing/selector/cpuload.go
Normal file
47
pkg/routing/selector/cpuload.go
Normal file
@@ -0,0 +1,47 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// CPULoadSelector eliminates nodes that have CPU usage higher than CPULoadLimit
|
||||
// then selects a node from nodes that are not overloaded
|
||||
type CPULoadSelector struct {
|
||||
CPULoadLimit float32
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *CPULoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) {
|
||||
nodes, err := FilterNodesByCriteria(nodes, s.CPULoadLimit, func(node *livekit.Node) float32 {
|
||||
return node.Stats.CpuLoad
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (s *CPULoadSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
51
pkg/routing/selector/cpuload_test.go
Normal file
51
pkg/routing/selector/cpuload_test.go
Normal file
@@ -0,0 +1,51 @@
|
||||
// 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 selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func TestCPULoadSelector_SelectNode(t *testing.T) {
|
||||
sel := selector.CPULoadSelector{CPULoadLimit: 0.8, SortBy: "random", Algorithm: "lowest"}
|
||||
|
||||
var nodes []*livekit.Node
|
||||
_, err := sel.SelectNode(nodes)
|
||||
require.Error(t, err, "should error no available nodes")
|
||||
|
||||
// Select a node with high load when no nodes with low load are available
|
||||
nodes = []*livekit.Node{nodeLoadHigh}
|
||||
if _, err := sel.SelectNode(nodes); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Select a node with low load when available
|
||||
nodes = []*livekit.Node{nodeLoadLow, nodeLoadHigh}
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node")
|
||||
}
|
||||
}
|
||||
}
|
||||
27
pkg/routing/selector/errors.go
Normal file
27
pkg/routing/selector/errors.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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 selector
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNoAvailableNodes = errors.New("could not find any available nodes")
|
||||
ErrCurrentRegionNotSet = errors.New("current region cannot be blank")
|
||||
ErrCurrentRegionUnknownLatLon = errors.New("unknown lat and lon for the current region")
|
||||
ErrSortByNotSet = errors.New("sort by option cannot be blank")
|
||||
ErrAlgorithmNotSet = errors.New("node selector algorithm option cannot be blank")
|
||||
ErrSortByUnknown = errors.New("unknown sort by option")
|
||||
ErrAlgorithmUnknown = errors.New("unknown node selector algorithm option")
|
||||
)
|
||||
66
pkg/routing/selector/interfaces.go
Normal file
66
pkg/routing/selector/interfaces.go
Normal file
@@ -0,0 +1,66 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
var ErrUnsupportedSelector = errors.New("unsupported node selector")
|
||||
|
||||
// NodeSelector selects an appropriate node to run the current session
|
||||
type NodeSelector interface {
|
||||
SelectNode(nodes []*livekit.Node) (*livekit.Node, error)
|
||||
}
|
||||
|
||||
func CreateNodeSelector(conf *config.Config) (NodeSelector, error) {
|
||||
kind := conf.NodeSelector.Kind
|
||||
if kind == "" {
|
||||
kind = "any"
|
||||
}
|
||||
switch kind {
|
||||
case "any":
|
||||
return &AnySelector{conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm}, nil
|
||||
case "cpuload":
|
||||
return &CPULoadSelector{
|
||||
CPULoadLimit: conf.NodeSelector.CPULoadLimit,
|
||||
SortBy: conf.NodeSelector.SortBy,
|
||||
Algorithm: conf.NodeSelector.Algorithm,
|
||||
}, nil
|
||||
case "sysload":
|
||||
return &SystemLoadSelector{
|
||||
SysloadLimit: conf.NodeSelector.SysloadLimit,
|
||||
SortBy: conf.NodeSelector.SortBy,
|
||||
Algorithm: conf.NodeSelector.Algorithm,
|
||||
}, nil
|
||||
case "regionaware":
|
||||
s, err := NewRegionAwareSelector(conf.Region, conf.NodeSelector.Regions, conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.SysloadLimit = conf.NodeSelector.SysloadLimit
|
||||
return s, nil
|
||||
case "random":
|
||||
logger.Warnw("random node selector is deprecated, please switch to \"any\" or another selector", nil)
|
||||
return &AnySelector{conf.NodeSelector.SortBy, conf.NodeSelector.Algorithm}, nil
|
||||
default:
|
||||
return nil, ErrUnsupportedSelector
|
||||
}
|
||||
}
|
||||
21
pkg/routing/selector/nodesfilter.go
Normal file
21
pkg/routing/selector/nodesfilter.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package selector
|
||||
|
||||
import "github.com/livekit/protocol/livekit"
|
||||
|
||||
func FilterNodesByCriteria(nodes []*livekit.Node, criteriaThreshold float32, calculateCriteriaFunc func(*livekit.Node) float32) ([]*livekit.Node, error) {
|
||||
nodes = GetAvailableNodes(nodes)
|
||||
if len(nodes) == 0 {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
filteredNodes := make([]*livekit.Node, 0)
|
||||
for _, node := range nodes {
|
||||
if calculateCriteriaFunc(node) < criteriaThreshold {
|
||||
filteredNodes = append(filteredNodes, node)
|
||||
}
|
||||
}
|
||||
if len(filteredNodes) > 0 {
|
||||
nodes = filteredNodes
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
127
pkg/routing/selector/regionaware.go
Normal file
127
pkg/routing/selector/regionaware.go
Normal file
@@ -0,0 +1,127 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
// RegionAwareSelector prefers available nodes that are closest to the region of the current instance
|
||||
type RegionAwareSelector struct {
|
||||
SystemLoadSelector
|
||||
CurrentRegion string
|
||||
regionDistances map[string]float64
|
||||
regions []config.RegionConfig
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func NewRegionAwareSelector(currentRegion string, regions []config.RegionConfig, sortBy string, algorithm string) (*RegionAwareSelector, error) {
|
||||
if currentRegion == "" {
|
||||
return nil, ErrCurrentRegionNotSet
|
||||
}
|
||||
// build internal map of distances
|
||||
s := &RegionAwareSelector{
|
||||
CurrentRegion: currentRegion,
|
||||
regionDistances: make(map[string]float64),
|
||||
regions: regions,
|
||||
SortBy: sortBy,
|
||||
Algorithm: algorithm,
|
||||
}
|
||||
|
||||
var currentRC *config.RegionConfig
|
||||
|
||||
for _, region := range regions {
|
||||
if region.Name == currentRegion {
|
||||
currentRC = ®ion
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if currentRC == nil && len(regions) > 0 {
|
||||
return nil, ErrCurrentRegionUnknownLatLon
|
||||
}
|
||||
|
||||
if currentRC != nil {
|
||||
for _, region := range regions {
|
||||
s.regionDistances[region.Name] = distanceBetween(currentRC.Lat, currentRC.Lon, region.Lat, region.Lon)
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *RegionAwareSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.SystemLoadSelector.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// find nodes nearest to current region
|
||||
var nearestNodes []*livekit.Node
|
||||
nearestRegion := ""
|
||||
minDist := math.MaxFloat64
|
||||
for _, node := range nodes {
|
||||
if node.Region == nearestRegion {
|
||||
nearestNodes = append(nearestNodes, node)
|
||||
continue
|
||||
}
|
||||
if dist, ok := s.regionDistances[node.Region]; ok {
|
||||
if dist < minDist {
|
||||
minDist = dist
|
||||
nearestRegion = node.Region
|
||||
nearestNodes = nearestNodes[:0]
|
||||
nearestNodes = append(nearestNodes, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(nearestNodes) > 0 {
|
||||
nodes = nearestNodes
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
|
||||
// haversine(θ) function
|
||||
func hsin(theta float64) float64 {
|
||||
return math.Pow(math.Sin(theta/2), 2)
|
||||
}
|
||||
|
||||
var piBy180 = math.Pi / 180
|
||||
|
||||
// Haversine Distance Formula
|
||||
// http://en.wikipedia.org/wiki/Haversine_formula
|
||||
// from https://gist.github.com/cdipaolo/d3f8db3848278b49db68
|
||||
func distanceBetween(lat1, lon1, lat2, lon2 float64) float64 {
|
||||
// convert to radians
|
||||
// must cast radius as float to multiply later
|
||||
var la1, lo1, la2, lo2, r float64
|
||||
la1 = lat1 * piBy180
|
||||
lo1 = lon1 * piBy180
|
||||
la2 = lat2 * piBy180
|
||||
lo2 = lon2 * piBy180
|
||||
|
||||
r = 6378100 // Earth radius in METERS
|
||||
|
||||
// calculate
|
||||
h := hsin(la2-la1) + math.Cos(la1)*math.Cos(la2)*hsin(lo2-lo1)
|
||||
|
||||
return 2 * r * math.Asin(math.Sqrt(h))
|
||||
}
|
||||
166
pkg/routing/selector/regionaware_test.go
Normal file
166
pkg/routing/selector/regionaware_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// 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 selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"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/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
loadLimit = 0.5
|
||||
regionWest = "us-west"
|
||||
regionEast = "us-east"
|
||||
regionSeattle = "seattle"
|
||||
sortBy = "random"
|
||||
algorithm = "lowest"
|
||||
)
|
||||
|
||||
func TestRegionAwareRouting(t *testing.T) {
|
||||
rc := []config.RegionConfig{
|
||||
{
|
||||
Name: regionWest,
|
||||
Lat: 37.64046607830567,
|
||||
Lon: -120.88026233189062,
|
||||
},
|
||||
{
|
||||
Name: regionEast,
|
||||
Lat: 40.68914362140307,
|
||||
Lon: -74.04445748616385,
|
||||
},
|
||||
{
|
||||
Name: regionSeattle,
|
||||
Lat: 47.620426730945454,
|
||||
Lon: -122.34938468973702,
|
||||
},
|
||||
}
|
||||
t.Run("works without region config", func(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion("", false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, nil, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
})
|
||||
|
||||
t.Run("picks available nodes in same region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionEast, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, true),
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionEast, false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("picks available nodes in same region when current node is first in the list", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionEast, true)
|
||||
nodes := []*livekit.Node{
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionSeattle, true),
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
newTestNodeInRegion(regionEast, false),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("picks closest node in a diff region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionWest, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, false),
|
||||
expectedNode,
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionSeattle, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("handles multiple nodes in same region", func(t *testing.T) {
|
||||
expectedNode := newTestNodeInRegion(regionWest, true)
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionSeattle, false),
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
newTestNodeInRegion(regionEast, true),
|
||||
expectedNode,
|
||||
expectedNode,
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionSeattle, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
s.SysloadLimit = loadLimit
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, expectedNode, node)
|
||||
})
|
||||
|
||||
t.Run("functions when current region is full", func(t *testing.T) {
|
||||
nodes := []*livekit.Node{
|
||||
newTestNodeInRegion(regionWest, true),
|
||||
}
|
||||
s, err := selector.NewRegionAwareSelector(regionEast, rc, sortBy, algorithm)
|
||||
require.NoError(t, err)
|
||||
|
||||
node, err := s.SelectNode(nodes)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, node)
|
||||
})
|
||||
}
|
||||
|
||||
func newTestNodeInRegion(region string, available bool) *livekit.Node {
|
||||
load := float32(0.4)
|
||||
if !available {
|
||||
load = 1.0
|
||||
}
|
||||
return &livekit.Node{
|
||||
Id: guid.New(utils.NodePrefix),
|
||||
Region: region,
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
LoadAvgLast1Min: load,
|
||||
},
|
||||
}
|
||||
}
|
||||
64
pkg/routing/selector/sortby_test.go
Normal file
64
pkg/routing/selector/sortby_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
// 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 selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func SortByTest(t *testing.T, sortBy string) {
|
||||
sel := selector.SystemLoadSelector{SortBy: sortBy, Algorithm: "lowest"}
|
||||
nodes := []*livekit.Node{nodeLoadLow, nodeLoadMedium, nodeLoadHigh}
|
||||
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node for SortBy:", sortBy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortByErrors(t *testing.T) {
|
||||
sel := selector.SystemLoadSelector{Algorithm: "lowest"}
|
||||
nodes := []*livekit.Node{nodeLoadLow, nodeLoadMedium, nodeLoadHigh}
|
||||
|
||||
// Test unset sort by option error
|
||||
_, err := sel.SelectNode(nodes)
|
||||
if err != selector.ErrSortByNotSet {
|
||||
t.Error("shouldn't allow empty sortBy")
|
||||
}
|
||||
|
||||
// Test unknown sort by option error
|
||||
sel.SortBy = "testFail"
|
||||
_, err = sel.SelectNode(nodes)
|
||||
if err != selector.ErrSortByUnknown {
|
||||
t.Error("shouldn't allow unknown sortBy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortBy(t *testing.T) {
|
||||
sortByTests := []string{"sysload", "cpuload", "rooms", "clients", "tracks", "bytespersec"}
|
||||
|
||||
for _, sortBy := range sortByTests {
|
||||
SortByTest(t, sortBy)
|
||||
}
|
||||
}
|
||||
45
pkg/routing/selector/sysload.go
Normal file
45
pkg/routing/selector/sysload.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
// SystemLoadSelector eliminates nodes that surpass has a per-cpu node higher than SysloadLimit
|
||||
// then selects a node from nodes that are not overloaded
|
||||
type SystemLoadSelector struct {
|
||||
SysloadLimit float32
|
||||
SortBy string
|
||||
Algorithm string
|
||||
}
|
||||
|
||||
func (s *SystemLoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) {
|
||||
nodes, err := FilterNodesByCriteria(nodes, s.SysloadLimit, GetNodeSysload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (s *SystemLoadSelector) SelectNode(nodes []*livekit.Node) (*livekit.Node, error) {
|
||||
nodes, err := s.filterNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return SelectSortedNode(nodes, s.SortBy, s.Algorithm)
|
||||
}
|
||||
114
pkg/routing/selector/sysload_test.go
Normal file
114
pkg/routing/selector/sysload_test.go
Normal file
@@ -0,0 +1,114 @@
|
||||
// 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 selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
var (
|
||||
nodeLoadLow = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.1,
|
||||
LoadAvgLast1Min: 0.0,
|
||||
NumRooms: 1,
|
||||
NumClients: 2,
|
||||
NumTracksIn: 4,
|
||||
NumTracksOut: 8,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 1000,
|
||||
BytesOut: 2000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeLoadMedium = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.5,
|
||||
LoadAvgLast1Min: 0.5,
|
||||
NumRooms: 5,
|
||||
NumClients: 10,
|
||||
NumTracksIn: 20,
|
||||
NumTracksOut: 200,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 5000,
|
||||
BytesOut: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
nodeLoadHigh = &livekit.Node{
|
||||
State: livekit.NodeState_SERVING,
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix(),
|
||||
NumCpus: 1,
|
||||
CpuLoad: 0.99,
|
||||
LoadAvgLast1Min: 2.0,
|
||||
NumRooms: 10,
|
||||
NumClients: 20,
|
||||
NumTracksIn: 40,
|
||||
NumTracksOut: 800,
|
||||
Rates: []*livekit.NodeStatsRate{
|
||||
{
|
||||
BytesIn: 10000,
|
||||
BytesOut: 40000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestSystemLoadSelector_SelectNode(t *testing.T) {
|
||||
sel := selector.SystemLoadSelector{SysloadLimit: 1.0, SortBy: "random", Algorithm: "lowest"}
|
||||
|
||||
var nodes []*livekit.Node
|
||||
_, err := sel.SelectNode(nodes)
|
||||
require.Error(t, err, "should error no available nodes")
|
||||
|
||||
// Select a node with high load when no nodes with low load are available
|
||||
nodes = []*livekit.Node{nodeLoadHigh}
|
||||
if _, err := sel.SelectNode(nodes); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
|
||||
// Select a node with low load when available
|
||||
nodes = []*livekit.Node{nodeLoadLow, nodeLoadHigh}
|
||||
for range 5 {
|
||||
node, err := sel.SelectNode(nodes)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if node != nodeLoadLow {
|
||||
t.Error("selected the wrong node")
|
||||
}
|
||||
}
|
||||
}
|
||||
179
pkg/routing/selector/utils.go
Normal file
179
pkg/routing/selector/utils.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// 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 selector
|
||||
|
||||
import (
|
||||
"math/rand/v2"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/thoas/go-funk"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
)
|
||||
|
||||
const AvailableSeconds = 5
|
||||
|
||||
// checks if a node has been updated recently to be considered for selection
|
||||
func IsAvailable(node *livekit.Node) bool {
|
||||
if node.Stats == nil {
|
||||
// available till stats are available
|
||||
return true
|
||||
}
|
||||
|
||||
delta := time.Now().Unix() - node.Stats.UpdatedAt
|
||||
return int(delta) < AvailableSeconds
|
||||
}
|
||||
|
||||
func GetAvailableNodes(nodes []*livekit.Node) []*livekit.Node {
|
||||
return funk.Filter(nodes, func(node *livekit.Node) bool {
|
||||
return IsAvailable(node) && node.State == livekit.NodeState_SERVING
|
||||
}).([]*livekit.Node)
|
||||
}
|
||||
|
||||
func GetNodeSysload(node *livekit.Node) float32 {
|
||||
stats := node.Stats
|
||||
numCpus := stats.NumCpus
|
||||
if numCpus == 0 {
|
||||
numCpus = 1
|
||||
}
|
||||
return stats.LoadAvgLast1Min / float32(numCpus)
|
||||
}
|
||||
|
||||
// TODO: check remote node configured limit, instead of this node's config
|
||||
func LimitsReached(limitConfig config.LimitConfig, nodeStats *livekit.NodeStats) bool {
|
||||
if nodeStats == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if limitConfig.NumTracks > 0 && limitConfig.NumTracks <= nodeStats.NumTracksIn+nodeStats.NumTracksOut {
|
||||
return true
|
||||
}
|
||||
|
||||
rate := &livekit.NodeStatsRate{}
|
||||
if len(nodeStats.Rates) > 0 {
|
||||
rate = nodeStats.Rates[0]
|
||||
}
|
||||
if limitConfig.BytesPerSec > 0 && limitConfig.BytesPerSec <= rate.BytesIn+rate.BytesOut {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func SelectSortedNode(nodes []*livekit.Node, sortBy string, algorithm string) (*livekit.Node, error) {
|
||||
if sortBy == "" {
|
||||
return nil, ErrSortByNotSet
|
||||
}
|
||||
if algorithm == "" {
|
||||
return nil, ErrAlgorithmNotSet
|
||||
}
|
||||
|
||||
switch algorithm {
|
||||
case "lowest": // examine all nodes and select the lowest based on sort criteria
|
||||
return selectLowestSortedNode(nodes, sortBy)
|
||||
case "twochoice": // randomly select two nodes and return the lowest based on sort criteria "Power of Two Random Choices"
|
||||
return selectTwoChoiceSortedNode(nodes, sortBy)
|
||||
default:
|
||||
return nil, ErrAlgorithmUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func selectTwoChoiceSortedNode(nodes []*livekit.Node, sortBy string) (*livekit.Node, error) {
|
||||
if len(nodes) <= 2 {
|
||||
return selectLowestSortedNode(nodes, sortBy)
|
||||
}
|
||||
|
||||
// randomly select two nodes
|
||||
node1, node2, err := selectTwoRandomNodes(nodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// compare the two nodes based on the sort criteria
|
||||
if node1 == nil || node2 == nil {
|
||||
return nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
selectedNode, err := selectLowestSortedNode([]*livekit.Node{node1, node2}, sortBy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return selectedNode, nil
|
||||
}
|
||||
|
||||
func selectLowestSortedNode(nodes []*livekit.Node, sortBy string) (*livekit.Node, error) {
|
||||
// Return a node based on what it should be sorted by for priority
|
||||
switch sortBy {
|
||||
case "random":
|
||||
idx := funk.RandomInt(0, len(nodes))
|
||||
return nodes[idx], nil
|
||||
case "sysload":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(GetNodeSysload(a) - GetNodeSysload(b))
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "cpuload":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.CpuLoad - b.Stats.CpuLoad)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "rooms":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.NumRooms - b.Stats.NumRooms)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "clients":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum(a.Stats.NumClients - b.Stats.NumClients)
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "tracks":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
return utils.Signum((a.Stats.NumTracksIn + a.Stats.NumTracksOut) - (b.Stats.NumTracksIn + b.Stats.NumTracksOut))
|
||||
})
|
||||
return nodes[0], nil
|
||||
case "bytespersec":
|
||||
slices.SortFunc(nodes, func(a, b *livekit.Node) int {
|
||||
ratea := &livekit.NodeStatsRate{}
|
||||
if len(a.Stats.Rates) > 0 {
|
||||
ratea = a.Stats.Rates[0]
|
||||
}
|
||||
|
||||
rateb := &livekit.NodeStatsRate{}
|
||||
if len(b.Stats.Rates) > 0 {
|
||||
rateb = b.Stats.Rates[0]
|
||||
}
|
||||
return utils.Signum((ratea.BytesIn + ratea.BytesOut) - (rateb.BytesIn + rateb.BytesOut))
|
||||
})
|
||||
return nodes[0], nil
|
||||
default:
|
||||
return nil, ErrSortByUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func selectTwoRandomNodes(nodes []*livekit.Node) (*livekit.Node, *livekit.Node, error) {
|
||||
if len(nodes) < 2 {
|
||||
return nil, nil, ErrNoAvailableNodes
|
||||
}
|
||||
|
||||
shuffledIndices := rand.Perm(len(nodes))
|
||||
|
||||
return nodes[shuffledIndices[0]], nodes[shuffledIndices[1]], nil
|
||||
}
|
||||
46
pkg/routing/selector/utils_test.go
Normal file
46
pkg/routing/selector/utils_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// 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 selector_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/selector"
|
||||
)
|
||||
|
||||
func TestIsAvailable(t *testing.T) {
|
||||
t.Run("still available", func(t *testing.T) {
|
||||
n := &livekit.Node{
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 3,
|
||||
},
|
||||
}
|
||||
require.True(t, selector.IsAvailable(n))
|
||||
})
|
||||
|
||||
t.Run("expired", func(t *testing.T) {
|
||||
n := &livekit.Node{
|
||||
Stats: &livekit.NodeStats{
|
||||
UpdatedAt: time.Now().Unix() - 20,
|
||||
},
|
||||
}
|
||||
require.False(t, selector.IsAvailable(n))
|
||||
})
|
||||
}
|
||||
385
pkg/routing/signal.go
Normal file
385
pkg/routing/signal.go
Normal file
@@ -0,0 +1,385 @@
|
||||
// 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 routing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
"github.com/livekit/psrpc"
|
||||
"github.com/livekit/psrpc/pkg/middleware"
|
||||
)
|
||||
|
||||
var ErrSignalWriteFailed = errors.New("signal write failed")
|
||||
var ErrSignalMessageDropped = errors.New("signal message dropped")
|
||||
|
||||
//counterfeiter:generate . SignalClient
|
||||
type SignalClient interface {
|
||||
ActiveCount() int
|
||||
StartParticipantSignal(ctx context.Context, roomName livekit.RoomName, pi ParticipantInit, nodeID livekit.NodeID) (connectionID livekit.ConnectionID, reqSink MessageSink, resSource MessageSource, err error)
|
||||
}
|
||||
|
||||
type signalClient struct {
|
||||
nodeID livekit.NodeID
|
||||
config config.SignalRelayConfig
|
||||
client rpc.TypedSignalClient
|
||||
active atomic.Int32
|
||||
}
|
||||
|
||||
func NewSignalClient(nodeID livekit.NodeID, bus psrpc.MessageBus, config config.SignalRelayConfig) (SignalClient, error) {
|
||||
client, err := rpc.NewTypedSignalClient(
|
||||
nodeID,
|
||||
bus,
|
||||
middleware.WithClientMetrics(rpc.PSRPCMetricsObserver{}),
|
||||
psrpc.WithClientChannelSize(config.StreamBufferSize),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &signalClient{
|
||||
nodeID: nodeID,
|
||||
config: config,
|
||||
client: client,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *signalClient) ActiveCount() int {
|
||||
return int(r.active.Load())
|
||||
}
|
||||
|
||||
func (r *signalClient) StartParticipantSignal(
|
||||
ctx context.Context,
|
||||
roomName livekit.RoomName,
|
||||
pi ParticipantInit,
|
||||
nodeID livekit.NodeID,
|
||||
) (
|
||||
connectionID livekit.ConnectionID,
|
||||
reqSink MessageSink,
|
||||
resSource MessageSource,
|
||||
err error,
|
||||
) {
|
||||
connectionID = livekit.ConnectionID(guid.New("CO_"))
|
||||
ss, err := pi.ToStartSession(roomName, connectionID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
l := utils.GetLogger(ctx).WithValues(
|
||||
"room", roomName,
|
||||
"reqNodeID", nodeID,
|
||||
"participant", pi.Identity,
|
||||
"connID", connectionID,
|
||||
"participantInit", &pi,
|
||||
"startSession", logger.Proto(ss),
|
||||
)
|
||||
|
||||
l.Debugw("starting signal connection")
|
||||
|
||||
stream, err := r.client.RelaySignal(ctx, nodeID)
|
||||
if err != nil {
|
||||
prometheus.RecordSignalRequestFailure()
|
||||
return
|
||||
}
|
||||
|
||||
err = stream.Send(&rpc.RelaySignalRequest{StartSession: ss})
|
||||
if err != nil {
|
||||
stream.Close(err)
|
||||
prometheus.RecordSignalRequestFailure()
|
||||
return
|
||||
}
|
||||
|
||||
sink := NewSignalMessageSink(SignalSinkParams[*rpc.RelaySignalRequest, *rpc.RelaySignalResponse]{
|
||||
Logger: l,
|
||||
Stream: stream,
|
||||
Config: r.config,
|
||||
Writer: signalRequestMessageWriter{},
|
||||
CloseOnFailure: true,
|
||||
BlockOnClose: true,
|
||||
ConnectionID: connectionID,
|
||||
})
|
||||
resChan := NewDefaultMessageChannel(connectionID)
|
||||
|
||||
go func() {
|
||||
r.active.Inc()
|
||||
defer r.active.Dec()
|
||||
|
||||
err := CopySignalStreamToMessageChannel[*rpc.RelaySignalRequest, *rpc.RelaySignalResponse](
|
||||
stream,
|
||||
resChan,
|
||||
signalResponseMessageReader{},
|
||||
r.config,
|
||||
prometheus.RecordSignalResponseSuccess,
|
||||
prometheus.RecordSignalResponseFailure,
|
||||
)
|
||||
l.Debugw("signal stream closed", "error", err)
|
||||
|
||||
resChan.Close()
|
||||
}()
|
||||
|
||||
return connectionID, sink, resChan, nil
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
|
||||
type signalRequestMessageWriter struct{}
|
||||
|
||||
func (e signalRequestMessageWriter) Write(seq uint64, close bool, msgs []proto.Message) *rpc.RelaySignalRequest {
|
||||
r := &rpc.RelaySignalRequest{
|
||||
Seq: seq,
|
||||
Requests: make([]*livekit.SignalRequest, 0, len(msgs)),
|
||||
Close: close,
|
||||
}
|
||||
for _, m := range msgs {
|
||||
r.Requests = append(r.Requests, m.(*livekit.SignalRequest))
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
|
||||
type signalResponseMessageReader struct{}
|
||||
|
||||
func (e signalResponseMessageReader) Read(rm *rpc.RelaySignalResponse) ([]proto.Message, error) {
|
||||
msgs := make([]proto.Message, 0, len(rm.Responses))
|
||||
for _, m := range rm.Responses {
|
||||
msgs = append(msgs, m)
|
||||
}
|
||||
return msgs, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
|
||||
type RelaySignalMessage interface {
|
||||
proto.Message
|
||||
GetSeq() uint64
|
||||
GetClose() bool
|
||||
}
|
||||
|
||||
type SignalMessageWriter[SendType RelaySignalMessage] interface {
|
||||
Write(seq uint64, close bool, msgs []proto.Message) SendType
|
||||
}
|
||||
|
||||
type SignalMessageReader[RecvType RelaySignalMessage] interface {
|
||||
Read(msg RecvType) ([]proto.Message, error)
|
||||
}
|
||||
|
||||
func CopySignalStreamToMessageChannel[SendType, RecvType RelaySignalMessage](
|
||||
stream psrpc.Stream[SendType, RecvType],
|
||||
ch *MessageChannel,
|
||||
reader SignalMessageReader[RecvType],
|
||||
config config.SignalRelayConfig,
|
||||
promSignalSuccess func(),
|
||||
promSignalFailure func(),
|
||||
) error {
|
||||
r := &signalMessageReader[SendType, RecvType]{
|
||||
reader: reader,
|
||||
config: config,
|
||||
}
|
||||
for msg := range stream.Channel() {
|
||||
res, err := r.Read(msg)
|
||||
if err != nil {
|
||||
promSignalFailure()
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range res {
|
||||
if err = ch.WriteMessage(r); err != nil {
|
||||
promSignalFailure()
|
||||
return err
|
||||
}
|
||||
promSignalSuccess()
|
||||
}
|
||||
|
||||
if msg.GetClose() {
|
||||
return stream.Close(nil)
|
||||
}
|
||||
}
|
||||
return stream.Err()
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
type signalMessageReader[SendType, RecvType RelaySignalMessage] struct {
|
||||
seq uint64
|
||||
reader SignalMessageReader[RecvType]
|
||||
config config.SignalRelayConfig
|
||||
}
|
||||
|
||||
func (r *signalMessageReader[SendType, RecvType]) Read(msg RecvType) ([]proto.Message, error) {
|
||||
res, err := r.reader.Read(msg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if r.seq < msg.GetSeq() {
|
||||
return nil, ErrSignalMessageDropped
|
||||
}
|
||||
if r.seq > msg.GetSeq() {
|
||||
n := int(r.seq - msg.GetSeq())
|
||||
if n > len(res) {
|
||||
n = len(res)
|
||||
}
|
||||
res = res[n:]
|
||||
}
|
||||
r.seq += uint64(len(res))
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ----------------------------------------
|
||||
|
||||
type SignalSinkParams[SendType, RecvType RelaySignalMessage] struct {
|
||||
Stream psrpc.Stream[SendType, RecvType]
|
||||
Logger logger.Logger
|
||||
Config config.SignalRelayConfig
|
||||
Writer SignalMessageWriter[SendType]
|
||||
CloseOnFailure bool
|
||||
BlockOnClose bool
|
||||
ConnectionID livekit.ConnectionID
|
||||
}
|
||||
|
||||
func NewSignalMessageSink[SendType, RecvType RelaySignalMessage](params SignalSinkParams[SendType, RecvType]) MessageSink {
|
||||
return &signalMessageSink[SendType, RecvType]{
|
||||
SignalSinkParams: params,
|
||||
}
|
||||
}
|
||||
|
||||
type signalMessageSink[SendType, RecvType RelaySignalMessage] struct {
|
||||
SignalSinkParams[SendType, RecvType]
|
||||
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
queue []proto.Message
|
||||
writing bool
|
||||
draining bool
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) Close() {
|
||||
s.mu.Lock()
|
||||
s.draining = true
|
||||
if !s.writing {
|
||||
s.writing = true
|
||||
go s.write()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// conditionally block while closing to wait for outgoing messages to drain
|
||||
//
|
||||
// on media the signal sink shares a goroutine with other signal connection
|
||||
// attempts from the same participant so blocking delays establishing new
|
||||
// sessions during reconnect.
|
||||
//
|
||||
// on controller closing without waiting for the outstanding messages to
|
||||
// drain causes leave messages to be dropped from the write queue. when
|
||||
// this happens other participants in the room aren't notified about the
|
||||
// departure until the participant times out.
|
||||
if s.BlockOnClose {
|
||||
<-s.Stream.Context().Done()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) IsClosed() bool {
|
||||
return s.Stream.Err() != nil
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) write() {
|
||||
interval := s.Config.MinRetryInterval
|
||||
deadline := time.Now().Add(s.Config.RetryTimeout)
|
||||
var err error
|
||||
|
||||
s.mu.Lock()
|
||||
for {
|
||||
close := s.draining
|
||||
if (!close && len(s.queue) == 0) || s.IsClosed() {
|
||||
break
|
||||
}
|
||||
msg, n := s.Writer.Write(s.seq, close, s.queue), len(s.queue)
|
||||
s.mu.Unlock()
|
||||
|
||||
err = s.Stream.Send(msg, psrpc.WithTimeout(interval))
|
||||
if err != nil {
|
||||
if time.Now().After(deadline) {
|
||||
s.Logger.Warnw("could not send signal message", err)
|
||||
|
||||
s.mu.Lock()
|
||||
s.seq += uint64(len(s.queue))
|
||||
s.queue = nil
|
||||
break
|
||||
}
|
||||
|
||||
interval *= 2
|
||||
if interval > s.Config.MaxRetryInterval {
|
||||
interval = s.Config.MaxRetryInterval
|
||||
}
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if err == nil {
|
||||
interval = s.Config.MinRetryInterval
|
||||
deadline = time.Now().Add(s.Config.RetryTimeout)
|
||||
|
||||
s.seq += uint64(n)
|
||||
s.queue = s.queue[n:]
|
||||
|
||||
if close {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.writing = false
|
||||
if s.draining {
|
||||
s.Stream.Close(nil)
|
||||
}
|
||||
if err != nil && s.CloseOnFailure {
|
||||
s.Stream.Close(ErrSignalWriteFailed)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) WriteMessage(msg proto.Message) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if err := s.Stream.Err(); err != nil {
|
||||
return err
|
||||
} else if s.draining {
|
||||
return psrpc.ErrStreamClosed
|
||||
}
|
||||
|
||||
s.queue = append(s.queue, msg)
|
||||
if !s.writing {
|
||||
s.writing = true
|
||||
go s.write()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *signalMessageSink[SendType, RecvType]) ConnectionID() livekit.ConnectionID {
|
||||
return s.SignalSinkParams.ConnectionID
|
||||
}
|
||||
158
pkg/rtc/clientinfo.go
Normal file
158
pkg/rtc/clientinfo.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// 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 (
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type ClientInfo struct {
|
||||
*livekit.ClientInfo
|
||||
}
|
||||
|
||||
func (c ClientInfo) isFirefox() bool {
|
||||
return c.ClientInfo != nil && (strings.EqualFold(c.ClientInfo.Browser, "firefox") || strings.EqualFold(c.ClientInfo.Browser, "firefox mobile"))
|
||||
}
|
||||
|
||||
func (c ClientInfo) isSafari() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Browser, "safari")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isGo() bool {
|
||||
return c.ClientInfo != nil && c.ClientInfo.Sdk == livekit.ClientInfo_GO
|
||||
}
|
||||
|
||||
func (c ClientInfo) isLinux() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Os, "linux")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isAndroid() bool {
|
||||
return c.ClientInfo != nil && strings.EqualFold(c.ClientInfo.Os, "android")
|
||||
}
|
||||
|
||||
func (c ClientInfo) isOBS() bool {
|
||||
return c.ClientInfo != nil && strings.Contains(c.ClientInfo.Browser, "OBS")
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsAudioRED() bool {
|
||||
return !c.isFirefox() && !c.isSafari()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsPrflxOverRelay() bool {
|
||||
return !c.isFirefox()
|
||||
}
|
||||
|
||||
// GoSDK(pion) relies on rtp packets to fire ontrack event, browsers and native (libwebrtc) rely on sdp
|
||||
func (c ClientInfo) FireTrackByRTPPacket() bool {
|
||||
return c.isGo()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsCodecChange() bool {
|
||||
return c.ClientInfo != nil && c.ClientInfo.Sdk != livekit.ClientInfo_GO && c.ClientInfo.Sdk != livekit.ClientInfo_UNKNOWN
|
||||
}
|
||||
|
||||
func (c ClientInfo) CanHandleReconnectResponse() bool {
|
||||
if c.Sdk == livekit.ClientInfo_JS {
|
||||
// JS handles Reconnect explicitly in 1.6.3, prior to 1.6.4 it could not handle unknown responses
|
||||
if c.compareVersion("1.6.3") < 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsICETCP() bool {
|
||||
if c.ClientInfo == nil {
|
||||
return false
|
||||
}
|
||||
if c.ClientInfo.Sdk == livekit.ClientInfo_GO {
|
||||
// Go does not support active TCP
|
||||
return false
|
||||
}
|
||||
if c.ClientInfo.Sdk == livekit.ClientInfo_SWIFT {
|
||||
// ICE/TCP added in 1.0.5
|
||||
return c.compareVersion("1.0.5") >= 0
|
||||
}
|
||||
// most SDKs support ICE/TCP
|
||||
return true
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsChangeRTPSenderEncodingActive() bool {
|
||||
return !c.isFirefox()
|
||||
}
|
||||
|
||||
func (c ClientInfo) ComplyWithCodecOrderInSDPAnswer() bool {
|
||||
return (!c.isLinux() && !c.isAndroid()) || !c.isFirefox()
|
||||
}
|
||||
|
||||
// Rust SDK can't decode unknown signal message (TrackSubscribed and ErrorResponse)
|
||||
func (c ClientInfo) SupportsTrackSubscribedEvent() bool {
|
||||
return c.ClientInfo.GetSdk() != livekit.ClientInfo_RUST || c.ClientInfo.GetProtocol() >= 10
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsRequestResponse() bool {
|
||||
return c.SupportsTrackSubscribedEvent()
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsSctpZeroChecksum() bool {
|
||||
return c.ClientInfo.GetSdk() != livekit.ClientInfo_UNKNOWN &&
|
||||
(!c.isGo() || c.compareVersion("2.4.0") >= 0)
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsTransceiverReuse() bool {
|
||||
return !c.isSafari()
|
||||
}
|
||||
|
||||
func (c ClientInfo) HasCapability(cap livekit.ClientInfo_Capability) bool {
|
||||
if c.ClientInfo == nil {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(c.ClientInfo.Capabilities, cap)
|
||||
}
|
||||
|
||||
func (c ClientInfo) SupportsPacketTrailer() bool {
|
||||
return c.HasCapability(livekit.ClientInfo_CAP_PACKET_TRAILER)
|
||||
}
|
||||
|
||||
// compareVersion compares a semver against the current client SDK version
|
||||
// returning 1 if current version is greater than version
|
||||
// 0 if they are the same, and -1 if it's an earlier version
|
||||
func (c ClientInfo) compareVersion(version string) int {
|
||||
if c.ClientInfo == nil {
|
||||
return -1
|
||||
}
|
||||
parts0 := strings.Split(c.ClientInfo.Version, ".")
|
||||
parts1 := strings.Split(version, ".")
|
||||
ints0 := make([]int, 3)
|
||||
ints1 := make([]int, 3)
|
||||
for i := range 3 {
|
||||
if len(parts0) > i {
|
||||
ints0[i], _ = strconv.Atoi(parts0[i])
|
||||
}
|
||||
if len(parts1) > i {
|
||||
ints1[i], _ = strconv.Atoi(parts1[i])
|
||||
}
|
||||
if ints0[i] > ints1[i] {
|
||||
return 1
|
||||
} else if ints0[i] < ints1[i] {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
59
pkg/rtc/clientinfo_test.go
Normal file
59
pkg/rtc/clientinfo_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2022 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func TestClientInfo_CompareVersion(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Version: "1",
|
||||
},
|
||||
}
|
||||
require.Equal(t, 1, c.compareVersion("0.1.0"))
|
||||
require.Equal(t, 0, c.compareVersion("1.0.0"))
|
||||
require.Equal(t, -1, c.compareVersion("1.0.5"))
|
||||
}
|
||||
|
||||
func TestClientInfo_SupportsICETCP(t *testing.T) {
|
||||
t.Run("GO SDK cannot support TCP", func(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: livekit.ClientInfo_GO,
|
||||
},
|
||||
}
|
||||
require.False(t, c.SupportsICETCP())
|
||||
})
|
||||
|
||||
t.Run("Swift SDK cannot support TCP before 1.0.5", func(t *testing.T) {
|
||||
c := ClientInfo{
|
||||
ClientInfo: &livekit.ClientInfo{
|
||||
Sdk: livekit.ClientInfo_SWIFT,
|
||||
Version: "1.0.4",
|
||||
},
|
||||
}
|
||||
require.False(t, c.SupportsICETCP())
|
||||
c.Version = "1.0.5"
|
||||
require.True(t, c.SupportsICETCP())
|
||||
})
|
||||
}
|
||||
207
pkg/rtc/config.go
Normal file
207
pkg/rtc/config.go
Normal file
@@ -0,0 +1,207 @@
|
||||
// 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 (
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
act "github.com/livekit/livekit-server/pkg/sfu/rtpextension/abscapturetime"
|
||||
dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor"
|
||||
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
|
||||
)
|
||||
|
||||
const (
|
||||
frameMarkingURI = "urn:ietf:params:rtp-hdrext:framemarking"
|
||||
repairedRTPStreamIDURI = "urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id"
|
||||
)
|
||||
|
||||
type WebRTCConfig struct {
|
||||
rtcconfig.WebRTCConfig
|
||||
|
||||
BufferFactory *buffer.Factory
|
||||
Receiver ReceiverConfig
|
||||
Publisher DirectionConfig
|
||||
Subscriber DirectionConfig
|
||||
}
|
||||
|
||||
type ReceiverConfig struct {
|
||||
PacketBufferSizeVideo int
|
||||
PacketBufferSizeAudio int
|
||||
}
|
||||
|
||||
type RTPHeaderExtensionConfig struct {
|
||||
Audio []string
|
||||
Video []string
|
||||
}
|
||||
|
||||
type RTCPFeedbackConfig struct {
|
||||
Audio []webrtc.RTCPFeedback
|
||||
Video []webrtc.RTCPFeedback
|
||||
}
|
||||
|
||||
type DirectionConfig struct {
|
||||
RTPHeaderExtension RTPHeaderExtensionConfig
|
||||
RTCPFeedback RTCPFeedbackConfig
|
||||
}
|
||||
|
||||
func NewWebRTCConfig(conf *config.Config) (*WebRTCConfig, error) {
|
||||
rtcConf := conf.RTC
|
||||
|
||||
webRTCConfig, err := rtcconfig.NewWebRTCConfig(&rtcConf.RTCConfig, conf.Development)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// we don't want to use active TCP on a server, clients should be dialing
|
||||
webRTCConfig.SettingEngine.DisableActiveTCP(true)
|
||||
|
||||
if rtcConf.PacketBufferSize == 0 {
|
||||
rtcConf.PacketBufferSize = 500
|
||||
}
|
||||
if rtcConf.PacketBufferSizeVideo == 0 {
|
||||
rtcConf.PacketBufferSizeVideo = rtcConf.PacketBufferSize
|
||||
}
|
||||
if rtcConf.PacketBufferSizeAudio == 0 {
|
||||
rtcConf.PacketBufferSizeAudio = rtcConf.PacketBufferSize
|
||||
}
|
||||
|
||||
return &WebRTCConfig{
|
||||
WebRTCConfig: *webRTCConfig,
|
||||
Receiver: ReceiverConfig{
|
||||
PacketBufferSizeVideo: rtcConf.PacketBufferSizeVideo,
|
||||
PacketBufferSizeAudio: rtcConf.PacketBufferSizeAudio,
|
||||
},
|
||||
Publisher: getPublisherConfig(false),
|
||||
Subscriber: getSubscriberConfig(rtcConf.CongestionControl.UseSendSideBWEInterceptor || rtcConf.CongestionControl.UseSendSideBWE),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) UpdatePublisherConfig(consolidated bool) {
|
||||
c.Publisher = getPublisherConfig(consolidated)
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) UpdateSubscriberConfig(ccConf config.CongestionControlConfig) {
|
||||
c.Subscriber = getSubscriberConfig(ccConf.UseSendSideBWEInterceptor || ccConf.UseSendSideBWE)
|
||||
}
|
||||
|
||||
func (c *WebRTCConfig) SetBufferFactory(factory *buffer.Factory) {
|
||||
c.BufferFactory = factory
|
||||
c.SettingEngine.BufferFactory = factory.GetOrNew
|
||||
}
|
||||
|
||||
func getPublisherConfig(consolidated bool) DirectionConfig {
|
||||
if consolidated {
|
||||
return DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Audio: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.AudioLevelURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Video: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.TransportCCURI,
|
||||
sdp.ABSSendTimeURI,
|
||||
frameMarkingURI,
|
||||
dd.ExtensionURI,
|
||||
repairedRTPStreamIDURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBTransportCC},
|
||||
{Type: webrtc.TypeRTCPFBGoogREMB},
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Audio: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.AudioLevelURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Video: []string{
|
||||
sdp.SDESMidURI,
|
||||
sdp.SDESRTPStreamIDURI,
|
||||
sdp.TransportCCURI,
|
||||
frameMarkingURI,
|
||||
dd.ExtensionURI,
|
||||
repairedRTPStreamIDURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBTransportCC},
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func getSubscriberConfig(enableTWCC bool) DirectionConfig {
|
||||
subscriberConfig := DirectionConfig{
|
||||
RTPHeaderExtension: RTPHeaderExtensionConfig{
|
||||
Video: []string{
|
||||
dd.ExtensionURI,
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
Audio: []string{
|
||||
act.AbsCaptureTimeURI,
|
||||
},
|
||||
},
|
||||
RTCPFeedback: RTCPFeedbackConfig{
|
||||
Audio: []webrtc.RTCPFeedback{
|
||||
// always enable NACK for audio but disable it later for red enabled transceiver. https://github.com/pion/webrtc/pull/2972
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
},
|
||||
Video: []webrtc.RTCPFeedback{
|
||||
{Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"},
|
||||
{Type: webrtc.TypeRTCPFBNACK},
|
||||
{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if enableTWCC {
|
||||
subscriberConfig.RTPHeaderExtension.Video = append(subscriberConfig.RTPHeaderExtension.Video, sdp.TransportCCURI)
|
||||
subscriberConfig.RTCPFeedback.Video = append(subscriberConfig.RTCPFeedback.Video, webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBTransportCC})
|
||||
} else {
|
||||
subscriberConfig.RTPHeaderExtension.Video = append(subscriberConfig.RTPHeaderExtension.Video, sdp.ABSSendTimeURI)
|
||||
subscriberConfig.RTCPFeedback.Video = append(subscriberConfig.RTCPFeedback.Video, webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBGoogREMB})
|
||||
}
|
||||
|
||||
return subscriberConfig
|
||||
}
|
||||
137
pkg/rtc/datadowntrack.go
Normal file
137
pkg/rtc/datadowntrack.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ types.DataDownTrack = (*DataDownTrack)(nil)
|
||||
var _ types.DataTrackSender = (*DataDownTrack)(nil)
|
||||
|
||||
type DataDownTrackParams struct {
|
||||
Logger logger.Logger
|
||||
SubscriberID livekit.ParticipantID
|
||||
PublishDataTrack types.DataTrack
|
||||
Handle uint16
|
||||
Transport types.DataTrackTransport
|
||||
BytesTrackStats *BytesTrackStats
|
||||
}
|
||||
|
||||
type DataDownTrack struct {
|
||||
params DataDownTrackParams
|
||||
logger logger.Logger
|
||||
createdAt int64
|
||||
|
||||
lock sync.Mutex
|
||||
closed bool
|
||||
onClose func()
|
||||
}
|
||||
|
||||
func NewDataDownTrack(params DataDownTrackParams) (*DataDownTrack, error) {
|
||||
d := &DataDownTrack{
|
||||
params: params,
|
||||
createdAt: time.Now().UnixNano(),
|
||||
}
|
||||
d.logger = params.Logger.WithValues("name", d.Name(), "handle", d.Handle())
|
||||
|
||||
if err := d.params.PublishDataTrack.AddDataDownTrack(d); err != nil {
|
||||
d.logger.Warnw("could not add data down track", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.logger.Infow("created data down track")
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Close() {
|
||||
d.lock.Lock()
|
||||
onClose := d.onClose
|
||||
if d.closed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
d.closed = true
|
||||
d.lock.Unlock()
|
||||
|
||||
d.logger.Infow("closing data down track")
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.Stop()
|
||||
}
|
||||
d.params.PublishDataTrack.DeleteDataDownTrack(d.SubscriberID())
|
||||
|
||||
if onClose != nil {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) OnClose(fn func()) {
|
||||
d.lock.Lock()
|
||||
d.onClose = fn
|
||||
closed := d.closed
|
||||
d.lock.Unlock()
|
||||
if closed && fn != nil {
|
||||
fn()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Handle() uint16 {
|
||||
return d.params.Handle
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) PublishDataTrack() types.DataTrack {
|
||||
return d.params.PublishDataTrack
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) ID() livekit.TrackID {
|
||||
return d.params.PublishDataTrack.ID()
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) Name() string {
|
||||
return d.params.PublishDataTrack.Name()
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) SubscriberID() livekit.ParticipantID {
|
||||
// add `createdAt` to ensure repeated subscriptions from same subscriber to same publisher does not collide
|
||||
return livekit.ParticipantID(fmt.Sprintf("%s:%d", d.params.SubscriberID, d.createdAt))
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) WritePacket(data []byte, packet *datatrack.Packet, _arrivalTime int64) {
|
||||
forwardedPacket := *packet
|
||||
forwardedPacket.Handle = d.params.Handle
|
||||
buf, err := forwardedPacket.Marshal()
|
||||
if err != nil {
|
||||
d.logger.Warnw("could not marshal data track message", err)
|
||||
return
|
||||
}
|
||||
if err := d.params.Transport.SendDataTrackMessage(buf); err != nil {
|
||||
d.logger.Warnw("could not send data track message", err)
|
||||
return
|
||||
}
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.AddBytes(uint64(len(buf)), true)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataDownTrack) UpdateSubscriptionOptions(subscriptionOptions *livekit.DataTrackSubscriptionOptions) {
|
||||
// DT-TODO
|
||||
}
|
||||
228
pkg/rtc/datatrack.go
Normal file
228
pkg/rtc/datatrack.go
Normal file
@@ -0,0 +1,228 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
sfuutils "github.com/livekit/livekit-server/pkg/sfu/utils"
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
var (
|
||||
errReceiverClosed = errors.New("datatrack is closed")
|
||||
)
|
||||
|
||||
var _ types.DataTrack = (*DataTrack)(nil)
|
||||
|
||||
type DataTrackParams struct {
|
||||
Logger logger.Logger
|
||||
ParticipantID func() livekit.ParticipantID
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
BytesTrackStats *BytesTrackStats
|
||||
}
|
||||
|
||||
type subscribedDataTrack struct {
|
||||
subscriber types.LocalParticipant
|
||||
dataDownTrack types.DataDownTrack
|
||||
}
|
||||
|
||||
type DataTrack struct {
|
||||
params DataTrackParams
|
||||
|
||||
logger logger.Logger
|
||||
|
||||
lock sync.Mutex
|
||||
dti *livekit.DataTrackInfo
|
||||
subscribedTracks map[livekit.ParticipantID]subscribedDataTrack
|
||||
|
||||
downTrackSpreader *sfuutils.DownTrackSpreader[types.DataTrackSender]
|
||||
|
||||
stats *dataTrackStats
|
||||
|
||||
closed core.Fuse
|
||||
}
|
||||
|
||||
func NewDataTrack(params DataTrackParams, dti *livekit.DataTrackInfo) *DataTrack {
|
||||
d := &DataTrack{
|
||||
params: params,
|
||||
dti: dti,
|
||||
subscribedTracks: make(map[livekit.ParticipantID]subscribedDataTrack),
|
||||
}
|
||||
d.logger = params.Logger.WithValues("name", d.Name(), "handle", dti.PubHandle)
|
||||
d.downTrackSpreader = sfuutils.NewDownTrackSpreader[types.DataTrackSender](sfuutils.DownTrackSpreaderParams{
|
||||
Threshold: 20,
|
||||
Logger: d.logger,
|
||||
})
|
||||
d.stats = newDataTrackStats(dataTrackStatsParams{Logger: d.logger})
|
||||
d.logger.Infow("created data track", "dataTrackInfo", logger.Proto(d.dti))
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *DataTrack) Close() {
|
||||
d.logger.Infow("closing data track")
|
||||
d.closed.Break()
|
||||
|
||||
d.stats.Close()
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataTrack) PublisherID() livekit.ParticipantID {
|
||||
return d.params.ParticipantID()
|
||||
}
|
||||
|
||||
func (d *DataTrack) PublisherIdentity() livekit.ParticipantIdentity {
|
||||
return d.params.ParticipantIdentity
|
||||
}
|
||||
|
||||
func (d *DataTrack) ToProto() *livekit.DataTrackInfo {
|
||||
return utils.CloneProto(d.dti)
|
||||
}
|
||||
|
||||
func (d *DataTrack) PubHandle() uint16 {
|
||||
return uint16(d.dti.PubHandle)
|
||||
}
|
||||
|
||||
func (d *DataTrack) ID() livekit.TrackID {
|
||||
return livekit.TrackID(d.dti.Sid)
|
||||
}
|
||||
|
||||
func (d *DataTrack) Name() string {
|
||||
return d.dti.Name
|
||||
}
|
||||
|
||||
func (d *DataTrack) AddSubscriber(sub types.LocalParticipant) (types.DataDownTrack, error) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if _, ok := d.subscribedTracks[sub.ID()]; ok {
|
||||
return nil, errAlreadySubscribed
|
||||
}
|
||||
|
||||
bytesStats := NewBytesTrackStats(
|
||||
sub.GetCountry(),
|
||||
d.ID(),
|
||||
sub.ID(),
|
||||
sub.Kind(),
|
||||
sub.KindDetails(),
|
||||
sub.GetTelemetryListener,
|
||||
sub.GetReporter(),
|
||||
)
|
||||
dataDownTrack, err := NewDataDownTrack(DataDownTrackParams{
|
||||
Logger: sub.GetLogger().WithValues("trackID", d.ID()),
|
||||
SubscriberID: sub.ID(),
|
||||
PublishDataTrack: d,
|
||||
Handle: sub.GetNextSubscribedDataTrackHandle(),
|
||||
Transport: sub.GetDataTrackTransport(),
|
||||
BytesTrackStats: bytesStats,
|
||||
})
|
||||
if err != nil {
|
||||
bytesStats.Stop()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d.subscribedTracks[sub.ID()] = subscribedDataTrack{
|
||||
subscriber: sub,
|
||||
dataDownTrack: dataDownTrack,
|
||||
}
|
||||
return dataDownTrack, nil
|
||||
}
|
||||
|
||||
func (d *DataTrack) RemoveSubscriber(subID livekit.ParticipantID) {
|
||||
d.lock.Lock()
|
||||
subscribedTrack, ok := d.subscribedTracks[subID]
|
||||
delete(d.subscribedTracks, subID)
|
||||
d.lock.Unlock()
|
||||
|
||||
if ok {
|
||||
subscribedTrack.dataDownTrack.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *DataTrack) IsSubscriber(subID livekit.ParticipantID) bool {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
_, ok := d.subscribedTracks[subID]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (d *DataTrack) RevokeDisallowedSubscribers(allowedSubscriberIdentities []livekit.ParticipantIdentity) []livekit.ParticipantIdentity {
|
||||
var revokedSubscriberIdentities []livekit.ParticipantIdentity
|
||||
|
||||
d.lock.Lock()
|
||||
disallowed := make(map[livekit.ParticipantID]livekit.ParticipantIdentity)
|
||||
for subID, subscribedTrack := range d.subscribedTracks {
|
||||
if IsParticipantExemptFromTrackPermissionsRestrictions(subscribedTrack.subscriber) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !slices.Contains(allowedSubscriberIdentities, subscribedTrack.subscriber.Identity()) {
|
||||
disallowed[subID] = subscribedTrack.subscriber.Identity()
|
||||
}
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
for subID, subIdentity := range disallowed {
|
||||
d.logger.Infow("revoking data track subscription",
|
||||
"subscriber", subIdentity,
|
||||
"subscriberID", subID,
|
||||
)
|
||||
d.RemoveSubscriber(subID)
|
||||
revokedSubscriberIdentities = append(revokedSubscriberIdentities, subIdentity)
|
||||
}
|
||||
|
||||
return revokedSubscriberIdentities
|
||||
}
|
||||
|
||||
func (d *DataTrack) AddDataDownTrack(dts types.DataTrackSender) error {
|
||||
if d.closed.IsBroken() {
|
||||
return errReceiverClosed
|
||||
}
|
||||
|
||||
if d.downTrackSpreader.HasDownTrack(dts.SubscriberID()) {
|
||||
d.logger.Infow("subscriberID already exists, replacing data downtrack", "subscriberID", dts.SubscriberID())
|
||||
}
|
||||
|
||||
d.downTrackSpreader.Store(dts)
|
||||
d.logger.Infow("data downtrack added", "subscriberID", dts.SubscriberID())
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DataTrack) DeleteDataDownTrack(subscriberID livekit.ParticipantID) {
|
||||
d.downTrackSpreader.Free(subscriberID)
|
||||
d.logger.Infow("data downtrack deleted", "subscriberID", subscriberID)
|
||||
}
|
||||
|
||||
func (d *DataTrack) HandlePacket(data []byte, packet *datatrack.Packet, arrivalTime int64) {
|
||||
d.stats.Update(packet, arrivalTime, len(data))
|
||||
if d.params.BytesTrackStats != nil {
|
||||
d.params.BytesTrackStats.AddBytes(uint64(len(data)), false)
|
||||
}
|
||||
|
||||
d.downTrackSpreader.Broadcast(func(dts types.DataTrackSender) {
|
||||
dts.WritePacket(data, packet, arrivalTime)
|
||||
})
|
||||
}
|
||||
110
pkg/rtc/datatrack_stats.go
Normal file
110
pkg/rtc/datatrack_stats.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
)
|
||||
|
||||
type dataTrackStatsParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dataTrackStats struct {
|
||||
params dataTrackStatsParams
|
||||
|
||||
lock sync.Mutex
|
||||
startTime int64
|
||||
endTime int64
|
||||
highestSequenceNumber uint16
|
||||
numPackets int
|
||||
numPacketsLost int
|
||||
numPacketsOutOfOrder int
|
||||
numFrames int // count of `F` tagged packets, i. e. packets with final packet of frame marker
|
||||
numBytes int
|
||||
}
|
||||
|
||||
func newDataTrackStats(params dataTrackStatsParams) *dataTrackStats {
|
||||
return &dataTrackStats{
|
||||
params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dataTrackStats) Update(packet *datatrack.Packet, arrivalTime int64, payloadLength int) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.numBytes += payloadLength
|
||||
|
||||
if d.endTime != 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if d.startTime == 0 {
|
||||
d.startTime = arrivalTime
|
||||
d.highestSequenceNumber = packet.SequenceNumber
|
||||
d.numPackets = 1
|
||||
} else {
|
||||
diff := packet.SequenceNumber - d.highestSequenceNumber
|
||||
switch {
|
||||
case diff == 0: // duplicate
|
||||
return
|
||||
|
||||
case diff > (1 << 15): // out of order
|
||||
d.numPackets++
|
||||
d.numPacketsOutOfOrder++
|
||||
if d.numPacketsLost > 0 {
|
||||
d.numPacketsLost--
|
||||
}
|
||||
|
||||
default: // in order
|
||||
d.numPackets++
|
||||
d.numPacketsLost += int(diff) - 1
|
||||
d.highestSequenceNumber = packet.SequenceNumber
|
||||
}
|
||||
}
|
||||
|
||||
if packet.IsFinalOfFrame {
|
||||
d.numFrames++
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dataTrackStats) Close() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
d.endTime = mono.UnixNano()
|
||||
|
||||
if d.startTime != 0 {
|
||||
duration := time.Duration(d.endTime - d.startTime).Seconds()
|
||||
fps := float64(d.numFrames) / duration
|
||||
|
||||
d.params.Logger.Infow(
|
||||
"data track stats",
|
||||
"duration", duration,
|
||||
"numPackets", d.numPackets,
|
||||
"numPacketsLost", d.numPacketsLost,
|
||||
"numPacketsOutOfOrder", d.numPacketsOutOfOrder,
|
||||
"numFrames", d.numFrames,
|
||||
"fps", fps,
|
||||
"numBytes", d.numBytes,
|
||||
)
|
||||
}
|
||||
}
|
||||
71
pkg/rtc/datatrack_test.go
Normal file
71
pkg/rtc/datatrack_test.go
Normal file
@@ -0,0 +1,71 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
)
|
||||
|
||||
func newTestDataTrackSubscriber(id livekit.ParticipantID, identity livekit.ParticipantIdentity, isRecorder bool) *typesfakes.FakeLocalParticipant {
|
||||
sub := &typesfakes.FakeLocalParticipant{}
|
||||
sub.IDReturns(id)
|
||||
sub.IdentityReturns(identity)
|
||||
sub.IsRecorderReturns(isRecorder)
|
||||
sub.GetLoggerReturns(logger.GetLogger())
|
||||
sub.GetReporterReturns(roomobs.NewNoopParticipantSessionReporter())
|
||||
sub.GetTelemetryListenerReturns(&typesfakes.FakeParticipantTelemetryListener{})
|
||||
return sub
|
||||
}
|
||||
|
||||
func TestDataTrackRevokeDisallowedSubscribers(t *testing.T) {
|
||||
dt := NewDataTrack(
|
||||
DataTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
ParticipantID: func() livekit.ParticipantID { return "pubID" },
|
||||
ParticipantIdentity: "pub",
|
||||
},
|
||||
&livekit.DataTrackInfo{
|
||||
PubHandle: 1,
|
||||
Sid: "DTR_test",
|
||||
Name: "test",
|
||||
},
|
||||
)
|
||||
defer dt.Close()
|
||||
|
||||
allowed := newTestDataTrackSubscriber("allowedID", "allowed", false)
|
||||
disallowed := newTestDataTrackSubscriber("disallowedID", "disallowed", false)
|
||||
recorder := newTestDataTrackSubscriber("recorderID", "recorder", true)
|
||||
|
||||
for _, sub := range []*typesfakes.FakeLocalParticipant{allowed, disallowed, recorder} {
|
||||
_, err := dt.AddSubscriber(sub)
|
||||
require.NoError(t, err)
|
||||
require.True(t, dt.IsSubscriber(sub.ID()))
|
||||
}
|
||||
|
||||
revoked := dt.RevokeDisallowedSubscribers([]livekit.ParticipantIdentity{"allowed"})
|
||||
require.Equal(t, []livekit.ParticipantIdentity{"disallowed"}, revoked)
|
||||
|
||||
// disallowed subscriber is removed, allowed and permission exempt (recorder) subscribers are kept
|
||||
require.True(t, dt.IsSubscriber(allowed.ID()))
|
||||
require.False(t, dt.IsSubscriber(disallowed.ID()))
|
||||
require.True(t, dt.IsSubscriber(recorder.ID()))
|
||||
}
|
||||
523
pkg/rtc/dynacast/dynacastmanager_test.go
Normal file
523
pkg/rtc/dynacast/dynacastmanager_test.go
Normal file
@@ -0,0 +1,523 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type testDynacastManagerListener struct {
|
||||
onSubscribedMaxQualityChange func(subscribedQualties []*livekit.SubscribedCodec)
|
||||
onSubscribedAudioCodecChange func(subscribedCodecs []*livekit.SubscribedAudioCodec)
|
||||
}
|
||||
|
||||
func (t *testDynacastManagerListener) OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
_maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
t.onSubscribedMaxQualityChange(subscribedQualities)
|
||||
}
|
||||
|
||||
func (t *testDynacastManagerListener) OnDynacastSubscribedAudioCodecChange(
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
t.onSubscribedAudioCodecChange(codecs)
|
||||
}
|
||||
|
||||
func TestSubscribedMaxQuality(t *testing.T) {
|
||||
t.Run("subscribers muted", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeAV1, livekit.VideoQuality_HIGH)
|
||||
|
||||
// mute all subscribers of vp8
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("subscribers max quality", func(t *testing.T) {
|
||||
lock := sync.RWMutex{}
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.(*dynacastManagerVideo).maxSubscribedQuality = map[mime.MimeType]livekit.VideoQuality{
|
||||
mime.MimeTypeVP8: livekit.VideoQuality_LOW,
|
||||
mime.MimeTypeAV1: livekit.VideoQuality_LOW,
|
||||
}
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_MEDIUM)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_MEDIUM)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// "s1" dropping to MEDIUM should disable HIGH layer
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_MEDIUM)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// "s1" , "s2" , "s3" dropping to LOW should disable HIGH & MEDIUM
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_LOW)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// muting "s2" only should not disable all qualities of vp8, no change of expected qualities
|
||||
dm.NotifySubscriberMaxQuality("s2", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// muting "s1" and s3 also should disable all qualities
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF)
|
||||
dm.NotifySubscriberMaxQuality("s3", mime.MimeTypeAV1, livekit.VideoQuality_OFF)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// unmuting "s1" should enable vp8 previously set max quality
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// a higher quality from a different node should trigger that quality
|
||||
dm.NotifySubscriberNodeMaxQuality("n1", []types.SubscribedCodecQuality{
|
||||
{CodecMime: mime.MimeTypeVP8, Quality: livekit.VideoQuality_HIGH},
|
||||
{CodecMime: mime.MimeTypeAV1, Quality: livekit.VideoQuality_MEDIUM},
|
||||
})
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCodecRegression(t *testing.T) {
|
||||
t.Run("codec regression video", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedQualities := make([]*livekit.SubscribedCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerVideo(DynacastManagerVideoParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedQualities = subscribedQualities
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeAV1, livekit.VideoQuality_HIGH)
|
||||
|
||||
expectedSubscribedQualities := []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
dm.HandleCodecRegression(mime.MimeTypeAV1, mime.MimeTypeVP8)
|
||||
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// av1 quality change should be forwarded to vp8
|
||||
// av1 quality change of node should be ignored
|
||||
dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeAV1, livekit.VideoQuality_MEDIUM)
|
||||
dm.NotifySubscriberNodeMaxQuality("n1", []types.SubscribedCodecQuality{
|
||||
{CodecMime: mime.MimeTypeAV1, Quality: livekit.VideoQuality_HIGH},
|
||||
})
|
||||
expectedSubscribedQualities = []*livekit.SubscribedCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeAV1.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeVP8.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: true},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedCodecsAsString(expectedSubscribedQualities) == subscribedCodecsAsString(actualSubscribedQualities)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("codec regression audio", func(t *testing.T) {
|
||||
var lock sync.Mutex
|
||||
actualSubscribedCodecs := make([]*livekit.SubscribedAudioCodec, 0)
|
||||
|
||||
dm := NewDynacastManagerAudio(DynacastManagerAudioParams{
|
||||
Listener: &testDynacastManagerListener{
|
||||
onSubscribedAudioCodecChange: func(subscribedCodecs []*livekit.SubscribedAudioCodec) {
|
||||
lock.Lock()
|
||||
actualSubscribedCodecs = subscribedCodecs
|
||||
lock.Unlock()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
dm.NotifySubscription("s1", mime.MimeTypeRED, true)
|
||||
|
||||
expectedSubscribedCodecs := []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
dm.HandleCodecRegression(mime.MimeTypeRED, mime.MimeTypeOpus)
|
||||
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// RED disable as subscriber or subscriber node should be ignored as it has been regressed
|
||||
dm.NotifySubscription("s1", mime.MimeTypeRED, false)
|
||||
dm.NotifySubscriptionNode("n1", []*livekit.SubscribedAudioCodec{
|
||||
{Codec: mime.MimeTypeRED.String(), Enabled: false},
|
||||
})
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// `s1` unsubscription should turn off `opus`
|
||||
dm.NotifySubscription("s1", mime.MimeTypeOpus, false)
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
// a node subscription should turn `opus` back on
|
||||
dm.NotifySubscriptionNode("n1", []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
})
|
||||
expectedSubscribedCodecs = []*livekit.SubscribedAudioCodec{
|
||||
{
|
||||
Codec: mime.MimeTypeRED.String(),
|
||||
Enabled: false,
|
||||
},
|
||||
{
|
||||
Codec: mime.MimeTypeOpus.String(),
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
lock.Lock()
|
||||
defer lock.Unlock()
|
||||
|
||||
return subscribedAudioCodecsAsString(expectedSubscribedCodecs) == subscribedAudioCodecsAsString(actualSubscribedCodecs)
|
||||
}, 10*time.Second, 100*time.Millisecond)
|
||||
|
||||
})
|
||||
}
|
||||
|
||||
func subscribedCodecsAsString(c1 []*livekit.SubscribedCodec) string {
|
||||
slices.SortFunc(c1, func(a, b *livekit.SubscribedCodec) int {
|
||||
return strings.Compare(a.Codec, b.Codec)
|
||||
})
|
||||
|
||||
var s1 strings.Builder
|
||||
for _, c := range c1 {
|
||||
s1.WriteString(c.String())
|
||||
}
|
||||
return s1.String()
|
||||
}
|
||||
|
||||
func subscribedAudioCodecsAsString(c1 []*livekit.SubscribedAudioCodec) string {
|
||||
slices.SortFunc(c1, func(a, b *livekit.SubscribedAudioCodec) int {
|
||||
return strings.Compare(a.Codec, b.Codec)
|
||||
})
|
||||
|
||||
var s1 strings.Builder
|
||||
for _, c := range c1 {
|
||||
s1.WriteString(c.String())
|
||||
}
|
||||
return s1.String()
|
||||
}
|
||||
198
pkg/rtc/dynacast/dynacastmanageraudio.go
Normal file
198
pkg/rtc/dynacast/dynacastmanageraudio.go
Normal file
@@ -0,0 +1,198 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
)
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerAudio)(nil)
|
||||
var _ dynacastQualityListener = (*dynacastManagerAudio)(nil)
|
||||
|
||||
type DynacastManagerAudioParams struct {
|
||||
Listener DynacastManagerListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dynacastManagerAudio struct {
|
||||
params DynacastManagerAudioParams
|
||||
|
||||
subscribedCodecs map[mime.MimeType]bool
|
||||
committedSubscribedCodecs map[mime.MimeType]bool
|
||||
|
||||
isClosed bool
|
||||
|
||||
*dynacastManagerBase
|
||||
}
|
||||
|
||||
func NewDynacastManagerAudio(params DynacastManagerAudioParams) DynacastManager {
|
||||
if params.Logger == nil {
|
||||
params.Logger = logger.GetLogger()
|
||||
}
|
||||
d := &dynacastManagerAudio{
|
||||
params: params,
|
||||
subscribedCodecs: make(map[mime.MimeType]bool),
|
||||
committedSubscribedCodecs: make(map[mime.MimeType]bool),
|
||||
}
|
||||
d.dynacastManagerBase = newDynacastManagerBase(dynacastManagerBaseParams{
|
||||
Logger: params.Logger,
|
||||
OpsQueueDepth: 4,
|
||||
OnRestart: func() {
|
||||
d.committedSubscribedCodecs = make(map[mime.MimeType]bool)
|
||||
},
|
||||
OnDynacastQualityCreate: func(mimeType mime.MimeType) dynacastQuality {
|
||||
dq := newDynacastQualityAudio(dynacastQualityAudioParams{
|
||||
MimeType: mimeType,
|
||||
Listener: d,
|
||||
Logger: d.params.Logger,
|
||||
})
|
||||
return dq
|
||||
},
|
||||
OnRegressCodec: func(fromMime, toMime mime.MimeType) {
|
||||
d.subscribedCodecs[fromMime] = false
|
||||
|
||||
// if the new codec is not added, notify the publisher to start publishing
|
||||
if _, ok := d.subscribedCodecs[toMime]; !ok {
|
||||
d.subscribedCodecs[toMime] = true
|
||||
}
|
||||
},
|
||||
OnUpdateNeeded: d.update,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
// It is possible for tracks to be in pending close state. When track
|
||||
// is waiting to be closed, a node is not streaming a track. This can
|
||||
// be used to force an update announcing that subscribed codec is disabled,
|
||||
// i.e. indicating not pulling track any more.
|
||||
func (d *dynacastManagerAudio) ForceEnable(enabled bool) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for mime := range d.committedSubscribedCodecs {
|
||||
d.committedSubscribedCodecs[mime] = enabled
|
||||
}
|
||||
|
||||
d.enqueueSubscribedChange()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
dq := d.getOrCreateDynacastQuality(mime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscription(subscriberID, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
for _, codec := range codecs {
|
||||
dq := d.getOrCreateDynacastQuality(mime.NormalizeMimeType(codec.Codec))
|
||||
if dq != nil {
|
||||
dq.NotifySubscriptionNode(nodeID, codec.Enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) OnUpdateAudioCodecForMime(mime mime.MimeType, enabled bool) {
|
||||
d.lock.Lock()
|
||||
if _, ok := d.regressedCodec[mime]; !ok {
|
||||
d.subscribedCodecs[mime] = enabled
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.update(false)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) update(force bool) {
|
||||
d.lock.Lock()
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"processing subscribed codec change",
|
||||
"force", force,
|
||||
"committedSubscribedCodecs", d.committedSubscribedCodecs,
|
||||
"subscribedCodecs", d.subscribedCodecs,
|
||||
)
|
||||
|
||||
if len(d.subscribedCodecs) == 0 {
|
||||
// no mime has been added, nothing to update
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// add or remove of a mime triggers an update
|
||||
changed := len(d.subscribedCodecs) != len(d.committedSubscribedCodecs)
|
||||
if !changed {
|
||||
for mime, enabled := range d.subscribedCodecs {
|
||||
if ce, ok := d.committedSubscribedCodecs[mime]; ok {
|
||||
if ce != enabled {
|
||||
changed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !force && !changed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"committing subscribed codec change",
|
||||
"force", force,
|
||||
"committedSubscribedCoecs", d.committedSubscribedCodecs,
|
||||
"subscribedcodecs", d.subscribedCodecs,
|
||||
)
|
||||
|
||||
// commit change
|
||||
d.committedSubscribedCodecs = make(map[mime.MimeType]bool, len(d.subscribedCodecs))
|
||||
for mime, enabled := range d.subscribedCodecs {
|
||||
d.committedSubscribedCodecs[mime] = enabled
|
||||
}
|
||||
|
||||
d.enqueueSubscribedChange()
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerAudio) enqueueSubscribedChange() {
|
||||
if d.isClosed || d.params.Listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedCodecs := make([]*livekit.SubscribedAudioCodec, 0, len(d.committedSubscribedCodecs))
|
||||
for mime, enabled := range d.committedSubscribedCodecs {
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedAudioCodec{
|
||||
Codec: mime.String(),
|
||||
Enabled: enabled,
|
||||
})
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"subscribedAudioCodecChange",
|
||||
"subscribedCodecs", logger.ProtoSlice(subscribedCodecs),
|
||||
)
|
||||
d.notifyOpsQueue.Enqueue(func() {
|
||||
d.params.Listener.OnDynacastSubscribedAudioCodecChange(subscribedCodecs)
|
||||
})
|
||||
}
|
||||
165
pkg/rtc/dynacast/dynacastmanagerbase.go
Normal file
165
pkg/rtc/dynacast/dynacastmanagerbase.go
Normal file
@@ -0,0 +1,165 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
)
|
||||
|
||||
type dynacastManagerBaseParams struct {
|
||||
Logger logger.Logger
|
||||
OpsQueueDepth uint
|
||||
OnRestart func()
|
||||
OnDynacastQualityCreate func(mimeType mime.MimeType) dynacastQuality
|
||||
OnRegressCodec func(fromMime, toMime mime.MimeType)
|
||||
OnUpdateNeeded func(force bool)
|
||||
}
|
||||
|
||||
type dynacastManagerBase struct {
|
||||
params dynacastManagerBaseParams
|
||||
|
||||
lock sync.RWMutex
|
||||
regressedCodec map[mime.MimeType]struct{}
|
||||
dynacastQuality map[mime.MimeType]dynacastQuality
|
||||
|
||||
notifyOpsQueue *utils.OpsQueue
|
||||
|
||||
isClosed bool
|
||||
|
||||
dynacastManagerNull
|
||||
dynacastQualityListenerNull
|
||||
}
|
||||
|
||||
func newDynacastManagerBase(params dynacastManagerBaseParams) *dynacastManagerBase {
|
||||
if params.OpsQueueDepth == 0 {
|
||||
params.OpsQueueDepth = 4
|
||||
}
|
||||
d := &dynacastManagerBase{
|
||||
params: params,
|
||||
regressedCodec: make(map[mime.MimeType]struct{}),
|
||||
dynacastQuality: make(map[mime.MimeType]dynacastQuality),
|
||||
notifyOpsQueue: utils.NewOpsQueue(utils.OpsQueueParams{
|
||||
Name: "dynacast-notify",
|
||||
MinSize: params.OpsQueueDepth,
|
||||
FlushOnStop: true,
|
||||
Logger: params.Logger,
|
||||
}),
|
||||
}
|
||||
d.notifyOpsQueue.Start()
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) AddCodec(mime mime.MimeType) {
|
||||
d.getOrCreateDynacastQuality(mime)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) HandleCodecRegression(fromMime, toMime mime.MimeType) {
|
||||
fromDq := d.getOrCreateDynacastQuality(fromMime)
|
||||
|
||||
d.lock.Lock()
|
||||
if d.isClosed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if fromDq == nil {
|
||||
// should not happen as we have added the codec on setup receiver
|
||||
d.params.Logger.Warnw("regression from codec not found", nil, "mime", fromMime, "toMime", toMime)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
d.regressedCodec[fromMime] = struct{}{}
|
||||
d.params.OnRegressCodec(fromMime, toMime)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.OnUpdateNeeded(false)
|
||||
|
||||
fromDq.Stop()
|
||||
fromDq.RegressTo(d.getOrCreateDynacastQuality(toMime))
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) Restart() {
|
||||
d.lock.Lock()
|
||||
d.params.OnRestart()
|
||||
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.lock.Unlock()
|
||||
|
||||
for _, dq := range dqs {
|
||||
dq.Restart()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) Close() {
|
||||
d.notifyOpsQueue.Stop()
|
||||
|
||||
d.lock.Lock()
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.dynacastQuality = make(map[mime.MimeType]dynacastQuality)
|
||||
|
||||
d.isClosed = true
|
||||
d.lock.Unlock()
|
||||
|
||||
for _, dq := range dqs {
|
||||
dq.Stop()
|
||||
}
|
||||
}
|
||||
|
||||
// There are situations like track unmute or streaming from a different node
|
||||
// where subscription changes needs to sent to the provider immediately.
|
||||
// This bypasses any debouncing and forces a subscription change update
|
||||
// with immediate effect.
|
||||
func (d *dynacastManagerBase) ForceUpdate() {
|
||||
d.params.OnUpdateNeeded(true)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
dqs := d.getDynacastQualitiesLocked()
|
||||
d.lock.Unlock()
|
||||
for _, dq := range dqs {
|
||||
dq.ClearSubscriberNodes()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) getOrCreateDynacastQuality(mimeType mime.MimeType) dynacastQuality {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.isClosed || mimeType == mime.MimeTypeUnknown {
|
||||
return nil
|
||||
}
|
||||
|
||||
if dq := d.dynacastQuality[mimeType]; dq != nil {
|
||||
return dq
|
||||
}
|
||||
|
||||
dq := d.params.OnDynacastQualityCreate(mimeType)
|
||||
dq.Start()
|
||||
|
||||
d.dynacastQuality[mimeType] = dq
|
||||
return dq
|
||||
}
|
||||
|
||||
func (d *dynacastManagerBase) getDynacastQualitiesLocked() []dynacastQuality {
|
||||
return slices.Collect(maps.Values(d.dynacastQuality))
|
||||
}
|
||||
271
pkg/rtc/dynacast/dynacastmanagervideo.go
Normal file
271
pkg/rtc/dynacast/dynacastmanagervideo.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
"github.com/bep/debounce"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerVideo)(nil)
|
||||
var _ dynacastQualityListener = (*dynacastManagerVideo)(nil)
|
||||
|
||||
type DynacastManagerVideoParams struct {
|
||||
DynacastPauseDelay time.Duration
|
||||
Listener DynacastManagerListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type dynacastManagerVideo struct {
|
||||
params DynacastManagerVideoParams
|
||||
|
||||
maxSubscribedQuality map[mime.MimeType]livekit.VideoQuality
|
||||
committedMaxSubscribedQuality map[mime.MimeType]livekit.VideoQuality
|
||||
|
||||
maxSubscribedQualityDebounce func(func())
|
||||
maxSubscribedQualityDebouncePending bool
|
||||
|
||||
isClosed bool
|
||||
|
||||
*dynacastManagerBase
|
||||
}
|
||||
|
||||
func NewDynacastManagerVideo(params DynacastManagerVideoParams) DynacastManager {
|
||||
if params.Logger == nil {
|
||||
params.Logger = logger.GetLogger()
|
||||
}
|
||||
d := &dynacastManagerVideo{
|
||||
params: params,
|
||||
maxSubscribedQuality: make(map[mime.MimeType]livekit.VideoQuality),
|
||||
committedMaxSubscribedQuality: make(map[mime.MimeType]livekit.VideoQuality),
|
||||
}
|
||||
if params.DynacastPauseDelay > 0 {
|
||||
d.maxSubscribedQualityDebounce = debounce.New(params.DynacastPauseDelay)
|
||||
}
|
||||
d.dynacastManagerBase = newDynacastManagerBase(dynacastManagerBaseParams{
|
||||
Logger: params.Logger,
|
||||
OpsQueueDepth: 64,
|
||||
OnRestart: func() {
|
||||
d.committedMaxSubscribedQuality = make(map[mime.MimeType]livekit.VideoQuality)
|
||||
},
|
||||
OnDynacastQualityCreate: func(mimeType mime.MimeType) dynacastQuality {
|
||||
dq := newDynacastQualityVideo(dynacastQualityVideoParams{
|
||||
MimeType: mimeType,
|
||||
Listener: d,
|
||||
Logger: d.params.Logger,
|
||||
})
|
||||
return dq
|
||||
},
|
||||
OnRegressCodec: func(fromMime, toMime mime.MimeType) {
|
||||
d.maxSubscribedQuality[fromMime] = livekit.VideoQuality_OFF
|
||||
|
||||
// if the new codec is not added, notify the publisher to start publishing
|
||||
if _, ok := d.maxSubscribedQuality[toMime]; !ok {
|
||||
d.maxSubscribedQuality[toMime] = livekit.VideoQuality_HIGH
|
||||
}
|
||||
},
|
||||
OnUpdateNeeded: d.update,
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
// It is possible for tracks to be in pending close state. When track
|
||||
// is waiting to be closed, a node is not streaming a track. This can
|
||||
// be used to force an update announcing that subscribed quality is OFF,
|
||||
// i.e. indicating not pulling track any more.
|
||||
func (d *dynacastManagerVideo) ForceQuality(quality livekit.VideoQuality) {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for mime := range d.committedMaxSubscribedQuality {
|
||||
d.committedMaxSubscribedQuality[mime] = quality
|
||||
}
|
||||
|
||||
d.enqueueSubscribedQualityChange()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
) {
|
||||
dq := d.getOrCreateDynacastQuality(mime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscriberMaxQuality(subscriberID, quality)
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
for _, quality := range qualities {
|
||||
dq := d.getOrCreateDynacastQuality(quality.CodecMime)
|
||||
if dq != nil {
|
||||
dq.NotifySubscriberNodeMaxQuality(nodeID, quality.Quality)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) OnUpdateMaxQualityForMime(
|
||||
mime mime.MimeType,
|
||||
maxQuality livekit.VideoQuality,
|
||||
) {
|
||||
d.lock.Lock()
|
||||
if _, ok := d.regressedCodec[mime]; !ok {
|
||||
d.maxSubscribedQuality[mime] = maxQuality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.update(false)
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) update(force bool) {
|
||||
d.lock.Lock()
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"processing quality change",
|
||||
"force", force,
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
|
||||
if len(d.maxSubscribedQuality) == 0 {
|
||||
// no mime has been added, nothing to update
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// add or remove of a mime triggers an update
|
||||
changed := len(d.maxSubscribedQuality) != len(d.committedMaxSubscribedQuality)
|
||||
downgradesOnly := !changed
|
||||
if !changed {
|
||||
for mime, quality := range d.maxSubscribedQuality {
|
||||
if cq, ok := d.committedMaxSubscribedQuality[mime]; ok {
|
||||
if cq != quality {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if (cq == livekit.VideoQuality_OFF && quality != livekit.VideoQuality_OFF) || (cq != livekit.VideoQuality_OFF && quality != livekit.VideoQuality_OFF && cq < quality) {
|
||||
downgradesOnly = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !force {
|
||||
if !changed {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if downgradesOnly && d.maxSubscribedQualityDebounce != nil {
|
||||
if !d.maxSubscribedQualityDebouncePending {
|
||||
d.params.Logger.Debugw(
|
||||
"debouncing quality downgrade",
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
d.maxSubscribedQualityDebounce(func() {
|
||||
d.update(true)
|
||||
})
|
||||
d.maxSubscribedQualityDebouncePending = true
|
||||
} else {
|
||||
d.params.Logger.Debugw(
|
||||
"quality downgrade waiting for debounce",
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
}
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// clear debounce on send
|
||||
if d.maxSubscribedQualityDebounce != nil {
|
||||
d.maxSubscribedQualityDebounce(func() {})
|
||||
d.maxSubscribedQualityDebouncePending = false
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"committing quality change",
|
||||
"force", force,
|
||||
"committedMaxSubscribedQuality", d.committedMaxSubscribedQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
)
|
||||
|
||||
// commit change
|
||||
d.committedMaxSubscribedQuality = make(map[mime.MimeType]livekit.VideoQuality, len(d.maxSubscribedQuality))
|
||||
maps.Copy(d.committedMaxSubscribedQuality, d.maxSubscribedQuality)
|
||||
|
||||
d.enqueueSubscribedQualityChange()
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastManagerVideo) enqueueSubscribedQualityChange() {
|
||||
if d.isClosed || d.params.Listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
subscribedCodecs := make([]*livekit.SubscribedCodec, 0, len(d.committedMaxSubscribedQuality))
|
||||
maxSubscribedQualities := make([]types.SubscribedCodecQuality, 0, len(d.committedMaxSubscribedQuality))
|
||||
for mime, quality := range d.committedMaxSubscribedQuality {
|
||||
maxSubscribedQualities = append(maxSubscribedQualities, types.SubscribedCodecQuality{
|
||||
CodecMime: mime,
|
||||
Quality: quality,
|
||||
})
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedCodec{
|
||||
Codec: mime.String(),
|
||||
Qualities: []*livekit.SubscribedQuality{
|
||||
{Quality: livekit.VideoQuality_LOW, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_MEDIUM, Enabled: false},
|
||||
{Quality: livekit.VideoQuality_HIGH, Enabled: false},
|
||||
},
|
||||
})
|
||||
} else {
|
||||
var subscribedQualities []*livekit.SubscribedQuality
|
||||
for q := livekit.VideoQuality_LOW; q <= livekit.VideoQuality_HIGH; q++ {
|
||||
subscribedQualities = append(subscribedQualities, &livekit.SubscribedQuality{
|
||||
Quality: q,
|
||||
Enabled: q <= quality,
|
||||
})
|
||||
}
|
||||
subscribedCodecs = append(subscribedCodecs, &livekit.SubscribedCodec{
|
||||
Codec: mime.String(),
|
||||
Qualities: subscribedQualities,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
d.params.Logger.Debugw(
|
||||
"subscribedMaxQualityChange",
|
||||
"subscribedCodecs", subscribedCodecs,
|
||||
"maxSubscribedQualities", maxSubscribedQualities,
|
||||
)
|
||||
d.notifyOpsQueue.Enqueue(func() {
|
||||
d.params.Listener.OnDynacastSubscribedMaxQualityChange(subscribedCodecs, maxSubscribedQualities)
|
||||
})
|
||||
}
|
||||
168
pkg/rtc/dynacast/dynacastqualityaudio.go
Normal file
168
pkg/rtc/dynacast/dynacastqualityaudio.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityAudio)(nil)
|
||||
|
||||
type dynacastQualityAudioParams struct {
|
||||
MimeType mime.MimeType
|
||||
Listener dynacastQualityListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// dynacastQualityAudio manages enable a single receiver of a media track
|
||||
type dynacastQualityAudio struct {
|
||||
params dynacastQualityAudioParams
|
||||
|
||||
// quality level enable/disable
|
||||
lock sync.RWMutex
|
||||
initialized bool
|
||||
subscriberEnables map[livekit.ParticipantID]bool
|
||||
subscriberNodeEnables map[livekit.NodeID]bool
|
||||
enabled bool
|
||||
regressTo dynacastQuality
|
||||
|
||||
dynacastQualityNull
|
||||
}
|
||||
|
||||
func newDynacastQualityAudio(params dynacastQualityAudioParams) dynacastQuality {
|
||||
return &dynacastQualityAudio{
|
||||
params: params,
|
||||
subscriberEnables: make(map[livekit.ParticipantID]bool),
|
||||
subscriberNodeEnables: make(map[livekit.NodeID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Start() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Restart() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Stop() {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) NotifySubscription(subscriberID livekit.ParticipantID, enabled bool) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber codec enable",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberID", subscriberID,
|
||||
"enabled", enabled,
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
delete(d.subscriberEnables, subscriberID)
|
||||
} else {
|
||||
d.subscriberEnables[subscriberID] = true
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber node codec enabled",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberNodeID", nodeID,
|
||||
"enabled", enabled,
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
// the downstream node will synthesize correct enable (its dynacast manager has codec regression), just ignore it
|
||||
d.params.Logger.Debugw(
|
||||
"ignoring node codec change, regressed to another dynacast quality",
|
||||
"mime", d.params.MimeType,
|
||||
"regressedMime", d.regressTo.Mime(),
|
||||
)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if !enabled {
|
||||
delete(d.subscriberNodeEnables, nodeID)
|
||||
} else {
|
||||
d.subscriberNodeEnables[nodeID] = true
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
d.subscriberNodeEnables = make(map[livekit.NodeID]bool)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) Mime() mime.MimeType {
|
||||
return d.params.MimeType
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) RegressTo(other dynacastQuality) {
|
||||
d.lock.Lock()
|
||||
d.regressTo = other
|
||||
d.lock.Unlock()
|
||||
|
||||
other.Restart()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) reset() {
|
||||
d.lock.Lock()
|
||||
d.initialized = false
|
||||
d.lock.Unlock()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityAudio) updateQualityChange(force bool) {
|
||||
d.lock.Lock()
|
||||
enabled := len(d.subscriberEnables) != 0 || len(d.subscriberNodeEnables) != 0
|
||||
if enabled == d.enabled && d.initialized && !force {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.initialized = true
|
||||
d.enabled = enabled
|
||||
d.params.Logger.Debugw(
|
||||
"notifying enabled change",
|
||||
"mime", d.params.MimeType,
|
||||
"enabled", d.enabled,
|
||||
"subscriberNodeEnables", d.subscriberNodeEnables,
|
||||
"subscribedEnables", d.subscriberEnables,
|
||||
"force", force,
|
||||
)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.Listener.OnUpdateAudioCodecForMime(d.params.MimeType, enabled)
|
||||
}
|
||||
249
pkg/rtc/dynacast/dynacastqualityvideo.go
Normal file
249
pkg/rtc/dynacast/dynacastqualityvideo.go
Normal file
@@ -0,0 +1,249 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityVideo)(nil)
|
||||
|
||||
const (
|
||||
initialQualityUpdateWait = 10 * time.Second
|
||||
)
|
||||
|
||||
type dynacastQualityVideoParams struct {
|
||||
MimeType mime.MimeType
|
||||
Listener dynacastQualityListener
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
// dynacastQualityVideo manages max subscribed quality of a single receiver of a media track
|
||||
type dynacastQualityVideo struct {
|
||||
params dynacastQualityVideoParams
|
||||
|
||||
// quality level enable/disable
|
||||
lock sync.RWMutex
|
||||
initialized bool
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality
|
||||
maxSubscribedQuality livekit.VideoQuality
|
||||
maxQualityTimer *time.Timer
|
||||
regressTo dynacastQuality
|
||||
|
||||
dynacastQualityNull
|
||||
}
|
||||
|
||||
func newDynacastQualityVideo(params dynacastQualityVideoParams) dynacastQuality {
|
||||
return &dynacastQualityVideo{
|
||||
params: params,
|
||||
maxSubscriberQuality: make(map[livekit.ParticipantID]livekit.VideoQuality),
|
||||
maxSubscriberNodeQuality: make(map[livekit.NodeID]livekit.VideoQuality),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Start() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Restart() {
|
||||
d.reset()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Stop() {
|
||||
d.stopMaxQualityTimer()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber max quality",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberID", subscriberID,
|
||||
"quality", quality.String(),
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
d.lock.Unlock()
|
||||
r.NotifySubscriberMaxQuality(subscriberID, quality)
|
||||
return
|
||||
}
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
delete(d.maxSubscriberQuality, subscriberID)
|
||||
} else {
|
||||
d.maxSubscriberQuality[subscriberID] = quality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality) {
|
||||
d.params.Logger.Debugw(
|
||||
"setting subscriber node max quality",
|
||||
"mime", d.params.MimeType,
|
||||
"subscriberNodeID", nodeID,
|
||||
"quality", quality.String(),
|
||||
)
|
||||
|
||||
d.lock.Lock()
|
||||
if r := d.regressTo; r != nil {
|
||||
// the downstream node will synthesize correct quality notify (its dynacast manager has codec regression), just ignore it
|
||||
d.params.Logger.Debugw(
|
||||
"ignoring node quality change, regressed to another dynacast quality",
|
||||
"mime", d.params.MimeType,
|
||||
"regressedMime", d.regressTo.Mime(),
|
||||
)
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
if quality == livekit.VideoQuality_OFF {
|
||||
delete(d.maxSubscriberNodeQuality, nodeID)
|
||||
} else {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) ClearSubscriberNodes() {
|
||||
d.lock.Lock()
|
||||
d.maxSubscriberNodeQuality = make(map[livekit.NodeID]livekit.VideoQuality)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.updateQualityChange(false)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Mime() mime.MimeType {
|
||||
return d.params.MimeType
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) RegressTo(other dynacastQuality) {
|
||||
d.lock.Lock()
|
||||
d.regressTo = other
|
||||
maxSubscriberQuality := d.maxSubscriberQuality
|
||||
maxSubscriberNodeQuality := d.maxSubscriberNodeQuality
|
||||
d.maxSubscriberQuality = make(map[livekit.ParticipantID]livekit.VideoQuality)
|
||||
d.maxSubscriberNodeQuality = make(map[livekit.NodeID]livekit.VideoQuality)
|
||||
d.lock.Unlock()
|
||||
|
||||
other.Replace(maxSubscriberQuality, maxSubscriberNodeQuality)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
) {
|
||||
d.lock.Lock()
|
||||
for subID, quality := range maxSubscriberQuality {
|
||||
if oldQuality, ok := d.maxSubscriberQuality[subID]; ok {
|
||||
// no QUALITY_OFF in the map
|
||||
if quality > oldQuality {
|
||||
d.maxSubscriberQuality[subID] = quality
|
||||
}
|
||||
} else {
|
||||
d.maxSubscriberQuality[subID] = quality
|
||||
}
|
||||
}
|
||||
|
||||
for nodeID, quality := range maxSubscriberNodeQuality {
|
||||
if oldQuality, ok := d.maxSubscriberNodeQuality[nodeID]; ok {
|
||||
// no QUALITY_OFF in the map
|
||||
if quality > oldQuality {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
} else {
|
||||
d.maxSubscriberNodeQuality[nodeID] = quality
|
||||
}
|
||||
}
|
||||
d.lock.Unlock()
|
||||
|
||||
d.Restart()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) reset() {
|
||||
d.lock.Lock()
|
||||
d.initialized = false
|
||||
d.lock.Unlock()
|
||||
|
||||
d.startMaxQualityTimer()
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) updateQualityChange(force bool) {
|
||||
d.lock.Lock()
|
||||
maxSubscribedQuality := livekit.VideoQuality_OFF
|
||||
for _, subQuality := range d.maxSubscriberQuality {
|
||||
if maxSubscribedQuality == livekit.VideoQuality_OFF || (subQuality != livekit.VideoQuality_OFF && subQuality > maxSubscribedQuality) {
|
||||
maxSubscribedQuality = subQuality
|
||||
}
|
||||
}
|
||||
for _, nodeQuality := range d.maxSubscriberNodeQuality {
|
||||
if maxSubscribedQuality == livekit.VideoQuality_OFF || (nodeQuality != livekit.VideoQuality_OFF && nodeQuality > maxSubscribedQuality) {
|
||||
maxSubscribedQuality = nodeQuality
|
||||
}
|
||||
}
|
||||
|
||||
if maxSubscribedQuality == d.maxSubscribedQuality && d.initialized && !force {
|
||||
d.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
d.initialized = true
|
||||
d.maxSubscribedQuality = maxSubscribedQuality
|
||||
d.params.Logger.Debugw(
|
||||
"notifying quality change",
|
||||
"mime", d.params.MimeType,
|
||||
"maxSubscriberQuality", d.maxSubscriberQuality,
|
||||
"maxSubscriberNodeQuality", d.maxSubscriberNodeQuality,
|
||||
"maxSubscribedQuality", d.maxSubscribedQuality,
|
||||
"force", force,
|
||||
)
|
||||
d.lock.Unlock()
|
||||
|
||||
d.params.Listener.OnUpdateMaxQualityForMime(d.params.MimeType, maxSubscribedQuality)
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) startMaxQualityTimer() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.maxQualityTimer != nil {
|
||||
d.maxQualityTimer.Stop()
|
||||
d.maxQualityTimer = nil
|
||||
}
|
||||
|
||||
d.maxQualityTimer = time.AfterFunc(initialQualityUpdateWait, func() {
|
||||
d.stopMaxQualityTimer()
|
||||
d.updateQualityChange(true)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *dynacastQualityVideo) stopMaxQualityTimer() {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if d.maxQualityTimer != nil {
|
||||
d.maxQualityTimer.Stop()
|
||||
d.maxQualityTimer = nil
|
||||
}
|
||||
}
|
||||
185
pkg/rtc/dynacast/interfaces.go
Normal file
185
pkg/rtc/dynacast/interfaces.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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 dynacast
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
type DynacastManagerListener interface {
|
||||
OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
)
|
||||
|
||||
OnDynacastSubscribedAudioCodecChange(codecs []*livekit.SubscribedAudioCodec)
|
||||
}
|
||||
|
||||
var _ DynacastManagerListener = (*DynacastManagerListenerNull)(nil)
|
||||
|
||||
type DynacastManagerListenerNull struct {
|
||||
}
|
||||
|
||||
func (d *DynacastManagerListenerNull) OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
}
|
||||
func (d *DynacastManagerListenerNull) OnDynacastSubscribedAudioCodecChange(
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
}
|
||||
|
||||
// -----------------------------------------
|
||||
|
||||
type DynacastManager interface {
|
||||
AddCodec(mime mime.MimeType)
|
||||
HandleCodecRegression(fromMime, toMime mime.MimeType)
|
||||
Restart()
|
||||
Close()
|
||||
ForceUpdate()
|
||||
ForceQuality(quality livekit.VideoQuality)
|
||||
ForceEnable(enabled bool)
|
||||
|
||||
NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
)
|
||||
NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
)
|
||||
|
||||
NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
)
|
||||
NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
)
|
||||
ClearSubscriberNodes()
|
||||
}
|
||||
|
||||
var _ DynacastManager = (*dynacastManagerNull)(nil)
|
||||
|
||||
type dynacastManagerNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastManagerNull) AddCodec(mime mime.MimeType) {}
|
||||
func (d *dynacastManagerNull) HandleCodecRegression(fromMime, toMime mime.MimeType) {}
|
||||
func (d *dynacastManagerNull) Restart() {}
|
||||
func (d *dynacastManagerNull) Close() {}
|
||||
func (d *dynacastManagerNull) ForceUpdate() {}
|
||||
func (d *dynacastManagerNull) ForceQuality(quality livekit.VideoQuality) {}
|
||||
func (d *dynacastManagerNull) ForceEnable(enabled bool) {}
|
||||
func (d *dynacastManagerNull) NotifySubscriberMaxQuality(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
quality livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscription(
|
||||
subscriberID livekit.ParticipantID,
|
||||
mime mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscriberNodeMaxQuality(
|
||||
nodeID livekit.NodeID,
|
||||
qualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) NotifySubscriptionNode(
|
||||
nodeID livekit.NodeID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastManagerNull) ClearSubscriberNodes() {}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type dynacastQualityListener interface {
|
||||
OnUpdateMaxQualityForMime(mimeType mime.MimeType, maxQuality livekit.VideoQuality)
|
||||
OnUpdateAudioCodecForMime(mimeType mime.MimeType, enabled bool)
|
||||
}
|
||||
|
||||
var _ dynacastQualityListener = (*dynacastQualityListenerNull)(nil)
|
||||
|
||||
type dynacastQualityListenerNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityListenerNull) OnUpdateMaxQualityForMime(
|
||||
mimeType mime.MimeType,
|
||||
maxQuality livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityListenerNull) OnUpdateAudioCodecForMime(
|
||||
mimeType mime.MimeType,
|
||||
enabled bool,
|
||||
) {
|
||||
}
|
||||
|
||||
// ------------------------------------------------
|
||||
|
||||
type dynacastQuality interface {
|
||||
Start()
|
||||
Restart()
|
||||
Stop()
|
||||
|
||||
NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality)
|
||||
NotifySubscription(subscriberID livekit.ParticipantID, enabled bool)
|
||||
|
||||
NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality)
|
||||
NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool)
|
||||
ClearSubscriberNodes()
|
||||
|
||||
Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
)
|
||||
|
||||
Mime() mime.MimeType
|
||||
RegressTo(other dynacastQuality)
|
||||
}
|
||||
|
||||
var _ dynacastQuality = (*dynacastQualityNull)(nil)
|
||||
|
||||
type dynacastQualityNull struct {
|
||||
}
|
||||
|
||||
func (d *dynacastQualityNull) Start() {}
|
||||
func (d *dynacastQualityNull) Restart() {}
|
||||
func (d *dynacastQualityNull) Stop() {}
|
||||
func (d *dynacastQualityNull) NotifySubscriberMaxQuality(subscriberID livekit.ParticipantID, quality livekit.VideoQuality) {
|
||||
}
|
||||
func (d *dynacastQualityNull) NotifySubscription(subscriberID livekit.ParticipantID, enabled bool) {}
|
||||
func (d *dynacastQualityNull) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, quality livekit.VideoQuality) {
|
||||
}
|
||||
func (d *dynacastQualityNull) NotifySubscriptionNode(nodeID livekit.NodeID, enabled bool) {}
|
||||
func (d *dynacastQualityNull) ClearSubscriberNodes() {}
|
||||
func (d *dynacastQualityNull) Replace(
|
||||
maxSubscriberQuality map[livekit.ParticipantID]livekit.VideoQuality,
|
||||
maxSubscriberNodeQuality map[livekit.NodeID]livekit.VideoQuality,
|
||||
) {
|
||||
}
|
||||
func (d *dynacastQualityNull) Mime() mime.MimeType { return mime.MimeTypeUnknown }
|
||||
func (d *dynacastQualityNull) RegressTo(other dynacastQuality) {}
|
||||
176
pkg/rtc/egress.go
Normal file
176
pkg/rtc/egress.go
Normal file
@@ -0,0 +1,176 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/rpc"
|
||||
"github.com/livekit/protocol/webhook"
|
||||
)
|
||||
|
||||
type EgressLauncher interface {
|
||||
StartEgress(context.Context, *rpc.StartEgressRequest) (*livekit.EgressInfo, error)
|
||||
StopEgress(context.Context, *livekit.StopEgressRequest) (*livekit.EgressInfo, error)
|
||||
}
|
||||
|
||||
func StartParticipantEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
ts telemetry.TelemetryService,
|
||||
opts *livekit.AutoParticipantEgress,
|
||||
identity livekit.ParticipantIdentity,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) error {
|
||||
if req, err := startParticipantEgress(ctx, launcher, opts, identity, roomName, roomID); err != nil {
|
||||
// send egress failed webhook
|
||||
|
||||
info := &livekit.EgressInfo{
|
||||
RoomId: string(roomID),
|
||||
RoomName: string(roomName),
|
||||
Status: livekit.EgressStatus_EGRESS_FAILED,
|
||||
Error: err.Error(),
|
||||
Request: &livekit.EgressInfo_Participant{Participant: req},
|
||||
}
|
||||
|
||||
ts.NotifyEgressEvent(ctx, webhook.EventEgressEnded, info)
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startParticipantEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
opts *livekit.AutoParticipantEgress,
|
||||
identity livekit.ParticipantIdentity,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) (*livekit.ParticipantEgressRequest, error) {
|
||||
req := &livekit.ParticipantEgressRequest{
|
||||
RoomName: string(roomName),
|
||||
Identity: string(identity),
|
||||
FileOutputs: opts.FileOutputs,
|
||||
SegmentOutputs: opts.SegmentOutputs,
|
||||
}
|
||||
|
||||
switch o := opts.Options.(type) {
|
||||
case *livekit.AutoParticipantEgress_Preset:
|
||||
req.Options = &livekit.ParticipantEgressRequest_Preset{Preset: o.Preset}
|
||||
case *livekit.AutoParticipantEgress_Advanced:
|
||||
req.Options = &livekit.ParticipantEgressRequest_Advanced{Advanced: o.Advanced}
|
||||
}
|
||||
|
||||
if launcher == nil {
|
||||
return req, errors.New("egress launcher not found")
|
||||
}
|
||||
|
||||
_, err := launcher.StartEgress(ctx, &rpc.StartEgressRequest{
|
||||
Request: &rpc.StartEgressRequest_Participant{
|
||||
Participant: req,
|
||||
},
|
||||
RoomId: string(roomID),
|
||||
})
|
||||
return req, err
|
||||
}
|
||||
|
||||
func StartTrackEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
ts telemetry.TelemetryService,
|
||||
opts *livekit.AutoTrackEgress,
|
||||
track types.MediaTrack,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) error {
|
||||
if req, err := startTrackEgress(ctx, launcher, opts, track, roomName, roomID); err != nil {
|
||||
// send egress failed webhook
|
||||
|
||||
info := &livekit.EgressInfo{
|
||||
RoomId: string(roomID),
|
||||
RoomName: string(roomName),
|
||||
Status: livekit.EgressStatus_EGRESS_FAILED,
|
||||
Error: err.Error(),
|
||||
Request: &livekit.EgressInfo_Track{Track: req},
|
||||
}
|
||||
ts.NotifyEgressEvent(ctx, webhook.EventEgressEnded, info)
|
||||
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func startTrackEgress(
|
||||
ctx context.Context,
|
||||
launcher EgressLauncher,
|
||||
opts *livekit.AutoTrackEgress,
|
||||
track types.MediaTrack,
|
||||
roomName livekit.RoomName,
|
||||
roomID livekit.RoomID,
|
||||
) (*livekit.TrackEgressRequest, error) {
|
||||
output := &livekit.DirectFileOutput{
|
||||
Filepath: getFilePath(opts.Filepath),
|
||||
}
|
||||
|
||||
switch out := opts.Output.(type) {
|
||||
case *livekit.AutoTrackEgress_Azure:
|
||||
output.Output = &livekit.DirectFileOutput_Azure{Azure: out.Azure}
|
||||
case *livekit.AutoTrackEgress_Gcp:
|
||||
output.Output = &livekit.DirectFileOutput_Gcp{Gcp: out.Gcp}
|
||||
case *livekit.AutoTrackEgress_S3:
|
||||
output.Output = &livekit.DirectFileOutput_S3{S3: out.S3}
|
||||
}
|
||||
|
||||
req := &livekit.TrackEgressRequest{
|
||||
RoomName: string(roomName),
|
||||
TrackId: string(track.ID()),
|
||||
Output: &livekit.TrackEgressRequest_File{
|
||||
File: output,
|
||||
},
|
||||
}
|
||||
|
||||
if launcher == nil {
|
||||
return req, errors.New("egress launcher not found")
|
||||
}
|
||||
|
||||
_, err := launcher.StartEgress(ctx, &rpc.StartEgressRequest{
|
||||
Request: &rpc.StartEgressRequest_Track{
|
||||
Track: req,
|
||||
},
|
||||
RoomId: string(roomID),
|
||||
})
|
||||
return req, err
|
||||
}
|
||||
|
||||
func getFilePath(filepath string) string {
|
||||
if filepath == "" || strings.HasSuffix(filepath, "/") || strings.Contains(filepath, "{track_id}") {
|
||||
return filepath
|
||||
}
|
||||
|
||||
idx := strings.Index(filepath, ".")
|
||||
if idx == -1 {
|
||||
return fmt.Sprintf("%s-{track_id}", filepath)
|
||||
} else {
|
||||
return fmt.Sprintf("%s-%s%s", filepath[:idx], "{track_id}", filepath[idx:])
|
||||
}
|
||||
}
|
||||
44
pkg/rtc/errors.go
Normal file
44
pkg/rtc/errors.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrRoomClosed = errors.New("room has already closed")
|
||||
ErrParticipantSessionClosed = errors.New("participant session is already closed")
|
||||
ErrPermissionDenied = errors.New("no permissions to access the room")
|
||||
ErrMaxParticipantsExceeded = errors.New("room has exceeded its max participants")
|
||||
ErrLimitExceeded = errors.New("node has exceeded its configured limit")
|
||||
ErrAlreadyJoined = errors.New("a participant with the same identity is already in the room")
|
||||
ErrDataChannelUnavailable = errors.New("data channel is not available")
|
||||
ErrDataChannelBufferFull = errors.New("data channel buffer is full")
|
||||
ErrTransportFailure = errors.New("transport failure")
|
||||
ErrEmptyIdentity = errors.New("participant identity cannot be empty")
|
||||
ErrEmptyParticipantID = errors.New("participant ID cannot be empty")
|
||||
ErrMissingGrants = errors.New("VideoGrant is missing")
|
||||
ErrInternalError = errors.New("internal error")
|
||||
|
||||
// Track subscription related
|
||||
ErrNoTrackPermission = errors.New("participant is not allowed to subscribe to this track")
|
||||
ErrNoSubscribePermission = errors.New("participant is not given permission to subscribe to tracks")
|
||||
ErrTrackNotFound = errors.New("track cannot be found")
|
||||
ErrTrackNotBound = errors.New("track not bound")
|
||||
ErrSubscriptionLimitExceeded = errors.New("participant has exceeded its subscription limit")
|
||||
|
||||
ErrNoSubscribeMetricsPermission = errors.New("participant is not given permission to subscribe to metrics")
|
||||
)
|
||||
245
pkg/rtc/mediaengine.go
Normal file
245
pkg/rtc/mediaengine.go
Normal file
@@ -0,0 +1,245 @@
|
||||
// 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"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
protoCodecs "github.com/livekit/protocol/codecs"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type codecToRegister struct {
|
||||
webrtc.RTPCodecParameters
|
||||
strictFmtp bool
|
||||
}
|
||||
|
||||
func registerCodecs(me *webrtc.MediaEngine, codecs []*livekit.Codec, rtcpFeedback RTCPFeedbackConfig, filterOutH264HighProfile bool) error {
|
||||
// audio codecs
|
||||
if IsCodecEnabled(codecs, protoCodecs.OpusCodecParameters.RTPCodecCapability) {
|
||||
cp := protoCodecs.OpusCodecParameters
|
||||
cp.RTPCodecCapability.RTCPFeedback = rtcpFeedback.Audio
|
||||
if err := me.RegisterCodec(cp, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if IsCodecEnabled(codecs, protoCodecs.RedCodecParameters.RTPCodecCapability) {
|
||||
if err := me.RegisterCodec(protoCodecs.RedCodecParameters, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, codec := range []webrtc.RTPCodecParameters{protoCodecs.PCMUCodecParameters, protoCodecs.PCMACodecParameters} {
|
||||
if !IsCodecEnabled(codecs, codec.RTPCodecCapability) {
|
||||
continue
|
||||
}
|
||||
|
||||
cp := codec
|
||||
cp.RTPCodecCapability.RTCPFeedback = rtcpFeedback.Audio
|
||||
if err := me.RegisterCodec(cp, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// video codecs
|
||||
rtxEnabled := IsCodecEnabled(codecs, protoCodecs.VideoRTXCodecParameters.RTPCodecCapability)
|
||||
|
||||
var codecsToRegister []codecToRegister
|
||||
for _, codec := range protoCodecs.VideoCodecsParameters {
|
||||
codecsToRegister = append(codecsToRegister, codecToRegister{RTPCodecParameters: codec, strictFmtp: false})
|
||||
}
|
||||
codecsToRegister = append(codecsToRegister,
|
||||
codecToRegister{
|
||||
RTPCodecParameters: protoCodecs.H264ProfileLevelId42001fPacketizationMode0CodecParameters,
|
||||
strictFmtp: true,
|
||||
},
|
||||
codecToRegister{
|
||||
RTPCodecParameters: protoCodecs.H264ProfileLevelId42001fPacketizationMode1CodecParameters,
|
||||
strictFmtp: true,
|
||||
},
|
||||
codecToRegister{
|
||||
RTPCodecParameters: protoCodecs.H264ProfileLevelId4d001fPacketizationMode0CodecParameters,
|
||||
strictFmtp: true,
|
||||
},
|
||||
codecToRegister{
|
||||
RTPCodecParameters: protoCodecs.H264ProfileLevelId4d001fPacketizationMode1CodecParameters,
|
||||
strictFmtp: true,
|
||||
},
|
||||
)
|
||||
|
||||
for _, codec := range codecsToRegister {
|
||||
if filterOutH264HighProfile && codec.RTPCodecCapability.SDPFmtpLine == protoCodecs.H264HighProfileFmtp {
|
||||
continue
|
||||
}
|
||||
if mime.IsMimeTypeStringRTX(codec.MimeType) {
|
||||
continue
|
||||
}
|
||||
if !isCodecEnabledWithFmtp(codecs, codec.RTPCodecCapability, codec.strictFmtp) {
|
||||
continue
|
||||
}
|
||||
|
||||
cp := codec
|
||||
cp.RTPCodecCapability.RTCPFeedback = rtcpFeedback.Video
|
||||
if err := me.RegisterCodec(cp.RTPCodecParameters, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !rtxEnabled {
|
||||
continue
|
||||
}
|
||||
|
||||
cpRtx := protoCodecs.VideoRTXCodecParameters
|
||||
cpRtx.RTPCodecCapability.SDPFmtpLine = fmt.Sprintf("apt=%d", codec.PayloadType)
|
||||
cpRtx.PayloadType = codec.PayloadType + 1
|
||||
if err := me.RegisterCodec(cpRtx, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerHeaderExtensions(me *webrtc.MediaEngine, rtpHeaderExtension RTPHeaderExtensionConfig) error {
|
||||
for _, extension := range rtpHeaderExtension.Video {
|
||||
if err := me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: extension}, webrtc.RTPCodecTypeVideo); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, extension := range rtpHeaderExtension.Audio {
|
||||
if err := me.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: extension}, webrtc.RTPCodecTypeAudio); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createMediaEngine(codecs []*livekit.Codec, config DirectionConfig, filterOutH264HighProfile bool) (*webrtc.MediaEngine, error) {
|
||||
me := &webrtc.MediaEngine{}
|
||||
if err := registerCodecs(me, codecs, config.RTCPFeedback, filterOutH264HighProfile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := registerHeaderExtensions(me, config.RTPHeaderExtension); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return me, nil
|
||||
}
|
||||
|
||||
func IsCodecEnabled(codecs []*livekit.Codec, cap webrtc.RTPCodecCapability) bool {
|
||||
return isCodecEnabledWithFmtp(codecs, cap, false)
|
||||
}
|
||||
|
||||
func isCodecEnabledWithFmtp(codecs []*livekit.Codec, cap webrtc.RTPCodecCapability, strictFmtp bool) bool {
|
||||
for _, codec := range codecs {
|
||||
if !mime.IsMimeTypeStringEqual(codec.Mime, cap.MimeType) {
|
||||
continue
|
||||
}
|
||||
if (!strictFmtp && codec.FmtpLine == "") || strings.EqualFold(codec.FmtpLine, cap.SDPFmtpLine) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func selectAlternativeVideoCodec(enabledCodecs []*livekit.Codec) string {
|
||||
for _, c := range enabledCodecs {
|
||||
if mime.IsMimeTypeStringVideo(c.Mime) {
|
||||
return c.Mime
|
||||
}
|
||||
}
|
||||
// no viable codec in the list of enabled codecs, fall back to the most widely supported codec
|
||||
return mime.MimeTypeVP8.String()
|
||||
}
|
||||
|
||||
func selectAlternativeAudioCodec(enabledCodecs []*livekit.Codec) string {
|
||||
for _, c := range enabledCodecs {
|
||||
if mime.IsMimeTypeStringAudio(c.Mime) {
|
||||
return c.Mime
|
||||
}
|
||||
}
|
||||
// no viable codec in the list of enabled codecs, fall back to the most widely supported codec
|
||||
return mime.MimeTypeOpus.String()
|
||||
}
|
||||
|
||||
// mergeCodecsByMime returns a union b, deduplicated by mime type.
|
||||
func mergeCodecsByMime(a, b []*livekit.Codec) []*livekit.Codec {
|
||||
merged := make([]*livekit.Codec, 0, len(a)+len(b))
|
||||
merged = append(merged, a...)
|
||||
for _, c := range b {
|
||||
seen := false
|
||||
for _, existing := range a {
|
||||
if mime.IsMimeTypeStringEqual(c.Mime, existing.Mime) {
|
||||
seen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !seen {
|
||||
merged = append(merged, c)
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func filterCodecs(
|
||||
codecs []webrtc.RTPCodecParameters,
|
||||
enabledCodecs []*livekit.Codec,
|
||||
rtcpFeedbackConfig RTCPFeedbackConfig,
|
||||
filterOutH264HighProfile bool,
|
||||
) []webrtc.RTPCodecParameters {
|
||||
filteredCodecs := make([]webrtc.RTPCodecParameters, 0, len(codecs))
|
||||
for _, c := range codecs {
|
||||
if filterOutH264HighProfile && isH264HighProfile(c.RTPCodecCapability.SDPFmtpLine) {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, enabledCodec := range enabledCodecs {
|
||||
if mime.NormalizeMimeType(enabledCodec.Mime) == mime.NormalizeMimeType(c.RTPCodecCapability.MimeType) {
|
||||
if !mime.IsMimeTypeStringEqual(c.RTPCodecCapability.MimeType, mime.MimeTypeRTX.String()) {
|
||||
if mime.IsMimeTypeStringVideo(c.RTPCodecCapability.MimeType) {
|
||||
c.RTPCodecCapability.RTCPFeedback = rtcpFeedbackConfig.Video
|
||||
} else {
|
||||
c.RTPCodecCapability.RTCPFeedback = rtcpFeedbackConfig.Audio
|
||||
}
|
||||
}
|
||||
filteredCodecs = append(filteredCodecs, c)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return filteredCodecs
|
||||
}
|
||||
|
||||
func isH264HighProfile(fmtp string) bool {
|
||||
params := strings.Split(fmtp, ";")
|
||||
for _, param := range params {
|
||||
parts := strings.Split(param, "=")
|
||||
if len(parts) == 2 {
|
||||
if parts[0] == "profile-level-id" {
|
||||
// https://datatracker.ietf.org/doc/html/rfc6184#section-8.1
|
||||
// hex value 0x64 for profile_idc is high profile
|
||||
return strings.HasPrefix(parts[1], "64")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
224
pkg/rtc/mediaengine_test.go
Normal file
224
pkg/rtc/mediaengine_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// 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 (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
)
|
||||
|
||||
const (
|
||||
h264MainProfilePacketizationMode0Fmtp = "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=4d001f"
|
||||
h264MainProfilePacketizationMode1Fmtp = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=4d001f"
|
||||
h264BaselineProfilePacketizationMode0Fmtp = "level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42001f"
|
||||
h264BaselineProfilePacketizationMode1Fmtp = "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42001f"
|
||||
)
|
||||
|
||||
func TestIsCodecEnabled(t *testing.T) {
|
||||
t.Run("empty fmtp requirement should match all", func(t *testing.T) {
|
||||
enabledCodecs := []*livekit.Codec{{Mime: "video/h264"}}
|
||||
require.True(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String(), SDPFmtpLine: "special"}))
|
||||
require.True(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String()}))
|
||||
require.False(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}))
|
||||
})
|
||||
|
||||
t.Run("when fmtp is provided, require match", func(t *testing.T) {
|
||||
enabledCodecs := []*livekit.Codec{{Mime: "video/h264", FmtpLine: "special"}}
|
||||
require.True(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String(), SDPFmtpLine: "special"}))
|
||||
require.False(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String()}))
|
||||
require.False(t, IsCodecEnabled(enabledCodecs, webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}))
|
||||
})
|
||||
|
||||
t.Run("strict fmtp requires an exact fmtp, mime alone is not enough", func(t *testing.T) {
|
||||
cap := webrtc.RTPCodecCapability{
|
||||
MimeType: mime.MimeTypeH264.String(),
|
||||
SDPFmtpLine: h264MainProfilePacketizationMode1Fmtp,
|
||||
}
|
||||
|
||||
// mime-only config opts into every non-strict codec, but never a strict one
|
||||
mimeOnly := []*livekit.Codec{{Mime: "video/h264"}}
|
||||
require.True(t, isCodecEnabledWithFmtp(mimeOnly, cap, false))
|
||||
require.False(t, isCodecEnabledWithFmtp(mimeOnly, cap, true))
|
||||
|
||||
// spelling the fmtp out enables it
|
||||
explicit := []*livekit.Codec{{Mime: "video/h264", FmtpLine: h264MainProfilePacketizationMode1Fmtp}}
|
||||
require.True(t, isCodecEnabledWithFmtp(explicit, cap, true))
|
||||
|
||||
// ...but only for the profile that was asked for
|
||||
require.False(t, isCodecEnabledWithFmtp(explicit, webrtc.RTPCodecCapability{
|
||||
MimeType: mime.MimeTypeH264.String(),
|
||||
SDPFmtpLine: h264MainProfilePacketizationMode0Fmtp,
|
||||
}, true))
|
||||
})
|
||||
}
|
||||
|
||||
type offeredCodec struct {
|
||||
payloadType webrtc.PayloadType
|
||||
name string
|
||||
fmtp string
|
||||
}
|
||||
|
||||
func (o offeredCodec) String() string {
|
||||
return strconv.Itoa(int(o.payloadType)) + " " + o.name + " " + o.fmtp
|
||||
}
|
||||
|
||||
// offeredVideoCodecs returns the video codecs an offer built from enabledCodecs carries.
|
||||
// It goes through a real offer rather than inspecting the MediaEngine so that payload
|
||||
// type assignment, which is what collides, is covered too.
|
||||
func offeredVideoCodecs(t *testing.T, enabledCodecs []*livekit.Codec, filterOutH264HighProfile bool) []offeredCodec {
|
||||
t.Helper()
|
||||
|
||||
me, err := createMediaEngine(enabledCodecs, DirectionConfig{}, filterOutH264HighProfile)
|
||||
require.NoError(t, err)
|
||||
|
||||
pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(me)).NewPeerConnection(webrtc.Configuration{})
|
||||
require.NoError(t, err)
|
||||
defer pc.Close()
|
||||
|
||||
_, err = pc.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo)
|
||||
require.NoError(t, err)
|
||||
|
||||
offer, err := pc.CreateOffer(nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
var codecs []offeredCodec
|
||||
indexByPayloadType := make(map[webrtc.PayloadType]int)
|
||||
for _, line := range strings.Split(offer.SDP, "\r\n") {
|
||||
attr, value, found := strings.Cut(line, ":")
|
||||
if !found || (attr != "a=rtpmap" && attr != "a=fmtp") {
|
||||
continue
|
||||
}
|
||||
|
||||
rawPayloadType, rest, _ := strings.Cut(value, " ")
|
||||
payloadType, err := strconv.Atoi(rawPayloadType)
|
||||
require.NoError(t, err)
|
||||
|
||||
if attr == "a=rtpmap" {
|
||||
indexByPayloadType[webrtc.PayloadType(payloadType)] = len(codecs)
|
||||
codecs = append(codecs, offeredCodec{payloadType: webrtc.PayloadType(payloadType), name: rest})
|
||||
continue
|
||||
}
|
||||
|
||||
i, ok := indexByPayloadType[webrtc.PayloadType(payloadType)]
|
||||
require.True(t, ok, "fmtp for payload type %d without an rtpmap", payloadType)
|
||||
codecs[i].fmtp = rest
|
||||
}
|
||||
|
||||
require.NotEmpty(t, codecs)
|
||||
return codecs
|
||||
}
|
||||
|
||||
func hasOfferedFmtp(codecs []offeredCodec, mimeType mime.MimeType, fmtp string) bool {
|
||||
for _, c := range codecs {
|
||||
name, _, _ := strings.Cut(c.name, "/")
|
||||
if mime.NormalizeMimeType("video/"+name) == mimeType && c.fmtp == fmtp {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// defaultEnabledCodecs mirrors what roomallocator derives from config, so these tests
|
||||
// track the shipped default codec set instead of a hand-maintained copy of it.
|
||||
func defaultEnabledCodecs() []*livekit.Codec {
|
||||
codecs := make([]*livekit.Codec, 0, len(config.DefaultConfig.Room.EnabledCodecs))
|
||||
for _, c := range config.DefaultConfig.Room.EnabledCodecs {
|
||||
codecs = append(codecs, &livekit.Codec{Mime: c.Mime, FmtpLine: c.FmtpLine})
|
||||
}
|
||||
return codecs
|
||||
}
|
||||
|
||||
func TestRegisterCodecsH264Profiles(t *testing.T) {
|
||||
t.Run("constrained baseline are offered by default", func(t *testing.T) {
|
||||
codecs := offeredVideoCodecs(t, defaultEnabledCodecs(), false)
|
||||
|
||||
for _, fmtp := range []string{
|
||||
"level-asymmetry-allowed=1;packetization-mode=0;profile-level-id=42e01f",
|
||||
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f",
|
||||
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032",
|
||||
} {
|
||||
require.True(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, fmtp), "missing %q in %v", fmtp, codecs)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("main/baseline profile is not offered unless explicitly enabled", func(t *testing.T) {
|
||||
codecs := offeredVideoCodecs(t, defaultEnabledCodecs(), false)
|
||||
|
||||
require.False(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, h264MainProfilePacketizationMode0Fmtp), "%v", codecs)
|
||||
require.False(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, h264MainProfilePacketizationMode1Fmtp), "%v", codecs)
|
||||
require.False(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, h264BaselineProfilePacketizationMode0Fmtp), "%v", codecs)
|
||||
require.False(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, h264BaselineProfilePacketizationMode1Fmtp), "%v", codecs)
|
||||
})
|
||||
|
||||
t.Run("main/baseline profile is offered when explicitly enabled", func(t *testing.T) {
|
||||
for _, fmtp := range []string{
|
||||
h264MainProfilePacketizationMode0Fmtp,
|
||||
h264MainProfilePacketizationMode1Fmtp,
|
||||
h264BaselineProfilePacketizationMode0Fmtp,
|
||||
h264BaselineProfilePacketizationMode1Fmtp,
|
||||
} {
|
||||
t.Run(fmtp, func(t *testing.T) {
|
||||
enabled := append(defaultEnabledCodecs(), &livekit.Codec{
|
||||
Mime: mime.MimeTypeH264.String(),
|
||||
FmtpLine: fmtp,
|
||||
})
|
||||
|
||||
codecs := offeredVideoCodecs(t, enabled, false)
|
||||
require.True(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, fmtp), "missing %q in %v", fmtp, codecs)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("high profile is filtered out for the answerer", func(t *testing.T) {
|
||||
highProfileFmtp := "level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=640032"
|
||||
|
||||
codecs := offeredVideoCodecs(t, defaultEnabledCodecs(), true)
|
||||
require.False(t, hasOfferedFmtp(codecs, mime.MimeTypeH264, highProfileFmtp), "%v", codecs)
|
||||
|
||||
// the other profiles survive the filter
|
||||
require.True(t, hasOfferedFmtp(codecs, mime.MimeTypeH264,
|
||||
"level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f"), "%v", codecs)
|
||||
})
|
||||
}
|
||||
|
||||
// Every video codec claims its own payload type plus the next one for its RTX stream,
|
||||
// so a codec whose payload type lands on another's RTX slot makes pion refuse to build
|
||||
// the MediaEngine at all.
|
||||
func TestRegisterCodecsPayloadTypesAreUnique(t *testing.T) {
|
||||
enabled := append(defaultEnabledCodecs(),
|
||||
&livekit.Codec{Mime: mime.MimeTypeH264.String(), FmtpLine: h264MainProfilePacketizationMode0Fmtp},
|
||||
&livekit.Codec{Mime: mime.MimeTypeH264.String(), FmtpLine: h264MainProfilePacketizationMode1Fmtp},
|
||||
)
|
||||
|
||||
for _, filterOutH264HighProfile := range []bool{false, true} {
|
||||
codecs := offeredVideoCodecs(t, enabled, filterOutH264HighProfile)
|
||||
|
||||
seen := make(map[webrtc.PayloadType]offeredCodec, len(codecs))
|
||||
for _, c := range codecs {
|
||||
previous, duplicate := seen[c.payloadType]
|
||||
require.False(t, duplicate, "payload type %d claimed by both %v and %v", c.payloadType, previous, c)
|
||||
seen[c.payloadType] = c
|
||||
}
|
||||
}
|
||||
}
|
||||
105
pkg/rtc/medialossproxy.go
Normal file
105
pkg/rtc/medialossproxy.go
Normal file
@@ -0,0 +1,105 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/rtcp"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
)
|
||||
|
||||
const (
|
||||
downLostUpdateDelta = time.Second
|
||||
)
|
||||
|
||||
type MediaLossProxyParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type MediaLossProxy struct {
|
||||
params MediaLossProxyParams
|
||||
|
||||
lock sync.Mutex
|
||||
maxDownFracLost uint8
|
||||
maxDownFracLostTs time.Time
|
||||
maxDownFracLostValid bool
|
||||
|
||||
onMediaLossUpdate func(fractionalLoss uint8)
|
||||
}
|
||||
|
||||
func NewMediaLossProxy(params MediaLossProxyParams) *MediaLossProxy {
|
||||
return &MediaLossProxy{params: params}
|
||||
}
|
||||
|
||||
func (m *MediaLossProxy) OnMediaLossUpdate(f func(fractionalLoss uint8)) {
|
||||
m.lock.Lock()
|
||||
m.onMediaLossUpdate = f
|
||||
m.lock.Unlock()
|
||||
}
|
||||
|
||||
func (m *MediaLossProxy) HandleMaxLossFeedback(_ *sfu.DownTrack, report *rtcp.ReceiverReport) {
|
||||
m.lock.Lock()
|
||||
for _, rr := range report.Reports {
|
||||
m.maxDownFracLostValid = true
|
||||
if m.maxDownFracLost < rr.FractionLost {
|
||||
m.maxDownFracLost = rr.FractionLost
|
||||
}
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
m.maybeUpdateLoss()
|
||||
}
|
||||
|
||||
func (m *MediaLossProxy) NotifySubscriberNodeMediaLoss(_nodeID livekit.NodeID, fractionalLoss uint8) {
|
||||
m.lock.Lock()
|
||||
m.maxDownFracLostValid = true
|
||||
if m.maxDownFracLost < fractionalLoss {
|
||||
m.maxDownFracLost = fractionalLoss
|
||||
}
|
||||
m.lock.Unlock()
|
||||
|
||||
m.maybeUpdateLoss()
|
||||
}
|
||||
|
||||
func (m *MediaLossProxy) maybeUpdateLoss() {
|
||||
var (
|
||||
shouldUpdate bool
|
||||
maxLost uint8
|
||||
)
|
||||
|
||||
m.lock.Lock()
|
||||
now := time.Now()
|
||||
if now.Sub(m.maxDownFracLostTs) > downLostUpdateDelta && m.maxDownFracLostValid {
|
||||
shouldUpdate = true
|
||||
maxLost = m.maxDownFracLost
|
||||
m.maxDownFracLost = 0
|
||||
m.maxDownFracLostTs = now
|
||||
m.maxDownFracLostValid = false
|
||||
}
|
||||
onMediaLossUpdate := m.onMediaLossUpdate
|
||||
m.lock.Unlock()
|
||||
|
||||
if shouldUpdate {
|
||||
if onMediaLossUpdate != nil {
|
||||
onMediaLossUpdate(maxLost)
|
||||
}
|
||||
}
|
||||
}
|
||||
701
pkg/rtc/mediatrack.go
Normal file
701
pkg/rtc/mediatrack.go
Normal file
@@ -0,0 +1,701 @@
|
||||
// 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 (
|
||||
"math"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pion/rtcp"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
"github.com/livekit/protocol/utils/mono"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/dynacast"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/connectionquality"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/interceptor"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
util "github.com/livekit/mediatransportutil"
|
||||
)
|
||||
|
||||
var _ types.LocalMediaTrack = (*MediaTrack)(nil)
|
||||
|
||||
// MediaTrack represents a WebRTC track that needs to be forwarded
|
||||
// Implements MediaTrack and PublishedTrack interface
|
||||
type MediaTrack struct {
|
||||
params MediaTrackParams
|
||||
buffer atomic.Pointer[buffer.Buffer]
|
||||
everSubscribed atomic.Bool
|
||||
|
||||
*MediaTrackReceiver
|
||||
*MediaLossProxy
|
||||
|
||||
dynacastManager dynacast.DynacastManager
|
||||
|
||||
// serialises receiver creation in AddReceiver, i. e. the check-then-create
|
||||
// of a receiver for a mime type. Held across receiver set up
|
||||
addReceiverLock sync.Mutex
|
||||
|
||||
regressionTargetCodecReceived atomic.Bool
|
||||
|
||||
migrated atomic.Bool
|
||||
published atomic.Bool
|
||||
|
||||
rttFromXR atomic.Bool
|
||||
|
||||
backupCodecPolicy livekit.BackupCodecPolicy
|
||||
// immutable after construction
|
||||
regressionTargetCodec mime.MimeType
|
||||
}
|
||||
|
||||
type MediaTrackParams struct {
|
||||
ParticipantID func() livekit.ParticipantID
|
||||
ParticipantIdentity livekit.ParticipantIdentity
|
||||
ParticipantVersion uint32
|
||||
ParticipantCountry string
|
||||
ParticipantKind livekit.ParticipantInfo_Kind
|
||||
ParticipantKindDetails []livekit.ParticipantInfo_KindDetail
|
||||
BufferFactory *buffer.Factory
|
||||
ReceiverConfig ReceiverConfig
|
||||
SubscriberConfig DirectionConfig
|
||||
PLIThrottleConfig sfu.PLIThrottleConfig
|
||||
AudioConfig sfu.AudioConfig
|
||||
VideoConfig config.VideoConfig
|
||||
TelemetryListener func() types.ParticipantTelemetryListener
|
||||
Logger logger.Logger
|
||||
Reporter roomobs.TrackReporter
|
||||
SimTracks map[uint32]interceptor.SimulcastTrackInfo
|
||||
OnRTCP func([]rtcp.Packet)
|
||||
ForwardStats *sfu.ForwardStats
|
||||
OnTrackEverSubscribed func(livekit.TrackID)
|
||||
ShouldRegressCodec func() bool
|
||||
PreferVideoSizeFromMedia bool
|
||||
EnableRTPStreamRestartDetection bool
|
||||
UpdateTrackInfoByVideoSizeChange bool
|
||||
ForceBackupCodecPolicySimulcast bool
|
||||
OnSubscribedMaxQualityChange func(
|
||||
trackID livekit.TrackID,
|
||||
trackInfo *livekit.TrackInfo,
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) error
|
||||
OnSubscribedAudioCodecChange func(
|
||||
trackID livekit.TrackID,
|
||||
codecs []*livekit.SubscribedAudioCodec,
|
||||
) error
|
||||
}
|
||||
|
||||
func NewMediaTrack(params MediaTrackParams, ti *livekit.TrackInfo) *MediaTrack {
|
||||
t := &MediaTrack{
|
||||
params: params,
|
||||
backupCodecPolicy: ti.BackupCodecPolicy,
|
||||
}
|
||||
|
||||
if t.params.ForceBackupCodecPolicySimulcast {
|
||||
t.backupCodecPolicy = livekit.BackupCodecPolicy_SIMULCAST
|
||||
}
|
||||
|
||||
if t.backupCodecPolicy != livekit.BackupCodecPolicy_SIMULCAST && len(ti.Codecs) > 1 {
|
||||
t.regressionTargetCodec = mime.NormalizeMimeType(ti.Codecs[1].MimeType)
|
||||
t.params.Logger.Debugw("track enabled codec regression", "regressionCodec", t.regressionTargetCodec)
|
||||
}
|
||||
|
||||
t.MediaTrackReceiver = NewMediaTrackReceiver(MediaTrackReceiverParams{
|
||||
MediaTrack: t,
|
||||
IsRelayed: false,
|
||||
ParticipantID: params.ParticipantID,
|
||||
ParticipantIdentity: params.ParticipantIdentity,
|
||||
ParticipantVersion: params.ParticipantVersion,
|
||||
ReceiverConfig: params.ReceiverConfig,
|
||||
SubscriberConfig: params.SubscriberConfig,
|
||||
AudioConfig: params.AudioConfig,
|
||||
TelemetryListener: params.TelemetryListener,
|
||||
Logger: params.Logger,
|
||||
RegressionTargetCodec: t.regressionTargetCodec,
|
||||
PreferVideoSizeFromMedia: params.PreferVideoSizeFromMedia,
|
||||
}, ti)
|
||||
|
||||
if ti.Type == livekit.TrackType_AUDIO {
|
||||
t.MediaLossProxy = NewMediaLossProxy(MediaLossProxyParams{
|
||||
Logger: params.Logger,
|
||||
})
|
||||
t.MediaLossProxy.OnMediaLossUpdate(func(fractionalLoss uint8) {
|
||||
if buff := t.buffer.Load(); buff != nil {
|
||||
buff.SetLastFractionLostReport(fractionalLoss)
|
||||
}
|
||||
})
|
||||
t.MediaTrackReceiver.OnMediaLossFeedback(t.MediaLossProxy.HandleMaxLossFeedback)
|
||||
}
|
||||
|
||||
switch ti.Type {
|
||||
case livekit.TrackType_VIDEO:
|
||||
t.dynacastManager = dynacast.NewDynacastManagerVideo(dynacast.DynacastManagerVideoParams{
|
||||
DynacastPauseDelay: params.VideoConfig.DynacastPauseDelay,
|
||||
Listener: t,
|
||||
Logger: params.Logger,
|
||||
})
|
||||
|
||||
case livekit.TrackType_AUDIO:
|
||||
if len(ti.Codecs) > 1 {
|
||||
t.dynacastManager = dynacast.NewDynacastManagerAudio(dynacast.DynacastManagerAudioParams{
|
||||
Listener: t,
|
||||
Logger: params.Logger,
|
||||
})
|
||||
}
|
||||
}
|
||||
t.MediaTrackReceiver.OnSetupReceiver(func(mime mime.MimeType) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.AddCodec(mime)
|
||||
}
|
||||
})
|
||||
t.MediaTrackReceiver.OnSubscriberMaxQualityChange(
|
||||
func(subscriberID livekit.ParticipantID, mimeType mime.MimeType, layer int32) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.NotifySubscriberMaxQuality(
|
||||
subscriberID,
|
||||
mimeType,
|
||||
buffer.GetVideoQualityForSpatialLayer(
|
||||
mimeType,
|
||||
layer,
|
||||
t.MediaTrackReceiver.TrackInfo(),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
t.MediaTrackReceiver.OnSubscriberAudioCodecChange(
|
||||
func(subscriberID livekit.ParticipantID, mimeType mime.MimeType, enabled bool) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.NotifySubscription(subscriberID, mimeType, enabled)
|
||||
}
|
||||
},
|
||||
)
|
||||
t.MediaTrackReceiver.OnCodecRegression(func(old, new webrtc.RTPCodecParameters) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.HandleCodecRegression(
|
||||
mime.NormalizeMimeType(old.MimeType),
|
||||
mime.NormalizeMimeType(new.MimeType),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
func (t *MediaTrack) NotifySubscriberNodeMaxQuality(nodeID livekit.NodeID, qualities []types.SubscribedCodecQuality) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.NotifySubscriberNodeMaxQuality(nodeID, qualities)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) NotifySubscriptionNode(nodeID livekit.NodeID, codecs []*livekit.SubscribedAudioCodec) {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.NotifySubscriptionNode(nodeID, codecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) ClearSubscriberNodes() {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.ClearSubscriberNodes()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) HasSignalCid(cid string) bool {
|
||||
if cid != "" {
|
||||
ti := t.MediaTrackReceiver.TrackInfoClone()
|
||||
for _, c := range ti.Codecs {
|
||||
if c.Cid == cid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *MediaTrack) HasSdpCid(cid string) bool {
|
||||
if cid != "" {
|
||||
ti := t.MediaTrackReceiver.TrackInfoClone()
|
||||
for _, c := range ti.Codecs {
|
||||
if c.Cid == cid || c.SdpCid == cid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *MediaTrack) GetMimeTypeForSdpCid(cid string) mime.MimeType {
|
||||
if cid != "" {
|
||||
ti := t.MediaTrackReceiver.TrackInfoClone()
|
||||
for _, c := range ti.Codecs {
|
||||
if c.Cid == cid || c.SdpCid == cid {
|
||||
return mime.NormalizeMimeType(c.MimeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
return mime.MimeTypeUnknown
|
||||
}
|
||||
|
||||
func (t *MediaTrack) GetCidsForMimeType(mimeType mime.MimeType) (string, string) {
|
||||
ti := t.MediaTrackReceiver.TrackInfoClone()
|
||||
for _, c := range ti.Codecs {
|
||||
if mime.NormalizeMimeType(c.MimeType) == mimeType {
|
||||
return c.Cid, c.SdpCid
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func (t *MediaTrack) ToProto() *livekit.TrackInfo {
|
||||
return t.MediaTrackReceiver.TrackInfoClone()
|
||||
}
|
||||
|
||||
// AddReceiver adds a new RTP receiver to the track, returns true when receiver represents a new codec
|
||||
// and if a receiver was added successfully
|
||||
func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRemote, mid string) (bool, bool) {
|
||||
var newCodec bool
|
||||
ssrc := uint32(track.SSRC())
|
||||
buff, rtcpReader := t.params.BufferFactory.GetBufferPair(ssrc)
|
||||
if buff == nil || rtcpReader == nil {
|
||||
t.params.Logger.Errorw("could not retrieve buffer pair", nil)
|
||||
return newCodec, false
|
||||
}
|
||||
|
||||
var lastRR uint32
|
||||
rtcpReader.OnPacket(func(bytes []byte) {
|
||||
pkts, err := rtcp.Unmarshal(bytes)
|
||||
if err != nil {
|
||||
t.params.Logger.Errorw("could not unmarshal RTCP", err, "size", len(bytes))
|
||||
return
|
||||
}
|
||||
|
||||
for _, pkt := range pkts {
|
||||
switch pkt := pkt.(type) {
|
||||
case *rtcp.SourceDescription:
|
||||
case *rtcp.SenderReport:
|
||||
if pkt.SSRC == uint32(track.SSRC()) {
|
||||
buff.SetSenderReportData(&livekit.RTCPSenderReportState{
|
||||
RtpTimestamp: pkt.RTPTime,
|
||||
NtpTimestamp: pkt.NTPTime,
|
||||
Packets: pkt.PacketCount,
|
||||
Octets: uint64(pkt.OctetCount),
|
||||
At: mono.UnixNano(),
|
||||
})
|
||||
}
|
||||
case *rtcp.ExtendedReport:
|
||||
rttFromXR:
|
||||
for _, report := range pkt.Reports {
|
||||
if rr, ok := report.(*rtcp.DLRRReportBlock); ok {
|
||||
for _, dlrrReport := range rr.Reports {
|
||||
if dlrrReport.LastRR <= lastRR {
|
||||
continue
|
||||
}
|
||||
nowNTP := util.ToNtpTime(time.Now())
|
||||
nowNTP32 := uint32(nowNTP >> 16)
|
||||
ntpDiff := nowNTP32 - dlrrReport.LastRR - dlrrReport.DLRR
|
||||
rtt := uint32(math.Ceil(float64(ntpDiff) * 1000.0 / 65536.0))
|
||||
buff.SetRTT(rtt)
|
||||
t.rttFromXR.Store(true)
|
||||
lastRR = dlrrReport.LastRR
|
||||
break rttFromXR
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
ti := t.MediaTrackReceiver.TrackInfoClone()
|
||||
|
||||
var regressCodec bool
|
||||
enableRegression := t.enableRegression()
|
||||
|
||||
t.addReceiverLock.Lock()
|
||||
mimeType := mime.NormalizeMimeType(track.Codec().MimeType)
|
||||
layer := buffer.GetSpatialLayerForRid(mimeType, track.RID(), ti)
|
||||
if layer < 0 {
|
||||
t.params.Logger.Warnw(
|
||||
"AddReceiver failed due to negative layer", nil,
|
||||
"rid", track.RID(),
|
||||
"layer", layer,
|
||||
"ssrc", track.SSRC(),
|
||||
"codec", track.Codec(),
|
||||
"trackInfo", logger.Proto(ti),
|
||||
)
|
||||
t.addReceiverLock.Unlock()
|
||||
return newCodec, false
|
||||
}
|
||||
|
||||
t.params.Logger.Debugw(
|
||||
"AddReceiver",
|
||||
"rid", track.RID(),
|
||||
"layer", layer,
|
||||
"ssrc", track.SSRC(),
|
||||
"codec", track.Codec(),
|
||||
"trackInfo", logger.Proto(ti),
|
||||
)
|
||||
wr := t.MediaTrackReceiver.Receiver(mimeType)
|
||||
if wr == nil {
|
||||
priority := -1
|
||||
for idx, c := range ti.Codecs {
|
||||
if mime.IsMimeTypeStringEqual(track.Codec().MimeType, c.MimeType) {
|
||||
priority = idx
|
||||
break
|
||||
}
|
||||
}
|
||||
if priority < 0 {
|
||||
switch len(ti.Codecs) {
|
||||
case 0:
|
||||
// audio track
|
||||
t.params.Logger.Warnw(
|
||||
"unexpected 0 codecs in track info", nil,
|
||||
"mime", mimeType,
|
||||
"track", logger.Proto(ti),
|
||||
)
|
||||
priority = 0
|
||||
case 1:
|
||||
// older clients or non simulcast-codec, mime type only set later
|
||||
if ti.Codecs[0].MimeType == "" {
|
||||
priority = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
if priority < 0 {
|
||||
t.params.Logger.Warnw(
|
||||
"could not find codec for webrtc receiver", nil,
|
||||
"mime", mimeType,
|
||||
"track", logger.Proto(ti),
|
||||
)
|
||||
t.addReceiverLock.Unlock()
|
||||
return newCodec, false
|
||||
}
|
||||
|
||||
newWR := sfu.NewWebRTCReceiver(
|
||||
receiver,
|
||||
track,
|
||||
ti,
|
||||
LoggerWithCodecMime(t.params.Logger, mimeType),
|
||||
t.params.OnRTCP,
|
||||
t.params.VideoConfig.StreamTrackerManager,
|
||||
sfu.WithPliThrottleConfig(t.params.PLIThrottleConfig),
|
||||
sfu.WithAudioConfig(t.params.AudioConfig),
|
||||
sfu.WithLoadBalanceThreshold(20),
|
||||
sfu.WithForwardStats(t.params.ForwardStats),
|
||||
sfu.WithEnableRTPStreamRestartDetection(t.params.EnableRTPStreamRestartDetection),
|
||||
)
|
||||
newWR.OnCloseHandler(func() {
|
||||
t.MediaTrackReceiver.SetClosing(false)
|
||||
t.MediaTrackReceiver.ClearReceiver(mimeType, false)
|
||||
if t.MediaTrackReceiver.TryClose() {
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.Close()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// SIMULCAST-CODEC-TODO: these need to be receiver/mime aware, setting it up only for primary now
|
||||
statsKey := telemetry.StatsKeyForTrack(
|
||||
t.params.ParticipantCountry,
|
||||
livekit.StreamType_UPSTREAM,
|
||||
t.PublisherID(),
|
||||
t.ID(),
|
||||
ti.Source,
|
||||
ti.Type,
|
||||
)
|
||||
for _, c := range ti.Codecs {
|
||||
for _, l := range c.Layers {
|
||||
t.params.Reporter.ReportLayer(roomobs.PackTrackLayer(l.Height, l.Width))
|
||||
}
|
||||
}
|
||||
newWR.OnStatsUpdate(func(_ *sfu.WebRTCReceiver, stat *livekit.AnalyticsStat) {
|
||||
// send for only one codec, either primary (priority == 0) OR regressed codec
|
||||
if priority == 0 || t.regressionTargetCodecReceived.Load() {
|
||||
t.params.TelemetryListener().OnTrackStats(statsKey, stat)
|
||||
|
||||
if cs, ok := telemetry.CondenseStat(stat); ok {
|
||||
t.params.Reporter.Tx(func(tx roomobs.TrackTx) {
|
||||
tx.ParticipantSession().ReportKind(t.params.ParticipantKind.String())
|
||||
tx.ParticipantSession().ReportKindCode(roomobs.ParticipantKindCode(t.params.ParticipantKind))
|
||||
tx.ParticipantSession().ReportKindDetailsCodes(roomobs.ParticipantKindDetailsCodes(t.params.ParticipantKindDetails))
|
||||
tx.ReportName(ti.Name)
|
||||
tx.ReportKind(roomobs.TrackKindPub)
|
||||
tx.ReportType(roomobs.TrackTypeFromProto(ti.Type))
|
||||
tx.ReportSource(roomobs.TrackSourceFromProto(ti.Source))
|
||||
tx.ReportMime(mime.NormalizeMimeType(ti.MimeType).ReporterType())
|
||||
tx.ReportDuration(uint16(cs.EndTime.Sub(cs.StartTime).Milliseconds()))
|
||||
tx.ReportFrames(uint16(cs.Frames))
|
||||
tx.ReportRecvBytes(uint32(cs.Bytes))
|
||||
tx.ReportRecvPackets(cs.Packets)
|
||||
tx.ReportPacketsLost(cs.PacketsLost)
|
||||
tx.ReportScore(stat.Score)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
newWR.OnMaxLayerChange(func(mimeType mime.MimeType, maxLayer int32) {
|
||||
// send for only one codec, either primary (priority == 0) OR regressed codec
|
||||
if priority == 0 || t.regressionTargetCodecReceived.Load() {
|
||||
t.MediaTrackReceiver.NotifyMaxLayerChange(mimeType, maxLayer)
|
||||
}
|
||||
})
|
||||
// SIMULCAST-CODEC-TODO END: these need to be receiver/mime aware, setting it up only for primary now
|
||||
|
||||
if t.PrimaryReceiver() == nil {
|
||||
// primary codec published, set potential codecs
|
||||
potentialCodecs := make([]webrtc.RTPCodecParameters, 0, len(ti.Codecs))
|
||||
parameters := receiver.GetParameters()
|
||||
for _, c := range ti.Codecs {
|
||||
for _, nc := range parameters.Codecs {
|
||||
if mime.IsMimeTypeStringEqual(nc.MimeType, c.MimeType) {
|
||||
potentialCodecs = append(potentialCodecs, nc)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(potentialCodecs) > 0 {
|
||||
t.params.Logger.Debugw("primary codec published, set potential codecs", "potential", potentialCodecs)
|
||||
t.MediaTrackReceiver.SetPotentialCodecs(potentialCodecs, parameters.HeaderExtensions)
|
||||
}
|
||||
}
|
||||
|
||||
t.buffer.Store(buff)
|
||||
|
||||
t.MediaTrackReceiver.SetupReceiver(newWR, priority, mid)
|
||||
|
||||
for ssrc, info := range t.params.SimTracks {
|
||||
if info.Mid == mid && !info.IsRepairStream {
|
||||
t.MediaTrackReceiver.SetLayerSsrcsForRid(mimeType, info.StreamID, ssrc, info.RepairSSRC)
|
||||
}
|
||||
}
|
||||
wr = newWR
|
||||
newCodec = true
|
||||
|
||||
newWR.AddOnCodecStateChange(func(codec webrtc.RTPCodecParameters, state sfu.ReceiverCodecState) {
|
||||
t.MediaTrackReceiver.HandleReceiverCodecChange(newWR, codec, state)
|
||||
})
|
||||
|
||||
// update subscriber video layers when video size changes
|
||||
newWR.OnVideoSizeChanged(func() {
|
||||
if t.params.UpdateTrackInfoByVideoSizeChange {
|
||||
t.MediaTrackReceiver.UpdateVideoSize(mimeType, newWR.VideoSizes())
|
||||
}
|
||||
|
||||
t.MediaTrackSubscriptions.UpdateVideoLayers()
|
||||
})
|
||||
}
|
||||
|
||||
if newCodec && enableRegression {
|
||||
if mimeType == t.regressionTargetCodec {
|
||||
t.params.Logger.Infow("regression target codec received", "codec", mimeType)
|
||||
t.regressionTargetCodecReceived.Store(true)
|
||||
regressCodec = true
|
||||
} else if t.regressionTargetCodecReceived.Load() {
|
||||
regressCodec = true
|
||||
}
|
||||
}
|
||||
t.addReceiverLock.Unlock()
|
||||
|
||||
if err := wr.(*sfu.WebRTCReceiver).AddUpTrack(track, buff); err != nil {
|
||||
t.params.Logger.Warnw(
|
||||
"adding up track failed", err,
|
||||
"rid", track.RID(),
|
||||
"layer", layer,
|
||||
"ssrc", track.SSRC(),
|
||||
"newCodec", newCodec,
|
||||
)
|
||||
buff.Close()
|
||||
return newCodec, false
|
||||
}
|
||||
|
||||
var expectedBitrate int
|
||||
layers := buffer.GetVideoLayersForMimeType(mimeType, ti)
|
||||
if layer >= 0 && len(layers) > int(layer) {
|
||||
expectedBitrate = int(layers[layer].GetBitrate())
|
||||
}
|
||||
if err := buff.Bind(receiver.GetParameters(), track.Codec().RTPCodecCapability, expectedBitrate); err != nil {
|
||||
t.params.Logger.Warnw(
|
||||
"binding buffer failed", err,
|
||||
"rid", track.RID(),
|
||||
"layer", layer,
|
||||
"ssrc", track.SSRC(),
|
||||
"newCodec", newCodec,
|
||||
)
|
||||
buff.Close()
|
||||
return newCodec, false
|
||||
}
|
||||
|
||||
t.MediaTrackReceiver.MaybeSetSimulcast()
|
||||
t.MediaTrackReceiver.SetLayerSsrcsForRid(mimeType, track.RID(), uint32(track.SSRC()), 0)
|
||||
|
||||
if regressCodec {
|
||||
for _, c := range ti.Codecs {
|
||||
if mime.NormalizeMimeType(c.MimeType) == t.regressionTargetCodec {
|
||||
continue
|
||||
}
|
||||
|
||||
t.params.Logger.Debugw("suspending codec for codec regression", "codec", c.MimeType)
|
||||
if r := t.MediaTrackReceiver.Receiver(mime.NormalizeMimeType(c.MimeType)); r != nil {
|
||||
if rtcreceiver, ok := r.(*sfu.WebRTCReceiver); ok {
|
||||
rtcreceiver.SetCodecState(sfu.ReceiverCodecStateSuspended)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buff.OnNotifyRTX(t.MediaTrackReceiver.setLayerRtxInfo)
|
||||
|
||||
// if subscriber request fps before fps calculated, update them after fps updated.
|
||||
buff.OnFpsChanged(func() {
|
||||
t.MediaTrackSubscriptions.UpdateVideoLayers()
|
||||
})
|
||||
|
||||
buff.OnFinalRtpStats(func(stats *livekit.RTPStats) {
|
||||
t.params.TelemetryListener().OnTrackPublishRTPStats(
|
||||
t.params.ParticipantID(),
|
||||
t.ID(),
|
||||
mimeType,
|
||||
int(layer),
|
||||
stats,
|
||||
)
|
||||
})
|
||||
return newCodec, true
|
||||
}
|
||||
|
||||
func (t *MediaTrack) GetConnectionScoreAndQuality() (float32, livekit.ConnectionQuality) {
|
||||
receiver := t.ActiveReceiver()
|
||||
if rtcReceiver, ok := receiver.(*sfu.WebRTCReceiver); ok {
|
||||
return rtcReceiver.GetConnectionScoreAndQuality()
|
||||
}
|
||||
|
||||
return connectionquality.MaxMOS, livekit.ConnectionQuality_EXCELLENT
|
||||
}
|
||||
|
||||
func (t *MediaTrack) SetRTT(rtt uint32) {
|
||||
if !t.rttFromXR.Load() {
|
||||
t.MediaTrackReceiver.SetRTT(rtt)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) HasPendingCodec() bool {
|
||||
return t.MediaTrackReceiver.PrimaryReceiver() == nil
|
||||
}
|
||||
|
||||
func (t *MediaTrack) Restart() {
|
||||
t.MediaTrackReceiver.Restart()
|
||||
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.Restart()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) Close(isExpectedToResume bool) {
|
||||
t.MediaTrackReceiver.SetClosing(isExpectedToResume)
|
||||
if t.dynacastManager != nil {
|
||||
t.dynacastManager.Close()
|
||||
}
|
||||
t.MediaTrackReceiver.Close(isExpectedToResume)
|
||||
}
|
||||
|
||||
func (t *MediaTrack) SetMuted(muted bool) {
|
||||
// update quality based on subscription if unmuting.
|
||||
// This will queue up the current state, but subscriber
|
||||
// driven changes could update it.
|
||||
if !muted && t.dynacastManager != nil {
|
||||
t.dynacastManager.ForceUpdate()
|
||||
}
|
||||
|
||||
t.MediaTrackReceiver.SetMuted(muted)
|
||||
}
|
||||
|
||||
// OnTrackSubscribed is called when the track is subscribed by a non-hidden subscriber
|
||||
// this allows the publisher to know when they should start sending data
|
||||
func (t *MediaTrack) OnTrackSubscribed() {
|
||||
if !t.everSubscribed.Swap(true) && t.params.OnTrackEverSubscribed != nil {
|
||||
go t.params.OnTrackEverSubscribed(t.ID())
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) enableRegression() bool {
|
||||
return t.backupCodecPolicy == livekit.BackupCodecPolicy_REGRESSION ||
|
||||
(t.backupCodecPolicy == livekit.BackupCodecPolicy_PREFER_REGRESSION && t.params.ShouldRegressCodec())
|
||||
}
|
||||
|
||||
func (t *MediaTrack) Logger() logger.Logger {
|
||||
return t.params.Logger
|
||||
}
|
||||
|
||||
// dynacast.DynacastManagerListtener implementation
|
||||
var _ dynacast.DynacastManagerListener = (*MediaTrack)(nil)
|
||||
|
||||
func (t *MediaTrack) OnDynacastSubscribedMaxQualityChange(
|
||||
subscribedQualities []*livekit.SubscribedCodec,
|
||||
maxSubscribedQualities []types.SubscribedCodecQuality,
|
||||
) {
|
||||
if t.params.OnSubscribedMaxQualityChange != nil && !t.IsMuted() {
|
||||
_ = t.params.OnSubscribedMaxQualityChange(
|
||||
t.ID(),
|
||||
t.ToProto(),
|
||||
subscribedQualities,
|
||||
maxSubscribedQualities,
|
||||
)
|
||||
}
|
||||
|
||||
for _, q := range maxSubscribedQualities {
|
||||
receiver := t.Receiver(q.CodecMime)
|
||||
if receiver != nil {
|
||||
receiver.SetMaxExpectedSpatialLayer(
|
||||
buffer.GetSpatialLayerForVideoQuality(
|
||||
q.CodecMime,
|
||||
q.Quality,
|
||||
t.MediaTrackReceiver.TrackInfo(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) OnDynacastSubscribedAudioCodecChange(codecs []*livekit.SubscribedAudioCodec) {
|
||||
if t.params.OnSubscribedAudioCodecChange != nil {
|
||||
_ = t.params.OnSubscribedAudioCodecChange(t.ID(), codecs)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrack) SetMigrated(migrated bool) {
|
||||
t.migrated.Store(migrated)
|
||||
}
|
||||
|
||||
func (t *MediaTrack) Migrated() bool {
|
||||
return t.migrated.Load()
|
||||
}
|
||||
|
||||
func (t *MediaTrack) SetPublished(published bool) {
|
||||
t.published.Store(published)
|
||||
}
|
||||
|
||||
func (t *MediaTrack) Published() bool {
|
||||
return t.published.Load()
|
||||
}
|
||||
197
pkg/rtc/mediatrack_test.go
Normal file
197
pkg/rtc/mediatrack_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
func TestTrackInfo(t *testing.T) {
|
||||
// ensures that persisted trackinfo is being returned
|
||||
ti := livekit.TrackInfo{
|
||||
Sid: "testsid",
|
||||
Name: "testtrack",
|
||||
Source: livekit.TrackSource_SCREEN_SHARE,
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Simulcast: false,
|
||||
Width: 100,
|
||||
Height: 80,
|
||||
Muted: true,
|
||||
}
|
||||
|
||||
mt := NewMediaTrack(MediaTrackParams{}, &ti)
|
||||
outInfo := mt.ToProto()
|
||||
require.Equal(t, ti.Muted, outInfo.Muted)
|
||||
require.Equal(t, ti.Name, outInfo.Name)
|
||||
require.Equal(t, ti.Name, mt.Name())
|
||||
require.Equal(t, livekit.TrackID(ti.Sid), mt.ID())
|
||||
require.Equal(t, ti.Type, outInfo.Type)
|
||||
require.Equal(t, ti.Type, mt.Kind())
|
||||
require.Equal(t, ti.Source, outInfo.Source)
|
||||
require.Equal(t, ti.Width, outInfo.Width)
|
||||
require.Equal(t, ti.Height, outInfo.Height)
|
||||
require.Equal(t, ti.Simulcast, outInfo.Simulcast)
|
||||
}
|
||||
|
||||
func TestGetQualityForDimension(t *testing.T) {
|
||||
t.Run("landscape source", func(t *testing.T) {
|
||||
mt := NewMediaTrack(MediaTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
}, &livekit.TrackInfo{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
})
|
||||
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeVP8, 120, 120))
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeVP8, 300, 200))
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeVP8, 200, 250))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeVP8, 700, 480))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeVP8, 500, 1000))
|
||||
})
|
||||
|
||||
t.Run("portrait source", func(t *testing.T) {
|
||||
mt := NewMediaTrack(MediaTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
}, &livekit.TrackInfo{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Width: 540,
|
||||
Height: 960,
|
||||
})
|
||||
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeVP8, 200, 400))
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeVP8, 400, 400))
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeVP8, 400, 700))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeVP8, 600, 900))
|
||||
})
|
||||
|
||||
t.Run("layers provided", func(t *testing.T) {
|
||||
mt := NewMediaTrack(MediaTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
}, &livekit.TrackInfo{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
Codecs: []*livekit.SimulcastCodecInfo{
|
||||
{
|
||||
MimeType: mime.MimeTypeH264.String(),
|
||||
Layers: []*livekit.VideoLayer{
|
||||
{
|
||||
Quality: livekit.VideoQuality_LOW,
|
||||
Width: 480,
|
||||
Height: 270,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_MEDIUM,
|
||||
Width: 960,
|
||||
Height: 540,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_HIGH,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeH264, 120, 120))
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeH264, 300, 300))
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeH264, 800, 500))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 1000, 700))
|
||||
})
|
||||
|
||||
t.Run("highest layer with smallest dimensions", func(t *testing.T) {
|
||||
mt := NewMediaTrack(MediaTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
}, &livekit.TrackInfo{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
Codecs: []*livekit.SimulcastCodecInfo{
|
||||
{
|
||||
MimeType: mime.MimeTypeH264.String(),
|
||||
Layers: []*livekit.VideoLayer{
|
||||
{
|
||||
Quality: livekit.VideoQuality_LOW,
|
||||
Width: 480,
|
||||
Height: 270,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_MEDIUM,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_HIGH,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeH264, 120, 120))
|
||||
require.Equal(t, livekit.VideoQuality_LOW, mt.GetQualityForDimension(mime.MimeTypeH264, 300, 300))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 800, 500))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 1000, 700))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 1200, 800))
|
||||
|
||||
mt = NewMediaTrack(MediaTrackParams{
|
||||
Logger: logger.GetLogger(),
|
||||
}, &livekit.TrackInfo{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
Codecs: []*livekit.SimulcastCodecInfo{
|
||||
{
|
||||
MimeType: mime.MimeTypeH264.String(),
|
||||
Layers: []*livekit.VideoLayer{
|
||||
{
|
||||
Quality: livekit.VideoQuality_LOW,
|
||||
Width: 480,
|
||||
Height: 270,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_MEDIUM,
|
||||
Width: 480,
|
||||
Height: 270,
|
||||
},
|
||||
{
|
||||
Quality: livekit.VideoQuality_HIGH,
|
||||
Width: 1080,
|
||||
Height: 720,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeH264, 120, 120))
|
||||
require.Equal(t, livekit.VideoQuality_MEDIUM, mt.GetQualityForDimension(mime.MimeTypeH264, 300, 300))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 800, 500))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 1000, 700))
|
||||
require.Equal(t, livekit.VideoQuality_HIGH, mt.GetQualityForDimension(mime.MimeTypeH264, 1200, 800))
|
||||
})
|
||||
|
||||
}
|
||||
1248
pkg/rtc/mediatrackreceiver.go
Normal file
1248
pkg/rtc/mediatrackreceiver.go
Normal file
File diff suppressed because it is too large
Load Diff
366
pkg/rtc/mediatracksubscriptions.go
Normal file
366
pkg/rtc/mediatracksubscriptions.go
Normal file
@@ -0,0 +1,366 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"slices"
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
)
|
||||
|
||||
var (
|
||||
errAlreadySubscribed = errors.New("already subscribed")
|
||||
errNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
// MediaTrackSubscriptions manages subscriptions of a media track
|
||||
type MediaTrackSubscriptions struct {
|
||||
params MediaTrackSubscriptionsParams
|
||||
|
||||
subscribedTracksMu sync.RWMutex
|
||||
subscribedTracks map[livekit.ParticipantID]types.SubscribedTrack
|
||||
|
||||
onDownTrackCreated func(downTrack *sfu.DownTrack)
|
||||
onSubscriberMaxQualityChange func(subscriberID livekit.ParticipantID, mime mime.MimeType, layer int32)
|
||||
onSubscriberAudioCodecChange func(subscriberID livekit.ParticipantID, mime mime.MimeType, enabled bool)
|
||||
}
|
||||
|
||||
type MediaTrackSubscriptionsParams struct {
|
||||
MediaTrack types.MediaTrack
|
||||
IsRelayed bool
|
||||
|
||||
ReceiverConfig ReceiverConfig
|
||||
SubscriberConfig DirectionConfig
|
||||
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
func NewMediaTrackSubscriptions(params MediaTrackSubscriptionsParams) *MediaTrackSubscriptions {
|
||||
return &MediaTrackSubscriptions{
|
||||
params: params,
|
||||
subscribedTracks: make(map[livekit.ParticipantID]types.SubscribedTrack),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) OnDownTrackCreated(f func(downTrack *sfu.DownTrack)) {
|
||||
t.onDownTrackCreated = f
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) OnSubscriberMaxQualityChange(f func(subscriberID livekit.ParticipantID, mime mime.MimeType, layer int32)) {
|
||||
t.onSubscriberMaxQualityChange = f
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) OnSubscriberAudioCodecChange(f func(subscriberID livekit.ParticipantID, mime mime.MimeType, enabled bool)) {
|
||||
t.onSubscriberAudioCodecChange = f
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) SetMuted(muted bool) {
|
||||
// update mute of all subscribed tracks
|
||||
for _, st := range t.getAllSubscribedTracks() {
|
||||
st.SetPublisherMuted(muted)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) IsSubscriber(subID livekit.ParticipantID) bool {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
_, ok := t.subscribedTracks[subID]
|
||||
return ok
|
||||
}
|
||||
|
||||
// AddSubscriber subscribes sub to current mediaTrack
|
||||
func (t *MediaTrackSubscriptions) AddSubscriber(sub types.LocalParticipant, wr *WrappedReceiver) (types.SubscribedTrack, error) {
|
||||
trackID := t.params.MediaTrack.ID()
|
||||
subscriberID := sub.ID()
|
||||
|
||||
// don't subscribe to the same track multiple times
|
||||
t.subscribedTracksMu.Lock()
|
||||
if _, ok := t.subscribedTracks[subscriberID]; ok {
|
||||
t.subscribedTracksMu.Unlock()
|
||||
return nil, errAlreadySubscribed
|
||||
}
|
||||
t.subscribedTracksMu.Unlock()
|
||||
|
||||
subTrack, err := NewSubscribedTrack(SubscribedTrackParams{
|
||||
ReceiverConfig: t.params.ReceiverConfig,
|
||||
SubscriberConfig: t.params.SubscriberConfig,
|
||||
Subscriber: sub,
|
||||
MediaTrack: t.params.MediaTrack,
|
||||
AdaptiveStream: sub.GetAdaptiveStream(),
|
||||
EnableStartAtDesiredQuality: sub.GetEnableStartAtDesiredQuality(),
|
||||
TelemetryListener: sub.GetTelemetryListener(),
|
||||
WrappedReceiver: wr,
|
||||
IsRelayed: t.params.IsRelayed,
|
||||
OnDownTrackCreated: t.onDownTrackCreated,
|
||||
OnDownTrackClosed: func(subscriberID livekit.ParticipantID) {
|
||||
t.subscribedTracksMu.Lock()
|
||||
delete(t.subscribedTracks, subscriberID)
|
||||
t.subscribedTracksMu.Unlock()
|
||||
},
|
||||
OnSubscriberMaxQualityChange: t.onSubscriberMaxQualityChange,
|
||||
OnSubscriberAudioCodecChange: t.onSubscriberAudioCodecChange,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Bind callback can happen from replaceTrack, so set it up early
|
||||
var reusingTransceiver atomic.Bool
|
||||
var dtState sfu.DownTrackState
|
||||
downTrack := subTrack.DownTrack()
|
||||
downTrack.OnBinding(func(err error) {
|
||||
if err != nil {
|
||||
go subTrack.Bound(err)
|
||||
return
|
||||
}
|
||||
if reusingTransceiver.Load() {
|
||||
sub.GetLogger().Debugw("seeding downtrack state", "trackID", trackID)
|
||||
downTrack.SeedState(dtState)
|
||||
}
|
||||
if err = wr.AddDownTrack(downTrack); err != nil && err != sfu.ErrReceiverClosed {
|
||||
sub.GetLogger().Errorw(
|
||||
"could not add down track", err,
|
||||
"publisher", subTrack.PublisherIdentity(),
|
||||
"publisherID", subTrack.PublisherID(),
|
||||
"trackID", trackID,
|
||||
)
|
||||
}
|
||||
|
||||
go subTrack.Bound(nil)
|
||||
|
||||
subTrack.SetPublisherMuted(t.params.MediaTrack.IsMuted())
|
||||
})
|
||||
|
||||
var transceiver *webrtc.RTPTransceiver
|
||||
var sender *webrtc.RTPSender
|
||||
|
||||
// try cached RTP senders for a chance to replace track
|
||||
var existingTransceiver *webrtc.RTPTransceiver
|
||||
replacedTrack := false
|
||||
existingTransceiver, dtState = sub.GetCachedDownTrack(trackID)
|
||||
if existingTransceiver != nil {
|
||||
sub.GetLogger().Debugw(
|
||||
"trying to use existing transceiver",
|
||||
"publisher", subTrack.PublisherIdentity(),
|
||||
"publisherID", subTrack.PublisherID(),
|
||||
"trackID", trackID,
|
||||
)
|
||||
reusingTransceiver.Store(true)
|
||||
rtpSender := existingTransceiver.Sender()
|
||||
if rtpSender != nil {
|
||||
// replaced track will bind immediately without negotiation, SetTransceiver first before bind
|
||||
downTrack.SetTransceiver(existingTransceiver)
|
||||
err := rtpSender.ReplaceTrack(downTrack)
|
||||
if err == nil {
|
||||
sender = rtpSender
|
||||
transceiver = existingTransceiver
|
||||
replacedTrack = true
|
||||
sub.GetLogger().Debugw(
|
||||
"track replaced",
|
||||
"publisher", subTrack.PublisherIdentity(),
|
||||
"publisherID", subTrack.PublisherID(),
|
||||
"trackID", trackID,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !replacedTrack {
|
||||
// Could not re-use cached transceiver for this track.
|
||||
// Stop the transceiver so that it is at least not active.
|
||||
// It is not usable once stopped,
|
||||
//
|
||||
// Adding down track will create a new transceiver (or re-use
|
||||
// an inactive existing one). In either case, a renegotiation
|
||||
// will happen and that will notify remote of this stopped
|
||||
// transceiver
|
||||
existingTransceiver.Stop()
|
||||
reusingTransceiver.Store(false)
|
||||
}
|
||||
}
|
||||
|
||||
// if cannot replace, find an unused transceiver or add new one
|
||||
if transceiver == nil {
|
||||
info := t.params.MediaTrack.ToProto()
|
||||
addTrackParams := types.AddTrackParams{
|
||||
Stereo: slices.Contains(info.AudioFeatures, livekit.AudioTrackFeature_TF_STEREO),
|
||||
Red: IsRedEnabled(info),
|
||||
}
|
||||
codecs := wr.Codecs()
|
||||
if addTrackParams.Red && (len(codecs) == 1 && mime.IsMimeTypeStringOpus(codecs[0].MimeType)) {
|
||||
addTrackParams.Red = false
|
||||
}
|
||||
|
||||
sub.VerifySubscribeParticipantInfo(subTrack.PublisherID(), subTrack.PublisherVersion())
|
||||
if sub.SupportsTransceiverReuse(t.params.MediaTrack) {
|
||||
//
|
||||
// AddTrack will create a new transceiver or re-use an unused one
|
||||
// if the attributes match. This prevents SDP from bloating
|
||||
// because of dormant transceivers building up.
|
||||
//
|
||||
sender, transceiver, err = sub.AddTrackLocal(downTrack, addTrackParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
sender, transceiver, err = sub.AddTransceiverFromTrackLocal(downTrack, addTrackParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// whether re-using or stopping remove transceiver from cache
|
||||
// NOTE: safety net, if somehow a cached transceiver is re-used by a different track
|
||||
sub.UncacheDownTrack(transceiver)
|
||||
|
||||
// negotiation isn't required if we've replaced track
|
||||
// ONE-SHOT-SIGNALLING-MODE: this should not be needed, but that mode information is not available here,
|
||||
// but it is not detrimental to set this, needs clean up when participants modes are separated out better.
|
||||
subTrack.SetNeedsNegotiation(!replacedTrack)
|
||||
subTrack.SetRTPSender(sender)
|
||||
|
||||
// it is possible that subscribed track is closed before subscription manager sets
|
||||
// the `OnClose` callback. That handler in subscription manager removes the track
|
||||
// from the peer connection.
|
||||
//
|
||||
// But, the subscription could be removed early if the published track is closed
|
||||
// while adding subscription. In those cases, subscription manager would not have set
|
||||
// the `OnClose` callback. So, set it here to handle cases of early close.
|
||||
// Subscription manager will reset this if this subscription proceeds till that point.
|
||||
subTrack.OnClose(func(isExpectedToResume bool) {
|
||||
if !isExpectedToResume {
|
||||
if err := sub.RemoveTrackLocal(sender); err != nil {
|
||||
t.params.Logger.Warnw("could not remove track from peer connection", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
downTrack.SetTransceiver(transceiver)
|
||||
|
||||
t.subscribedTracksMu.Lock()
|
||||
t.subscribedTracks[subscriberID] = subTrack
|
||||
t.subscribedTracksMu.Unlock()
|
||||
|
||||
return subTrack, nil
|
||||
}
|
||||
|
||||
// RemoveSubscriber removes participant from subscription
|
||||
// stop all forwarders to the client
|
||||
func (t *MediaTrackSubscriptions) RemoveSubscriber(subscriberID livekit.ParticipantID, isExpectedToResume bool) error {
|
||||
subTrack := t.getSubscribedTrack(subscriberID)
|
||||
if subTrack == nil {
|
||||
return errNotFound
|
||||
}
|
||||
|
||||
t.params.Logger.Debugw("removing subscriber", "subscriberID", subscriberID, "isExpectedToResume", isExpectedToResume)
|
||||
t.closeSubscribedTrack(subTrack, isExpectedToResume)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) closeSubscribedTrack(subTrack types.SubscribedTrack, isExpectedToResume bool) {
|
||||
dt := subTrack.DownTrack()
|
||||
if dt == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if isExpectedToResume {
|
||||
dt.CloseWithFlush(false, false)
|
||||
} else {
|
||||
// flushing blocks, avoid blocking when publisher removes all its subscribers
|
||||
go dt.CloseWithFlush(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) GetAllSubscribers() []livekit.ParticipantID {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
subs := make([]livekit.ParticipantID, 0, len(t.subscribedTracks))
|
||||
for id := range t.subscribedTracks {
|
||||
subs = append(subs, id)
|
||||
}
|
||||
return subs
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) GetAllSubscribersForMime(mime mime.MimeType) []livekit.ParticipantID {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
subs := make([]livekit.ParticipantID, 0, len(t.subscribedTracks))
|
||||
for id, subTrack := range t.subscribedTracks {
|
||||
if subTrack.DownTrack().Mime() != mime {
|
||||
continue
|
||||
}
|
||||
|
||||
subs = append(subs, id)
|
||||
}
|
||||
return subs
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) GetNumSubscribers() int {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
return len(t.subscribedTracks)
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) UpdateVideoLayers() {
|
||||
for _, st := range t.getAllSubscribedTracks() {
|
||||
st.UpdateVideoLayer()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) getSubscribedTrack(subscriberID livekit.ParticipantID) types.SubscribedTrack {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
return t.subscribedTracks[subscriberID]
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) getAllSubscribedTracks() []types.SubscribedTrack {
|
||||
t.subscribedTracksMu.RLock()
|
||||
defer t.subscribedTracksMu.RUnlock()
|
||||
|
||||
return t.getAllSubscribedTracksLocked()
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) getAllSubscribedTracksLocked() []types.SubscribedTrack {
|
||||
subTracks := make([]types.SubscribedTrack, 0, len(t.subscribedTracks))
|
||||
for _, subTrack := range t.subscribedTracks {
|
||||
subTracks = append(subTracks, subTrack)
|
||||
}
|
||||
return subTracks
|
||||
}
|
||||
|
||||
func (t *MediaTrackSubscriptions) DebugInfo() []map[string]any {
|
||||
subscribedTrackInfo := make([]map[string]any, 0)
|
||||
for _, val := range t.getAllSubscribedTracks() {
|
||||
if st, ok := val.(*SubscribedTrack); ok {
|
||||
subscribedTrackInfo = append(subscribedTrackInfo, st.DownTrack().DebugInfo())
|
||||
}
|
||||
}
|
||||
|
||||
return subscribedTrackInfo
|
||||
}
|
||||
69
pkg/rtc/migrationdatacache.go
Normal file
69
pkg/rtc/migrationdatacache.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
type MigrationDataCacheState int
|
||||
|
||||
const (
|
||||
MigrationDataCacheStateWaiting MigrationDataCacheState = iota
|
||||
MigrationDataCacheStateTimeout
|
||||
MigrationDataCacheStateDone
|
||||
)
|
||||
|
||||
const (
|
||||
migrationDataCacheMaxSize = 4 << 20 // 4 MiB
|
||||
)
|
||||
|
||||
type MigrationDataCache struct {
|
||||
lastSeq uint32
|
||||
pkts []*livekit.DataPacket
|
||||
size int
|
||||
state MigrationDataCacheState
|
||||
expiredAt time.Time
|
||||
}
|
||||
|
||||
func NewMigrationDataCache(lastSeq uint32, expiredAt time.Time) *MigrationDataCache {
|
||||
return &MigrationDataCache{
|
||||
lastSeq: lastSeq,
|
||||
expiredAt: expiredAt,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds a message to the cache if there is a gap between the last sequence number and cached messages then return the cache State:
|
||||
// - MigrationDataCacheStateWaiting: waiting for the next packet (lastSeq + 1) of last sequence from old node
|
||||
// - MigrationDataCacheStateTimeout: the next packet is not received before the expiredAt or the cache is full, participant
|
||||
// will continue to process the reliable messages, subscribers will see the gap after the publisher migration
|
||||
// - MigrationDataCacheStateDone: the next packet is received, participant can continue to process the reliable messages
|
||||
func (c *MigrationDataCache) Add(pkt *livekit.DataPacket, size int) MigrationDataCacheState {
|
||||
if c.state == MigrationDataCacheStateDone || c.state == MigrationDataCacheStateTimeout {
|
||||
return c.state
|
||||
}
|
||||
|
||||
if pkt.Sequence <= c.lastSeq {
|
||||
return c.state
|
||||
}
|
||||
|
||||
if pkt.Sequence == c.lastSeq+1 {
|
||||
c.state = MigrationDataCacheStateDone
|
||||
return c.state
|
||||
}
|
||||
|
||||
c.pkts = append(c.pkts, pkt)
|
||||
c.size += size
|
||||
if c.size >= migrationDataCacheMaxSize || time.Now().After(c.expiredAt) {
|
||||
c.state = MigrationDataCacheStateTimeout
|
||||
}
|
||||
return c.state
|
||||
}
|
||||
|
||||
func (c *MigrationDataCache) Get() []*livekit.DataPacket {
|
||||
return c.pkts
|
||||
}
|
||||
|
||||
func (c *MigrationDataCache) Size() int {
|
||||
return c.size
|
||||
}
|
||||
57
pkg/rtc/migrationdatacache_test.go
Normal file
57
pkg/rtc/migrationdatacache_test.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
)
|
||||
|
||||
func TestMigrationDataCache_Add(t *testing.T) {
|
||||
expiredAt := time.Now().Add(100 * time.Millisecond)
|
||||
cache := NewMigrationDataCache(10, expiredAt)
|
||||
|
||||
pkt1 := &livekit.DataPacket{Sequence: 9}
|
||||
state := cache.Add(pkt1, 0)
|
||||
require.Equal(t, MigrationDataCacheStateWaiting, state)
|
||||
require.Empty(t, cache.Get())
|
||||
|
||||
pkt2 := &livekit.DataPacket{Sequence: 11}
|
||||
state = cache.Add(pkt2, 0)
|
||||
require.Equal(t, MigrationDataCacheStateDone, state)
|
||||
require.Empty(t, cache.Get())
|
||||
|
||||
pkt3 := &livekit.DataPacket{Sequence: 12}
|
||||
state = cache.Add(pkt3, 0)
|
||||
require.Equal(t, MigrationDataCacheStateDone, state)
|
||||
require.Empty(t, cache.Get())
|
||||
|
||||
cache2 := NewMigrationDataCache(20, time.Now().Add(10*time.Millisecond))
|
||||
pkt4 := &livekit.DataPacket{Sequence: 22}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
state = cache2.Add(pkt4, 0)
|
||||
require.Equal(t, MigrationDataCacheStateTimeout, state)
|
||||
require.Len(t, cache2.Get(), 1)
|
||||
require.Equal(t, uint32(22), cache2.Get()[0].Sequence)
|
||||
}
|
||||
|
||||
func TestMigrationDataCache_MaxSize(t *testing.T) {
|
||||
// the cache should not grow past the size budget even if the expiry is far in the future
|
||||
cache := NewMigrationDataCache(10, time.Now().Add(time.Minute))
|
||||
|
||||
pktSize := 1000
|
||||
seq := uint32(12)
|
||||
state := MigrationDataCacheStateWaiting
|
||||
for ; state == MigrationDataCacheStateWaiting; seq++ {
|
||||
state = cache.Add(&livekit.DataPacket{Sequence: seq}, pktSize)
|
||||
}
|
||||
|
||||
require.Equal(t, MigrationDataCacheStateTimeout, state)
|
||||
require.LessOrEqual(t, cache.Size(), migrationDataCacheMaxSize+pktSize)
|
||||
require.Len(t, cache.Get(), migrationDataCacheMaxSize/pktSize+1)
|
||||
|
||||
// once full, further packets are dropped, including the continuous one
|
||||
require.Equal(t, MigrationDataCacheStateTimeout, cache.Add(&livekit.DataPacket{Sequence: 11}, pktSize))
|
||||
}
|
||||
4570
pkg/rtc/participant.go
Normal file
4570
pkg/rtc/participant.go
Normal file
File diff suppressed because it is too large
Load Diff
90
pkg/rtc/participant_data_blob.go
Normal file
90
pkg/rtc/participant_data_blob.go
Normal file
@@ -0,0 +1,90 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
type ParticipantDataBlobParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type ParticipantDataBlob struct {
|
||||
params ParticipantDataBlobParams
|
||||
lock sync.Mutex
|
||||
blobs map[string]*livekit.DataBlob
|
||||
}
|
||||
|
||||
func NewParticipantDataBlob(params ParticipantDataBlobParams) *ParticipantDataBlob {
|
||||
return &ParticipantDataBlob{
|
||||
params: params,
|
||||
blobs: make(map[string]*livekit.DataBlob),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ParticipantDataBlob) Add(db *livekit.DataBlob) {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
if db.Key == nil {
|
||||
return
|
||||
}
|
||||
|
||||
p.blobs[db.Key.String()] = db
|
||||
}
|
||||
|
||||
func (p *ParticipantDataBlob) Delete(dbKey *livekit.DataBlobKey) {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
if dbKey == nil {
|
||||
return
|
||||
}
|
||||
|
||||
delete(p.blobs, dbKey.String())
|
||||
}
|
||||
|
||||
func (p *ParticipantDataBlob) Get(dbKey *livekit.DataBlobKey) *livekit.DataBlob {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
if dbKey == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
db, ok := p.blobs[dbKey.String()]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
func (p *ParticipantDataBlob) GetAll() []*livekit.DataBlob {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
all := make([]*livekit.DataBlob, 0, len(p.blobs))
|
||||
for _, db := range p.blobs {
|
||||
all = append(all, db)
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
// -------------------------------
|
||||
123
pkg/rtc/participant_data_blob_handler.go
Normal file
123
pkg/rtc/participant_data_blob_handler.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobRequest) {
|
||||
if !p.params.EnableParticipantDataBlob {
|
||||
p.pubLogger.Warnw("data blob not enabled", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_NOT_ALLOWED,
|
||||
Message: "data blob not enabled",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.Blob == nil || req.Blob.Key == nil || len(req.Blob.Key.String()) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key.String()) {
|
||||
p.pubLogger.Warnw("data blob is invalid", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_INVALID_REQUEST,
|
||||
Message: "data blob is invalid",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !p.params.LimitConfig.CheckDataTrackSchemaID(req.Blob.Key.GetSchemaId()) {
|
||||
p.pubLogger.Warnw("data blob key schema id is invalid", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_INVALID_REQUEST,
|
||||
Message: "encoding identifier is empty or exceeds the maximum length",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Blob.Contents) == 0 {
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_INVALID_REQUEST,
|
||||
Message: "data blob is empty",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !p.params.LimitConfig.CanAddDataBlob(p.dataBlob.GetAll(), req.Blob) {
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_LIMIT_EXCEEDED,
|
||||
Message: "async attribute definition exceeds limit",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
p.AddDataBlob(req.Blob)
|
||||
p.listener().OnStoreDataBlob(p, req.Blob)
|
||||
p.sendStoreDataBlobResponse(req.RequestId, req.Blob.Key)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) HandleGetDataBlobRequest(req *livekit.GetDataBlobRequest) {
|
||||
if req.Key == nil {
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_INVALID_REQUEST,
|
||||
Message: "data blob key is required",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
p.listener().OnGetDataBlob(p, req)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) AddDataBlob(dataBlob *livekit.DataBlob) {
|
||||
p.dataBlob.Add(dataBlob)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob {
|
||||
return p.dataBlob.Get(key)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) ProcessGetDataBlobRequest(req *livekit.GetDataBlobRequest, publisher types.Participant) {
|
||||
if publisher == nil {
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_NOT_FOUND,
|
||||
Message: "participant not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dataBlob := publisher.GetDataBlob(req.Key)
|
||||
if dataBlob == nil {
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
RequestId: req.RequestId,
|
||||
Reason: livekit.RequestResponse_NOT_FOUND,
|
||||
Message: "data blob not found",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
p.sendGetDataBlobResponse(req.RequestId, dataBlob)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetAllDataBlob() []*livekit.DataBlob {
|
||||
return p.dataBlob.GetAll()
|
||||
}
|
||||
300
pkg/rtc/participant_data_blob_handler_test.go
Normal file
300
pkg/rtc/participant_data_blob_handler_test.go
Normal file
@@ -0,0 +1,300 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/routing/routingfakes"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
|
||||
)
|
||||
|
||||
func newParticipantWithDataBlob(t *testing.T, enabled bool, maxKeyLength int, maxSize uint32) *ParticipantImpl {
|
||||
t.Helper()
|
||||
p := newParticipantForTest("test")
|
||||
p.params.EnableParticipantDataBlob = enabled
|
||||
p.params.LimitConfig = config.LimitConfig{
|
||||
MaxDataBlobKeyLength: maxKeyLength,
|
||||
MaxDataBlobSize: maxSize,
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func lastRequestResponse(t *testing.T, sink *routingfakes.FakeMessageSink, idx int) *livekit.RequestResponse {
|
||||
t.Helper()
|
||||
msg := sink.WriteMessageArgsForCall(idx).(*livekit.SignalResponse)
|
||||
rr, ok := msg.Message.(*livekit.SignalResponse_RequestResponse)
|
||||
require.True(t, ok, "expected SignalResponse_RequestResponse, got %T", msg.Message)
|
||||
return rr.RequestResponse
|
||||
}
|
||||
|
||||
func TestHandleStoreDataBlobRequest(t *testing.T) {
|
||||
t.Run("returns NOT_ALLOWED when feature not enabled", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, false, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
req := &livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Key: genericKey("blob-1"),
|
||||
Contents: []byte("def"),
|
||||
},
|
||||
}
|
||||
p.HandleStoreDataBlobRequest(req)
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_NOT_ALLOWED, rr.Reason)
|
||||
require.Empty(t, p.dataBlob.GetAll())
|
||||
})
|
||||
|
||||
t.Run("returns INVALID_REQUEST when blob is nil", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
require.Empty(t, p.dataBlob.GetAll())
|
||||
})
|
||||
|
||||
t.Run("returns INVALID_REQUEST when key is nil", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Contents: []byte("def"),
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
})
|
||||
|
||||
t.Run("returns INVALID_REQUEST when key has no oneof set", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Key: &livekit.DataBlobKey{},
|
||||
Contents: []byte("def"),
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
})
|
||||
|
||||
t.Run("returns INVALID_REQUEST when key exceeds length limit", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 5, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Key: genericKey(strings.Repeat("a", 64)),
|
||||
Contents: []byte("def"),
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
})
|
||||
|
||||
t.Run("returns INVALID_REQUEST when contents is empty", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Key: genericKey("blob-1"),
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
require.Empty(t, p.dataBlob.GetAll())
|
||||
})
|
||||
|
||||
t.Run("returns LIMIT_EXCEEDED when adding would breach the limit", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 16)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
Blob: &livekit.DataBlob{
|
||||
Key: genericKey("blob-1"),
|
||||
Contents: []byte(strings.Repeat("x", 32)),
|
||||
},
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_LIMIT_EXCEEDED, rr.Reason)
|
||||
require.Empty(t, p.dataBlob.GetAll())
|
||||
})
|
||||
|
||||
t.Run("stores a valid blob, notifies listener, and sends response", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener)
|
||||
|
||||
key := genericKey("blob-1")
|
||||
contents := []byte("definition-bytes")
|
||||
blob := &livekit.DataBlob{Key: key, Contents: contents}
|
||||
|
||||
p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{
|
||||
RequestId: 42,
|
||||
Blob: blob,
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
|
||||
response, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse)
|
||||
require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message)
|
||||
require.Equal(t, uint32(42), response.StoreDataBlobResponse.RequestId)
|
||||
require.Equal(t, key, response.StoreDataBlobResponse.Key)
|
||||
|
||||
stored := p.dataBlob.Get(key)
|
||||
require.NotNil(t, stored)
|
||||
require.Equal(t, contents, stored.Contents)
|
||||
|
||||
require.Equal(t, 1, listener.OnStoreDataBlobCallCount())
|
||||
gotParticipant, gotBlob := listener.OnStoreDataBlobArgsForCall(0)
|
||||
require.Equal(t, p, gotParticipant)
|
||||
require.Equal(t, blob, gotBlob)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGetDataBlobRequest(t *testing.T) {
|
||||
t.Run("returns INVALID_REQUEST when key is missing", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.HandleGetDataBlobRequest(&livekit.GetDataBlobRequest{
|
||||
ParticipantIdentity: "other",
|
||||
})
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason)
|
||||
})
|
||||
|
||||
t.Run("forwards request to listener when key is provided", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
listener := p.params.ParticipantListener.(*typesfakes.FakeLocalParticipantListener)
|
||||
|
||||
req := &livekit.GetDataBlobRequest{
|
||||
ParticipantIdentity: "other",
|
||||
Key: genericKey("blob-1"),
|
||||
}
|
||||
p.HandleGetDataBlobRequest(req)
|
||||
|
||||
require.Equal(t, 1, listener.OnGetDataBlobCallCount())
|
||||
gotParticipant, gotReq := listener.OnGetDataBlobArgsForCall(0)
|
||||
require.Equal(t, p, gotParticipant)
|
||||
require.Equal(t, req, gotReq)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGetDataBlob(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
|
||||
key := genericKey("blob-1")
|
||||
require.Nil(t, p.GetDataBlob(key))
|
||||
|
||||
blob := &livekit.DataBlob{
|
||||
Key: key,
|
||||
Contents: []byte("definition"),
|
||||
}
|
||||
p.dataBlob.Add(blob)
|
||||
got := p.GetDataBlob(key)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, key.String(), got.Key.String())
|
||||
require.Equal(t, []byte("definition"), got.Contents)
|
||||
}
|
||||
|
||||
func TestProcessGetDataBlobRequest(t *testing.T) {
|
||||
t.Run("returns NOT_FOUND when publisher is nil", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{
|
||||
Key: genericKey("blob-1"),
|
||||
}, nil)
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason)
|
||||
require.Contains(t, rr.Message, "participant")
|
||||
})
|
||||
|
||||
t.Run("returns NOT_FOUND when publisher has no matching blob", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
publisher := &typesfakes.FakeParticipant{}
|
||||
publisher.GetDataBlobReturns(nil)
|
||||
|
||||
req := &livekit.GetDataBlobRequest{
|
||||
Key: genericKey("blob-1"),
|
||||
}
|
||||
p.ProcessGetDataBlobRequest(req, publisher)
|
||||
|
||||
require.Equal(t, 1, publisher.GetDataBlobCallCount())
|
||||
require.Equal(t, req.Key, publisher.GetDataBlobArgsForCall(0))
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
rr := lastRequestResponse(t, sink, 0)
|
||||
require.Equal(t, livekit.RequestResponse_NOT_FOUND, rr.Reason)
|
||||
})
|
||||
|
||||
t.Run("sends blob response when publisher has a matching blob", func(t *testing.T) {
|
||||
p := newParticipantWithDataBlob(t, true, 0, 0)
|
||||
sink := p.params.Sink.(*routingfakes.FakeMessageSink)
|
||||
|
||||
key := genericKey("blob-1")
|
||||
blob := &livekit.DataBlob{
|
||||
Key: key,
|
||||
Contents: []byte("definition-bytes"),
|
||||
}
|
||||
|
||||
publisher := &typesfakes.FakeParticipant{}
|
||||
publisher.GetDataBlobReturns(blob)
|
||||
|
||||
p.ProcessGetDataBlobRequest(&livekit.GetDataBlobRequest{
|
||||
RequestId: 42,
|
||||
Key: key,
|
||||
}, publisher)
|
||||
|
||||
require.Equal(t, 1, sink.WriteMessageCallCount())
|
||||
msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse)
|
||||
response, ok := msg.Message.(*livekit.SignalResponse_GetDataBlobResponse)
|
||||
require.True(t, ok, "expected SignalResponse_GetDataBlobResponse, got %T", msg.Message)
|
||||
require.Equal(t, uint32(42), response.GetDataBlobResponse.RequestId)
|
||||
require.Equal(t, blob, response.GetDataBlobResponse.Blob)
|
||||
})
|
||||
}
|
||||
175
pkg/rtc/participant_data_blob_test.go
Normal file
175
pkg/rtc/participant_data_blob_test.go
Normal file
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 LiveKit, Inc.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package rtc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
func newTestDataBlob() *ParticipantDataBlob {
|
||||
return NewParticipantDataBlob(ParticipantDataBlobParams{
|
||||
Logger: logger.GetLogger(),
|
||||
})
|
||||
}
|
||||
|
||||
func genericKey(name string) *livekit.DataBlobKey {
|
||||
return &livekit.DataBlobKey{
|
||||
Key: &livekit.DataBlobKey_Generic{
|
||||
Generic: name,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_AddAndGet(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
key := genericKey("blob-1")
|
||||
contents := []byte("definition-bytes")
|
||||
|
||||
a.Add(&livekit.DataBlob{Key: key, Contents: contents})
|
||||
|
||||
got := a.Get(key)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, key.String(), got.Key.String())
|
||||
require.Equal(t, contents, got.Contents)
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_AddOverwrites(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
key := genericKey("blob-1")
|
||||
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v1")})
|
||||
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v2")})
|
||||
|
||||
got := a.Get(key)
|
||||
require.NotNil(t, got)
|
||||
require.Equal(t, []byte("v2"), got.Contents)
|
||||
|
||||
require.Len(t, a.GetAll(), 1)
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_DistinctKeys(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
key1 := genericKey("blob-1")
|
||||
key2 := genericKey("blob-2")
|
||||
|
||||
a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("c1")})
|
||||
a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("c2")})
|
||||
|
||||
got1 := a.Get(key1)
|
||||
require.NotNil(t, got1)
|
||||
require.Equal(t, []byte("c1"), got1.Contents)
|
||||
|
||||
got2 := a.Get(key2)
|
||||
require.NotNil(t, got2)
|
||||
require.Equal(t, []byte("c2"), got2.Contents)
|
||||
|
||||
require.Len(t, a.GetAll(), 2)
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_Delete(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
key := genericKey("blob-1")
|
||||
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("definition")})
|
||||
|
||||
a.Delete(key)
|
||||
require.Nil(t, a.Get(key))
|
||||
require.Empty(t, a.GetAll())
|
||||
|
||||
// deleting a non-existent key is a no-op
|
||||
a.Delete(key)
|
||||
require.Empty(t, a.GetAll())
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_NilKey(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
// nil key should be silently ignored, not panic
|
||||
a.Add(&livekit.DataBlob{Contents: []byte("definition")})
|
||||
require.Empty(t, a.GetAll())
|
||||
|
||||
require.Nil(t, a.Get(nil))
|
||||
|
||||
a.Delete(nil)
|
||||
require.Empty(t, a.GetAll())
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_GetMissing(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
require.Nil(t, a.Get(genericKey("missing")))
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_GetAllContents(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
key1 := genericKey("blob-1")
|
||||
key2 := genericKey("blob-2")
|
||||
|
||||
a.Add(&livekit.DataBlob{Key: key1, Contents: []byte("def-1")})
|
||||
a.Add(&livekit.DataBlob{Key: key2, Contents: []byte("def-2")})
|
||||
|
||||
all := a.GetAll()
|
||||
require.Len(t, all, 2)
|
||||
for _, db := range all {
|
||||
switch key := db.Key.Key.(type) {
|
||||
case *livekit.DataBlobKey_Generic:
|
||||
switch key.Generic {
|
||||
case "blob-1":
|
||||
require.Equal(t, []byte("def-1"), db.Contents)
|
||||
case "blob-2":
|
||||
require.Equal(t, []byte("def-2"), db.Contents)
|
||||
default:
|
||||
require.Fail(t, "unexpected key", key.Generic)
|
||||
}
|
||||
default:
|
||||
require.Fail(t, "unexpected key type", "Generic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParticipantDataBlob_ConcurrentAccess(t *testing.T) {
|
||||
a := newTestDataBlob()
|
||||
|
||||
const numGoroutines = 16
|
||||
const opsPerGoroutine = 100
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numGoroutines)
|
||||
for g := 0; g < numGoroutines; g++ {
|
||||
go func(g int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < opsPerGoroutine; i++ {
|
||||
key := genericKey(fmt.Sprintf("blob-%d", g%8))
|
||||
a.Add(&livekit.DataBlob{Key: key, Contents: []byte("v")})
|
||||
_ = a.Get(key)
|
||||
_ = a.GetAll()
|
||||
if i%3 == 0 {
|
||||
a.Delete(key)
|
||||
}
|
||||
}
|
||||
}(g)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
212
pkg/rtc/participant_data_track.go
Normal file
212
pkg/rtc/participant_data_track.go
Normal file
@@ -0,0 +1,212 @@
|
||||
// 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 (
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/datatrack"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
)
|
||||
|
||||
func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishDataTrackRequest) {
|
||||
if !p.CanPublishData() || !p.params.EnableDataTracks {
|
||||
p.pubLogger.Warnw("no permission to publish data track", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: livekit.RequestResponse_NOT_ALLOWED,
|
||||
Message: "does not have permission to publish data",
|
||||
Request: &livekit.RequestResponse_PublishDataTrack{
|
||||
PublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if req.PubHandle == 0 || req.PubHandle > 65535 {
|
||||
p.pubLogger.Warnw("invalid data track handle", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: livekit.RequestResponse_INVALID_HANDLE,
|
||||
Message: "handle should be > 0 AND < 65536",
|
||||
Request: &livekit.RequestResponse_PublishDataTrack{
|
||||
PublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if len(req.Name) == 0 || len(req.Name) > 256 {
|
||||
p.pubLogger.Warnw("invalid data track name", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: livekit.RequestResponse_INVALID_NAME,
|
||||
Message: "name should not be empty and should not exceed 256 characters",
|
||||
Request: &livekit.RequestResponse_PublishDataTrack{
|
||||
PublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if !p.params.LimitConfig.CheckDataTrackFrameEncoding(req.FrameEncoding) ||
|
||||
!p.params.LimitConfig.CheckDataTrackSchemaID(req.Schema) {
|
||||
p.pubLogger.Warnw("invalid encoding identifier", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: livekit.RequestResponse_INVALID_REQUEST,
|
||||
Message: "encoding identifier is empty or exceeds the maximum length",
|
||||
Request: &livekit.RequestResponse_PublishDataTrack{
|
||||
PublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
publishedDataTracks := p.UpDataTrackManager.GetPublishedDataTracks()
|
||||
for _, dt := range publishedDataTracks {
|
||||
message := ""
|
||||
reason := livekit.RequestResponse_OK
|
||||
switch {
|
||||
case dt.PubHandle() == uint16(req.PubHandle):
|
||||
message = "a data track with same handle already exists"
|
||||
reason = livekit.RequestResponse_DUPLICATE_HANDLE
|
||||
case dt.Name() == req.Name:
|
||||
message = "a data track with same name already exists"
|
||||
reason = livekit.RequestResponse_DUPLICATE_NAME
|
||||
}
|
||||
if message != "" {
|
||||
p.pubLogger.Warnw(
|
||||
"cannot publish duplicate data track", nil,
|
||||
"req", logger.Proto(req),
|
||||
"existing", logger.Proto(dt.ToProto()),
|
||||
)
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: reason,
|
||||
Message: message,
|
||||
Request: &livekit.RequestResponse_PublishDataTrack{
|
||||
PublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
dti := &livekit.DataTrackInfo{
|
||||
PubHandle: req.PubHandle,
|
||||
Sid: guid.New(utils.DataTrackPrefix),
|
||||
Name: req.Name,
|
||||
Encryption: req.Encryption,
|
||||
}
|
||||
dti.FrameEncoding = utils.CloneProto(req.GetFrameEncoding())
|
||||
dti.Schema = utils.CloneProto(req.GetSchema())
|
||||
dt := NewDataTrack(
|
||||
DataTrackParams{
|
||||
Logger: p.params.Logger.WithValues("trackID", dti.Sid),
|
||||
ParticipantID: p.ID,
|
||||
ParticipantIdentity: p.params.Identity,
|
||||
BytesTrackStats: NewBytesTrackStats(
|
||||
p.params.Country,
|
||||
livekit.TrackID(dti.Sid),
|
||||
p.ID(),
|
||||
p.Kind(),
|
||||
p.KindDetails(),
|
||||
p.GetTelemetryListener,
|
||||
p.params.Reporter,
|
||||
),
|
||||
},
|
||||
dti,
|
||||
)
|
||||
|
||||
p.UpDataTrackManager.AddPublishedDataTrack(dt)
|
||||
|
||||
p.sendPublishDataTrackResponse(dti)
|
||||
|
||||
p.setIsPublisher(true)
|
||||
p.dirty.Store(true)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) HandleUnpublishDataTrackRequest(req *livekit.UnpublishDataTrackRequest) {
|
||||
dt := p.UpDataTrackManager.GetPublishedDataTrack(uint16(req.PubHandle))
|
||||
if dt == nil {
|
||||
p.pubLogger.Warnw("unpublish data track not found", nil, "req", logger.Proto(req))
|
||||
p.sendRequestResponse(&livekit.RequestResponse{
|
||||
Reason: livekit.RequestResponse_NOT_FOUND,
|
||||
Request: &livekit.RequestResponse_UnpublishDataTrack{
|
||||
UnpublishDataTrack: utils.CloneProto(req),
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
p.UpDataTrackManager.RemovePublishedDataTrack(dt)
|
||||
|
||||
p.sendUnpublishDataTrackResponse(dt.ToProto())
|
||||
|
||||
p.dirty.Store(true)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) HandleUpdateDataSubscription(req *livekit.UpdateDataSubscription) {
|
||||
p.listener().OnUpdateDataSubscriptions(p, req)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) onReceivedDataTrackMessage(data []byte, arrivalTime int64) {
|
||||
var packet datatrack.Packet
|
||||
if err := packet.Unmarshal(data); err != nil {
|
||||
p.params.Logger.Errorw("could not unmarshal data track message", err)
|
||||
return
|
||||
}
|
||||
|
||||
p.UpDataTrackManager.HandleReceivedDataTrackMessage(data, &packet, arrivalTime)
|
||||
|
||||
p.listener().OnDataTrackMessage(p, data, &packet)
|
||||
}
|
||||
|
||||
// wraps the promoted UpTrackManager.UpdateSubscriptionPermission to also revoke
|
||||
// data track subscriptions that are no longer permitted
|
||||
func (p *ParticipantImpl) UpdateSubscriptionPermission(
|
||||
subscriptionPermission *livekit.SubscriptionPermission,
|
||||
timedVersion utils.TimedVersion,
|
||||
resolverBySid func(participantID livekit.ParticipantID) types.LocalParticipant,
|
||||
) error {
|
||||
if err := p.UpTrackManager.UpdateSubscriptionPermission(subscriptionPermission, timedVersion, resolverBySid); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
p.maybeRevokeDataTrackSubscriptions()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) maybeRevokeDataTrackSubscriptions() {
|
||||
for _, dt := range p.UpDataTrackManager.GetPublishedDataTracks() {
|
||||
allowed := p.UpTrackManager.GetAllowedSubscribers(dt.ID())
|
||||
if allowed == nil {
|
||||
// no restrictions
|
||||
continue
|
||||
}
|
||||
|
||||
dt.RevokeDisallowedSubscribers(allowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetNextSubscribedDataTrackHandle() uint16 {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
p.nextSubscribedDataTrackHandle++
|
||||
if p.nextSubscribedDataTrackHandle == 0 {
|
||||
p.nextSubscribedDataTrackHandle++
|
||||
}
|
||||
|
||||
return p.nextSubscribedDataTrackHandle
|
||||
}
|
||||
1049
pkg/rtc/participant_internal_test.go
Normal file
1049
pkg/rtc/participant_internal_test.go
Normal file
File diff suppressed because it is too large
Load Diff
408
pkg/rtc/participant_sdp.go
Normal file
408
pkg/rtc/participant_sdp.go
Normal file
@@ -0,0 +1,408 @@
|
||||
// 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"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/pion/sdp/v3"
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
lksdp "github.com/livekit/protocol/sdp"
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([]*sdp.MediaDescription, []*sdp.MediaDescription) {
|
||||
processUnmatch := func(unmatches []*sdp.MediaDescription, trackType livekit.TrackType) {
|
||||
for _, unmatch := range unmatches {
|
||||
streamID, ok := lksdp.ExtractStreamID(unmatch)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
sdpCodecs, err := lksdp.CodecsFromMediaDescription(unmatch)
|
||||
if err != nil || len(sdpCodecs) == 0 {
|
||||
p.pubLogger.Errorw(
|
||||
"extract codecs from media section failed", err,
|
||||
"media", unmatch,
|
||||
"parsedOffer", parsedOffer,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
p.pendingTracksLock.Lock()
|
||||
signalCid, info, _, migrated, _ := p.getPendingTrack(streamID, trackType, false)
|
||||
if migrated {
|
||||
p.pendingTracksLock.Unlock()
|
||||
continue
|
||||
}
|
||||
|
||||
if info == nil {
|
||||
p.pendingTracksLock.Unlock()
|
||||
|
||||
// could be already published track and the unmatch could be a back up codec publish
|
||||
numUnmatchedTracks := 0
|
||||
var unmatchedTrack types.MediaTrack
|
||||
var unmatchedSdpMimeType mime.MimeType
|
||||
|
||||
found := false
|
||||
for _, sdpCodec := range sdpCodecs {
|
||||
sdpMimeType := mime.NormalizeMimeTypeCodec(sdpCodec.Name).ToMimeType()
|
||||
for _, publishedTrack := range p.GetPublishedTracks() {
|
||||
if sigCid, sdpCid := publishedTrack.(*MediaTrack).GetCidsForMimeType(sdpMimeType); sigCid != "" && sdpCid == "" {
|
||||
// a back up codec has a SDP cid match
|
||||
if sigCid == streamID {
|
||||
found = true
|
||||
break
|
||||
} else {
|
||||
numUnmatchedTracks++
|
||||
unmatchedTrack = publishedTrack
|
||||
unmatchedSdpMimeType = sdpMimeType
|
||||
}
|
||||
}
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && unmatchedTrack != nil {
|
||||
if numUnmatchedTracks != 1 {
|
||||
p.pubLogger.Warnw(
|
||||
"too many unmatched tracks", nil,
|
||||
"media", unmatch,
|
||||
"parsedOffer", parsedOffer,
|
||||
)
|
||||
}
|
||||
unmatchedTrack.(*MediaTrack).UpdateCodecSdpCid(unmatchedSdpMimeType, streamID)
|
||||
p.pubLogger.Debugw(
|
||||
"published track SDP cid updated",
|
||||
"trackID", unmatchedTrack.ID(),
|
||||
"track", logger.Proto(unmatchedTrack.ToProto()),
|
||||
)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(info.Codecs) == 0 {
|
||||
p.pendingTracksLock.Unlock()
|
||||
p.pubLogger.Warnw(
|
||||
"track without codecs", nil,
|
||||
"trackID", info.Sid,
|
||||
"pendingTrack", p.pendingTracks[signalCid],
|
||||
"media", unmatch,
|
||||
"parsedOffer", parsedOffer,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
found := false
|
||||
updated := false
|
||||
for _, sdpCodec := range sdpCodecs {
|
||||
if mime.NormalizeMimeTypeCodec(sdpCodec.Name) == mime.GetMimeTypeCodec(info.Codecs[0].MimeType) {
|
||||
// set SdpCid only if different from SignalCid
|
||||
if streamID != info.Codecs[0].Cid {
|
||||
info.Codecs[0].SdpCid = streamID
|
||||
updated = true
|
||||
}
|
||||
found = true
|
||||
break
|
||||
}
|
||||
if found {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
// not using SimulcastCodec, i. e. mime type not available till track publish
|
||||
if len(info.Codecs) == 1 {
|
||||
// set SdpCid only if different from SignalCid
|
||||
if streamID != info.Codecs[0].Cid {
|
||||
info.Codecs[0].SdpCid = streamID
|
||||
updated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if updated {
|
||||
p.pendingTracks[signalCid].trackInfos[0] = utils.CloneProto(info)
|
||||
p.pubLogger.Debugw(
|
||||
"pending track SDP cid updated",
|
||||
"signalCid", signalCid,
|
||||
"trackID", info.Sid,
|
||||
"pendingTrack", p.pendingTracks[signalCid],
|
||||
)
|
||||
}
|
||||
p.pendingTracksLock.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
unmatchAudios, err := p.TransportManager.GetUnmatchMediaForOffer(parsedOffer, "audio")
|
||||
if err != nil {
|
||||
p.pubLogger.Warnw("could not get unmatch audios", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
unmatchVideos, err := p.TransportManager.GetUnmatchMediaForOffer(parsedOffer, "video")
|
||||
if err != nil {
|
||||
p.pubLogger.Warnw("could not get unmatch videos", err)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
processUnmatch(unmatchAudios, livekit.TrackType_AUDIO)
|
||||
processUnmatch(unmatchVideos, livekit.TrackType_VIDEO)
|
||||
return unmatchAudios, unmatchVideos
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) setCodecPreferencesForPublisher(
|
||||
parsedOffer *sdp.SessionDescription,
|
||||
unmatchAudios []*sdp.MediaDescription,
|
||||
unmatchVideos []*sdp.MediaDescription,
|
||||
) {
|
||||
unprocessedUnmatchAudios := p.setCodecPreferencesForPublisherMedia(
|
||||
parsedOffer,
|
||||
unmatchAudios,
|
||||
livekit.TrackType_AUDIO,
|
||||
)
|
||||
p.setCodecPreferencesOpusRedForPublisher(parsedOffer, unprocessedUnmatchAudios)
|
||||
_ = p.setCodecPreferencesForPublisherMedia(
|
||||
parsedOffer,
|
||||
unmatchVideos,
|
||||
livekit.TrackType_VIDEO,
|
||||
)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) setCodecPreferencesForPublisherMedia(
|
||||
parsedOffer *sdp.SessionDescription,
|
||||
unmatches []*sdp.MediaDescription,
|
||||
trackType livekit.TrackType,
|
||||
) []*sdp.MediaDescription {
|
||||
unprocessed := make([]*sdp.MediaDescription, 0, len(unmatches))
|
||||
for _, unmatch := range unmatches {
|
||||
var ti *livekit.TrackInfo
|
||||
var mimeType string
|
||||
|
||||
mid := lksdp.GetMidValue(unmatch)
|
||||
if mid == "" {
|
||||
unprocessed = append(unprocessed, unmatch)
|
||||
continue
|
||||
}
|
||||
transceiver := p.TransportManager.GetPublisherRTPTransceiver(mid)
|
||||
if transceiver == nil {
|
||||
unprocessed = append(unprocessed, unmatch)
|
||||
continue
|
||||
}
|
||||
|
||||
streamID, ok := lksdp.ExtractStreamID(unmatch)
|
||||
if !ok {
|
||||
unprocessed = append(unprocessed, unmatch)
|
||||
continue
|
||||
}
|
||||
|
||||
p.pendingTracksLock.RLock()
|
||||
mt := p.getPublishedTrackBySdpCid(streamID)
|
||||
if mt != nil {
|
||||
ti = mt.ToProto()
|
||||
} else {
|
||||
_, ti, _, _, _ = p.getPendingTrack(streamID, trackType, false)
|
||||
}
|
||||
p.pendingTracksLock.RUnlock()
|
||||
|
||||
if ti == nil {
|
||||
unprocessed = append(unprocessed, unmatch)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, c := range ti.Codecs {
|
||||
if c.Cid == streamID || c.SdpCid == streamID {
|
||||
mimeType = c.MimeType
|
||||
break
|
||||
}
|
||||
}
|
||||
if mimeType == "" && len(ti.Codecs) > 0 {
|
||||
mimeType = ti.Codecs[0].MimeType
|
||||
}
|
||||
|
||||
if mimeType == "" {
|
||||
unprocessed = append(unprocessed, unmatch)
|
||||
continue
|
||||
}
|
||||
|
||||
configureReceiverCodecs(
|
||||
transceiver,
|
||||
mimeType,
|
||||
p.params.ClientInfo.ComplyWithCodecOrderInSDPAnswer(),
|
||||
)
|
||||
}
|
||||
|
||||
return unprocessed
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) setCodecPreferencesOpusRedForPublisher(
|
||||
parsedOffer *sdp.SessionDescription,
|
||||
unmatchAudios []*sdp.MediaDescription,
|
||||
) {
|
||||
for _, unmatchAudio := range unmatchAudios {
|
||||
mid := lksdp.GetMidValue(unmatchAudio)
|
||||
if mid == "" {
|
||||
continue
|
||||
}
|
||||
transceiver := p.TransportManager.GetPublisherRTPTransceiver(mid)
|
||||
if transceiver == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
streamID, ok := lksdp.ExtractStreamID(unmatchAudio)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
p.pendingTracksLock.RLock()
|
||||
_, ti, _, _, _ := p.getPendingTrack(streamID, livekit.TrackType_AUDIO, false)
|
||||
p.pendingTracksLock.RUnlock()
|
||||
if ti == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
codecs, err := lksdp.CodecsFromMediaDescription(unmatchAudio)
|
||||
if err != nil {
|
||||
p.pubLogger.Errorw(
|
||||
"extract codecs from media section failed", err,
|
||||
"media", unmatchAudio,
|
||||
"parsedOffer", parsedOffer,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
var opusPayload uint8
|
||||
for _, codec := range codecs {
|
||||
if mime.IsMimeTypeCodecStringOpus(codec.Name) {
|
||||
opusPayload = codec.PayloadType
|
||||
break
|
||||
}
|
||||
}
|
||||
if opusPayload == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
preferRED := IsRedEnabled(ti)
|
||||
// if RED is enabled for this track, prefer RED codec in offer
|
||||
for _, codec := range codecs {
|
||||
// codec contain opus/red
|
||||
if preferRED &&
|
||||
mime.IsMimeTypeCodecStringRED(codec.Name) &&
|
||||
strings.Contains(codec.Fmtp, strconv.FormatInt(int64(opusPayload), 10)) {
|
||||
configureReceiverCodecs(transceiver, "audio/red", true)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// configure publisher answer for audio track's dtx and stereo settings
|
||||
func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescription) webrtc.SessionDescription {
|
||||
offer := p.TransportManager.LastPublisherOffer()
|
||||
parsedOffer, err := offer.Unmarshal()
|
||||
if err != nil {
|
||||
return answer
|
||||
}
|
||||
|
||||
parsedAnswer, err := answer.Unmarshal()
|
||||
if err != nil {
|
||||
return answer
|
||||
}
|
||||
|
||||
for _, m := range parsedAnswer.MediaDescriptions {
|
||||
switch m.MediaName.Media {
|
||||
case "audio":
|
||||
_, ok := m.Attribute(sdp.AttrKeyInactive)
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
mid, ok := m.Attribute(sdp.AttrKeyMID)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// find track info from offer's stream id
|
||||
var ti *livekit.TrackInfo
|
||||
for _, om := range parsedOffer.MediaDescriptions {
|
||||
_, ok := om.Attribute(sdp.AttrKeyInactive)
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
omid, ok := om.Attribute(sdp.AttrKeyMID)
|
||||
if ok && omid == mid {
|
||||
streamID, ok := lksdp.ExtractStreamID(om)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
track, _ := p.getPublishedTrackBySdpCid(streamID).(*MediaTrack)
|
||||
if track == nil {
|
||||
p.pendingTracksLock.RLock()
|
||||
_, ti, _, _, _ = p.getPendingTrack(streamID, livekit.TrackType_AUDIO, false)
|
||||
p.pendingTracksLock.RUnlock()
|
||||
} else {
|
||||
ti = track.ToProto()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if ti == nil {
|
||||
// no need to configure
|
||||
continue
|
||||
}
|
||||
|
||||
opusPT, err := parsedAnswer.GetPayloadTypeForCodec(sdp.Codec{Name: mime.MimeTypeCodecOpus.String()})
|
||||
if err != nil {
|
||||
p.pubLogger.Infow("failed to get opus payload type", "error", err, "trackID", ti.Sid)
|
||||
continue
|
||||
}
|
||||
|
||||
for i, attr := range m.Attributes {
|
||||
if strings.HasPrefix(attr.String(), fmt.Sprintf("fmtp:%d", opusPT)) {
|
||||
if !slices.Contains(ti.AudioFeatures, livekit.AudioTrackFeature_TF_NO_DTX) {
|
||||
attr.Value += ";usedtx=1"
|
||||
} else {
|
||||
attr.Value = strings.ReplaceAll(attr.Value, ";usedtx=1", "")
|
||||
}
|
||||
if slices.Contains(ti.AudioFeatures, livekit.AudioTrackFeature_TF_STEREO) {
|
||||
attr.Value += ";stereo=1;maxaveragebitrate=510000"
|
||||
} else {
|
||||
attr.Value = strings.ReplaceAll(attr.Value, ";stereo=1", "")
|
||||
}
|
||||
m.Attributes[i] = attr
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
bytes, err := parsedAnswer.Marshal()
|
||||
if err != nil {
|
||||
p.pubLogger.Infow("failed to marshal answer", "error", err)
|
||||
return answer
|
||||
}
|
||||
answer.SDP = string(bytes)
|
||||
return answer
|
||||
}
|
||||
436
pkg/rtc/participant_signal.go
Normal file
436
pkg/rtc/participant_signal.go
Normal file
@@ -0,0 +1,436 @@
|
||||
// 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 (
|
||||
"time"
|
||||
|
||||
"github.com/pion/webrtc/v4"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
protosignalling "github.com/livekit/protocol/signalling"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
func (p *ParticipantImpl) SwapResponseSink(sink routing.MessageSink, reason types.SignallingCloseReason) {
|
||||
p.signaller.SwapResponseSink(sink, reason)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) GetResponseSink() routing.MessageSink {
|
||||
return p.signaller.GetResponseSink()
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) CloseSignalConnection(reason types.SignallingCloseReason) {
|
||||
p.signaller.CloseSignalConnection(reason)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendJoinResponse(joinResponse *livekit.JoinResponse) error {
|
||||
// keep track of participant updates and versions
|
||||
p.updateLock.Lock()
|
||||
for _, op := range joinResponse.OtherParticipants {
|
||||
p.updateCache.Add(livekit.ParticipantID(op.Sid), participantUpdateInfo{
|
||||
identity: livekit.ParticipantIdentity(op.Identity),
|
||||
version: op.Version,
|
||||
state: op.State,
|
||||
updatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
p.updateLock.Unlock()
|
||||
|
||||
// send Join response
|
||||
err := p.signaller.WriteMessage(p.signalling.SignalJoinResponse(joinResponse))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update state after sending message, so that no participant updates could slip through before JoinResponse is sent
|
||||
p.updateLock.Lock()
|
||||
if p.State() == livekit.ParticipantInfo_JOINING {
|
||||
p.updateState(livekit.ParticipantInfo_JOINED)
|
||||
}
|
||||
queuedUpdates := p.queuedUpdates
|
||||
p.queuedUpdates = nil
|
||||
p.updateLock.Unlock()
|
||||
|
||||
if len(queuedUpdates) > 0 {
|
||||
return p.SendParticipantUpdate(queuedUpdates)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendParticipantUpdate(participantsToUpdate []*livekit.ParticipantInfo) error {
|
||||
p.updateLock.Lock()
|
||||
if p.IsDisconnected() {
|
||||
p.updateLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// read under the lock the flush takes, so an update either queues before the flush
|
||||
// or goes out after the connection has opened
|
||||
if !p.IsReady() || p.signaller.HandshakePending() {
|
||||
// queue up updates
|
||||
p.queuedUpdates = append(p.queuedUpdates, participantsToUpdate...)
|
||||
p.updateLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
validUpdates := make([]*livekit.ParticipantInfo, 0, len(participantsToUpdate))
|
||||
for _, pi := range participantsToUpdate {
|
||||
isValid := true
|
||||
pID := livekit.ParticipantID(pi.Sid)
|
||||
if lastVersion, ok := p.updateCache.Get(pID); ok {
|
||||
// this is a message delivered out of order, a more recent version of the message had already been
|
||||
// sent.
|
||||
if pi.Version < lastVersion.version {
|
||||
p.params.Logger.Debugw(
|
||||
"skipping outdated participant update",
|
||||
"otherParticipant", pi.Identity,
|
||||
"otherPID", pi.Sid,
|
||||
"version", pi.Version,
|
||||
"lastVersion", lastVersion,
|
||||
)
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
if pi.Permission != nil && pi.Permission.Hidden && pi.Sid != string(p.ID()) {
|
||||
p.params.Logger.Debugw("skipping hidden participant update", "otherParticipant", pi.Identity)
|
||||
isValid = false
|
||||
}
|
||||
if isValid {
|
||||
p.updateCache.Add(pID, participantUpdateInfo{
|
||||
identity: livekit.ParticipantIdentity(pi.Identity),
|
||||
version: pi.Version,
|
||||
state: pi.State,
|
||||
updatedAt: time.Now(),
|
||||
})
|
||||
validUpdates = append(validUpdates, pi)
|
||||
}
|
||||
}
|
||||
p.updateLock.Unlock()
|
||||
|
||||
return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(validUpdates))
|
||||
}
|
||||
|
||||
// SendSpeakerUpdate notifies participant changes to speakers. only send members that have changed since last update
|
||||
func (p *ParticipantImpl) SendSpeakerUpdate(speakers []*livekit.SpeakerInfo, force bool) error {
|
||||
if !p.IsReady() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var scopedSpeakers []*livekit.SpeakerInfo
|
||||
if force {
|
||||
scopedSpeakers = speakers
|
||||
} else {
|
||||
for _, s := range speakers {
|
||||
participantID := livekit.ParticipantID(s.Sid)
|
||||
if p.IsSubscribedTo(participantID) || participantID == p.ID() {
|
||||
scopedSpeakers = append(scopedSpeakers, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSpeakerUpdate(scopedSpeakers))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendRoomUpdate(room *livekit.Room) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalRoomUpdate(room))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendConnectionQualityUpdate(update *livekit.ConnectionQualityUpdate) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalConnectionQualityUpdate(update))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendRefreshToken(token string) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalRefreshToken(token))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendRequestResponse(requestResponse *livekit.RequestResponse) error {
|
||||
if !p.params.ClientInfo.SupportsRequestResponse() {
|
||||
return nil
|
||||
}
|
||||
|
||||
if requestResponse.Reason == livekit.RequestResponse_OK && !p.ProtocolVersion().SupportsNonErrorSignalResponse() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return p.signaller.WriteMessage(p.signalling.SignalRequestResponse(requestResponse))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendRoomMovedResponse(roomMovedResponse *livekit.RoomMovedResponse) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalRoomMovedResponse(roomMovedResponse))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse) error {
|
||||
p.TransportManager.HandleClientReconnect(reconnectReason)
|
||||
|
||||
if !p.params.ClientInfo.CanHandleReconnectResponse() {
|
||||
// no ReconnectResponse opens this connection, so nothing to hold back
|
||||
return p.reconnectResponseSentAndFlush()
|
||||
}
|
||||
|
||||
// send reconnect response
|
||||
err := p.signaller.WriteMessage(p.signalling.SignalReconnectResponse(reconnectResponse))
|
||||
|
||||
// mark sent after sending the message, so that nothing could slip through before
|
||||
// ReconnectResponse is sent. Marked on a failed write too: the sink is gone in that
|
||||
// case, and leaving it unmarked would hold back every message after it.
|
||||
flushErr := p.reconnectResponseSentAndFlush()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if flushErr != nil {
|
||||
return flushErr
|
||||
}
|
||||
|
||||
if p.params.ProtocolVersion.SupportsDisconnectedUpdate() {
|
||||
return p.sendDisconnectUpdatesForReconnect()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// reconnectResponseSentAndFlush makes a migrating in participant ready and sends what was
|
||||
// queued up while the connection was waiting for its ReconnectResponse.
|
||||
func (p *ParticipantImpl) reconnectResponseSentAndFlush() error {
|
||||
// a successful write opens the connection by itself, this covers the paths that do
|
||||
// not write one, i. e. a client that cannot handle it and a failed write
|
||||
p.signaller.OpenHandshake()
|
||||
|
||||
p.updateLock.Lock()
|
||||
p.reconnectResponseSent.Store(true)
|
||||
queuedUpdates := p.queuedUpdates
|
||||
p.queuedUpdates = nil
|
||||
p.updateLock.Unlock()
|
||||
|
||||
if len(queuedUpdates) > 0 {
|
||||
return p.SendParticipantUpdate(queuedUpdates)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// flushQueuedUpdates sends the updates queued while the connection was closed for the
|
||||
// handshake. Called when the connection opens, including when it opens without a
|
||||
// ReconnectResponse having been written.
|
||||
func (p *ParticipantImpl) flushQueuedUpdates() {
|
||||
p.updateLock.Lock()
|
||||
queuedUpdates := p.queuedUpdates
|
||||
p.queuedUpdates = nil
|
||||
p.updateLock.Unlock()
|
||||
|
||||
if len(queuedUpdates) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if err := p.SendParticipantUpdate(queuedUpdates); err != nil {
|
||||
p.params.Logger.Warnw("could not send queued participant updates", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error {
|
||||
lastSignalAt := p.TransportManager.LastSeenSignalAt()
|
||||
var disconnectedParticipants []*livekit.ParticipantInfo
|
||||
p.updateLock.Lock()
|
||||
keys := p.updateCache.Keys()
|
||||
for i := len(keys) - 1; i >= 0; i-- {
|
||||
if info, ok := p.updateCache.Get(keys[i]); ok {
|
||||
if info.updatedAt.Before(lastSignalAt) {
|
||||
break
|
||||
} else if info.state == livekit.ParticipantInfo_DISCONNECTED {
|
||||
disconnectedParticipants = append(disconnectedParticipants, &livekit.ParticipantInfo{
|
||||
Sid: string(keys[i]),
|
||||
Identity: string(info.identity),
|
||||
Version: info.version,
|
||||
State: livekit.ParticipantInfo_DISCONNECTED,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
p.updateLock.Unlock()
|
||||
|
||||
return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(disconnectedParticipants))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendICECandidate(ic *webrtc.ICECandidate, target livekit.SignalTarget) error {
|
||||
prevIC := p.icQueue[target].Swap(ic)
|
||||
if prevIC == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
trickle := protosignalling.ToProtoTrickle(prevIC.ToJSON(), target, ic == nil)
|
||||
p.params.Logger.Debugw("sending ICE candidate", "transport", target, "trickle", logger.Proto(trickle))
|
||||
|
||||
return p.signaller.WriteMessage(p.signalling.SignalICECandidate(trickle))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendTrackMuted(trackID livekit.TrackID, muted bool) {
|
||||
_ = p.signaller.WriteMessage(p.signalling.SignalTrackMuted(&livekit.MuteTrackRequest{
|
||||
Sid: string(trackID),
|
||||
Muted: muted,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendTrackPublished(cid string, ti *livekit.TrackInfo) error {
|
||||
p.pubLogger.Debugw("sending track published", "cid", cid, "trackInfo", logger.Proto(ti))
|
||||
return p.signaller.WriteMessage(p.signalling.SignalTrackPublished(&livekit.TrackPublishedResponse{
|
||||
Cid: cid,
|
||||
Track: ti,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendTrackUnpublished(trackID livekit.TrackID) {
|
||||
_ = p.signaller.WriteMessage(p.signalling.SignalTrackUnpublished(&livekit.TrackUnpublishedResponse{
|
||||
TrackSid: string(trackID),
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendTrackHasBeenSubscribed(trackID livekit.TrackID) {
|
||||
if !p.params.ClientInfo.SupportsTrackSubscribedEvent() {
|
||||
return
|
||||
}
|
||||
_ = p.signaller.WriteMessage(p.signalling.SignalTrackSubscribed(&livekit.TrackSubscribed{
|
||||
TrackSid: string(trackID),
|
||||
}))
|
||||
p.params.Logger.Debugw("track has been subscribed", "trackID", trackID)
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendLeaveRequest(
|
||||
reason types.ParticipantCloseReason,
|
||||
isExpectedToResume bool,
|
||||
isExpectedToReconnect bool,
|
||||
sendOnlyIfSupportingLeaveRequestWithAction bool,
|
||||
) error {
|
||||
var leave *livekit.LeaveRequest
|
||||
if p.ProtocolVersion().SupportsRegionsInLeaveRequest() {
|
||||
leave = &livekit.LeaveRequest{
|
||||
Reason: reason.ToDisconnectReason(),
|
||||
}
|
||||
switch {
|
||||
case isExpectedToResume:
|
||||
leave.Action = livekit.LeaveRequest_RESUME
|
||||
case isExpectedToReconnect:
|
||||
leave.Action = livekit.LeaveRequest_RECONNECT
|
||||
default:
|
||||
leave.Action = livekit.LeaveRequest_DISCONNECT
|
||||
}
|
||||
if leave.Action != livekit.LeaveRequest_DISCONNECT {
|
||||
// sending region settings even for RESUME just in case client wants to a full reconnect despite server saying RESUME
|
||||
leave.Regions = p.helper().GetRegionSettings(p.params.ClientInfo.Address)
|
||||
}
|
||||
} else {
|
||||
if !sendOnlyIfSupportingLeaveRequestWithAction {
|
||||
leave = &livekit.LeaveRequest{
|
||||
CanReconnect: isExpectedToReconnect,
|
||||
Reason: reason.ToDisconnectReason(),
|
||||
}
|
||||
}
|
||||
}
|
||||
if leave != nil {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalLeaveRequest(leave))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendSdpAnswer(answer webrtc.SessionDescription, answerId uint32, midToTrackID map[string]string) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSdpAnswer(protosignalling.ToProtoSessionDescription(answer, answerId, midToTrackID)))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendSdpOffer(offer webrtc.SessionDescription, offerId uint32, midToTrackID map[string]string) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSdpOffer(protosignalling.ToProtoSessionDescription(offer, offerId, midToTrackID)))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendStreamStateUpdate(streamStateUpdate *livekit.StreamStateUpdate) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalStreamStateUpdate(streamStateUpdate))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendSubscribedQualityUpdate(subscribedQualityUpdate *livekit.SubscribedQualityUpdate) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSubscribedQualityUpdate(subscribedQualityUpdate))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate *livekit.SubscribedAudioCodecUpdate) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendSubscriptionResponse(trackID livekit.TrackID, subErr livekit.SubscriptionError) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalSubscriptionResponse(&livekit.SubscriptionResponse{
|
||||
TrackSid: string(trackID),
|
||||
Err: subErr,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendSubscriptionPermissionUpdate(publisherID livekit.ParticipantID, trackID livekit.TrackID, allowed bool) error {
|
||||
p.subLogger.Debugw("sending subscription permission update", "publisherID", publisherID, "trackID", trackID, "allowed", allowed)
|
||||
err := p.signaller.WriteMessage(p.signalling.SignalSubscriptionPermissionUpdate(&livekit.SubscriptionPermissionUpdate{
|
||||
ParticipantSid: string(publisherID),
|
||||
TrackSid: string(trackID),
|
||||
Allowed: allowed,
|
||||
}))
|
||||
if err != nil {
|
||||
p.subLogger.Errorw("could not send subscription permission update", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendMediaSectionsRequirement(numAudios uint32, numVideos uint32) error {
|
||||
p.pubLogger.Debugw(
|
||||
"sending media sections requirement",
|
||||
"numAudios", numAudios,
|
||||
"numVideos", numVideos,
|
||||
)
|
||||
err := p.signaller.WriteMessage(p.signalling.SignalMediaSectionsRequirement(&livekit.MediaSectionsRequirement{
|
||||
NumAudios: numAudios,
|
||||
NumVideos: numVideos,
|
||||
}))
|
||||
if err != nil {
|
||||
p.subLogger.Errorw("could not send media sections requirement", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendPublishDataTrackResponse(dti *livekit.DataTrackInfo) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalPublishDataTrackResponse(&livekit.PublishDataTrackResponse{
|
||||
Info: dti,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendUnpublishDataTrackResponse(dti *livekit.DataTrackInfo) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalUnpublishDataTrackResponse(&livekit.UnpublishDataTrackResponse{
|
||||
Info: dti,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) SendDataTrackSubscriberHandles(handles map[uint32]*livekit.DataTrackSubscriberHandles_PublishedDataTrack) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalDataTrackSubscriberHandles(&livekit.DataTrackSubscriberHandles{
|
||||
SubHandles: handles,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendStoreDataBlobResponse(requestId uint32, key *livekit.DataBlobKey) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalStoreDataBlobResponse(&livekit.StoreDataBlobResponse{
|
||||
RequestId: requestId,
|
||||
Key: key,
|
||||
}))
|
||||
}
|
||||
|
||||
func (p *ParticipantImpl) sendGetDataBlobResponse(requestId uint32, dataBlob *livekit.DataBlob) error {
|
||||
return p.signaller.WriteMessage(p.signalling.SignalGetDataBlobResponse(&livekit.GetDataBlobResponse{
|
||||
RequestId: requestId,
|
||||
Blob: dataBlob,
|
||||
}))
|
||||
}
|
||||
2428
pkg/rtc/room.go
Normal file
2428
pkg/rtc/room.go
Normal file
File diff suppressed because it is too large
Load Diff
869
pkg/rtc/room_test.go
Normal file
869
pkg/rtc/room_test.go
Normal file
@@ -0,0 +1,869 @@
|
||||
// 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"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/protocol/auth/authfakes"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/webhook"
|
||||
|
||||
"github.com/livekit/livekit-server/version"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/config"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/audio"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes"
|
||||
"github.com/livekit/livekit-server/pkg/testutils"
|
||||
)
|
||||
|
||||
func init() {
|
||||
prometheus.Init("test", livekit.NodeType_SERVER)
|
||||
}
|
||||
|
||||
const (
|
||||
numParticipants = 3
|
||||
defaultDelay = 10 * time.Millisecond
|
||||
audioUpdateInterval = 25
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.InitLoggerFromConfig(&config.DefaultConfig.Logging)
|
||||
roomUpdateInterval = defaultDelay
|
||||
}
|
||||
|
||||
var iceServersForRoom = []*livekit.ICEServer{{Urls: []string{"stun:stun.l.google.com:19302"}}}
|
||||
|
||||
func TestJoinedState(t *testing.T) {
|
||||
t.Run("new room should return joinedAt 0", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 0})
|
||||
require.Equal(t, int64(0), rm.FirstJoinedAt())
|
||||
require.Equal(t, int64(0), rm.LastLeftAt())
|
||||
})
|
||||
|
||||
t.Run("should be current time when a participant joins", func(t *testing.T) {
|
||||
s := time.Now().Unix()
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
require.LessOrEqual(t, s, rm.FirstJoinedAt())
|
||||
require.Equal(t, int64(0), rm.LastLeftAt())
|
||||
})
|
||||
|
||||
t.Run("should be set when a participant leaves", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
p0 := rm.GetParticipants()[0]
|
||||
s := time.Now().Unix()
|
||||
rm.RemoveParticipant(p0.Identity(), p0.ID(), types.ParticipantCloseReasonClientRequestLeave)
|
||||
require.LessOrEqual(t, s, rm.LastLeftAt())
|
||||
})
|
||||
|
||||
t.Run("LastLeftAt should be set when there are still participants in the room", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2})
|
||||
p0 := rm.GetParticipants()[0]
|
||||
rm.RemoveParticipant(p0.Identity(), p0.ID(), types.ParticipantCloseReasonClientRequestLeave)
|
||||
require.Greater(t, rm.LastLeftAt(), int64(0))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoomJoin(t *testing.T) {
|
||||
t.Run("joining returns existing participant data", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: numParticipants})
|
||||
pNew := NewMockParticipant("new", types.CurrentProtocol, false, false, rm.LocalParticipantListener())
|
||||
|
||||
_ = rm.Join(pNew, nil, nil, iceServersForRoom)
|
||||
|
||||
// expect new participant to get a JoinReply
|
||||
res := pNew.SendJoinResponseArgsForCall(0)
|
||||
require.Equal(t, livekit.RoomID(res.Room.Sid), rm.ID())
|
||||
require.Len(t, res.OtherParticipants, numParticipants)
|
||||
require.Len(t, rm.GetParticipants(), numParticipants+1)
|
||||
require.NotEmpty(t, res.IceServers)
|
||||
})
|
||||
|
||||
t.Run("subscribe to existing channels upon join", func(t *testing.T) {
|
||||
numExisting := 3
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: numExisting})
|
||||
lpl := rm.LocalParticipantListener()
|
||||
p := NewMockParticipant("new", types.CurrentProtocol, false, false, lpl)
|
||||
|
||||
err := rm.Join(p, nil, &ParticipantOptions{AutoSubscribe: true}, iceServersForRoom)
|
||||
require.NoError(t, err)
|
||||
|
||||
p.StateReturns(livekit.ParticipantInfo_ACTIVE)
|
||||
lpl.OnStateChange(p)
|
||||
|
||||
// it should become a subscriber when connectivity changes
|
||||
numTracks := 0
|
||||
for _, op := range rm.GetParticipants() {
|
||||
if p == op {
|
||||
continue
|
||||
}
|
||||
|
||||
numTracks += len(op.GetPublishedTracks())
|
||||
}
|
||||
require.Equal(t, numTracks, p.SubscribeToTrackCallCount())
|
||||
})
|
||||
|
||||
t.Run("participant state change is broadcasted to others", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: numParticipants})
|
||||
var changedParticipant types.Participant
|
||||
rm.OnParticipantChanged(func(participant types.Participant) {
|
||||
changedParticipant = participant
|
||||
})
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
disconnectedParticipant := participants[1].(*typesfakes.FakeLocalParticipant)
|
||||
disconnectedParticipant.StateReturns(livekit.ParticipantInfo_DISCONNECTED)
|
||||
|
||||
rm.RemoveParticipant(p.Identity(), p.ID(), types.ParticipantCloseReasonClientRequestLeave)
|
||||
time.Sleep(defaultDelay)
|
||||
|
||||
require.Equal(t, p, changedParticipant)
|
||||
|
||||
numUpdates := 0
|
||||
for _, op := range participants {
|
||||
if op == p || op == disconnectedParticipant {
|
||||
require.Zero(t, p.SendParticipantUpdateCallCount())
|
||||
continue
|
||||
}
|
||||
fakeP := op.(*typesfakes.FakeLocalParticipant)
|
||||
require.Equal(t, 1, fakeP.SendParticipantUpdateCallCount())
|
||||
numUpdates += 1
|
||||
}
|
||||
require.Equal(t, numParticipants-2, numUpdates)
|
||||
})
|
||||
|
||||
t.Run("cannot exceed max participants", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
rm.lock.Lock()
|
||||
rm.protoRoom.MaxParticipants = 1
|
||||
rm.lock.Unlock()
|
||||
p := NewMockParticipant("second", types.ProtocolVersion(0), false, false, rm.LocalParticipantListener())
|
||||
|
||||
err := rm.Join(p, nil, nil, iceServersForRoom)
|
||||
require.Equal(t, ErrMaxParticipantsExceeded, err)
|
||||
})
|
||||
}
|
||||
|
||||
// various state changes to participant and that others are receiving update
|
||||
func TestParticipantUpdate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sendToSender bool // should sender receive it
|
||||
action func(p types.LocalParticipant)
|
||||
}{
|
||||
{
|
||||
"track mutes are sent to everyone",
|
||||
true,
|
||||
func(p types.LocalParticipant) {
|
||||
p.SetTrackMuted(&livekit.MuteTrackRequest{Muted: true}, false)
|
||||
},
|
||||
},
|
||||
{
|
||||
"track metadata updates are sent to everyone",
|
||||
true,
|
||||
func(p types.LocalParticipant) {
|
||||
p.SetMetadata("")
|
||||
},
|
||||
},
|
||||
{
|
||||
"track publishes are sent to existing participants",
|
||||
true,
|
||||
func(p types.LocalParticipant) {
|
||||
p.AddTrack(&livekit.AddTrackRequest{
|
||||
Type: livekit.TrackType_VIDEO,
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 3})
|
||||
// remember how many times send has been called for each
|
||||
callCounts := make(map[livekit.ParticipantID]int)
|
||||
for _, p := range rm.GetParticipants() {
|
||||
fp := p.(*typesfakes.FakeLocalParticipant)
|
||||
callCounts[p.ID()] = fp.SendParticipantUpdateCallCount()
|
||||
}
|
||||
|
||||
sender := rm.GetParticipants()[0]
|
||||
test.action(sender)
|
||||
|
||||
// go through the other participants, make sure they've received update
|
||||
for _, p := range rm.GetParticipants() {
|
||||
expected := callCounts[p.ID()]
|
||||
if p != sender || test.sendToSender {
|
||||
expected += 1
|
||||
}
|
||||
fp := p.(*typesfakes.FakeLocalParticipant)
|
||||
require.Equal(t, expected, fp.SendParticipantUpdateCallCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushAndDequeueUpdates(t *testing.T) {
|
||||
identity := "test_user"
|
||||
publisher1v1 := &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "1",
|
||||
IsPublisher: true,
|
||||
Version: 1,
|
||||
JoinedAt: 0,
|
||||
}
|
||||
publisher1v2 := &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "1",
|
||||
IsPublisher: true,
|
||||
Version: 2,
|
||||
JoinedAt: 1,
|
||||
}
|
||||
publisher2 := &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "2",
|
||||
IsPublisher: true,
|
||||
Version: 1,
|
||||
JoinedAt: 2,
|
||||
}
|
||||
subscriber1v1 := &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "1",
|
||||
Version: 1,
|
||||
JoinedAt: 0,
|
||||
}
|
||||
subscriber1v2 := &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "1",
|
||||
Version: 2,
|
||||
JoinedAt: 1,
|
||||
}
|
||||
|
||||
requirePIEquals := func(t *testing.T, a, b *livekit.ParticipantInfo) {
|
||||
require.Equal(t, a.Sid, b.Sid)
|
||||
require.Equal(t, a.Identity, b.Identity)
|
||||
require.Equal(t, a.Version, b.Version)
|
||||
}
|
||||
testCases := []struct {
|
||||
name string
|
||||
pi *livekit.ParticipantInfo
|
||||
closeReason types.ParticipantCloseReason
|
||||
immediate bool
|
||||
existing *ParticipantUpdate
|
||||
expected []*ParticipantUpdate
|
||||
validate func(t *testing.T, rm *Room, updates []*ParticipantUpdate)
|
||||
}{
|
||||
{
|
||||
name: "publisher updates are immediate",
|
||||
pi: publisher1v1,
|
||||
expected: []*ParticipantUpdate{{ParticipantInfo: publisher1v1}},
|
||||
},
|
||||
{
|
||||
name: "subscriber updates are queued",
|
||||
pi: subscriber1v1,
|
||||
},
|
||||
{
|
||||
name: "last version is enqueued",
|
||||
pi: subscriber1v2,
|
||||
existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, // clone the existing value since it can be modified when setting to disconnected
|
||||
validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) {
|
||||
queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)]
|
||||
require.NotNil(t, queued)
|
||||
requirePIEquals(t, subscriber1v2, queued.ParticipantInfo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "latest version when immediate",
|
||||
pi: subscriber1v2,
|
||||
existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)},
|
||||
immediate: true,
|
||||
expected: []*ParticipantUpdate{{ParticipantInfo: subscriber1v2}},
|
||||
validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) {
|
||||
queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)]
|
||||
require.Nil(t, queued)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "out of order updates are rejected",
|
||||
pi: subscriber1v1,
|
||||
existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2)},
|
||||
validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) {
|
||||
queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)]
|
||||
requirePIEquals(t, subscriber1v2, queued.ParticipantInfo)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sid change is broadcasted immediately with synthsized disconnect",
|
||||
pi: publisher2,
|
||||
closeReason: types.ParticipantCloseReasonServiceRequestRemoveParticipant, // just to test if update contain the close reason
|
||||
existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2), CloseReason: types.ParticipantCloseReasonStale},
|
||||
expected: []*ParticipantUpdate{
|
||||
{
|
||||
ParticipantInfo: &livekit.ParticipantInfo{
|
||||
Identity: identity,
|
||||
Sid: "1",
|
||||
Version: 2,
|
||||
State: livekit.ParticipantInfo_DISCONNECTED,
|
||||
},
|
||||
IsSynthesizedDisconnect: true,
|
||||
CloseReason: types.ParticipantCloseReasonStale,
|
||||
},
|
||||
{ParticipantInfo: publisher2, CloseReason: types.ParticipantCloseReasonServiceRequestRemoveParticipant},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "when switching to publisher, queue is cleared",
|
||||
pi: publisher1v2,
|
||||
existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)},
|
||||
expected: []*ParticipantUpdate{{ParticipantInfo: publisher1v2}},
|
||||
validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) {
|
||||
require.Empty(t, rm.batchedUpdates)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
if tc.existing != nil {
|
||||
rm.batchedUpdates[livekit.ParticipantIdentity(tc.existing.ParticipantInfo.Identity)] = tc.existing
|
||||
}
|
||||
rm.batchedUpdatesMu.Lock()
|
||||
updates := PushAndDequeueUpdates(
|
||||
tc.pi,
|
||||
tc.closeReason,
|
||||
tc.immediate,
|
||||
rm.GetParticipant(livekit.ParticipantIdentity(tc.pi.Identity)),
|
||||
rm.batchedUpdates,
|
||||
)
|
||||
rm.batchedUpdatesMu.Unlock()
|
||||
require.Equal(t, len(tc.expected), len(updates))
|
||||
for i, item := range tc.expected {
|
||||
requirePIEquals(t, item.ParticipantInfo, updates[i].ParticipantInfo)
|
||||
require.Equal(t, item.IsSynthesizedDisconnect, updates[i].IsSynthesizedDisconnect)
|
||||
require.Equal(t, item.CloseReason, updates[i].CloseReason)
|
||||
}
|
||||
|
||||
if tc.validate != nil {
|
||||
tc.validate(t, rm, updates)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoomClosure(t *testing.T) {
|
||||
t.Run("room closes after participant leaves", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
isClosed := false
|
||||
var closeReason types.RoomCloseReason
|
||||
rm.OnClose(func(reason types.RoomCloseReason) {
|
||||
isClosed = true
|
||||
closeReason = reason
|
||||
})
|
||||
p := rm.GetParticipants()[0]
|
||||
rm.lock.Lock()
|
||||
// allows immediate close after
|
||||
rm.protoRoom.EmptyTimeout = 0
|
||||
rm.lock.Unlock()
|
||||
rm.RemoveParticipant(p.Identity(), p.ID(), types.ParticipantCloseReasonClientRequestLeave)
|
||||
|
||||
time.Sleep(time.Duration(rm.ToProto().DepartureTimeout)*time.Second + defaultDelay)
|
||||
|
||||
rm.CloseIfEmpty()
|
||||
require.Len(t, rm.GetParticipants(), 0)
|
||||
require.True(t, isClosed)
|
||||
require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason)
|
||||
|
||||
require.Equal(t, ErrRoomClosed, rm.Join(p, nil, nil, iceServersForRoom))
|
||||
})
|
||||
|
||||
t.Run("room does not close before empty timeout", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 0})
|
||||
isClosed := false
|
||||
var closeReason types.RoomCloseReason
|
||||
rm.OnClose(func(reason types.RoomCloseReason) {
|
||||
isClosed = true
|
||||
closeReason = reason
|
||||
})
|
||||
require.NotZero(t, rm.protoRoom.EmptyTimeout)
|
||||
rm.CloseIfEmpty()
|
||||
require.False(t, isClosed)
|
||||
require.Equal(t, types.RoomCloseReasonUnknown, closeReason)
|
||||
})
|
||||
|
||||
t.Run("room closes after empty timeout", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 0})
|
||||
isClosed := false
|
||||
var closeReason types.RoomCloseReason
|
||||
rm.OnClose(func(reason types.RoomCloseReason) {
|
||||
isClosed = true
|
||||
closeReason = reason
|
||||
})
|
||||
rm.lock.Lock()
|
||||
rm.protoRoom.EmptyTimeout = 1
|
||||
rm.lock.Unlock()
|
||||
|
||||
time.Sleep(1010 * time.Millisecond)
|
||||
rm.CloseIfEmpty()
|
||||
require.True(t, isClosed)
|
||||
require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewTrack(t *testing.T) {
|
||||
t.Run("new track should be added to ready participants", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 3})
|
||||
lpl := rm.LocalParticipantListener()
|
||||
|
||||
participants := rm.GetParticipants()
|
||||
p0 := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
p0.StateReturns(livekit.ParticipantInfo_JOINED)
|
||||
p1 := participants[1].(*typesfakes.FakeLocalParticipant)
|
||||
p1.StateReturns(livekit.ParticipantInfo_ACTIVE)
|
||||
|
||||
pub := participants[2].(*typesfakes.FakeLocalParticipant)
|
||||
|
||||
// pub adds track
|
||||
track := NewMockTrack(livekit.TrackType_VIDEO, "webcam")
|
||||
lpl.OnTrackPublished(pub, track)
|
||||
|
||||
// only p1 should've been subscribed to
|
||||
require.Equal(t, 0, p0.SubscribeToTrackCallCount())
|
||||
require.Equal(t, 1, p1.SubscribeToTrackCallCount())
|
||||
})
|
||||
}
|
||||
|
||||
func TestActiveSpeakers(t *testing.T) {
|
||||
t.Parallel()
|
||||
getActiveSpeakerUpdates := func(p *typesfakes.FakeLocalParticipant) [][]*livekit.SpeakerInfo {
|
||||
var updates [][]*livekit.SpeakerInfo
|
||||
numCalls := p.SendSpeakerUpdateCallCount()
|
||||
for i := range numCalls {
|
||||
infos, _ := p.SendSpeakerUpdateArgsForCall(i)
|
||||
updates = append(updates, infos)
|
||||
}
|
||||
return updates
|
||||
}
|
||||
|
||||
audioUpdateDuration := (audioUpdateInterval + 10) * time.Millisecond
|
||||
t.Run("participant should not be getting audio updates (protocol 2)", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1, protocol: 2})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant)
|
||||
require.Empty(t, rm.GetActiveSpeakers())
|
||||
|
||||
time.Sleep(audioUpdateDuration)
|
||||
|
||||
updates := getActiveSpeakerUpdates(p)
|
||||
require.Empty(t, updates)
|
||||
})
|
||||
|
||||
t.Run("speakers should be sorted by loudness", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
p2 := participants[1].(*typesfakes.FakeLocalParticipant)
|
||||
p.GetAudioLevelReturns(20, true)
|
||||
p2.GetAudioLevelReturns(10, true)
|
||||
|
||||
speakers := rm.GetActiveSpeakers()
|
||||
require.Len(t, speakers, 2)
|
||||
require.Equal(t, string(p.ID()), speakers[0].Sid)
|
||||
require.Equal(t, string(p2.ID()), speakers[1].Sid)
|
||||
})
|
||||
|
||||
t.Run("participants are getting audio updates (protocol 3+)", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
time.Sleep(time.Millisecond) // let the first update cycle run
|
||||
p.GetAudioLevelReturns(30, true)
|
||||
|
||||
speakers := rm.GetActiveSpeakers()
|
||||
require.NotEmpty(t, speakers)
|
||||
require.Equal(t, string(p.ID()), speakers[0].Sid)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
for _, op := range participants {
|
||||
op := op.(*typesfakes.FakeLocalParticipant)
|
||||
updates := getActiveSpeakerUpdates(op)
|
||||
if len(updates) == 0 {
|
||||
return fmt.Sprintf("%s did not get any audio updates", op.Identity())
|
||||
}
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
// no longer speaking, send update with empty items
|
||||
p.GetAudioLevelReturns(127, false)
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
updates := getActiveSpeakerUpdates(p)
|
||||
lastUpdate := updates[len(updates)-1]
|
||||
if len(lastUpdate) == 0 {
|
||||
return "did not get updates of speaker going quiet"
|
||||
}
|
||||
if lastUpdate[0].Active {
|
||||
return "speaker should not have been active"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("audio level is smoothed", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3, audioSmoothIntervals: 3})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
op := participants[1].(*typesfakes.FakeLocalParticipant)
|
||||
p.GetAudioLevelReturns(30, true)
|
||||
convertedLevel := float32(audio.ConvertAudioLevel(30))
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
updates := getActiveSpeakerUpdates(op)
|
||||
if len(updates) == 0 {
|
||||
return "no speaker updates received"
|
||||
}
|
||||
lastSpeakers := updates[len(updates)-1]
|
||||
if len(lastSpeakers) == 0 {
|
||||
return "no speakers in the update"
|
||||
}
|
||||
if lastSpeakers[0].Level > convertedLevel {
|
||||
return ""
|
||||
}
|
||||
return "level mismatch"
|
||||
})
|
||||
|
||||
testutils.WithTimeout(t, func() string {
|
||||
updates := getActiveSpeakerUpdates(op)
|
||||
if len(updates) == 0 {
|
||||
return "no updates received"
|
||||
}
|
||||
lastSpeakers := updates[len(updates)-1]
|
||||
if len(lastSpeakers) == 0 {
|
||||
return "no speakers found"
|
||||
}
|
||||
if lastSpeakers[0].Level > convertedLevel {
|
||||
return ""
|
||||
}
|
||||
return "did not match expected levels"
|
||||
})
|
||||
|
||||
p.GetAudioLevelReturns(127, false)
|
||||
testutils.WithTimeout(t, func() string {
|
||||
updates := getActiveSpeakerUpdates(op)
|
||||
if len(updates) == 0 {
|
||||
return "no speaker updates received"
|
||||
}
|
||||
lastSpeakers := updates[len(updates)-1]
|
||||
if len(lastSpeakers) == 1 && !lastSpeakers[0].Active {
|
||||
return ""
|
||||
}
|
||||
return "speakers didn't go back to zero"
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestDataChannel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
curAPI = iota
|
||||
legacySID
|
||||
legacyIdentity
|
||||
)
|
||||
modes := []int{
|
||||
curAPI, legacySID, legacyIdentity,
|
||||
}
|
||||
modeNames := []string{
|
||||
"cur", "legacy sid", "legacy identity",
|
||||
}
|
||||
|
||||
setSource := func(mode int, dp *livekit.DataPacket, p types.LocalParticipant) {
|
||||
switch mode {
|
||||
case curAPI:
|
||||
dp.ParticipantIdentity = string(p.Identity())
|
||||
case legacySID:
|
||||
dp.GetUser().ParticipantSid = string(p.ID())
|
||||
case legacyIdentity:
|
||||
dp.GetUser().ParticipantIdentity = string(p.Identity())
|
||||
}
|
||||
}
|
||||
setDest := func(mode int, dp *livekit.DataPacket, p types.LocalParticipant) {
|
||||
switch mode {
|
||||
case curAPI:
|
||||
dp.DestinationIdentities = []string{string(p.Identity())}
|
||||
case legacySID:
|
||||
dp.GetUser().DestinationSids = []string{string(p.ID())}
|
||||
case legacyIdentity:
|
||||
dp.GetUser().DestinationIdentities = []string{string(p.Identity())}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("participants should receive data", func(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
mode := mode
|
||||
t.Run(modeNames[mode], func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 3})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
lpl := rm.LocalParticipantListener()
|
||||
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
|
||||
packet := &livekit.DataPacket{
|
||||
Kind: livekit.DataPacket_RELIABLE,
|
||||
Value: &livekit.DataPacket_User{
|
||||
User: &livekit.UserPacket{
|
||||
Payload: []byte("message.."),
|
||||
},
|
||||
},
|
||||
}
|
||||
setSource(mode, packet, p)
|
||||
|
||||
packetExp := utils.CloneProto(packet)
|
||||
if mode != legacySID {
|
||||
packetExp.ParticipantIdentity = string(p.Identity())
|
||||
packetExp.GetUser().ParticipantIdentity = string(p.Identity())
|
||||
}
|
||||
|
||||
encoded, _ := proto.Marshal(packetExp)
|
||||
lpl.OnDataMessage(p, packet.Kind, packet)
|
||||
|
||||
// ensure everyone has received the packet
|
||||
for _, op := range participants {
|
||||
fp := op.(*typesfakes.FakeLocalParticipant)
|
||||
if fp == p {
|
||||
require.Zero(t, fp.SendDataMessageCallCount())
|
||||
continue
|
||||
}
|
||||
require.Equal(t, 1, fp.SendDataMessageCallCount())
|
||||
_, got, _, _ := fp.SendDataMessageArgsForCall(0)
|
||||
require.Equal(t, encoded, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only one participant should receive the data", func(t *testing.T) {
|
||||
for _, mode := range modes {
|
||||
mode := mode
|
||||
t.Run(modeNames[mode], func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 4})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
lpl := rm.LocalParticipantListener()
|
||||
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
p1 := participants[1].(*typesfakes.FakeLocalParticipant)
|
||||
|
||||
packet := &livekit.DataPacket{
|
||||
Kind: livekit.DataPacket_RELIABLE,
|
||||
Value: &livekit.DataPacket_User{
|
||||
User: &livekit.UserPacket{
|
||||
Payload: []byte("message to p1.."),
|
||||
},
|
||||
},
|
||||
}
|
||||
setSource(mode, packet, p)
|
||||
setDest(mode, packet, p1)
|
||||
|
||||
packetExp := utils.CloneProto(packet)
|
||||
if mode != legacySID {
|
||||
packetExp.ParticipantIdentity = string(p.Identity())
|
||||
packetExp.GetUser().ParticipantIdentity = string(p.Identity())
|
||||
packetExp.DestinationIdentities = []string{string(p1.Identity())}
|
||||
packetExp.GetUser().DestinationIdentities = []string{string(p1.Identity())}
|
||||
}
|
||||
|
||||
encoded, _ := proto.Marshal(packetExp)
|
||||
lpl.OnDataMessage(p, packet.Kind, packet)
|
||||
|
||||
// only p1 should receive the data
|
||||
for _, op := range participants {
|
||||
fp := op.(*typesfakes.FakeLocalParticipant)
|
||||
if fp != p1 {
|
||||
require.Zero(t, fp.SendDataMessageCallCount())
|
||||
}
|
||||
}
|
||||
require.Equal(t, 1, p1.SendDataMessageCallCount())
|
||||
_, got, _, _ := p1.SendDataMessageArgsForCall(0)
|
||||
require.Equal(t, encoded, got)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("publishing disallowed", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
participants := rm.GetParticipants()
|
||||
p := participants[0].(*typesfakes.FakeLocalParticipant)
|
||||
p.CanPublishDataReturns(false)
|
||||
|
||||
packet := livekit.DataPacket{
|
||||
Kind: livekit.DataPacket_RELIABLE,
|
||||
Value: &livekit.DataPacket_User{
|
||||
User: &livekit.UserPacket{
|
||||
Payload: []byte{},
|
||||
},
|
||||
},
|
||||
}
|
||||
if p.CanPublishData() {
|
||||
lpl := rm.LocalParticipantListener()
|
||||
lpl.OnDataMessage(p, packet.Kind, &packet)
|
||||
}
|
||||
|
||||
// no one should've been sent packet
|
||||
for _, op := range participants {
|
||||
fp := op.(*typesfakes.FakeLocalParticipant)
|
||||
require.Zero(t, fp.SendDataMessageCallCount())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHiddenParticipants(t *testing.T) {
|
||||
t.Run("other participants don't receive hidden updates", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2, numHidden: 1})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
pNew := NewMockParticipant("new", types.CurrentProtocol, false, false, rm.LocalParticipantListener())
|
||||
rm.Join(pNew, nil, nil, iceServersForRoom)
|
||||
|
||||
// expect new participant to get a JoinReply
|
||||
res := pNew.SendJoinResponseArgsForCall(0)
|
||||
require.Equal(t, livekit.RoomID(res.Room.Sid), rm.ID())
|
||||
require.Len(t, res.OtherParticipants, 2)
|
||||
require.Len(t, rm.GetParticipants(), 4)
|
||||
require.NotEmpty(t, res.IceServers)
|
||||
require.Equal(t, "testregion", res.ServerInfo.Region)
|
||||
})
|
||||
|
||||
t.Run("hidden participant subscribes to tracks", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2})
|
||||
lpl := rm.LocalParticipantListener()
|
||||
hidden := NewMockParticipant("hidden", types.CurrentProtocol, true, false, lpl)
|
||||
|
||||
err := rm.Join(hidden, nil, &ParticipantOptions{AutoSubscribe: true}, iceServersForRoom)
|
||||
require.NoError(t, err)
|
||||
|
||||
hidden.StateReturns(livekit.ParticipantInfo_ACTIVE)
|
||||
lpl.OnStateChange(hidden)
|
||||
|
||||
require.Eventually(t, func() bool { return hidden.SubscribeToTrackCallCount() == 2 }, 5*time.Second, 10*time.Millisecond)
|
||||
})
|
||||
}
|
||||
|
||||
func TestRoomUpdate(t *testing.T) {
|
||||
t.Run("updates are sent when participant joined", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 1})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
p1 := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant)
|
||||
require.Equal(t, 0, p1.SendRoomUpdateCallCount())
|
||||
|
||||
p2 := NewMockParticipant("p2", types.CurrentProtocol, false, false, rm.LocalParticipantListener())
|
||||
require.NoError(t, rm.Join(p2, nil, nil, iceServersForRoom))
|
||||
|
||||
// p1 should have received an update
|
||||
time.Sleep(2 * defaultDelay)
|
||||
require.LessOrEqual(t, 1, p1.SendRoomUpdateCallCount())
|
||||
require.EqualValues(t, 2, p1.SendRoomUpdateArgsForCall(p1.SendRoomUpdateCallCount()-1).NumParticipants)
|
||||
})
|
||||
|
||||
t.Run("participants should receive metadata update", func(t *testing.T) {
|
||||
rm := newRoomWithParticipants(t, testRoomOpts{num: 2})
|
||||
defer rm.Close(types.RoomCloseReasonUnknown)
|
||||
|
||||
rm.SetMetadata("test metadata...")
|
||||
|
||||
// callbacks are updated from goroutine
|
||||
time.Sleep(2 * defaultDelay)
|
||||
|
||||
for _, op := range rm.GetParticipants() {
|
||||
fp := op.(*typesfakes.FakeLocalParticipant)
|
||||
// room updates are now sent for both participant joining and room metadata
|
||||
require.GreaterOrEqual(t, fp.SendRoomUpdateCallCount(), 1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type testRoomOpts struct {
|
||||
num int
|
||||
numHidden int
|
||||
protocol types.ProtocolVersion
|
||||
audioSmoothIntervals uint32
|
||||
}
|
||||
|
||||
func newRoomWithParticipants(t *testing.T, opts testRoomOpts) *Room {
|
||||
kp := &authfakes.FakeKeyProvider{}
|
||||
kp.GetSecretReturns("testkey")
|
||||
|
||||
n, err := webhook.NewDefaultNotifier(webhook.DefaultWebHookConfig, kp)
|
||||
require.NoError(t, err)
|
||||
|
||||
rm := NewRoom(
|
||||
&livekit.Room{Name: "room"},
|
||||
nil,
|
||||
WebRTCConfig{},
|
||||
config.RoomConfig{
|
||||
EmptyTimeout: 5 * 60,
|
||||
DepartureTimeout: 1,
|
||||
},
|
||||
&sfu.AudioConfig{
|
||||
AudioLevelConfig: audio.AudioLevelConfig{
|
||||
UpdateInterval: audioUpdateInterval,
|
||||
SmoothIntervals: opts.audioSmoothIntervals,
|
||||
},
|
||||
},
|
||||
&livekit.ServerInfo{
|
||||
Edition: livekit.ServerInfo_Standard,
|
||||
Version: version.Version,
|
||||
Protocol: types.CurrentProtocol,
|
||||
NodeId: "testnode",
|
||||
Region: "testregion",
|
||||
},
|
||||
telemetry.NewTelemetryService(n, &telemetryfakes.FakeAnalyticsService{}),
|
||||
nil, nil, nil,
|
||||
)
|
||||
for i := 0; i < opts.num+opts.numHidden; i++ {
|
||||
identity := livekit.ParticipantIdentity(fmt.Sprintf("p%d", i))
|
||||
participant := NewMockParticipant(identity, opts.protocol, i >= opts.num, true, rm.LocalParticipantListener())
|
||||
err := rm.Join(participant, nil, &ParticipantOptions{AutoSubscribe: true}, iceServersForRoom)
|
||||
require.NoError(t, err)
|
||||
participant.StateReturns(livekit.ParticipantInfo_ACTIVE)
|
||||
participant.IsReadyReturns(true)
|
||||
// each participant has a track
|
||||
participant.GetPublishedTracksReturns([]types.MediaTrack{
|
||||
&typesfakes.FakeMediaTrack{},
|
||||
})
|
||||
}
|
||||
return rm
|
||||
}
|
||||
263
pkg/rtc/roomtrackmanager.go
Normal file
263
pkg/rtc/roomtrackmanager.go
Normal file
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
* 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 (
|
||||
"sync"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
// RoomTrackManager holds tracks that are published to the room
|
||||
type RoomTrackManager struct {
|
||||
logger logger.Logger
|
||||
|
||||
lock sync.RWMutex
|
||||
changedNotifier *utils.ChangeNotifierManager
|
||||
removedNotifier *utils.ChangeNotifierManager
|
||||
tracks map[livekit.TrackID][]*TrackInfo
|
||||
dataTracks map[livekit.TrackID][]*DataTrackInfo
|
||||
}
|
||||
|
||||
type TrackInfo struct {
|
||||
Track types.MediaTrack
|
||||
PublisherIdentity livekit.ParticipantIdentity
|
||||
PublisherID livekit.ParticipantID
|
||||
}
|
||||
|
||||
type DataTrackInfo struct {
|
||||
DataTrack types.DataTrack
|
||||
PublisherIdentity livekit.ParticipantIdentity
|
||||
PublisherID livekit.ParticipantID
|
||||
}
|
||||
|
||||
func NewRoomTrackManager(logger logger.Logger) *RoomTrackManager {
|
||||
return &RoomTrackManager{
|
||||
logger: logger,
|
||||
tracks: make(map[livekit.TrackID][]*TrackInfo),
|
||||
dataTracks: make(map[livekit.TrackID][]*DataTrackInfo),
|
||||
changedNotifier: utils.NewChangeNotifierManager(),
|
||||
removedNotifier: utils.NewChangeNotifierManager(),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) AddTrack(track types.MediaTrack, publisherIdentity livekit.ParticipantIdentity, publisherID livekit.ParticipantID) {
|
||||
trackID := track.ID()
|
||||
r.lock.Lock()
|
||||
infos, ok := r.tracks[trackID]
|
||||
if ok {
|
||||
for _, info := range infos {
|
||||
if info.Track == track {
|
||||
r.lock.Unlock()
|
||||
r.logger.Infow("not adding duplicate track", "trackID", trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
r.tracks[trackID] = append(r.tracks[trackID], &TrackInfo{
|
||||
Track: track,
|
||||
PublisherIdentity: publisherIdentity,
|
||||
PublisherID: publisherID,
|
||||
})
|
||||
r.lock.Unlock()
|
||||
|
||||
r.NotifyTrackChanged(trackID)
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) RemoveTrack(track types.MediaTrack) {
|
||||
trackID := track.ID()
|
||||
r.lock.Lock()
|
||||
// ensure we are removing the same track as added
|
||||
infos, ok := r.tracks[trackID]
|
||||
if !ok {
|
||||
r.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
numRemoved := 0
|
||||
idx := 0
|
||||
for _, info := range infos {
|
||||
if info.Track == track {
|
||||
numRemoved++
|
||||
} else {
|
||||
r.tracks[trackID][idx] = info
|
||||
idx++
|
||||
}
|
||||
}
|
||||
for j := idx; j < len(infos); j++ {
|
||||
r.tracks[trackID][j] = nil
|
||||
}
|
||||
r.tracks[trackID] = r.tracks[trackID][:idx]
|
||||
if len(r.tracks[trackID]) == 0 {
|
||||
delete(r.tracks, trackID)
|
||||
}
|
||||
r.lock.Unlock()
|
||||
if numRemoved == 0 {
|
||||
return
|
||||
}
|
||||
if numRemoved > 1 {
|
||||
r.logger.Warnw("removed multiple tracks", nil, "trackID", trackID, "numRemoved", numRemoved)
|
||||
}
|
||||
|
||||
n := r.removedNotifier.GetNotifier(string(trackID))
|
||||
if n != nil {
|
||||
n.NotifyChanged()
|
||||
}
|
||||
|
||||
r.changedNotifier.RemoveNotifier(string(trackID), true)
|
||||
r.removedNotifier.RemoveNotifier(string(trackID), true)
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) GetTrackInfo(trackID livekit.TrackID) *TrackInfo {
|
||||
r.lock.RLock()
|
||||
defer r.lock.RUnlock()
|
||||
|
||||
infos := r.tracks[trackID]
|
||||
if len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// earliest added track is used till it is removed
|
||||
info := infos[0]
|
||||
|
||||
// when track is about to close, do not resolve
|
||||
if info.Track != nil && !info.Track.IsOpen() {
|
||||
return nil
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) NotifyTrackChanged(trackID livekit.TrackID) {
|
||||
n := r.changedNotifier.GetNotifier(string(trackID))
|
||||
if n != nil {
|
||||
n.NotifyChanged()
|
||||
}
|
||||
}
|
||||
|
||||
// HasObservers lets caller know if the current media track has any observers
|
||||
// this is used to signal interest in the track. when another MediaTrack with the same
|
||||
// trackID is being used, track is not considered to be observed.
|
||||
func (r *RoomTrackManager) HasObservers(track types.MediaTrack) bool {
|
||||
n := r.changedNotifier.GetNotifier(string(track.ID()))
|
||||
if n == nil || !n.HasObservers() {
|
||||
return false
|
||||
}
|
||||
|
||||
info := r.GetTrackInfo(track.ID())
|
||||
if info == nil || info.Track != track {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) GetOrCreateTrackChangeNotifier(trackID livekit.TrackID) *utils.ChangeNotifier {
|
||||
return r.changedNotifier.GetOrCreateNotifier(string(trackID))
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) GetOrCreateTrackRemoveNotifier(trackID livekit.TrackID) *utils.ChangeNotifier {
|
||||
return r.removedNotifier.GetOrCreateNotifier(string(trackID))
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) AddDataTrack(dataTrack types.DataTrack, publisherIdentity livekit.ParticipantIdentity, publisherID livekit.ParticipantID) {
|
||||
trackID := dataTrack.ID()
|
||||
r.lock.Lock()
|
||||
infos, ok := r.dataTracks[trackID]
|
||||
if ok {
|
||||
for _, info := range infos {
|
||||
if info.DataTrack == dataTrack {
|
||||
r.lock.Unlock()
|
||||
r.logger.Infow("not adding duplicate data track", "trackID", trackID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
r.dataTracks[trackID] = append(r.dataTracks[trackID], &DataTrackInfo{
|
||||
DataTrack: dataTrack,
|
||||
PublisherIdentity: publisherIdentity,
|
||||
PublisherID: publisherID,
|
||||
})
|
||||
r.lock.Unlock()
|
||||
|
||||
r.NotifyTrackChanged(trackID)
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) RemoveDataTrack(dataTrack types.DataTrack) {
|
||||
trackID := dataTrack.ID()
|
||||
r.lock.Lock()
|
||||
// ensure we are removing the same track as added
|
||||
infos, ok := r.dataTracks[trackID]
|
||||
if !ok {
|
||||
r.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
numRemoved := 0
|
||||
idx := 0
|
||||
for _, info := range infos {
|
||||
if info.DataTrack == dataTrack {
|
||||
numRemoved++
|
||||
} else {
|
||||
r.dataTracks[trackID][idx] = info
|
||||
idx++
|
||||
}
|
||||
}
|
||||
for j := idx; j < len(infos); j++ {
|
||||
r.dataTracks[trackID][j] = nil
|
||||
}
|
||||
r.dataTracks[trackID] = r.dataTracks[trackID][:idx]
|
||||
if len(r.dataTracks[trackID]) == 0 {
|
||||
delete(r.dataTracks, trackID)
|
||||
}
|
||||
r.lock.Unlock()
|
||||
if numRemoved == 0 {
|
||||
return
|
||||
}
|
||||
if numRemoved > 1 {
|
||||
r.logger.Warnw("removed multiple data tracks", nil, "trackID", trackID, "numRemoved", numRemoved)
|
||||
}
|
||||
|
||||
n := r.removedNotifier.GetNotifier(string(trackID))
|
||||
if n != nil {
|
||||
n.NotifyChanged()
|
||||
}
|
||||
|
||||
r.changedNotifier.RemoveNotifier(string(trackID), true)
|
||||
r.removedNotifier.RemoveNotifier(string(trackID), true)
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) GetDataTrackInfo(trackID livekit.TrackID) *DataTrackInfo {
|
||||
r.lock.RLock()
|
||||
defer r.lock.RUnlock()
|
||||
|
||||
infos := r.dataTracks[trackID]
|
||||
if len(infos) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// earliest added data track is used till it is removed
|
||||
return infos[0]
|
||||
}
|
||||
|
||||
func (r *RoomTrackManager) Report() (int, int) {
|
||||
r.lock.RLock()
|
||||
defer r.lock.RUnlock()
|
||||
|
||||
return len(r.tracks), len(r.dataTracks)
|
||||
}
|
||||
311
pkg/rtc/signalanddatastats.go
Normal file
311
pkg/rtc/signalanddatastats.go
Normal file
@@ -0,0 +1,311 @@
|
||||
// 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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frostbyte73/core"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
"github.com/livekit/protocol/utils"
|
||||
)
|
||||
|
||||
type BytesTrackType string
|
||||
|
||||
const (
|
||||
BytesTrackTypeData BytesTrackType = "DT"
|
||||
BytesTrackTypeSignal BytesTrackType = "SG"
|
||||
)
|
||||
|
||||
// -------------------------------
|
||||
|
||||
type TrafficTotals struct {
|
||||
At time.Time
|
||||
SendBytes uint64
|
||||
SendMessages uint32
|
||||
RecvBytes uint64
|
||||
RecvMessages uint32
|
||||
}
|
||||
|
||||
// --------------------------------
|
||||
|
||||
// stats for signal and data channel
|
||||
type BytesTrackStats struct {
|
||||
country string
|
||||
pID livekit.ParticipantID
|
||||
kind livekit.ParticipantInfo_Kind
|
||||
kindDetails []livekit.ParticipantInfo_KindDetail
|
||||
trackID livekit.TrackID
|
||||
send, recv atomic.Uint64
|
||||
sendMessages, recvMessages atomic.Uint32
|
||||
totalSendBytes, totalRecvBytes atomic.Uint64
|
||||
totalSendMessages, totalRecvMessages atomic.Uint32
|
||||
telemetryListener func() types.ParticipantTelemetryListener
|
||||
reporter roomobs.TrackReporter
|
||||
done core.Fuse
|
||||
}
|
||||
|
||||
func NewBytesTrackStats(
|
||||
country string,
|
||||
trackID livekit.TrackID,
|
||||
pID livekit.ParticipantID,
|
||||
kind livekit.ParticipantInfo_Kind,
|
||||
kindDetails []livekit.ParticipantInfo_KindDetail,
|
||||
telemetryListener func() types.ParticipantTelemetryListener,
|
||||
participantReporter roomobs.ParticipantSessionReporter,
|
||||
) *BytesTrackStats {
|
||||
s := &BytesTrackStats{
|
||||
country: country,
|
||||
pID: pID,
|
||||
kind: kind,
|
||||
kindDetails: kindDetails,
|
||||
trackID: trackID,
|
||||
telemetryListener: telemetryListener,
|
||||
reporter: participantReporter.WithTrack(trackID.String()),
|
||||
}
|
||||
go s.worker()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *BytesTrackStats) AddBytes(bytes uint64, isSend bool) {
|
||||
if isSend {
|
||||
s.send.Add(bytes)
|
||||
s.sendMessages.Inc()
|
||||
s.totalSendBytes.Add(bytes)
|
||||
s.totalSendMessages.Inc()
|
||||
|
||||
s.reporter.Tx(func(tx roomobs.TrackTx) {
|
||||
tx.ParticipantSession().ReportKindCode(roomobs.ParticipantKindCode(s.kind))
|
||||
tx.ParticipantSession().ReportKindDetailsCodes(roomobs.ParticipantKindDetailsCodes(s.kindDetails))
|
||||
tx.ReportType(roomobs.TrackTypeData)
|
||||
tx.ReportSendBytes(uint32(bytes))
|
||||
tx.ReportSendPackets(1)
|
||||
})
|
||||
} else {
|
||||
s.recv.Add(bytes)
|
||||
s.recvMessages.Inc()
|
||||
s.totalRecvBytes.Add(bytes)
|
||||
s.totalRecvMessages.Inc()
|
||||
|
||||
s.reporter.Tx(func(tx roomobs.TrackTx) {
|
||||
tx.ParticipantSession().ReportKindCode(roomobs.ParticipantKindCode(s.kind))
|
||||
tx.ParticipantSession().ReportKindDetailsCodes(roomobs.ParticipantKindDetailsCodes(s.kindDetails))
|
||||
tx.ReportType(roomobs.TrackTypeData)
|
||||
tx.ReportRecvBytes(uint32(bytes))
|
||||
tx.ReportRecvPackets(1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BytesTrackStats) GetTrafficTotals() *TrafficTotals {
|
||||
return &TrafficTotals{
|
||||
At: time.Now(),
|
||||
SendBytes: s.totalSendBytes.Load(),
|
||||
SendMessages: s.totalSendMessages.Load(),
|
||||
RecvBytes: s.totalRecvBytes.Load(),
|
||||
RecvMessages: s.totalRecvMessages.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BytesTrackStats) Stop() {
|
||||
s.done.Break()
|
||||
}
|
||||
|
||||
func (s *BytesTrackStats) report() {
|
||||
if recv := s.recv.Swap(0); recv > 0 {
|
||||
packets := s.recvMessages.Swap(0)
|
||||
s.telemetryListener().OnTrackStats(
|
||||
telemetry.StatsKeyForData(s.country, livekit.StreamType_UPSTREAM, s.pID, s.trackID),
|
||||
&livekit.AnalyticsStat{
|
||||
Streams: []*livekit.AnalyticsStream{
|
||||
{
|
||||
PrimaryBytes: recv,
|
||||
PrimaryPackets: packets,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if send := s.send.Swap(0); send > 0 {
|
||||
packets := s.sendMessages.Swap(0)
|
||||
s.telemetryListener().OnTrackStats(
|
||||
telemetry.StatsKeyForData(s.country, livekit.StreamType_DOWNSTREAM, s.pID, s.trackID),
|
||||
&livekit.AnalyticsStat{
|
||||
Streams: []*livekit.AnalyticsStream{
|
||||
{
|
||||
PrimaryBytes: send,
|
||||
PrimaryPackets: packets,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BytesTrackStats) worker() {
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
s.report()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.done.Watch():
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.report()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
var _ types.ParticipantTelemetryListener = (*BytesSignalStats)(nil)
|
||||
|
||||
type BytesSignalStats struct {
|
||||
BytesTrackStats
|
||||
ctx context.Context
|
||||
|
||||
telemetry telemetry.TelemetryService
|
||||
guard *telemetry.ReferenceGuard
|
||||
|
||||
participantResolver roomobs.ParticipantReporterResolver
|
||||
trackResolver roomobs.KeyResolver
|
||||
|
||||
mu sync.Mutex
|
||||
ri *livekit.Room
|
||||
pi *livekit.ParticipantInfo
|
||||
stopped chan struct{}
|
||||
|
||||
types.NullParticipantTelemetryListener
|
||||
}
|
||||
|
||||
func NewBytesSignalStats(
|
||||
ctx context.Context,
|
||||
telemetryService telemetry.TelemetryService,
|
||||
) *BytesSignalStats {
|
||||
projectReporter := telemetryService.RoomProjectReporter(ctx)
|
||||
participantReporter, participantReporterResolver := roomobs.DeferredParticipantReporter(projectReporter)
|
||||
trackReporter, trackReporterResolver := participantReporter.WithDeferredTrack()
|
||||
b := &BytesSignalStats{
|
||||
ctx: ctx,
|
||||
telemetry: telemetryService,
|
||||
guard: &telemetry.ReferenceGuard{},
|
||||
participantResolver: participantReporterResolver,
|
||||
trackResolver: trackReporterResolver,
|
||||
}
|
||||
b.BytesTrackStats = BytesTrackStats{
|
||||
telemetryListener: b.telemetryListener,
|
||||
reporter: trackReporter,
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) ResolveRoom(ri *livekit.Room) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ri == nil && ri.GetSid() != "" {
|
||||
s.ri = &livekit.Room{
|
||||
Sid: ri.Sid,
|
||||
Name: ri.Name,
|
||||
}
|
||||
s.maybeStart()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) ResolveParticipant(pi *livekit.ParticipantInfo) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.pi == nil && pi != nil {
|
||||
s.pi = &livekit.ParticipantInfo{
|
||||
Sid: pi.Sid,
|
||||
Identity: pi.Identity,
|
||||
Kind: pi.Kind,
|
||||
KindDetails: pi.KindDetails,
|
||||
}
|
||||
s.kind = pi.Kind
|
||||
s.kindDetails = pi.KindDetails
|
||||
s.maybeStart()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) Reset() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.stopped != nil {
|
||||
s.done.Break()
|
||||
<-s.stopped
|
||||
s.stopped = nil
|
||||
s.done = core.Fuse{}
|
||||
s.guard = &telemetry.ReferenceGuard{}
|
||||
}
|
||||
s.ri = nil
|
||||
s.pi = nil
|
||||
|
||||
s.participantResolver.Reset()
|
||||
s.trackResolver.Reset()
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) maybeStart() {
|
||||
if s.ri == nil || s.pi == nil {
|
||||
return
|
||||
}
|
||||
|
||||
s.pID = livekit.ParticipantID(s.pi.Sid)
|
||||
s.trackID = BytesTrackIDForParticipantID(BytesTrackTypeSignal, s.pID)
|
||||
|
||||
s.participantResolver.Resolve(
|
||||
livekit.RoomName(s.ri.Name),
|
||||
livekit.RoomID(s.ri.Sid),
|
||||
livekit.ParticipantIdentity(s.pi.Identity),
|
||||
livekit.ParticipantID(s.pi.Sid),
|
||||
)
|
||||
s.trackResolver.Resolve(string(s.trackID))
|
||||
|
||||
s.telemetry.ParticipantJoined(s.ctx, s.ri, s.pi, nil, nil, false, s.guard)
|
||||
s.stopped = make(chan struct{})
|
||||
go s.worker()
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) worker() {
|
||||
s.BytesTrackStats.worker()
|
||||
s.telemetry.ParticipantLeft(s.ctx, s.ri, s.pi, false, s.guard)
|
||||
close(s.stopped)
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) telemetryListener() types.ParticipantTelemetryListener {
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *BytesSignalStats) OnTrackStats(key telemetry.StatsKey, stat *livekit.AnalyticsStat) {
|
||||
stat.RoomId, stat.RoomName = s.ri.Sid, s.ri.Name
|
||||
s.telemetry.TrackStats(livekit.RoomID(s.ri.Sid), livekit.RoomName(s.ri.Name), key, stat)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
func BytesTrackIDForParticipantID(typ BytesTrackType, participantID livekit.ParticipantID) livekit.TrackID {
|
||||
return livekit.TrackID(fmt.Sprintf("%s%s%s", utils.TrackPrefix, typ, participantID))
|
||||
}
|
||||
27
pkg/rtc/signalling/errors.go
Normal file
27
pkg/rtc/signalling/errors.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidMessageType = errors.New("invalid message type")
|
||||
ErrNameExceedsLimits = errors.New("name length exceeds limits")
|
||||
ErrMetadataExceedsLimits = errors.New("metadata size exceeds limits")
|
||||
ErrAttributesExceedsLimits = errors.New("attributes size exceeds limits")
|
||||
ErrUpdateOwnMetadataNotAllowed = errors.New("update own metadata not allowed")
|
||||
)
|
||||
74
pkg/rtc/signalling/interfaces.go
Normal file
74
pkg/rtc/signalling/interfaces.go
Normal file
@@ -0,0 +1,74 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type ParticipantSignalHandler interface {
|
||||
HandleMessage(msg proto.Message) error
|
||||
}
|
||||
|
||||
type ParticipantSignaller interface {
|
||||
SwapResponseSink(sink routing.MessageSink, reason types.SignallingCloseReason)
|
||||
GetResponseSink() routing.MessageSink
|
||||
CloseSignalConnection(reason types.SignallingCloseReason)
|
||||
|
||||
// HandshakePending reports whether a resumed connection is still waiting for the
|
||||
// ReconnectResponse it has to open with
|
||||
HandshakePending() bool
|
||||
// OpenHandshake lets messages flow on a resumed connection that does not open with
|
||||
// a ReconnectResponse
|
||||
OpenHandshake()
|
||||
|
||||
WriteMessage(msg proto.Message) error
|
||||
}
|
||||
|
||||
type ParticipantSignalling interface {
|
||||
SignalJoinResponse(join *livekit.JoinResponse) proto.Message
|
||||
SignalParticipantUpdate(participants []*livekit.ParticipantInfo) proto.Message
|
||||
SignalSpeakerUpdate(speakers []*livekit.SpeakerInfo) proto.Message
|
||||
SignalRoomUpdate(room *livekit.Room) proto.Message
|
||||
SignalConnectionQualityUpdate(connectionQuality *livekit.ConnectionQualityUpdate) proto.Message
|
||||
SignalRefreshToken(token string) proto.Message
|
||||
SignalRequestResponse(requestResponse *livekit.RequestResponse) proto.Message
|
||||
SignalRoomMovedResponse(roomMoved *livekit.RoomMovedResponse) proto.Message
|
||||
SignalReconnectResponse(reconnect *livekit.ReconnectResponse) proto.Message
|
||||
SignalICECandidate(trickle *livekit.TrickleRequest) proto.Message
|
||||
SignalTrackMuted(mute *livekit.MuteTrackRequest) proto.Message
|
||||
SignalTrackPublished(trackPublished *livekit.TrackPublishedResponse) proto.Message
|
||||
SignalTrackUnpublished(trackUnpublished *livekit.TrackUnpublishedResponse) proto.Message
|
||||
SignalTrackSubscribed(trackSubscribed *livekit.TrackSubscribed) proto.Message
|
||||
SignalLeaveRequest(leave *livekit.LeaveRequest) proto.Message
|
||||
SignalSdpAnswer(answer *livekit.SessionDescription) proto.Message
|
||||
SignalSdpOffer(offer *livekit.SessionDescription) proto.Message
|
||||
SignalStreamStateUpdate(streamStateUpdate *livekit.StreamStateUpdate) proto.Message
|
||||
SignalSubscribedQualityUpdate(subscribedQualityUpdate *livekit.SubscribedQualityUpdate) proto.Message
|
||||
SignalSubscriptionResponse(subscriptionResponse *livekit.SubscriptionResponse) proto.Message
|
||||
SignalSubscriptionPermissionUpdate(subscriptionPermissionUpdate *livekit.SubscriptionPermissionUpdate) proto.Message
|
||||
SignalMediaSectionsRequirement(mediaSectionsRequirement *livekit.MediaSectionsRequirement) proto.Message
|
||||
SignalSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate *livekit.SubscribedAudioCodecUpdate) proto.Message
|
||||
SignalPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse) proto.Message
|
||||
SignalUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse) proto.Message
|
||||
SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message
|
||||
SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message
|
||||
SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message
|
||||
}
|
||||
163
pkg/rtc/signalling/signalhandler.go
Normal file
163
pkg/rtc/signalling/signalhandler.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
var _ ParticipantSignalHandler = (*signalhandler)(nil)
|
||||
|
||||
type SignalHandlerParams struct {
|
||||
Logger logger.Logger
|
||||
Participant types.LocalParticipant
|
||||
}
|
||||
|
||||
type signalhandler struct {
|
||||
params SignalHandlerParams
|
||||
}
|
||||
|
||||
func NewSignalHandler(params SignalHandlerParams) ParticipantSignalHandler {
|
||||
return &signalhandler{
|
||||
params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalhandler) HandleMessage(msg proto.Message) error {
|
||||
req, ok := msg.(*livekit.SignalRequest)
|
||||
if !ok {
|
||||
s.params.Logger.Warnw(
|
||||
"unknown message type", nil,
|
||||
"messageType", fmt.Sprintf("%T", msg),
|
||||
)
|
||||
return ErrInvalidMessageType
|
||||
}
|
||||
s.params.Participant.UpdateLastSeenSignal()
|
||||
|
||||
s.params.Logger.Debugw("handling signal request", "request", logger.Proto(req))
|
||||
switch msg := req.GetMessage().(type) {
|
||||
case *livekit.SignalRequest_Offer:
|
||||
s.params.Participant.HandleOffer(msg.Offer)
|
||||
|
||||
case *livekit.SignalRequest_Answer:
|
||||
s.params.Participant.HandleAnswer(msg.Answer)
|
||||
|
||||
case *livekit.SignalRequest_Trickle:
|
||||
s.params.Participant.HandleICETrickle(msg.Trickle)
|
||||
|
||||
case *livekit.SignalRequest_AddTrack:
|
||||
s.params.Participant.AddTrack(msg.AddTrack)
|
||||
|
||||
case *livekit.SignalRequest_Mute:
|
||||
s.params.Participant.SetTrackMuted(msg.Mute, false)
|
||||
|
||||
case *livekit.SignalRequest_Subscription:
|
||||
// allow participant to indicate their interest in the subscription
|
||||
// permission check happens later in SubscriptionManager
|
||||
s.params.Participant.HandleUpdateSubscriptions(
|
||||
livekit.StringsAsIDs[livekit.TrackID](msg.Subscription.TrackSids),
|
||||
msg.Subscription.ParticipantTracks,
|
||||
msg.Subscription.Subscribe,
|
||||
)
|
||||
|
||||
case *livekit.SignalRequest_TrackSetting:
|
||||
for _, sid := range livekit.StringsAsIDs[livekit.TrackID](msg.TrackSetting.TrackSids) {
|
||||
s.params.Participant.UpdateSubscribedTrackSettings(sid, msg.TrackSetting)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_Leave:
|
||||
reason := types.ParticipantCloseReasonClientRequestLeave
|
||||
switch msg.Leave.Reason {
|
||||
case livekit.DisconnectReason_CLIENT_INITIATED:
|
||||
reason = types.ParticipantCloseReasonClientRequestLeave
|
||||
case livekit.DisconnectReason_USER_UNAVAILABLE:
|
||||
reason = types.ParticipantCloseReasonUserUnavailable
|
||||
case livekit.DisconnectReason_USER_REJECTED:
|
||||
reason = types.ParticipantCloseReasonUserRejected
|
||||
case livekit.DisconnectReason_AGENT_ERROR:
|
||||
reason = types.ParticipantCloseReasonAgentError
|
||||
}
|
||||
s.params.Logger.Debugw("client leaving room", "reason", reason)
|
||||
s.params.Participant.HandleLeaveRequest(reason)
|
||||
|
||||
case *livekit.SignalRequest_SubscriptionPermission:
|
||||
err := s.params.Participant.HandleUpdateSubscriptionPermission(msg.SubscriptionPermission)
|
||||
if err != nil {
|
||||
s.params.Logger.Warnw(
|
||||
"could not update subscription permission", err,
|
||||
"permissions", msg.SubscriptionPermission,
|
||||
)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_SyncState:
|
||||
err := s.params.Participant.HandleSyncState(msg.SyncState)
|
||||
if err != nil {
|
||||
s.params.Logger.Warnw(
|
||||
"could not sync state", err,
|
||||
"state", msg.SyncState,
|
||||
)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_Simulate:
|
||||
err := s.params.Participant.HandleSimulateScenario(msg.Simulate)
|
||||
if err != nil {
|
||||
s.params.Logger.Warnw(
|
||||
"could not simulate scenario", err,
|
||||
"simulate", msg.Simulate,
|
||||
)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_PingReq:
|
||||
if msg.PingReq.Rtt > 0 {
|
||||
s.params.Participant.UpdateSignalingRTT(uint32(msg.PingReq.Rtt))
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_UpdateMetadata:
|
||||
s.params.Participant.UpdateMetadata(msg.UpdateMetadata, false)
|
||||
|
||||
case *livekit.SignalRequest_UpdateAudioTrack:
|
||||
if err := s.params.Participant.UpdateAudioTrack(msg.UpdateAudioTrack); err != nil {
|
||||
s.params.Logger.Warnw("could not update audio track", err, "update", msg.UpdateAudioTrack)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_UpdateVideoTrack:
|
||||
if err := s.params.Participant.UpdateVideoTrack(msg.UpdateVideoTrack); err != nil {
|
||||
s.params.Logger.Warnw("could not update video track", err, "update", msg.UpdateVideoTrack)
|
||||
}
|
||||
|
||||
case *livekit.SignalRequest_PublishDataTrackRequest:
|
||||
s.params.Participant.HandlePublishDataTrackRequest(msg.PublishDataTrackRequest)
|
||||
|
||||
case *livekit.SignalRequest_UnpublishDataTrackRequest:
|
||||
s.params.Participant.HandleUnpublishDataTrackRequest(msg.UnpublishDataTrackRequest)
|
||||
|
||||
case *livekit.SignalRequest_UpdateDataSubscription:
|
||||
s.params.Participant.HandleUpdateDataSubscription(msg.UpdateDataSubscription)
|
||||
|
||||
case *livekit.SignalRequest_StoreDataBlobRequest:
|
||||
s.params.Participant.HandleStoreDataBlobRequest(msg.StoreDataBlobRequest)
|
||||
|
||||
case *livekit.SignalRequest_GetDataBlobRequest:
|
||||
s.params.Participant.HandleGetDataBlobRequest(msg.GetDataBlobRequest)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
27
pkg/rtc/signalling/signalhandlerunimplemented.go
Normal file
27
pkg/rtc/signalling/signalhandlerunimplemented.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ ParticipantSignalHandler = (*signalhandlerUnimplemented)(nil)
|
||||
|
||||
type signalhandlerUnimplemented struct{}
|
||||
|
||||
func (u *signalhandlerUnimplemented) HandleMessage(msg proto.Message) error {
|
||||
return nil
|
||||
}
|
||||
140
pkg/rtc/signalling/signallerasync.go
Normal file
140
pkg/rtc/signalling/signallerasync.go
Normal file
@@ -0,0 +1,140 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/psrpc"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ ParticipantSignaller = (*signallerAsync)(nil)
|
||||
|
||||
type SignallerAsyncParams struct {
|
||||
Logger logger.Logger
|
||||
Participant types.LocalParticipant
|
||||
OnHandshakeOpened func()
|
||||
}
|
||||
|
||||
type signallerAsync struct {
|
||||
params SignallerAsyncParams
|
||||
|
||||
*signallerAsyncBase
|
||||
}
|
||||
|
||||
func NewSignallerAsync(params SignallerAsyncParams) ParticipantSignaller {
|
||||
return &signallerAsync{
|
||||
params: params,
|
||||
signallerAsyncBase: newSignallerAsyncBase(signallerAsyncBaseParams{
|
||||
Logger: params.Logger,
|
||||
OnHandshakeOpened: params.OnHandshakeOpened,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signallerAsync) WriteMessage(msg proto.Message) error {
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
getMessageType := func(msg proto.Message) string {
|
||||
messageType := "unknown"
|
||||
if typed, ok := msg.(*livekit.SignalResponse); ok {
|
||||
messageType = fmt.Sprintf("%T", typed.Message)
|
||||
}
|
||||
return messageType
|
||||
}
|
||||
|
||||
if s.params.Participant.IsDisconnected() {
|
||||
s.params.Logger.Debugw(
|
||||
"could not send message to participant, participant disconnected",
|
||||
"messageType", getMessageType(msg),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// a signal connection opens with a join response, or with a reconnect response when
|
||||
// it is resumed or migrated in. The client reads that first message as the handshake,
|
||||
// so nothing may go out ahead of it.
|
||||
isHandshake := false
|
||||
isSdp := false
|
||||
if typed, ok := msg.(*livekit.SignalResponse); !ok {
|
||||
s.params.Logger.Warnw(
|
||||
"unknown message type", nil,
|
||||
"messageType", fmt.Sprintf("%T", msg),
|
||||
)
|
||||
} else {
|
||||
isHandshake = typed.GetJoin() != nil || typed.GetReconnect() != nil
|
||||
isSdp = typed.GetOffer() != nil || typed.GetAnswer() != nil
|
||||
}
|
||||
|
||||
if !isHandshake && (!s.params.Participant.IsReady() || s.HandshakePending()) {
|
||||
logFunc := s.params.Logger.Debugw
|
||||
if isSdp {
|
||||
// a dropped SDP leaves the negotiation waiting for the peer until the
|
||||
// negotiation state machine recovers it, so make it visible
|
||||
logFunc = func(msg string, keysAndValues ...any) {
|
||||
s.params.Logger.Infow(msg, keysAndValues...)
|
||||
}
|
||||
}
|
||||
logFunc(
|
||||
"dropping message, connection has not opened yet",
|
||||
"messageType", getMessageType(msg),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
sink := s.GetResponseSink()
|
||||
if sink == nil {
|
||||
s.params.Logger.Debugw(
|
||||
"could not send message to participant, no sink",
|
||||
"messageType", getMessageType(msg),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
err := sink.WriteMessage(msg)
|
||||
if err != nil {
|
||||
if utils.ErrorIsOneOf(err, psrpc.Canceled, routing.ErrChannelClosed) {
|
||||
s.params.Logger.Debugw(
|
||||
"could not send message to participant",
|
||||
"error", err,
|
||||
"messageType", getMessageType(msg),
|
||||
)
|
||||
return nil
|
||||
} else {
|
||||
s.params.Logger.Warnw(
|
||||
"could not send message to participant", err,
|
||||
"messageType", getMessageType(msg),
|
||||
)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if isHandshake {
|
||||
// the connection is open, hold nothing back any more
|
||||
s.OpenHandshake()
|
||||
}
|
||||
s.params.Logger.Debugw("sent signal response", "response", logger.Proto(msg))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
185
pkg/rtc/signalling/signallerasyncbase.go
Normal file
185
pkg/rtc/signalling/signallerasyncbase.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
type signallerAsyncBaseParams struct {
|
||||
Logger logger.Logger
|
||||
// called when the connection opens, i. e. when messages held back for the
|
||||
// handshake may be sent
|
||||
OnHandshakeOpened func()
|
||||
}
|
||||
|
||||
// how long a resumed connection holds messages back waiting for its ReconnectResponse.
|
||||
// A path that resumes without sending one lets messages flow after this instead of
|
||||
// holding them back for the rest of the session.
|
||||
const handshakeWindow = 5 * time.Second
|
||||
|
||||
type signallerAsyncBase struct {
|
||||
signallerUnimplemented
|
||||
|
||||
params signallerAsyncBaseParams
|
||||
|
||||
resSinkMu sync.Mutex
|
||||
resSink routing.MessageSink
|
||||
// set while a resumed connection holds messages back waiting for its
|
||||
// ReconnectResponse, the timer opens the connection if none is written.
|
||||
// handshakeGeneration tells a timeout whether it belongs to the current wait, a
|
||||
// Stop cannot cancel a callback that has already started running.
|
||||
handshakePending bool
|
||||
handshakeGeneration uint32
|
||||
handshakeTimer *time.Timer
|
||||
}
|
||||
|
||||
func newSignallerAsyncBase(params signallerAsyncBaseParams) *signallerAsyncBase {
|
||||
return &signallerAsyncBase{
|
||||
params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) SwapResponseSink(sink routing.MessageSink, reason types.SignallingCloseReason) {
|
||||
s.resSinkMu.Lock()
|
||||
oldSink := s.resSink
|
||||
s.resSink = sink
|
||||
// a resumed connection has to open with the ReconnectResponse, the client takes it
|
||||
// only as the first message it reads
|
||||
opened := false
|
||||
switch {
|
||||
case sink == nil:
|
||||
// the connection is gone, keep anything queued for the next one
|
||||
s.disarmHandshakeLocked()
|
||||
case reason == types.SignallingCloseReasonResume:
|
||||
s.armHandshakeLocked()
|
||||
default:
|
||||
opened = s.disarmHandshakeLocked()
|
||||
}
|
||||
s.resSinkMu.Unlock()
|
||||
|
||||
if opened {
|
||||
s.notifyHandshakeOpened()
|
||||
}
|
||||
|
||||
if oldSink != nil {
|
||||
if sink != nil {
|
||||
s.params.Logger.Debugw(
|
||||
"swapping signal connection",
|
||||
"reason", reason,
|
||||
"connID", oldSink.ConnectionID(),
|
||||
"newConnID", sink.ConnectionID(),
|
||||
)
|
||||
} else {
|
||||
s.params.Logger.Debugw(
|
||||
"closing signal connection",
|
||||
"reason", reason,
|
||||
"connID", oldSink.ConnectionID(),
|
||||
)
|
||||
}
|
||||
oldSink.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// HandshakePending is a plain read, so a caller can decide to hold a message back
|
||||
// while holding its own lock
|
||||
func (s *signallerAsyncBase) HandshakePending() bool {
|
||||
s.resSinkMu.Lock()
|
||||
defer s.resSinkMu.Unlock()
|
||||
|
||||
return s.handshakePending
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) OpenHandshake() {
|
||||
s.resSinkMu.Lock()
|
||||
opened := s.disarmHandshakeLocked()
|
||||
s.resSinkMu.Unlock()
|
||||
|
||||
if opened {
|
||||
s.notifyHandshakeOpened()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) armHandshakeLocked() {
|
||||
s.stopHandshakeTimerLocked()
|
||||
|
||||
s.handshakePending = true
|
||||
s.handshakeGeneration++
|
||||
generation := s.handshakeGeneration
|
||||
// the connection opens on its own if nothing writes a ReconnectResponse, a path
|
||||
// that resumes without one should not hold messages back for the whole session
|
||||
s.handshakeTimer = time.AfterFunc(handshakeWindow, func() { s.onHandshakeTimeout(generation) })
|
||||
}
|
||||
|
||||
// disarmHandshakeLocked reports whether it opened a connection that was holding
|
||||
// messages back
|
||||
func (s *signallerAsyncBase) disarmHandshakeLocked() bool {
|
||||
s.stopHandshakeTimerLocked()
|
||||
|
||||
wasPending := s.handshakePending
|
||||
s.handshakePending = false
|
||||
// a timeout of the wait that just ended is stale
|
||||
s.handshakeGeneration++
|
||||
return wasPending
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) stopHandshakeTimerLocked() {
|
||||
if s.handshakeTimer != nil {
|
||||
s.handshakeTimer.Stop()
|
||||
s.handshakeTimer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) onHandshakeTimeout(generation uint32) {
|
||||
s.resSinkMu.Lock()
|
||||
if generation != s.handshakeGeneration {
|
||||
// the wait this timeout was armed for has ended, a later one may be in progress
|
||||
s.resSinkMu.Unlock()
|
||||
return
|
||||
}
|
||||
opened := s.disarmHandshakeLocked()
|
||||
s.resSinkMu.Unlock()
|
||||
|
||||
if !opened {
|
||||
return
|
||||
}
|
||||
|
||||
s.params.Logger.Warnw("resumed connection did not open with a ReconnectResponse", nil)
|
||||
s.notifyHandshakeOpened()
|
||||
}
|
||||
|
||||
// notifyHandshakeOpened runs without resSinkMu held, the callback sends messages
|
||||
func (s *signallerAsyncBase) notifyHandshakeOpened() {
|
||||
if s.params.OnHandshakeOpened != nil {
|
||||
s.params.OnHandshakeOpened()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signallerAsyncBase) GetResponseSink() routing.MessageSink {
|
||||
s.resSinkMu.Lock()
|
||||
defer s.resSinkMu.Unlock()
|
||||
return s.resSink
|
||||
}
|
||||
|
||||
// closes signal connection to notify client to resume/reconnect
|
||||
func (s *signallerAsyncBase) CloseSignalConnection(reason types.SignallingCloseReason) {
|
||||
s.SwapResponseSink(nil, reason)
|
||||
}
|
||||
157
pkg/rtc/signalling/signallerasyncbase_test.go
Normal file
157
pkg/rtc/signalling/signallerasyncbase_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
package signalling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/routing/routingfakes"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
)
|
||||
|
||||
func newTestSignallerBase(onHandshakeOpened func()) *signallerAsyncBase {
|
||||
return newSignallerAsyncBase(signallerAsyncBaseParams{
|
||||
Logger: logger.GetLogger(),
|
||||
OnHandshakeOpened: onHandshakeOpened,
|
||||
})
|
||||
}
|
||||
|
||||
// currentHandshakeGeneration is what a timeout armed right now would be tagged with
|
||||
func currentHandshakeGeneration(s *signallerAsyncBase) uint32 {
|
||||
s.resSinkMu.Lock()
|
||||
defer s.resSinkMu.Unlock()
|
||||
|
||||
return s.handshakeGeneration
|
||||
}
|
||||
|
||||
func TestHandshakeGate(t *testing.T) {
|
||||
t.Run("a resumed connection holds messages back", func(t *testing.T) {
|
||||
s := newTestSignallerBase(nil)
|
||||
require.False(t, s.HandshakePending())
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.True(t, s.HandshakePending())
|
||||
|
||||
s.OpenHandshake()
|
||||
require.False(t, s.HandshakePending())
|
||||
})
|
||||
|
||||
t.Run("other sink swaps do not hold messages back", func(t *testing.T) {
|
||||
s := newTestSignallerBase(nil)
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown)
|
||||
require.False(t, s.HandshakePending())
|
||||
})
|
||||
|
||||
t.Run("closing the connection clears the gate", func(t *testing.T) {
|
||||
s := newTestSignallerBase(nil)
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.True(t, s.HandshakePending())
|
||||
|
||||
s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose)
|
||||
require.False(t, s.HandshakePending())
|
||||
})
|
||||
|
||||
t.Run("the handshake window opens the gate", func(t *testing.T) {
|
||||
s := newTestSignallerBase(nil)
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.True(t, s.HandshakePending())
|
||||
|
||||
s.onHandshakeTimeout(currentHandshakeGeneration(s))
|
||||
require.False(t, s.HandshakePending())
|
||||
})
|
||||
|
||||
t.Run("a swap to a fresh connection opens the gate", func(t *testing.T) {
|
||||
s := newTestSignallerBase(nil)
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.True(t, s.HandshakePending())
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown)
|
||||
require.False(t, s.HandshakePending())
|
||||
})
|
||||
|
||||
t.Run("a timeout of a wait that has ended does nothing", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
stale := currentHandshakeGeneration(s)
|
||||
|
||||
// the client resumes again while the first timeout is running, Stop cannot
|
||||
// cancel a callback that has already started
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
s.onHandshakeTimeout(stale)
|
||||
|
||||
require.True(t, s.HandshakePending())
|
||||
require.Zero(t, opened.Load())
|
||||
|
||||
// and the wait in progress still opens on its own timeout
|
||||
s.onHandshakeTimeout(currentHandshakeGeneration(s))
|
||||
require.False(t, s.HandshakePending())
|
||||
require.EqualValues(t, 1, opened.Load())
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandshakeGateNotifiesOnOpen(t *testing.T) {
|
||||
t.Run("on an explicit open", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.Zero(t, opened.Load())
|
||||
|
||||
s.OpenHandshake()
|
||||
require.EqualValues(t, 1, opened.Load())
|
||||
|
||||
// only the transition notifies
|
||||
s.OpenHandshake()
|
||||
require.EqualValues(t, 1, opened.Load())
|
||||
})
|
||||
|
||||
t.Run("on the handshake window expiring", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
s.onHandshakeTimeout(currentHandshakeGeneration(s))
|
||||
|
||||
require.False(t, s.HandshakePending())
|
||||
require.EqualValues(t, 1, opened.Load())
|
||||
})
|
||||
|
||||
t.Run("the window fires without anything else touching the gate", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
require.Eventually(t, func() bool {
|
||||
return !s.HandshakePending() && opened.Load() == 1
|
||||
}, 2*handshakeWindow, 100*time.Millisecond)
|
||||
})
|
||||
|
||||
t.Run("not on a connection close", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume)
|
||||
s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose)
|
||||
|
||||
require.False(t, s.HandshakePending())
|
||||
require.Zero(t, opened.Load())
|
||||
})
|
||||
|
||||
t.Run("not when the gate was never armed", func(t *testing.T) {
|
||||
var opened atomic.Int32
|
||||
s := newTestSignallerBase(func() { opened.Inc() })
|
||||
|
||||
s.OpenHandshake()
|
||||
require.Zero(t, opened.Load())
|
||||
})
|
||||
}
|
||||
45
pkg/rtc/signalling/signallerunimplemented.go
Normal file
45
pkg/rtc/signalling/signallerunimplemented.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"github.com/livekit/livekit-server/pkg/routing"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ ParticipantSignaller = (*signallerUnimplemented)(nil)
|
||||
|
||||
type signallerUnimplemented struct{}
|
||||
|
||||
func (u *signallerUnimplemented) SwapResponseSink(sink routing.MessageSink, reason types.SignallingCloseReason) {
|
||||
}
|
||||
|
||||
func (u *signallerUnimplemented) GetResponseSink() routing.MessageSink {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallerUnimplemented) CloseSignalConnection(reason types.SignallingCloseReason) {}
|
||||
|
||||
func (u *signallerUnimplemented) HandshakePending() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (u *signallerUnimplemented) OpenHandshake() {}
|
||||
|
||||
func (u *signallerUnimplemented) WriteMessage(msg proto.Message) error {
|
||||
return nil
|
||||
}
|
||||
276
pkg/rtc/signalling/signalling.go
Normal file
276
pkg/rtc/signalling/signalling.go
Normal file
@@ -0,0 +1,276 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ ParticipantSignalling = (*signalling)(nil)
|
||||
|
||||
type SignallingParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type signalling struct {
|
||||
params SignallingParams
|
||||
}
|
||||
|
||||
func NewSignalling(params SignallingParams) ParticipantSignalling {
|
||||
return &signalling{
|
||||
params: params,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalJoinResponse(join *livekit.JoinResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Join{
|
||||
Join: join,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalParticipantUpdate(participants []*livekit.ParticipantInfo) proto.Message {
|
||||
if len(participants) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Update{
|
||||
Update: &livekit.ParticipantUpdate{
|
||||
Participants: participants,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSpeakerUpdate(speakers []*livekit.SpeakerInfo) proto.Message {
|
||||
if len(speakers) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_SpeakersChanged{
|
||||
SpeakersChanged: &livekit.SpeakersChanged{
|
||||
Speakers: speakers,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalRoomUpdate(room *livekit.Room) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_RoomUpdate{
|
||||
RoomUpdate: &livekit.RoomUpdate{
|
||||
Room: room,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalConnectionQualityUpdate(connectionQuality *livekit.ConnectionQualityUpdate) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_ConnectionQuality{
|
||||
ConnectionQuality: connectionQuality,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalRefreshToken(token string) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_RefreshToken{
|
||||
RefreshToken: token,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalRequestResponse(requestResponse *livekit.RequestResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_RequestResponse{
|
||||
RequestResponse: requestResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalRoomMovedResponse(roomMoved *livekit.RoomMovedResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_RoomMoved{
|
||||
RoomMoved: roomMoved,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalReconnectResponse(reconnect *livekit.ReconnectResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Reconnect{
|
||||
Reconnect: reconnect,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalICECandidate(trickle *livekit.TrickleRequest) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Trickle{
|
||||
Trickle: trickle,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalTrackMuted(mute *livekit.MuteTrackRequest) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Mute{
|
||||
Mute: mute,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalTrackPublished(trackPublished *livekit.TrackPublishedResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_TrackPublished{
|
||||
TrackPublished: trackPublished,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalTrackUnpublished(trackUnpublished *livekit.TrackUnpublishedResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_TrackUnpublished{
|
||||
TrackUnpublished: trackUnpublished,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalTrackSubscribed(trackSubscribed *livekit.TrackSubscribed) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_TrackSubscribed{
|
||||
TrackSubscribed: trackSubscribed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalLeaveRequest(leave *livekit.LeaveRequest) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Leave{
|
||||
Leave: leave,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSdpAnswer(answer *livekit.SessionDescription) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Answer{
|
||||
Answer: answer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSdpOffer(offer *livekit.SessionDescription) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_Offer{
|
||||
Offer: offer,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalStreamStateUpdate(streamStateUpdate *livekit.StreamStateUpdate) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_StreamStateUpdate{
|
||||
StreamStateUpdate: streamStateUpdate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSubscribedQualityUpdate(subscribedQualityUpdate *livekit.SubscribedQualityUpdate) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_SubscribedQualityUpdate{
|
||||
SubscribedQualityUpdate: subscribedQualityUpdate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSubscriptionResponse(subscriptionResponse *livekit.SubscriptionResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_SubscriptionResponse{
|
||||
SubscriptionResponse: subscriptionResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSubscriptionPermissionUpdate(subscriptionPermissionUpdate *livekit.SubscriptionPermissionUpdate) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_SubscriptionPermissionUpdate{
|
||||
SubscriptionPermissionUpdate: subscriptionPermissionUpdate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalMediaSectionsRequirement(mediaSectionsRequirement *livekit.MediaSectionsRequirement) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_MediaSectionsRequirement{
|
||||
MediaSectionsRequirement: mediaSectionsRequirement,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate *livekit.SubscribedAudioCodecUpdate) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_SubscribedAudioCodecUpdate{
|
||||
SubscribedAudioCodecUpdate: subscribedAudioCodecUpdate,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_PublishDataTrackResponse{
|
||||
PublishDataTrackResponse: publishDataTrackResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_UnpublishDataTrackResponse{
|
||||
UnpublishDataTrackResponse: unpublishDataTrackResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_DataTrackSubscriberHandles{
|
||||
DataTrackSubscriberHandles: dataTrackSubscriberHandles,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_StoreDataBlobResponse{
|
||||
StoreDataBlobResponse: storeDataBlobResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *signalling) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message {
|
||||
return &livekit.SignalResponse{
|
||||
Message: &livekit.SignalResponse_GetDataBlobResponse{
|
||||
GetDataBlobResponse: getDataBlobResponse,
|
||||
},
|
||||
}
|
||||
}
|
||||
137
pkg/rtc/signalling/signallingunimplemented.go
Normal file
137
pkg/rtc/signalling/signallingunimplemented.go
Normal file
@@ -0,0 +1,137 @@
|
||||
// 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 signalling
|
||||
|
||||
import (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
var _ ParticipantSignalling = (*signallingUnimplemented)(nil)
|
||||
|
||||
type signallingUnimplemented struct{}
|
||||
|
||||
func (u *signallingUnimplemented) SignalJoinResponse(join *livekit.JoinResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalParticipantUpdate(participants []*livekit.ParticipantInfo) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSpeakerUpdate(speakers []*livekit.SpeakerInfo) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalRoomUpdate(room *livekit.Room) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalConnectionQualityUpdate(connectionQuality *livekit.ConnectionQualityUpdate) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalRefreshToken(token string) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalRequestResponse(requestResponse *livekit.RequestResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalRoomMovedResponse(roomMoved *livekit.RoomMovedResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalReconnectResponse(reconnect *livekit.ReconnectResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalICECandidate(trickle *livekit.TrickleRequest) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalTrackMuted(mute *livekit.MuteTrackRequest) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalTrackPublished(trackPublished *livekit.TrackPublishedResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalTrackUnpublished(trackUnpublished *livekit.TrackUnpublishedResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalTrackSubscribed(trackSubscribed *livekit.TrackSubscribed) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalLeaveRequest(leave *livekit.LeaveRequest) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSdpAnswer(answer *livekit.SessionDescription) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSdpOffer(offer *livekit.SessionDescription) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalStreamStateUpdate(streamStateUpdate *livekit.StreamStateUpdate) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSubscribedQualityUpdate(subscribedQualityUpdate *livekit.SubscribedQualityUpdate) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSubscriptionResponse(subscriptionResponse *livekit.SubscriptionResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSubscriptionPermissionUpdate(subscriptionPermissionUpdate *livekit.SubscriptionPermissionUpdate) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalMediaSectionsRequirement(mediaSectionsRequirement *livekit.MediaSectionsRequirement) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate *livekit.SubscribedAudioCodecUpdate) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalStoreDataBlobResponse(storeDataBlobResponse *livekit.StoreDataBlobResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (u *signallingUnimplemented) SignalGetDataBlobResponse(getDataBlobResponse *livekit.GetDataBlobResponse) proto.Message {
|
||||
return nil
|
||||
}
|
||||
505
pkg/rtc/subscribedtrack.go
Normal file
505
pkg/rtc/subscribedtrack.go
Normal file
@@ -0,0 +1,505 @@
|
||||
// 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 (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bep/debounce"
|
||||
"github.com/pion/webrtc/v4"
|
||||
"go.uber.org/atomic"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/telemetry"
|
||||
sutils "github.com/livekit/livekit-server/pkg/utils"
|
||||
"github.com/livekit/protocol/codecs/mime"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
"github.com/livekit/protocol/utils"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/sfu"
|
||||
"github.com/livekit/livekit-server/pkg/sfu/buffer"
|
||||
)
|
||||
|
||||
const (
|
||||
subscriptionDebounceInterval = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
var _ types.SubscribedTrack = (*SubscribedTrack)(nil)
|
||||
|
||||
type SubscribedTrackParams struct {
|
||||
ReceiverConfig ReceiverConfig
|
||||
SubscriberConfig DirectionConfig
|
||||
Subscriber types.LocalParticipant
|
||||
MediaTrack types.MediaTrack
|
||||
AdaptiveStream bool
|
||||
EnableStartAtDesiredQuality bool
|
||||
TelemetryListener types.ParticipantTelemetryListener
|
||||
WrappedReceiver *WrappedReceiver
|
||||
IsRelayed bool
|
||||
OnDownTrackCreated func(downTrack *sfu.DownTrack)
|
||||
OnDownTrackClosed func(subscriberID livekit.ParticipantID)
|
||||
OnSubscriberMaxQualityChange func(subscriberID livekit.ParticipantID, mime mime.MimeType, layer int32)
|
||||
OnSubscriberAudioCodecChange func(subscriberID livekit.ParticipantID, mime mime.MimeType, enabled bool)
|
||||
}
|
||||
|
||||
type SubscribedTrack struct {
|
||||
params SubscribedTrackParams
|
||||
logger logger.Logger
|
||||
downTrack *sfu.DownTrack
|
||||
sender atomic.Pointer[webrtc.RTPSender]
|
||||
needsNegotiation atomic.Bool
|
||||
|
||||
versionGenerator utils.TimedVersionGenerator
|
||||
settingsLock sync.Mutex
|
||||
settings *livekit.UpdateTrackSettings
|
||||
settingsVersion utils.TimedVersion
|
||||
|
||||
bindLock sync.Mutex
|
||||
bound bool
|
||||
onBindCallbacks []func(error)
|
||||
|
||||
onSubscribeStreamStarted atomic.Value //func(time.Duration)
|
||||
onClose atomic.Value // func(bool)
|
||||
|
||||
debouncer func(func())
|
||||
|
||||
statsKey telemetry.StatsKey
|
||||
reporter roomobs.TrackReporter
|
||||
}
|
||||
|
||||
func NewSubscribedTrack(params SubscribedTrackParams) (*SubscribedTrack, error) {
|
||||
s := &SubscribedTrack{
|
||||
params: params,
|
||||
logger: params.Subscriber.GetLogger().WithComponent(sutils.ComponentSub).WithValues(
|
||||
"trackID", params.MediaTrack.ID(),
|
||||
"publisherID", params.MediaTrack.PublisherID(),
|
||||
"publisher", params.MediaTrack.PublisherIdentity(),
|
||||
),
|
||||
versionGenerator: utils.NewDefaultTimedVersionGenerator(),
|
||||
debouncer: debounce.New(subscriptionDebounceInterval),
|
||||
statsKey: telemetry.StatsKeyForTrack(
|
||||
params.Subscriber.GetCountry(),
|
||||
livekit.StreamType_DOWNSTREAM,
|
||||
params.Subscriber.ID(),
|
||||
params.MediaTrack.ID(),
|
||||
params.MediaTrack.Source(),
|
||||
params.MediaTrack.Kind(),
|
||||
),
|
||||
reporter: params.Subscriber.GetReporter().WithTrack(params.MediaTrack.ID().String()),
|
||||
}
|
||||
|
||||
var rtcpFeedback []webrtc.RTCPFeedback
|
||||
var maxTrack int
|
||||
switch params.MediaTrack.Kind() {
|
||||
case livekit.TrackType_AUDIO:
|
||||
rtcpFeedback = params.SubscriberConfig.RTCPFeedback.Audio
|
||||
maxTrack = params.ReceiverConfig.PacketBufferSizeAudio
|
||||
case livekit.TrackType_VIDEO:
|
||||
rtcpFeedback = params.SubscriberConfig.RTCPFeedback.Video
|
||||
maxTrack = params.ReceiverConfig.PacketBufferSizeVideo
|
||||
default:
|
||||
s.logger.Warnw("unexpected track type", nil, "kind", params.MediaTrack.Kind())
|
||||
}
|
||||
codecs := params.WrappedReceiver.Codecs()
|
||||
for _, c := range codecs {
|
||||
c.RTCPFeedback = rtcpFeedback
|
||||
}
|
||||
|
||||
streamID := params.WrappedReceiver.StreamID()
|
||||
if params.Subscriber.SupportsSyncStreamID() && params.MediaTrack.Stream() != "" {
|
||||
streamID = PackSyncStreamID(params.MediaTrack.PublisherID(), params.MediaTrack.Stream())
|
||||
}
|
||||
|
||||
isEncrypted := params.MediaTrack.IsEncrypted()
|
||||
var trailer []byte
|
||||
if isEncrypted {
|
||||
trailer = params.Subscriber.GetTrailer()
|
||||
}
|
||||
subClientInfo := ClientInfo{ClientInfo: params.Subscriber.GetClientInfo()}
|
||||
subSupportsPacketTrailer := subClientInfo.SupportsPacketTrailer()
|
||||
// Strip packet trailer if track has packet trailer but subscriber does not have cap
|
||||
stripPacketTrailer := params.MediaTrack.HasPacketTrailer() && !subSupportsPacketTrailer
|
||||
downTrack, err := sfu.NewDownTrack(sfu.DownTrackParams{
|
||||
Codecs: codecs,
|
||||
IsEncrypted: isEncrypted,
|
||||
Source: params.MediaTrack.Source(),
|
||||
Receiver: params.WrappedReceiver,
|
||||
BufferFactory: params.Subscriber.GetBufferFactory(),
|
||||
SubID: params.Subscriber.ID(),
|
||||
StreamID: streamID,
|
||||
MaxTrack: maxTrack,
|
||||
PlayoutDelayLimit: params.Subscriber.GetPlayoutDelayConfig(),
|
||||
Pacer: params.Subscriber.GetPacer(),
|
||||
Trailer: trailer,
|
||||
StripPacketTrailer: stripPacketTrailer,
|
||||
Logger: LoggerWithTrack(
|
||||
params.Subscriber.GetLogger().WithComponent(sutils.ComponentSub),
|
||||
params.MediaTrack.ID(),
|
||||
params.IsRelayed,
|
||||
),
|
||||
RTCPWriter: params.Subscriber.WriteSubscriberRTCP,
|
||||
DisableSenderReportPassThrough: params.Subscriber.GetDisableSenderReportPassThrough(),
|
||||
SupportsCodecChange: params.Subscriber.SupportsCodecChange(),
|
||||
EnableStartAtDesiredQuality: params.EnableStartAtDesiredQuality,
|
||||
Listener: s,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if params.OnDownTrackCreated != nil {
|
||||
params.OnDownTrackCreated(downTrack)
|
||||
}
|
||||
|
||||
downTrack.AddReceiverReportListener(params.Subscriber.HandleReceiverReport)
|
||||
|
||||
s.downTrack = downTrack
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) AddOnBind(f func(error)) {
|
||||
t.bindLock.Lock()
|
||||
bound := t.bound
|
||||
if !bound {
|
||||
t.onBindCallbacks = append(t.onBindCallbacks, f)
|
||||
}
|
||||
t.bindLock.Unlock()
|
||||
|
||||
if bound {
|
||||
// fire immediately, do not need to persist since bind is a one time event
|
||||
go f(nil)
|
||||
}
|
||||
}
|
||||
|
||||
// for DownTrack callback to notify us that it's bound
|
||||
func (t *SubscribedTrack) Bound(err error) {
|
||||
t.bindLock.Lock()
|
||||
if err == nil {
|
||||
t.bound = true
|
||||
}
|
||||
callbacks := t.onBindCallbacks
|
||||
t.onBindCallbacks = nil
|
||||
t.bindLock.Unlock()
|
||||
|
||||
if err == nil && t.MediaTrack().Kind() == livekit.TrackType_VIDEO {
|
||||
// When AdaptiveStream is enabled, default the subscriber to LOW quality stream
|
||||
// we would want LOW instead of OFF for a couple of reasons
|
||||
// 1. when a subscriber unsubscribes from a track, we would forget their previously defined settings
|
||||
// depending on client implementation, subscription on/off is kept separately from adaptive stream
|
||||
// So when there are no changes to desired resolution, but the user re-subscribes, we may leave stream at OFF
|
||||
// 2. when interacting with dynacast *and* adaptive stream. If the publisher was not publishing at the
|
||||
// time of subscription, we might not be able to trigger adaptive stream updates on the client side
|
||||
// (since there isn't any video frames coming through). this will leave the stream "stuck" on off, without
|
||||
// a trigger to re-enable it
|
||||
t.settingsLock.Lock()
|
||||
if t.settings != nil {
|
||||
if t.params.AdaptiveStream {
|
||||
// remove `disabled` flag to force a visibility update
|
||||
t.settings.Disabled = false
|
||||
t.logger.Debugw("enabling subscriber track settings on bind", "settings", logger.Proto(t.settings))
|
||||
}
|
||||
} else {
|
||||
if t.params.EnableStartAtDesiredQuality {
|
||||
// default to HIGH quality so the subscriber acquires the top layer directly instead of
|
||||
// ramping up from a lower layer. adaptive stream clients can still scale down afterwards
|
||||
// based on viewport.
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
|
||||
} else if t.params.AdaptiveStream {
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_LOW}
|
||||
} else {
|
||||
t.settings = &livekit.UpdateTrackSettings{Quality: livekit.VideoQuality_HIGH}
|
||||
}
|
||||
t.logger.Debugw("initializing subscriber track settings on bind", "settings", logger.Proto(t.settings))
|
||||
}
|
||||
t.settingsLock.Unlock()
|
||||
t.applySettings()
|
||||
}
|
||||
|
||||
for _, cb := range callbacks {
|
||||
go cb(err)
|
||||
}
|
||||
}
|
||||
|
||||
// for DownTrack callback to notify us that it's closed
|
||||
func (t *SubscribedTrack) Close(isExpectedToResume bool) {
|
||||
if onClose := t.onClose.Load(); onClose != nil {
|
||||
onClose.(func(bool))(isExpectedToResume)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnSubscribeStreamStarted(f func(time.Duration)) {
|
||||
t.onSubscribeStreamStarted.Store(f)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnClose(f func(bool)) {
|
||||
t.onClose.Store(f)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) IsBound() bool {
|
||||
t.bindLock.Lock()
|
||||
defer t.bindLock.Unlock()
|
||||
|
||||
return t.bound
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) ID() livekit.TrackID {
|
||||
return livekit.TrackID(t.downTrack.ID())
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) PublisherID() livekit.ParticipantID {
|
||||
return t.params.MediaTrack.PublisherID()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) PublisherIdentity() livekit.ParticipantIdentity {
|
||||
return t.params.MediaTrack.PublisherIdentity()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) PublisherVersion() uint32 {
|
||||
return t.params.MediaTrack.PublisherVersion()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) SubscriberID() livekit.ParticipantID {
|
||||
return t.params.Subscriber.ID()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) SubscriberIdentity() livekit.ParticipantIdentity {
|
||||
return t.params.Subscriber.Identity()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) Subscriber() types.LocalParticipant {
|
||||
return t.params.Subscriber
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) DownTrack() *sfu.DownTrack {
|
||||
return t.downTrack
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) MediaTrack() types.MediaTrack {
|
||||
return t.params.MediaTrack
|
||||
}
|
||||
|
||||
// has subscriber indicated it wants to mute this track
|
||||
func (t *SubscribedTrack) IsMuted() bool {
|
||||
t.settingsLock.Lock()
|
||||
defer t.settingsLock.Unlock()
|
||||
|
||||
return t.isMutedLocked()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) isMutedLocked() bool {
|
||||
if t.settings == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return t.settings.Disabled
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) SetPublisherMuted(muted bool) {
|
||||
t.downTrack.PubMute(muted)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) UpdateSubscriberSettings(settings *livekit.UpdateTrackSettings, isImmediate bool) {
|
||||
t.settingsLock.Lock()
|
||||
if proto.Equal(t.settings, settings) {
|
||||
t.logger.Debugw("skipping subscriber track settings", "settings", logger.Proto(t.settings))
|
||||
t.settingsLock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
isImmediate = isImmediate || (!settings.Disabled && settings.Disabled != t.isMutedLocked())
|
||||
t.settings = utils.CloneProto(settings)
|
||||
t.logger.Debugw("saving subscriber track settings", "settings", logger.Proto(t.settings))
|
||||
t.settingsLock.Unlock()
|
||||
|
||||
if isImmediate {
|
||||
t.applySettings()
|
||||
} else {
|
||||
// avoid frequent changes to mute & video layers, unless it became visible
|
||||
t.debouncer(t.applySettings)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) UpdateVideoLayer() {
|
||||
t.applySettings()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) applySettings() {
|
||||
t.settingsLock.Lock()
|
||||
if t.settings == nil {
|
||||
t.settingsLock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.settingsVersion = t.versionGenerator.Next()
|
||||
settingsVersion := t.settingsVersion
|
||||
t.settingsLock.Unlock()
|
||||
|
||||
dt := t.DownTrack()
|
||||
spatial := buffer.InvalidLayerSpatial
|
||||
temporal := buffer.InvalidLayerTemporal
|
||||
if dt.Kind() == webrtc.RTPCodecTypeVideo {
|
||||
mt := t.MediaTrack()
|
||||
quality := t.settings.Quality
|
||||
mimeType := dt.Mime()
|
||||
if t.settings.Width > 0 {
|
||||
quality = mt.GetQualityForDimension(mimeType, t.settings.Width, t.settings.Height)
|
||||
}
|
||||
|
||||
spatial = buffer.GetSpatialLayerForVideoQuality(mimeType, quality, mt.ToProto())
|
||||
if t.settings.Fps > 0 {
|
||||
temporal = mt.GetTemporalLayerForSpatialFps(mimeType, spatial, t.settings.Fps)
|
||||
}
|
||||
}
|
||||
|
||||
t.settingsLock.Lock()
|
||||
if settingsVersion != t.settingsVersion {
|
||||
// a newer settings has superseded this one
|
||||
t.settingsLock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
t.logger.Debugw("applying subscriber track settings", "settings", logger.Proto(t.settings))
|
||||
if t.settings.Disabled {
|
||||
dt.Mute(true)
|
||||
t.settingsLock.Unlock()
|
||||
return
|
||||
} else {
|
||||
dt.Mute(false)
|
||||
}
|
||||
|
||||
if dt.Kind() == webrtc.RTPCodecTypeVideo {
|
||||
dt.SetMaxSpatialLayer(spatial)
|
||||
if temporal != buffer.InvalidLayerTemporal {
|
||||
dt.SetMaxTemporalLayer(temporal)
|
||||
}
|
||||
}
|
||||
t.settingsLock.Unlock()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) NeedsNegotiation() bool {
|
||||
return t.needsNegotiation.Load()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) SetNeedsNegotiation(needs bool) {
|
||||
t.needsNegotiation.Store(needs)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) RTPSender() *webrtc.RTPSender {
|
||||
return t.sender.Load()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) SetRTPSender(sender *webrtc.RTPSender) {
|
||||
t.sender.Store(sender)
|
||||
}
|
||||
|
||||
// DownTrackListener implementation
|
||||
var _ sfu.DownTrackListener = (*SubscribedTrack)(nil)
|
||||
|
||||
func (t *SubscribedTrack) OnBindAndConnected() {
|
||||
if t.params.Subscriber.Hidden() {
|
||||
return
|
||||
}
|
||||
|
||||
t.params.MediaTrack.OnTrackSubscribed()
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnStatsUpdate(stat *livekit.AnalyticsStat) {
|
||||
t.params.TelemetryListener.OnTrackStats(t.statsKey, stat)
|
||||
|
||||
if cs, ok := telemetry.CondenseStat(stat); ok {
|
||||
ti := t.params.WrappedReceiver.TrackInfo()
|
||||
t.reporter.Tx(func(tx roomobs.TrackTx) {
|
||||
tx.ParticipantSession().ReportKindCode(roomobs.ParticipantKindCode(t.params.Subscriber.Kind()))
|
||||
tx.ParticipantSession().ReportKindDetailsCodes(roomobs.ParticipantKindDetailsCodes(t.params.Subscriber.KindDetails()))
|
||||
tx.ReportName(ti.Name)
|
||||
tx.ReportKind(roomobs.TrackKindSub)
|
||||
tx.ReportType(roomobs.TrackTypeFromProto(ti.Type))
|
||||
tx.ReportSource(roomobs.TrackSourceFromProto(ti.Source))
|
||||
tx.ReportMime(mime.NormalizeMimeType(ti.MimeType).ReporterType())
|
||||
tx.ReportLayer(roomobs.PackTrackLayer(ti.Height, ti.Width))
|
||||
tx.ReportDuration(uint16(cs.EndTime.Sub(cs.StartTime).Milliseconds()))
|
||||
tx.ReportFrames(uint16(cs.Frames))
|
||||
tx.ReportSendBytes(uint32(cs.Bytes))
|
||||
tx.ReportSendPackets(cs.Packets)
|
||||
tx.ReportPacketsLost(cs.PacketsLost)
|
||||
tx.ReportScore(stat.Score)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnMaxSubscribedLayerChanged(layer int32) {
|
||||
if t.params.OnSubscriberMaxQualityChange != nil {
|
||||
t.params.OnSubscriberMaxQualityChange(t.downTrack.SubscriberID(), t.downTrack.Mime(), layer)
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnRttUpdate(rtt uint32) {
|
||||
go t.params.Subscriber.UpdateMediaRTT(rtt)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnCodecNegotiated(codec webrtc.RTPCodecCapability) {
|
||||
if isAvailable, needsPublish := t.params.WrappedReceiver.DetermineReceiver(codec); !isAvailable || !needsPublish {
|
||||
return
|
||||
}
|
||||
|
||||
if t.params.OnSubscriberMaxQualityChange != nil || t.params.OnSubscriberAudioCodecChange != nil {
|
||||
go func() {
|
||||
mimeType := mime.NormalizeMimeType(codec.MimeType)
|
||||
switch t.params.MediaTrack.Kind() {
|
||||
case livekit.TrackType_VIDEO:
|
||||
spatial := buffer.GetSpatialLayerForVideoQuality(
|
||||
mimeType,
|
||||
livekit.VideoQuality_HIGH,
|
||||
t.params.MediaTrack.ToProto(),
|
||||
)
|
||||
if t.params.OnSubscriberMaxQualityChange != nil {
|
||||
t.params.OnSubscriberMaxQualityChange(t.downTrack.SubscriberID(), mimeType, spatial)
|
||||
}
|
||||
|
||||
case livekit.TrackType_AUDIO:
|
||||
if t.params.OnSubscriberAudioCodecChange != nil {
|
||||
t.params.OnSubscriberAudioCodecChange(t.downTrack.SubscriberID(), mimeType, true)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnDownTrackClose(isExpectedToResume bool) {
|
||||
// Cache transceiver for potential re-use on resume.
|
||||
// To ensure subscription manager does not re-subscribe before caching,
|
||||
// delete the subscribed track only after caching.
|
||||
if isExpectedToResume {
|
||||
if tr := t.downTrack.GetTransceiver(); tr != nil {
|
||||
t.params.Subscriber.CacheDownTrack(t.ID(), tr, t.downTrack.GetState())
|
||||
}
|
||||
}
|
||||
|
||||
if t.params.OnDownTrackClosed != nil {
|
||||
t.params.OnDownTrackClosed(t.params.Subscriber.ID())
|
||||
}
|
||||
t.Close(isExpectedToResume)
|
||||
}
|
||||
|
||||
func (t *SubscribedTrack) OnStreamStarted(elapsed time.Duration) {
|
||||
t.params.TelemetryListener.OnTrackSubscribeStreamStarted(t.params.Subscriber.ID(), t.params.MediaTrack.ToProto(), elapsed)
|
||||
if onSubscribeStreamStarted := t.onSubscribeStreamStarted.Load(); onSubscribeStreamStarted != nil {
|
||||
onSubscribeStreamStarted.(func(time.Duration))(elapsed)
|
||||
}
|
||||
}
|
||||
1681
pkg/rtc/subscriptionmanager.go
Normal file
1681
pkg/rtc/subscriptionmanager.go
Normal file
File diff suppressed because it is too large
Load Diff
714
pkg/rtc/subscriptionmanager_test.go
Normal file
714
pkg/rtc/subscriptionmanager_test.go
Normal file
@@ -0,0 +1,714 @@
|
||||
/*
|
||||
* 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 (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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 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 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("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")
|
||||
})
|
||||
}
|
||||
|
||||
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)()
|
||||
}
|
||||
183
pkg/rtc/supervisor/participant_supervisor.go
Normal file
183
pkg/rtc/supervisor/participant_supervisor.go
Normal file
@@ -0,0 +1,183 @@
|
||||
// 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 supervisor
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
monitorInterval = 1 * time.Second
|
||||
)
|
||||
|
||||
type ParticipantSupervisorParams struct {
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type trackMonitor struct {
|
||||
opMon types.OperationMonitor
|
||||
err error
|
||||
}
|
||||
|
||||
type ParticipantSupervisor struct {
|
||||
params ParticipantSupervisorParams
|
||||
|
||||
lock sync.RWMutex
|
||||
isPublisherConnected bool
|
||||
publications map[livekit.TrackID]*trackMonitor
|
||||
|
||||
isStopped atomic.Bool
|
||||
|
||||
onPublicationError func(trackID livekit.TrackID)
|
||||
}
|
||||
|
||||
func NewParticipantSupervisor(params ParticipantSupervisorParams) *ParticipantSupervisor {
|
||||
p := &ParticipantSupervisor{
|
||||
params: params,
|
||||
publications: make(map[livekit.TrackID]*trackMonitor),
|
||||
}
|
||||
|
||||
go p.checkState()
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) Stop() {
|
||||
p.isStopped.Store(true)
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) OnPublicationError(f func(trackID livekit.TrackID)) {
|
||||
p.lock.Lock()
|
||||
defer p.lock.Unlock()
|
||||
|
||||
p.onPublicationError = f
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) getOnPublicationError() func(trackID livekit.TrackID) {
|
||||
p.lock.RLock()
|
||||
defer p.lock.RUnlock()
|
||||
|
||||
return p.onPublicationError
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) SetPublisherPeerConnectionConnected(isConnected bool) {
|
||||
p.lock.Lock()
|
||||
p.isPublisherConnected = isConnected
|
||||
|
||||
for _, pm := range p.publications {
|
||||
pm.opMon.PostEvent(types.OperationMonitorEventPublisherPeerConnectionConnected, p.isPublisherConnected)
|
||||
}
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) AddPublication(trackID livekit.TrackID) {
|
||||
p.lock.Lock()
|
||||
pm, ok := p.publications[trackID]
|
||||
if !ok {
|
||||
pm = &trackMonitor{
|
||||
opMon: NewPublicationMonitor(
|
||||
PublicationMonitorParams{
|
||||
TrackID: trackID,
|
||||
IsPeerConnectionConnected: p.isPublisherConnected,
|
||||
Logger: p.params.Logger,
|
||||
},
|
||||
),
|
||||
}
|
||||
p.publications[trackID] = pm
|
||||
}
|
||||
pm.opMon.PostEvent(types.OperationMonitorEventAddPendingPublication, nil)
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) SetPublicationMute(trackID livekit.TrackID, isMuted bool) {
|
||||
p.lock.Lock()
|
||||
pm, ok := p.publications[trackID]
|
||||
if ok {
|
||||
pm.opMon.PostEvent(types.OperationMonitorEventSetPublicationMute, isMuted)
|
||||
}
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) SetPublishedTrack(trackID livekit.TrackID, pubTrack types.LocalMediaTrack) {
|
||||
p.lock.RLock()
|
||||
pm, ok := p.publications[trackID]
|
||||
if ok {
|
||||
pm.opMon.PostEvent(types.OperationMonitorEventSetPublishedTrack, pubTrack)
|
||||
}
|
||||
p.lock.RUnlock()
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) ClearPublishedTrack(trackID livekit.TrackID, pubTrack types.LocalMediaTrack) {
|
||||
p.lock.RLock()
|
||||
pm, ok := p.publications[trackID]
|
||||
if ok {
|
||||
pm.opMon.PostEvent(types.OperationMonitorEventClearPublishedTrack, pubTrack)
|
||||
}
|
||||
p.lock.RUnlock()
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) checkState() {
|
||||
ticker := time.NewTicker(monitorInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for !p.isStopped.Load() {
|
||||
<-ticker.C
|
||||
|
||||
p.checkPublications()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ParticipantSupervisor) checkPublications() {
|
||||
var erroredPublications []livekit.TrackID
|
||||
var removablePublications []livekit.TrackID
|
||||
p.lock.RLock()
|
||||
for trackID, pm := range p.publications {
|
||||
if err := pm.opMon.Check(); err != nil {
|
||||
if pm.err == nil {
|
||||
p.params.Logger.Errorw("supervisor error on publication", err, "trackID", trackID)
|
||||
pm.err = err
|
||||
erroredPublications = append(erroredPublications, trackID)
|
||||
}
|
||||
} else {
|
||||
if pm.err != nil {
|
||||
p.params.Logger.Infow("supervisor publication recovered", "trackID", trackID)
|
||||
pm.err = err
|
||||
}
|
||||
if pm.opMon.IsIdle() {
|
||||
removablePublications = append(removablePublications, trackID)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.lock.RUnlock()
|
||||
|
||||
p.lock.Lock()
|
||||
for _, trackID := range removablePublications {
|
||||
delete(p.publications, trackID)
|
||||
}
|
||||
p.lock.Unlock()
|
||||
|
||||
if onPublicationError := p.getOnPublicationError(); onPublicationError != nil {
|
||||
for _, trackID := range erroredPublications {
|
||||
onPublicationError(trackID)
|
||||
}
|
||||
}
|
||||
}
|
||||
190
pkg/rtc/supervisor/publication_monitor.go
Normal file
190
pkg/rtc/supervisor/publication_monitor.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// 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 supervisor
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gammazero/deque"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
publishWaitDuration = 30 * time.Second
|
||||
)
|
||||
|
||||
var (
|
||||
errPublishTimeout = errors.New("publish time out")
|
||||
)
|
||||
|
||||
type publish struct {
|
||||
isStart bool
|
||||
}
|
||||
|
||||
type PublicationMonitorParams struct {
|
||||
TrackID livekit.TrackID
|
||||
IsPeerConnectionConnected bool
|
||||
Logger logger.Logger
|
||||
}
|
||||
|
||||
type PublicationMonitor struct {
|
||||
params PublicationMonitorParams
|
||||
|
||||
lock sync.RWMutex
|
||||
desiredPublishes deque.Deque[*publish]
|
||||
|
||||
isConnected bool
|
||||
|
||||
publishedTrack types.LocalMediaTrack
|
||||
isMuted bool
|
||||
unmutedAt time.Time
|
||||
}
|
||||
|
||||
func NewPublicationMonitor(params PublicationMonitorParams) *PublicationMonitor {
|
||||
p := &PublicationMonitor{
|
||||
params: params,
|
||||
isConnected: params.IsPeerConnectionConnected,
|
||||
}
|
||||
p.desiredPublishes.SetBaseCap(4)
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) PostEvent(ome types.OperationMonitorEvent, omd types.OperationMonitorData) {
|
||||
switch ome {
|
||||
case types.OperationMonitorEventPublisherPeerConnectionConnected:
|
||||
p.setConnected(omd.(bool))
|
||||
case types.OperationMonitorEventAddPendingPublication:
|
||||
p.addPending()
|
||||
case types.OperationMonitorEventSetPublicationMute:
|
||||
p.setMute(omd.(bool))
|
||||
case types.OperationMonitorEventSetPublishedTrack:
|
||||
p.setPublishedTrack(omd.(types.LocalMediaTrack))
|
||||
case types.OperationMonitorEventClearPublishedTrack:
|
||||
p.clearPublishedTrack(omd.(types.LocalMediaTrack))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) addPending() {
|
||||
p.lock.Lock()
|
||||
p.desiredPublishes.PushBack(
|
||||
&publish{
|
||||
isStart: true,
|
||||
},
|
||||
)
|
||||
|
||||
// synthesize an end
|
||||
p.desiredPublishes.PushBack(
|
||||
&publish{
|
||||
isStart: false,
|
||||
},
|
||||
)
|
||||
p.update()
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) maybeStartMonitor() {
|
||||
if p.isConnected && !p.isMuted {
|
||||
p.unmutedAt = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) setConnected(isConnected bool) {
|
||||
p.lock.Lock()
|
||||
p.isConnected = isConnected
|
||||
p.maybeStartMonitor()
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) setMute(isMuted bool) {
|
||||
p.lock.Lock()
|
||||
p.isMuted = isMuted
|
||||
p.maybeStartMonitor()
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) setPublishedTrack(pubTrack types.LocalMediaTrack) {
|
||||
p.lock.Lock()
|
||||
p.publishedTrack = pubTrack
|
||||
p.update()
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) clearPublishedTrack(pubTrack types.LocalMediaTrack) {
|
||||
p.lock.Lock()
|
||||
if p.publishedTrack == pubTrack {
|
||||
p.publishedTrack = nil
|
||||
} else {
|
||||
p.params.Logger.Errorw("supervisor: mismatched published track on clear", nil, "trackID", p.params.TrackID)
|
||||
}
|
||||
|
||||
p.update()
|
||||
p.lock.Unlock()
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) Check() error {
|
||||
p.lock.RLock()
|
||||
var pub *publish
|
||||
if p.desiredPublishes.Len() > 0 {
|
||||
pub = p.desiredPublishes.Front()
|
||||
}
|
||||
|
||||
isMuted := p.isMuted
|
||||
unmutedAt := p.unmutedAt
|
||||
p.lock.RUnlock()
|
||||
|
||||
if pub == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if pub.isStart && !isMuted && !unmutedAt.IsZero() && time.Since(unmutedAt) > publishWaitDuration {
|
||||
// timed out waiting for publish
|
||||
return errPublishTimeout
|
||||
}
|
||||
|
||||
// give more time for publish to happen
|
||||
// NOTE: synthesized end events do not have a start time, so do not check them for time out
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) IsIdle() bool {
|
||||
p.lock.RLock()
|
||||
defer p.lock.RUnlock()
|
||||
|
||||
return p.desiredPublishes.Len() == 0 && p.publishedTrack == nil
|
||||
}
|
||||
|
||||
func (p *PublicationMonitor) update() {
|
||||
for {
|
||||
var pub *publish
|
||||
if p.desiredPublishes.Len() > 0 {
|
||||
pub = p.desiredPublishes.PopFront()
|
||||
}
|
||||
|
||||
if pub == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if (pub.isStart && p.publishedTrack == nil) || (!pub.isStart && p.publishedTrack != nil) {
|
||||
// put it back as the condition is not satisfied
|
||||
p.desiredPublishes.PushFront(pub)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
88
pkg/rtc/testutils.go
Normal file
88
pkg/rtc/testutils.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// 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 (
|
||||
"github.com/livekit/protocol/livekit"
|
||||
"github.com/livekit/protocol/logger"
|
||||
"github.com/livekit/protocol/observability/roomobs"
|
||||
"github.com/livekit/protocol/utils"
|
||||
"github.com/livekit/protocol/utils/guid"
|
||||
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types"
|
||||
"github.com/livekit/livekit-server/pkg/rtc/types/typesfakes"
|
||||
)
|
||||
|
||||
func NewMockParticipant(
|
||||
identity livekit.ParticipantIdentity,
|
||||
protocol types.ProtocolVersion,
|
||||
hidden bool,
|
||||
publisher bool,
|
||||
participantListener types.LocalParticipantListener,
|
||||
) *typesfakes.FakeLocalParticipant {
|
||||
p := &typesfakes.FakeLocalParticipant{}
|
||||
sid := guid.New(utils.ParticipantPrefix)
|
||||
p.IDReturns(livekit.ParticipantID(sid))
|
||||
p.IdentityReturns(identity)
|
||||
p.StateReturns(livekit.ParticipantInfo_JOINED)
|
||||
p.ProtocolVersionReturns(protocol)
|
||||
p.CanSubscribeReturns(true)
|
||||
p.CanPublishSourceReturns(!hidden)
|
||||
p.CanPublishDataReturns(!hidden)
|
||||
p.HiddenReturns(hidden)
|
||||
p.ToProtoReturns(&livekit.ParticipantInfo{
|
||||
Sid: sid,
|
||||
Identity: string(identity),
|
||||
State: livekit.ParticipantInfo_JOINED,
|
||||
IsPublisher: publisher,
|
||||
})
|
||||
p.ToProtoWithVersionReturns(&livekit.ParticipantInfo{
|
||||
Sid: sid,
|
||||
Identity: string(identity),
|
||||
State: livekit.ParticipantInfo_JOINED,
|
||||
IsPublisher: publisher,
|
||||
}, utils.TimedVersion(0))
|
||||
|
||||
p.SetMetadataCalls(func(m string) {
|
||||
participantListener.OnParticipantUpdate(p)
|
||||
})
|
||||
updateTrack := func() {
|
||||
participantListener.OnTrackUpdated(p, NewMockTrack(livekit.TrackType_VIDEO, "testcam"))
|
||||
}
|
||||
|
||||
p.SetTrackMutedCalls(func(mute *livekit.MuteTrackRequest, fromServer bool) *livekit.TrackInfo {
|
||||
updateTrack()
|
||||
return nil
|
||||
})
|
||||
p.AddTrackCalls(func(req *livekit.AddTrackRequest) {
|
||||
updateTrack()
|
||||
})
|
||||
p.GetLoggerReturns(logger.GetLogger())
|
||||
p.GetReporterReturns(roomobs.NewNoopParticipantSessionReporter())
|
||||
|
||||
return p
|
||||
}
|
||||
|
||||
func NewMockTrack(kind livekit.TrackType, name string) *typesfakes.FakeMediaTrack {
|
||||
t := &typesfakes.FakeMediaTrack{}
|
||||
t.IDReturns(livekit.TrackID(guid.New(utils.TrackPrefix)))
|
||||
t.KindReturns(kind)
|
||||
t.NameReturns(name)
|
||||
t.ToProtoReturns(&livekit.TrackInfo{
|
||||
Type: kind,
|
||||
Name: name,
|
||||
})
|
||||
return t
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user