Files
livekit/pkg/service/roommanager.go
T
Paul Wells 54496c765d report a reason on room-ended telemetry (#4815)
* report a reason on room-ended telemetry

Room.Close already took a ParticipantCloseReason, but OnClose was func()
with no arguments, so the reason was structurally dropped before it could
reach RoomEnded.

Add types.RoomCloseReason and carry it through Close -> OnClose -> RoomEnded,
where it lands on both the analytics event and the room_finished webhook.

Room.Close now takes the room reason and derives the participant reason from
it, so the two can never disagree. ToParticipantCloseReason maps each reason
to exactly what its call site passed before, so no participant-facing
behaviour changes; a table test pins that mapping.

* fix(test): update webhook test for RoomCloseReason

test/ was outside the packages checked before pushing, so this Close call
site was missed. Also assert the reason reaches the room_finished webhook,
which the unit test cannot cover since it runs with a nil notifier.
2026-08-28 23:41:59 -07:00

1247 lines
40 KiB
Go

// Copyright 2023 LiveKit, Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package service
import (
"context"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"fmt"
"maps"
"net"
"os"
"slices"
"strconv"
"sync"
"time"
"github.com/pkg/errors"
"github.com/livekit/mediatransportutil/pkg/rtcconfig"
"github.com/livekit/protocol/auth"
"github.com/livekit/protocol/codecs/mime"
"github.com/livekit/protocol/livekit"
"github.com/livekit/protocol/logger"
"github.com/livekit/protocol/observability"
"github.com/livekit/protocol/observability/roomobs"
"github.com/livekit/protocol/rpc"
"github.com/livekit/protocol/utils"
"github.com/livekit/protocol/utils/guid"
"github.com/livekit/protocol/utils/must"
"github.com/livekit/psrpc"
"github.com/livekit/psrpc/pkg/middleware"
"github.com/livekit/livekit-server/pkg/agent"
"github.com/livekit/livekit-server/pkg/sfu"
sutils "github.com/livekit/livekit-server/pkg/utils"
"github.com/livekit/livekit-server/pkg/clientconfiguration"
"github.com/livekit/livekit-server/pkg/config"
"github.com/livekit/livekit-server/pkg/routing"
"github.com/livekit/livekit-server/pkg/rtc"
"github.com/livekit/livekit-server/pkg/rtc/types"
"github.com/livekit/livekit-server/pkg/telemetry"
"github.com/livekit/livekit-server/pkg/telemetry/prometheus"
"github.com/livekit/livekit-server/version"
)
const (
tokenRefreshInterval = 5 * time.Minute
tokenDefaultTTL = 10 * time.Minute
)
type iceConfigCacheKey struct {
roomName livekit.RoomName
participantIdentity livekit.ParticipantIdentity
}
// RoomManager manages rooms and its interaction with participants.
// It's responsible for creating, deleting rooms, as well as running sessions for participants
type RoomManager struct {
lock sync.RWMutex
config *config.Config
rtcConfig *rtc.WebRTCConfig
serverInfo *livekit.ServerInfo
currentNode routing.LocalNode
router routing.Router
roomAllocator RoomAllocator
roomManagerServer rpc.TypedRoomManagerServer
whipServer rpc.WHIPServer[livekit.NodeID]
roomStore ObjectStore
telemetry telemetry.TelemetryService
clientConfManager clientconfiguration.ClientConfigurationManager
agentClient agent.Client
agentStore AgentStore
egressLauncher rtc.EgressLauncher
versionGenerator utils.TimedVersionGenerator
turnAuthHandler *TURNAuthHandler
bus psrpc.MessageBus
rooms map[livekit.RoomName]*rtc.Room
roomServers utils.MultitonService[rpc.RoomTopic]
agentDispatchServers utils.MultitonService[rpc.RoomTopic]
participantServers utils.MultitonService[rpc.ParticipantTopic]
httpSignalParticipantServers utils.MultitonService[rpc.ParticipantTopic]
whipParticipantServers utils.MultitonService[rpc.ParticipantTopic]
iceConfigCache *sutils.IceConfigCache[iceConfigCacheKey]
forwardStats *sfu.ForwardStats
rpc.UnimplementedParticipantServer
rpc.UnimplementedRoomServer
rpc.UnimplementedRoomManagerServer
}
func NewLocalRoomManager(
conf *config.Config,
roomStore ObjectStore,
currentNode routing.LocalNode,
router routing.Router,
roomAllocator RoomAllocator,
telemetry telemetry.TelemetryService,
agentClient agent.Client,
agentStore AgentStore,
egressLauncher rtc.EgressLauncher,
versionGenerator utils.TimedVersionGenerator,
turnAuthHandler *TURNAuthHandler,
bus psrpc.MessageBus,
forwardStats *sfu.ForwardStats,
) (*RoomManager, error) {
rtcConf, err := rtc.NewWebRTCConfig(conf)
if err != nil {
return nil, err
}
r := &RoomManager{
config: conf,
rtcConfig: rtcConf,
currentNode: currentNode,
router: router,
roomAllocator: roomAllocator,
roomStore: roomStore,
telemetry: telemetry,
clientConfManager: clientconfiguration.NewStaticClientConfigurationManager(clientconfiguration.StaticConfigurations),
egressLauncher: egressLauncher,
agentClient: agentClient,
agentStore: agentStore,
versionGenerator: versionGenerator,
turnAuthHandler: turnAuthHandler,
bus: bus,
forwardStats: forwardStats,
rooms: make(map[livekit.RoomName]*rtc.Room),
iceConfigCache: sutils.NewIceConfigCache[iceConfigCacheKey](0),
serverInfo: &livekit.ServerInfo{
Edition: livekit.ServerInfo_Standard,
Version: version.Version,
Protocol: types.CurrentProtocol,
AgentProtocol: agent.CurrentProtocol,
Region: conf.Region,
NodeId: string(currentNode.NodeID()),
},
}
r.roomManagerServer, err = rpc.NewTypedRoomManagerServer(r, bus, rpc.WithServerLogger(logger.GetLogger()), middleware.WithServerMetrics(rpc.PSRPCMetricsObserver{}), psrpc.WithServerChannelSize(conf.PSRPC.BufferSize))
if err != nil {
return nil, err
}
if err := r.roomManagerServer.RegisterAllNodeTopics(currentNode.NodeID()); err != nil {
return nil, err
}
whipService, err := newWhipService(r)
if err != nil {
return nil, err
}
r.whipServer, err = rpc.NewWHIPServer[livekit.NodeID](whipService, bus, rpc.WithDefaultServerOptions(conf.PSRPC, logger.GetLogger()))
if err != nil {
return nil, err
}
if err := r.whipServer.RegisterAllCommonTopics(currentNode.NodeID()); err != nil {
return nil, err
}
return r, nil
}
func (r *RoomManager) GetRoom(_ context.Context, roomName livekit.RoomName) *rtc.Room {
r.lock.RLock()
defer r.lock.RUnlock()
return r.rooms[roomName]
}
// deleteRoom completely deletes all room information, including active sessions, room store, and routing info
func (r *RoomManager) deleteRoom(ctx context.Context, roomName livekit.RoomName) error {
logger.Infow("deleting room state", "room", roomName)
r.lock.Lock()
delete(r.rooms, roomName)
r.lock.Unlock()
var err, err2 error
wg := sync.WaitGroup{}
wg.Add(2)
// clear routing information
go func() {
defer wg.Done()
err = r.router.ClearRoomState(ctx, roomName)
}()
// also delete room from db
go func() {
defer wg.Done()
err2 = r.roomStore.DeleteRoom(ctx, roomName)
}()
wg.Wait()
if err2 != nil {
err = err2
}
return err
}
func (r *RoomManager) CloseIdleRooms() {
r.lock.RLock()
rooms := slices.Collect(maps.Values(r.rooms))
r.lock.RUnlock()
for _, room := range rooms {
room.CloseIfEmpty()
}
}
func (r *RoomManager) HasParticipants() bool {
r.lock.RLock()
defer r.lock.RUnlock()
for _, room := range r.rooms {
if len(room.GetParticipants()) != 0 {
return true
}
}
return false
}
func (r *RoomManager) Stop() {
// disconnect all clients
r.lock.RLock()
rooms := slices.Collect(maps.Values(r.rooms))
r.lock.RUnlock()
for _, room := range rooms {
room.Close(types.RoomCloseReasonServerShutdown)
}
r.roomManagerServer.Kill()
r.whipServer.Kill()
r.roomServers.Kill()
r.agentDispatchServers.Kill()
r.participantServers.Kill()
r.httpSignalParticipantServers.Kill()
r.whipParticipantServers.Kill()
if r.rtcConfig != nil {
if r.rtcConfig.UDPMux != nil {
_ = r.rtcConfig.UDPMux.Close()
}
if r.rtcConfig.TCPMuxListener != nil {
_ = r.rtcConfig.TCPMuxListener.Close()
}
}
r.iceConfigCache.Stop()
if r.forwardStats != nil {
r.forwardStats.Stop()
}
}
func (r *RoomManager) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest) (*livekit.Room, error) {
room, err := r.getOrCreateRoom(ctx, req)
if err != nil {
return nil, err
}
defer room.Release()
return room.ToProto(), nil
}
// StartSession starts WebRTC session when a new participant is connected, takes place on RTC node
func (r *RoomManager) StartSession(
ctx context.Context,
pi routing.ParticipantInit,
requestSource routing.MessageSource,
responseSink routing.MessageSink,
useOneShotSignallingMode bool,
) error {
sessionStartTime := time.Now()
if pi.Identity != "" && pi.Grants != nil {
if !r.config.Limit.CheckMetadataSize(pi.Grants.Metadata) {
return ErrMetadataExceedsLimits
}
if !r.config.Limit.CheckAttributesSize(pi.Grants.Attributes) {
return ErrAttributeExceedsLimits
}
}
createRoom := pi.CreateRoom
room, err := r.getOrCreateRoom(ctx, createRoom)
if err != nil {
if pi.Identity != "" {
prometheus.IncrementParticipantRtcCanceled(1, false)
}
return err
}
defer room.Release()
protoRoom, roomInternal := room.ToProto(), room.Internal()
// only create the room, but don't start a participant session
if pi.Identity == "" {
return nil
}
// should not error out, error is logged in iceServersForParticipant even if it fails
// since this is used for TURN server credentials, we don't want to fail the request even if there's no TURN for the session
apiKey, _, _ := r.getFirstKeyPair()
participant := room.GetParticipant(pi.Identity)
if participant != nil {
// When reconnecting, it means WS has interrupted but underlying peer connection is still ok in this state,
// we'll keep the participant SID, and just swap the sink for the underlying connection
if pi.Reconnect {
if participant.IsClosed() {
// Send leave request if participant is closed, i. e. handle the case of client trying to resume crossing wires with
// server closing the participant due to some irrecoverable condition. Such a condition would have triggered
// a full reconnect when that condition occurred.
//
// It is possible that the client did not get that send request. So, send it again.
logger.Infow("cannot restart a closed participant",
"room", room.Name(),
"nodeID", r.currentNode.NodeID(),
"participant", pi.Identity,
"reason", pi.ReconnectReason,
)
var leave *livekit.LeaveRequest
pv := types.ProtocolVersion(pi.Client.Protocol)
if pv.SupportsRegionsInLeaveRequest() {
leave = &livekit.LeaveRequest{
Reason: livekit.DisconnectReason_STATE_MISMATCH,
Action: livekit.LeaveRequest_RECONNECT,
}
} else {
leave = &livekit.LeaveRequest{
CanReconnect: true,
Reason: livekit.DisconnectReason_STATE_MISMATCH,
}
}
_ = responseSink.WriteMessage(&livekit.SignalResponse{
Message: &livekit.SignalResponse_Leave{
Leave: leave,
},
})
prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled())
return errors.New("could not restart closed participant")
}
participant.GetLogger().Infow(
"resuming RTC session",
"nodeID", r.currentNode.NodeID(),
"participantInit", &pi,
"numParticipants", room.GetParticipantCount(),
)
iceConfig := r.getIceConfig(room.Name(), participant)
if err = room.ResumeParticipant(
participant,
requestSource,
responseSink,
iceConfig,
r.iceServersForParticipant(
apiKey,
participant,
iceConfig.PreferenceSubscriber == livekit.ICECandidateType_ICT_TLS,
),
pi.ReconnectReason,
); err != nil {
participant.GetLogger().Warnw("could not resume participant", err)
prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled())
return err
}
r.telemetry.ParticipantResumed(ctx, room.ToProto(), participant.ToProto(), r.currentNode.NodeID(), pi.ReconnectReason)
prometheus.IncrementParticipantRtcActive(1, participant.IsWarpEnabled())
go room.HandleSyncState(participant, pi.SyncState)
go r.rtcSessionWorker(room, participant, requestSource)
return nil
}
// we need to clean up the existing participant, so a new one can join
participant.GetLogger().Infow("removing duplicate participant")
room.RemoveParticipant(participant.Identity(), participant.ID(), types.ParticipantCloseReasonDuplicateIdentity)
} else if pi.Reconnect {
// send leave request if participant is trying to reconnect without keep subscribe state
// but missing from the room
var leave *livekit.LeaveRequest
pv := types.ProtocolVersion(pi.Client.Protocol)
if pv.SupportsRegionsInLeaveRequest() {
leave = &livekit.LeaveRequest{
Reason: livekit.DisconnectReason_STATE_MISMATCH,
Action: livekit.LeaveRequest_RECONNECT,
}
} else {
leave = &livekit.LeaveRequest{
CanReconnect: true,
Reason: livekit.DisconnectReason_STATE_MISMATCH,
}
}
_ = responseSink.WriteMessage(&livekit.SignalResponse{
Message: &livekit.SignalResponse_Leave{
Leave: leave,
},
})
prometheus.IncrementParticipantRtcCanceled(1, false)
return errors.New("could not restart participant")
}
sid := livekit.ParticipantID(guid.New(utils.ParticipantPrefix))
pLogger := rtc.LoggerWithParticipant(
rtc.LoggerWithRoom(logger.GetLogger(), room.Name(), room.ID()),
pi.Identity,
sid,
false,
)
pLogger.Infow(
"starting RTC session",
"room", room.Name(),
"nodeID", r.currentNode.NodeID(),
"numParticipants", room.GetParticipantCount(),
"participantInit", &pi,
)
clientConf := r.clientConfManager.GetConfiguration(pi.Client)
pv := types.ProtocolVersion(pi.Client.Protocol)
rtcConf := *r.rtcConfig
rtcConf.SetBufferFactory(room.GetBufferFactory())
if pi.DisableICELite {
rtcConf.SettingEngine.SetLite(false)
}
rtcConf.UpdatePublisherConfig(pi.UseSinglePeerConnection)
// default allow forceTCP
allowFallback := true
if r.config.RTC.AllowTCPFallback != nil {
allowFallback = *r.config.RTC.AllowTCPFallback
}
// default do not force full reconnect on a publication error
reconnectOnPublicationError := false
if r.config.RTC.ReconnectOnPublicationError != nil {
reconnectOnPublicationError = *r.config.RTC.ReconnectOnPublicationError
}
// default do not force full reconnect on a subscription error
reconnectOnSubscriptionError := false
if r.config.RTC.ReconnectOnSubscriptionError != nil {
reconnectOnSubscriptionError = *r.config.RTC.ReconnectOnSubscriptionError
}
// default do not force full reconnect on a data channel error
reconnectOnDataChannelError := false
if r.config.RTC.ReconnectOnDataChannelError != nil {
reconnectOnDataChannelError = *r.config.RTC.ReconnectOnDataChannelError
}
subscriberAllowPause := r.config.RTC.CongestionControl.AllowPause
if pi.SubscriberAllowPause != nil {
subscriberAllowPause = *pi.SubscriberAllowPause
}
enabledCodecs := protoRoom.EnabledCodecs
if !slices.ContainsFunc(enabledCodecs, func(codec *livekit.Codec) bool {
return mime.IsMimeTypeStringRTX(codec.Mime)
}) {
enabledCodecs = append(enabledCodecs, &livekit.Codec{Mime: mime.MimeTypeRTX.String()})
}
enableWarp := !useOneShotSignallingMode && r.config.RTC.EnableWarp
participant, err = rtc.NewParticipant(rtc.ParticipantParams{
Identity: pi.Identity,
Name: pi.Name,
SID: sid,
Config: &rtcConf,
Sink: responseSink,
AudioConfig: r.config.Audio,
VideoConfig: r.config.Video,
LimitConfig: r.config.Limit,
ProtocolVersion: pv,
SessionStartTime: sessionStartTime,
SessionTimer: observability.NewSessionTimer(sessionStartTime),
TelemetryListener: room.ParticipantTelemetryListener(),
Trailer: room.Trailer(),
PLIThrottleConfig: r.config.RTC.PLIThrottle,
CongestionControlConfig: r.config.RTC.CongestionControl,
PublishEnabledCodecs: enabledCodecs,
SubscribeEnabledCodecs: enabledCodecs,
Grants: pi.Grants,
TokenExpiresAt: pi.TokenExpiresAt,
Reconnect: pi.Reconnect,
Logger: pLogger,
Reporter: roomobs.NewNoopParticipantSessionReporter(),
ClientConf: clientConf,
ClientInfo: rtc.ClientInfo{ClientInfo: pi.Client},
Region: pi.Region,
AdaptiveStream: pi.AdaptiveStream,
AllowTCPFallback: allowFallback,
TCPFallbackRTTThreshold: r.config.RTC.TCPFallbackRTTThreshold,
AllowUDPUnstableFallback: r.config.RTC.AllowUDPUnstableFallback,
TURNSEnabled: r.config.IsTURNSEnabled(),
ParticipantListener: room.LocalParticipantListener(),
ParticipantHelper: &roomManagerParticipantHelper{
room: room,
codecRegressionThreshold: r.config.Video.CodecRegressionThreshold,
},
ReconnectOnPublicationError: reconnectOnPublicationError,
ReconnectOnSubscriptionError: reconnectOnSubscriptionError,
ReconnectOnDataChannelError: reconnectOnDataChannelError,
VersionGenerator: r.versionGenerator,
SubscriberAllowPause: subscriberAllowPause,
SubscriptionLimitAudio: r.config.Limit.SubscriptionLimitAudio,
SubscriptionLimitVideo: r.config.Limit.SubscriptionLimitVideo,
PlayoutDelay: roomInternal.GetPlayoutDelay(),
SyncStreams: roomInternal.GetSyncStreams(),
ForwardStats: r.forwardStats,
MetricConfig: r.config.Metric,
UseOneShotSignallingMode: useOneShotSignallingMode,
DataChannelMaxBufferedAmount: r.config.RTC.DataChannelMaxBufferedAmount,
DatachannelSlowThreshold: r.config.RTC.DatachannelSlowThreshold,
DatachannelLossyTargetLatency: r.config.RTC.DatachannelLossyTargetLatency,
DatachannelDataTrackTargetLatency: r.config.RTC.DatachannelDataTrackTargetLatency,
FireOnTrackBySdp: true,
UseSinglePeerConnection: pi.UseSinglePeerConnection,
EnableDataTracks: r.config.EnableDataTracks,
EnableParticipantDataBlob: r.config.EnableParticipantDataBlob,
EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection,
EnableWarp: enableWarp,
})
if err != nil {
prometheus.IncrementParticipantRtcCanceled(1, enableWarp)
return err
}
iceConfig := r.setIceConfig(room.Name(), participant)
// join room
opts := rtc.ParticipantOptions{
AutoSubscribe: pi.AutoSubscribe,
}
if pi.AutoSubscribeDataTrack != nil {
opts.AutoSubscribeDataTrack = *pi.AutoSubscribeDataTrack
}
iceServers := r.iceServersForParticipant(apiKey, participant, iceConfig.PreferenceSubscriber == livekit.ICECandidateType_ICT_TLS)
if err = room.Join(participant, requestSource, &opts, iceServers); err != nil {
pLogger.Errorw("could not join room", err)
_ = participant.Close(true, types.ParticipantCloseReasonJoinFailed, false)
prometheus.IncrementParticipantRtcCanceled(1, enableWarp)
return err
}
var participantServerClosers utils.Closers
participantTopic := rpc.FormatParticipantTopic(room.Name(), participant.Identity())
participantServer := must.Get(rpc.NewTypedParticipantServer(r, r.bus))
participantServerClosers = append(participantServerClosers, utils.CloseFunc(r.participantServers.Replace(participantTopic, participantServer)))
if err := participantServer.RegisterAllParticipantTopics(participantTopic); err != nil {
participantServerClosers.Close()
pLogger.Errorw("could not join register participant topic", err)
_ = participant.Close(true, types.ParticipantCloseReasonMessageBusFailed, false)
prometheus.IncrementParticipantRtcCanceled(1, enableWarp)
return err
}
if useOneShotSignallingMode {
whipParticipantServer := must.Get(rpc.NewTypedWHIPParticipantServer(whipParticipantService{r}, r.bus))
participantServerClosers = append(participantServerClosers, utils.CloseFunc(r.whipParticipantServers.Replace(participantTopic, whipParticipantServer)))
if err := whipParticipantServer.RegisterAllCommonTopics(participantTopic); err != nil {
participantServerClosers.Close()
pLogger.Errorw("could not join register participant topic for rtc rest participant server", err)
_ = participant.Close(true, types.ParticipantCloseReasonMessageBusFailed, false)
prometheus.IncrementParticipantRtcCanceled(1, enableWarp)
return err
}
}
if err = r.roomStore.StoreParticipant(ctx, room.Name(), participant.ToProto()); err != nil {
pLogger.Errorw("could not store participant", err)
}
persistRoomForParticipantCount := func(proto *livekit.Room) {
if !participant.Hidden() && !room.IsClosed() {
err = r.roomStore.StoreRoom(ctx, proto, room.Internal())
if err != nil {
logger.Errorw("could not store room", err)
}
}
}
// update room store with new numParticipants
persistRoomForParticipantCount(room.ToProto())
clientMeta := &livekit.AnalyticsClientMeta{Region: r.currentNode.Region(), Node: string(r.currentNode.NodeID())}
r.telemetry.ParticipantJoined(ctx, protoRoom, participant.ToProto(), pi.Client, clientMeta, true, participant.TelemetryGuard())
participant.AddOnClose(types.ParticipantCloseKeyNormal, func(p types.LocalParticipant) {
participantServerClosers.Close()
if err := r.roomStore.DeleteParticipant(ctx, room.Name(), p.Identity()); err != nil {
pLogger.Errorw("could not delete participant", err)
}
// update room store with new numParticipants
proto := room.ToProto()
persistRoomForParticipantCount(proto)
r.telemetry.ParticipantLeft(ctx, proto, p.ToProto(), true, participant.TelemetryGuard())
})
participant.OnClaimsChanged(func(participant types.LocalParticipant) {
pLogger.Debugw("refreshing client token after claims change")
if err := r.refreshToken(participant); err != nil {
pLogger.Errorw("could not refresh token", err)
}
})
participant.OnICEConfigChanged(func(participant types.LocalParticipant, iceConfig *livekit.ICEConfig) {
r.iceConfigCache.Put(iceConfigCacheKey{room.Name(), participant.Identity()}, iceConfig)
})
for _, addTrackRequest := range pi.AddTrackRequests {
participant.AddTrack(addTrackRequest)
}
if pi.PublisherOffer != nil {
participant.HandleOffer(pi.PublisherOffer)
}
go r.rtcSessionWorker(room, participant, requestSource)
return nil
}
// create the actual room object, to be used on RTC node
func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.CreateRoomRequest) (*rtc.Room, error) {
roomName := livekit.RoomName(createRoom.Name)
r.lock.RLock()
lastSeenRoom := r.rooms[roomName]
r.lock.RUnlock()
if lastSeenRoom != nil && lastSeenRoom.Hold() {
return lastSeenRoom, nil
}
// create new room, get details first
ri, internal, created, err := r.roomAllocator.CreateRoom(ctx, createRoom, true)
if err != nil {
return nil, err
}
r.lock.Lock()
currentRoom := r.rooms[roomName]
for currentRoom != lastSeenRoom {
r.lock.Unlock()
if currentRoom != nil && currentRoom.Hold() {
return currentRoom, nil
}
lastSeenRoom = currentRoom
r.lock.Lock()
currentRoom = r.rooms[roomName]
}
// construct ice servers
newRoom := rtc.NewRoom(ri, internal, *r.rtcConfig, r.config.Room, &r.config.Audio, r.serverInfo, r.telemetry, r.agentClient, r.agentStore, r.egressLauncher)
roomTopic := rpc.FormatRoomTopic(roomName)
roomServer := must.Get(rpc.NewTypedRoomServer(r, r.bus))
killRoomServer := r.roomServers.Replace(roomTopic, roomServer)
if err := roomServer.RegisterAllRoomTopics(roomTopic); err != nil {
killRoomServer()
r.lock.Unlock()
return nil, err
}
agentDispatchServer := must.Get(rpc.NewTypedAgentDispatchInternalServer(r, r.bus))
killDispServer := r.agentDispatchServers.Replace(roomTopic, agentDispatchServer)
if err := agentDispatchServer.RegisterAllRoomTopics(roomTopic); err != nil {
killRoomServer()
killDispServer()
r.lock.Unlock()
return nil, err
}
newRoom.OnClose(func(reason types.RoomCloseReason) {
killRoomServer()
killDispServer()
roomInfo := newRoom.ToProto()
r.telemetry.RoomEnded(ctx, roomInfo, reason.ToProto())
prometheus.RoomEnded(time.Unix(roomInfo.CreationTime, 0))
if err := r.deleteRoom(ctx, roomName); err != nil {
newRoom.Logger().Errorw("could not delete room", err)
}
newRoom.Logger().Infow("room closed")
})
newRoom.OnRoomUpdated(func() {
if err := r.roomStore.StoreRoom(ctx, newRoom.ToProto(), newRoom.Internal()); err != nil {
newRoom.Logger().Errorw("could not handle metadata update", err)
}
})
newRoom.OnParticipantChanged(func(p types.Participant) {
if !p.IsDisconnected() {
if err := r.roomStore.StoreParticipant(ctx, roomName, p.ToProto()); err != nil {
newRoom.Logger().Errorw("could not handle participant change", err)
}
}
})
r.rooms[roomName] = newRoom
r.lock.Unlock()
newRoom.Hold()
r.telemetry.RoomStarted(ctx, newRoom.ToProto())
prometheus.RoomStarted()
if created && createRoom.GetEgress().GetRoom() != nil {
// ensure room name matches
createRoom.Egress.Room.RoomName = createRoom.Name
_, err = r.egressLauncher.StartEgress(ctx, &rpc.StartEgressRequest{
Request: &rpc.StartEgressRequest_RoomComposite{
RoomComposite: createRoom.Egress.Room,
},
RoomId: ri.Sid,
})
if err != nil {
newRoom.Release()
return nil, err
}
}
return newRoom, nil
}
// manages an RTC session for a participant, runs on the RTC node
func (r *RoomManager) rtcSessionWorker(room *rtc.Room, participant types.LocalParticipant, requestSource routing.MessageSource) {
pLogger := participant.GetLogger()
defer func() {
pLogger.Debugw("RTC session finishing", "connID", requestSource.ConnectionID())
requestSource.Close()
}()
defer func() {
if r := rtc.Recover(pLogger); r != nil {
os.Exit(1)
}
}()
// send first refresh for cases when client token is close to expiring
_ = r.refreshToken(participant)
tokenTicker := time.NewTicker(tokenRefreshInterval)
defer tokenTicker.Stop()
for {
select {
case <-participant.Disconnected():
return
case <-tokenTicker.C:
// refresh token with the first API Key/secret pair
if err := r.refreshToken(participant); err != nil {
pLogger.Errorw("could not refresh token", err, "connID", requestSource.ConnectionID())
}
case obj := <-requestSource.ReadChan():
if obj == nil {
if room.GetParticipantRequestSource(participant.Identity()) == requestSource {
participant.HandleSignalSourceClose()
}
return
}
if err := participant.HandleSignalMessage(obj); err != nil {
// more specific errors are already logged
// treat errors returned as fatal
return
}
}
}
}
type participantReq interface {
GetRoom() string
GetIdentity() string
}
func (r *RoomManager) roomAndParticipantForReq(ctx context.Context, req participantReq) (*rtc.Room, types.LocalParticipant, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.GetRoom()))
if room == nil {
return nil, nil, ErrRoomNotFound
}
participant := room.GetParticipant(livekit.ParticipantIdentity(req.GetIdentity()))
if participant == nil {
return nil, nil, ErrParticipantNotFound
}
return room, participant, nil
}
func (r *RoomManager) ListParticipants(ctx context.Context, req *livekit.ListParticipantsRequest) (*livekit.ListParticipantsResponse, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
participants := room.GetParticipants()
items := make([]*livekit.ParticipantInfo, 0, len(participants))
for _, p := range participants {
items = append(items, p.ToProto())
}
return &livekit.ListParticipantsResponse{
Participants: items,
}, nil
}
func (r *RoomManager) GetParticipant(ctx context.Context, req *livekit.RoomParticipantIdentity) (*livekit.ParticipantInfo, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
participant := room.GetParticipant(livekit.ParticipantIdentity(req.Identity))
if participant == nil {
return nil, ErrParticipantNotFound
}
return participant.ToProto(), nil
}
func (r *RoomManager) RemoveParticipant(ctx context.Context, req *livekit.RoomParticipantIdentity) (*livekit.RemoveParticipantResponse, error) {
room, participant, err := r.roomAndParticipantForReq(ctx, req)
if err != nil {
return nil, err
}
participant.GetLogger().Infow("removing participant")
room.RemoveParticipant(livekit.ParticipantIdentity(req.Identity), "", types.ParticipantCloseReasonServiceRequestRemoveParticipant)
return &livekit.RemoveParticipantResponse{}, nil
}
func (r *RoomManager) MutePublishedTrack(ctx context.Context, req *livekit.MuteRoomTrackRequest) (*livekit.MuteRoomTrackResponse, error) {
_, participant, err := r.roomAndParticipantForReq(ctx, req)
if err != nil {
return nil, err
}
participant.GetLogger().Debugw("setting track muted",
"trackID", req.TrackSid, "muted", req.Muted)
if !req.Muted && !r.config.Room.EnableRemoteUnmute {
participant.GetLogger().Errorw("cannot unmute track, remote unmute is disabled", nil)
return nil, ErrRemoteUnmuteNoteEnabled
}
track := participant.SetTrackMuted(&livekit.MuteTrackRequest{
Sid: req.TrackSid,
Muted: req.Muted,
}, true)
return &livekit.MuteRoomTrackResponse{Track: track}, nil
}
func (r *RoomManager) UpdateParticipant(ctx context.Context, req *livekit.UpdateParticipantRequest) (*livekit.ParticipantInfo, error) {
_, participant, err := r.roomAndParticipantForReq(ctx, req)
if err != nil {
return nil, err
}
if err = participant.UpdateMetadata(&livekit.UpdateParticipantMetadata{
Name: req.Name,
Metadata: req.Metadata,
Attributes: req.Attributes,
}, true); err != nil {
return nil, err
}
if req.Permission != nil {
participant.GetLogger().Debugw(
"updating participant permission",
"permission", req.Permission,
)
participant.SetPermission(req.Permission)
}
return participant.ToProto(), nil
}
func (r *RoomManager) ForwardParticipant(ctx context.Context, req *livekit.ForwardParticipantRequest) (*livekit.ForwardParticipantResponse, error) {
return nil, errors.New("not implemented")
}
func (r *RoomManager) MoveParticipant(ctx context.Context, req *livekit.MoveParticipantRequest) (*livekit.MoveParticipantResponse, error) {
return nil, errors.New("not implemented")
}
func (r *RoomManager) PerformRpc(ctx context.Context, req *livekit.PerformRpcRequest) (*livekit.PerformRpcResponse, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.GetRoom()))
if room == nil {
return nil, ErrRoomNotFound
}
participant := room.GetParticipant(livekit.ParticipantIdentity(req.GetDestinationIdentity()))
if participant == nil {
return nil, ErrParticipantNotFound
}
resultChan := make(chan string, 1)
errorChan := make(chan error, 1)
participant.PerformRpc(req, resultChan, errorChan)
select {
case result := <-resultChan:
return &livekit.PerformRpcResponse{Payload: result}, nil
case err := <-errorChan:
return nil, err
}
}
func (r *RoomManager) DeleteRoom(ctx context.Context, req *livekit.DeleteRoomRequest) (*livekit.DeleteRoomResponse, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
// special case of a non-RTC room e.g. room created but no participants joined
logger.Debugw("Deleting non-rtc room, loading from roomstore")
err := r.roomStore.DeleteRoom(ctx, livekit.RoomName(req.Room))
if err != nil {
logger.Debugw("Error deleting non-rtc room", "err", err)
return nil, err
}
} else {
room.Logger().Infow("deleting room")
room.Close(types.RoomCloseReasonAPIDelete)
}
return &livekit.DeleteRoomResponse{}, nil
}
func (r *RoomManager) UpdateSubscriptions(ctx context.Context, req *livekit.UpdateSubscriptionsRequest) (*livekit.UpdateSubscriptionsResponse, error) {
room, participant, err := r.roomAndParticipantForReq(ctx, req)
if err != nil {
return nil, err
}
participant.GetLogger().Debugw("updating participant subscriptions")
room.UpdateSubscriptions(
participant,
livekit.StringsAsIDs[livekit.TrackID](req.TrackSids),
req.ParticipantTracks,
req.Subscribe,
)
return &livekit.UpdateSubscriptionsResponse{}, nil
}
func (r *RoomManager) SendData(ctx context.Context, req *livekit.SendDataRequest) (*livekit.SendDataResponse, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
room.Logger().Debugw("api send data", "size", len(req.Data))
room.SendDataPacket(&livekit.DataPacket{
Kind: req.Kind,
DestinationIdentities: req.DestinationIdentities,
Value: &livekit.DataPacket_User{
User: &livekit.UserPacket{
Payload: req.Data,
DestinationSids: req.DestinationSids,
DestinationIdentities: req.DestinationIdentities,
Topic: req.Topic,
Nonce: req.Nonce,
},
},
}, req.Kind)
return &livekit.SendDataResponse{}, nil
}
func (r *RoomManager) UpdateRoomMetadata(ctx context.Context, req *livekit.UpdateRoomMetadataRequest) (*livekit.Room, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
room.Logger().Debugw("updating room")
done := room.SetMetadata(req.Metadata)
// wait till the update is applied
<-done
return room.ToProto(), nil
}
func (r *RoomManager) ListDispatch(ctx context.Context, req *livekit.ListAgentDispatchRequest) (*livekit.ListAgentDispatchResponse, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
disp, err := room.GetAgentDispatches(req.DispatchId)
if err != nil {
return nil, err
}
ret := &livekit.ListAgentDispatchResponse{
AgentDispatches: disp,
}
return ret, nil
}
func (r *RoomManager) CreateDispatch(ctx context.Context, req *livekit.AgentDispatch) (*livekit.AgentDispatch, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
disp, err := room.AddAgentDispatch(req)
if err != nil {
return nil, err
}
return disp, nil
}
func (r *RoomManager) DeleteDispatch(ctx context.Context, req *livekit.DeleteAgentDispatchRequest) (*livekit.AgentDispatch, error) {
room := r.GetRoom(ctx, livekit.RoomName(req.Room))
if room == nil {
return nil, ErrRoomNotFound
}
disp, err := room.DeleteAgentDispatch(req.DispatchId)
if err != nil {
return nil, err
}
return disp, nil
}
func (r *RoomManager) iceServersForParticipant(apiKey string, participant types.LocalParticipant, tlsOnly bool) []*livekit.ICEServer {
var iceServers []*livekit.ICEServer
rtcConf := r.config.RTC
if tlsOnly && r.config.TURN.TLSPort == 0 {
logger.Warnw("tls only enabled but no turn tls config", nil)
tlsOnly = false
}
hasSTUN := false
if r.config.TURN.Enabled {
var urls []string
if r.config.TURN.UDPPort > 0 && !tlsOnly {
// UDP TURN is used as STUN
hasSTUN = true
for _, ip := range r.config.RTC.NodeIP.ToStringSlice() {
urls = append(urls, fmt.Sprintf("turn:%s?transport=udp", net.JoinHostPort(ip, strconv.Itoa(int(r.config.TURN.UDPPort)))))
}
}
if r.config.TURN.TLSPort > 0 {
urls = append(urls, fmt.Sprintf("turns:%s:443?transport=tcp", r.config.TURN.Domain))
}
if len(urls) > 0 {
username, expiry := r.turnAuthHandler.CreateUsername(apiKey, participant.ID(), r.config.TURN.TTLSeconds)
password, err := r.turnAuthHandler.CreatePassword(apiKey, participant.ID(), expiry)
if err != nil {
participant.GetLogger().Warnw("could not create turn password", err)
hasSTUN = false
} else {
iceServers = append(iceServers, &livekit.ICEServer{
Urls: urls,
Username: username,
Credential: password,
})
}
}
}
if len(rtcConf.TURNServers) > 0 {
hasSTUN = true
for _, s := range r.config.RTC.TURNServers {
scheme := "turn"
transport := "tcp"
switch s.Protocol {
case "tls":
scheme = "turns"
case "udp":
transport = "udp"
}
var username, credential string
if s.Secret != "" {
// Generate dynamic credentials using TURN static auth secrets.
// 0 means use the default; clamp the rest so a negative or overflowing TTL
// cannot produce a wrong-lifetime credential.
ttl := s.TTL
if ttl == 0 {
ttl = config.DefaultExternalTURNTTLSeconds
}
ttl, _ = config.ClampTURNTTLSeconds(ttl)
expiry := time.Now().Add(time.Duration(ttl) * time.Second).Unix()
participantID := string(participant.ID())
username = fmt.Sprintf("%d:%s", expiry, participantID)
// HMAC-SHA1 signature
h := hmac.New(sha1.New, []byte(s.Secret))
h.Write([]byte(username))
credential = base64.StdEncoding.EncodeToString(h.Sum(nil))
} else {
// Use static credentials
username = s.Username
credential = s.Credential
}
is := &livekit.ICEServer{
Urls: []string{
fmt.Sprintf("%s:%s:%d?transport=%s", scheme, s.Host, s.Port, transport),
},
Username: username,
Credential: credential,
}
iceServers = append(iceServers, is)
}
}
if len(rtcConf.STUNServers) > 0 {
hasSTUN = true
iceServers = append(iceServers, iceServerForStunServers(r.config.RTC.STUNServers))
}
if !hasSTUN {
iceServers = append(iceServers, iceServerForStunServers(rtcconfig.DefaultStunServers))
}
return iceServers
}
func (r *RoomManager) refreshToken(participant types.LocalParticipant) error {
key, secret, err := r.getFirstKeyPair()
if err != nil {
return err
}
grants := participant.ClaimGrants()
// Preserve the original token's expiry
validFor := tokenDefaultTTL
if expiresAt := participant.TokenExpiresAt(); !expiresAt.IsZero() {
if remaining := time.Until(expiresAt); remaining > validFor {
validFor = remaining
}
}
token := auth.NewAccessToken(key, secret)
token.SetName(grants.Name).
SetIdentity(string(participant.Identity())).
SetKind(grants.GetParticipantKind()).
SetValidFor(validFor).
SetMetadata(grants.Metadata).
SetAttributes(grants.Attributes).
SetVideoGrant(grants.Video).
SetRoomConfig(grants.GetRoomConfiguration()).
SetRoomPreset(grants.RoomPreset)
jwt, err := token.ToJWT()
if err == nil {
err = participant.SendRefreshToken(jwt)
}
if err != nil {
return err
}
return nil
}
func (r *RoomManager) setIceConfig(roomName livekit.RoomName, participant types.LocalParticipant) *livekit.ICEConfig {
iceConfig := r.getIceConfig(roomName, participant)
participant.SetICEConfig(iceConfig)
return iceConfig
}
func (r *RoomManager) getIceConfig(roomName livekit.RoomName, participant types.LocalParticipant) *livekit.ICEConfig {
return r.iceConfigCache.Get(iceConfigCacheKey{roomName, participant.Identity()})
}
func (r *RoomManager) getFirstKeyPair() (string, string, error) {
for key, secret := range r.config.Keys {
return key, secret, nil
}
return "", "", errors.New("no API keys configured")
}
// ------------------------------------
func iceServerForStunServers(servers []string) *livekit.ICEServer {
iceServer := &livekit.ICEServer{}
for _, stunServer := range servers {
iceServer.Urls = append(iceServer.Urls, fmt.Sprintf("stun:%s", stunServer))
}
return iceServer
}
// ------------------------------------
type roomManagerParticipantHelper struct {
room *rtc.Room
codecRegressionThreshold int
}
func (h *roomManagerParticipantHelper) GetParticipantInfo(pID livekit.ParticipantID) *livekit.ParticipantInfo {
if p := h.room.GetParticipantByID(pID); p != nil {
return p.ToProto()
}
return nil
}
func (h *roomManagerParticipantHelper) GetRegionSettings(ip string) *livekit.RegionSettings {
return nil
}
func (h *roomManagerParticipantHelper) GetSubscriberForwarderState(lp types.LocalParticipant) (map[livekit.TrackID]*livekit.RTPForwarderState, error) {
return nil, nil
}
func (h *roomManagerParticipantHelper) ResolveMediaTrack(lp types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult {
return h.room.ResolveMediaTrackForSubscriber(lp, trackID)
}
func (h *roomManagerParticipantHelper) ResolveDataTrack(lp types.LocalParticipant, trackID livekit.TrackID) types.DataResolverResult {
return h.room.ResolveDataTrackForSubscriber(lp, trackID)
}
func (h *roomManagerParticipantHelper) ShouldRegressCodec() bool {
return h.codecRegressionThreshold == 0 || h.room.GetParticipantCount() < h.codecRegressionThreshold
}
func (h *roomManagerParticipantHelper) GetCachedReliableDataMessage(seqs map[livekit.ParticipantID]uint32) []*types.DataMessageCache {
return h.room.GetCachedReliableDataMessage(seqs)
}