From 1f3e06107bd152438c8c1df4d8c8c80a69d75a25 Mon Sep 17 00:00:00 2001 From: David Colburn Date: Fri, 12 Jun 2026 15:17:02 -0400 Subject: [PATCH 01/44] egress v2 api (#4592) * egress v2 * reorganize --- pkg/service/egress.go | 43 ++++++++++++++++++++++++++++++++++--------- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/pkg/service/egress.go b/pkg/service/egress.go index 2e37ce4c2..697aecbb4 100644 --- a/pkg/service/egress.go +++ b/pkg/service/egress.go @@ -72,6 +72,33 @@ func NewEgressLauncher(client rpc.EgressClient, io IOClient, store ServiceStore) } } +func (s *EgressService) StartEgress(ctx context.Context, req *livekit.StartEgressRequest) (*livekit.EgressInfo, error) { + sourceType, outputType := egress.GetTypes(&livekit.EgressInfo_Egress{Egress: req}) + fields := []any{ + "room", req.RoomName, + "sourceType", sourceType, + "outputType", outputType, + } + defer func() { + AppendLogFields(ctx, fields...) + }() + + egressID, idFromCtx := EgressID(ctx) + ei, err := s.startEgress(ctx, &rpc.StartEgressRequest{ + EgressId: egressID, + Request: &rpc.StartEgressRequest_Egress{ + Egress: req, + }, + }) + if err != nil { + return nil, err + } + if !idFromCtx { + fields = append(fields, "egressID", ei.EgressId) + } + return ei, err +} + func (s *EgressService) StartRoomCompositeEgress(ctx context.Context, req *livekit.RoomCompositeEgressRequest) (*livekit.EgressInfo, error) { fields := []any{ "room", req.RoomName, @@ -230,6 +257,8 @@ func (s *egressLauncher) StartEgress(ctx context.Context, req *rpc.StartEgressRe roomName = v.TrackComposite.RoomName case *rpc.StartEgressRequest_Track: roomName = v.Track.RoomName + case *rpc.StartEgressRequest_Egress: + roomName = v.Egress.RoomName } if roomName != "" { @@ -254,13 +283,6 @@ func (s *egressLauncher) StartEgress(ctx context.Context, req *rpc.StartEgressRe return info, nil } -func (s *egressLauncher) StopEgress(ctx context.Context, req *livekit.StopEgressRequest) (*livekit.EgressInfo, error) { - if s.client == nil { - return nil, ErrEgressNotConnected - } - return s.client.StopEgress(ctx, req.EgressId, req) -} - type LayoutMetadata struct { Layout string `json:"layout"` } @@ -372,6 +394,9 @@ func (s *EgressService) StopEgress(ctx context.Context, req *livekit.StopEgressR return info, nil } -func (s *EgressService) StartEgress(ctx context.Context, req *livekit.StartEgressRequest) (*livekit.EgressInfo, error) { - return nil, errors.New("not implemented") +func (s *egressLauncher) StopEgress(ctx context.Context, req *livekit.StopEgressRequest) (*livekit.EgressInfo, error) { + if s.client == nil { + return nil, ErrEgressNotConnected + } + return s.client.StopEgress(ctx, req.EgressId, req) } From 12a023ae45b610e515f120280daf0aa2e91e6aed Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Tue, 16 Jun 2026 01:53:01 -0700 Subject: [PATCH 02/44] agent: thread attributes map from dispatch to job (#4598) * agent: thread simulation flag from dispatch to job Reads simulation from AgentDispatch / RoomAgentDispatch and copies it onto Job in agent.LaunchJob and the inline room-agent path so workers see the flag. Stacked on top of livekit/protocol#1629. * agent: replace simulation bool with attributes map Threads the renamed attributes map (was bool simulation) from dispatch to job and bumps the protocol pseudo-version. * deps --- go.mod | 2 +- go.sum | 4 ++-- pkg/agent/client.go | 2 ++ pkg/rtc/room.go | 3 +++ pkg/service/agent_dispatch_service.go | 1 + 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cd376e81e..1f3f4ead3 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051 + github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 454ef37c0..f5753898a 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051 h1:IYqiW7z5pblZBn6o0OHNz8MHd3wJ/TLJG4gh6lCI0/s= -github.com/livekit/protocol v1.46.7-0.20260611165352-04a0fe5b5051/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4 h1:5YChJaYTXdVsz7zIZ12vOY2ToLVq3Bzs8xZxmv5SA+0= +github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= diff --git a/pkg/agent/client.go b/pkg/agent/client.go index 51129d033..10c0abbed 100644 --- a/pkg/agent/client.go +++ b/pkg/agent/client.go @@ -64,6 +64,7 @@ type JobRequest struct { Metadata string AgentName string Deployment string + Attributes map[string]string } type agentClient struct { @@ -172,6 +173,7 @@ func (c *agentClient) LaunchJob(ctx context.Context, desc *JobRequest) *serverut Metadata: desc.Metadata, EnableRecording: c.config.EnableUserDataRecording, Deployment: desc.Deployment, + Attributes: desc.Attributes, } resp, err := c.client.JobRequest(context.Background(), topic, jobTypeTopic, job) if err != nil { diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 2062b5179..86ddcd05e 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1779,6 +1779,7 @@ func (r *Room) launchRoomAgents(ads []*agentDispatch) { AgentName: ad.AgentName, DispatchId: ad.Id, Deployment: ad.Deployment, + Attributes: ad.Attributes, }) r.handleNewJobs(ad.AgentDispatch, inc) done() @@ -1803,6 +1804,7 @@ func (r *Room) launchTargetAgents(ads []*agentDispatch, p types.Participant, job AgentName: ad.AgentName, DispatchId: ad.Id, Deployment: ad.Deployment, + Attributes: ad.Attributes, }) r.handleNewJobs(ad.AgentDispatch, inc) done() @@ -1869,6 +1871,7 @@ func (r *Room) createAgentDispatchFromRoomDispatch(rad *livekit.RoomAgentDispatc Room: r.protoRoom.Name, RestartPolicy: rad.GetRestartPolicy(), Deployment: rad.GetDeployment(), + Attributes: rad.GetAttributes(), }) } diff --git a/pkg/service/agent_dispatch_service.go b/pkg/service/agent_dispatch_service.go index b261c2d4b..49c820c52 100644 --- a/pkg/service/agent_dispatch_service.go +++ b/pkg/service/agent_dispatch_service.go @@ -79,6 +79,7 @@ func (ag *AgentDispatchService) CreateDispatch(ctx context.Context, req *livekit Metadata: req.Metadata, RestartPolicy: req.RestartPolicy, Deployment: req.Deployment, + Attributes: req.Attributes, } return ag.agentDispatchClient.CreateDispatch(ctx, ag.topicFormatter.RoomTopic(ctx, livekit.RoomName(req.Room)), dispatch) } From 67ca7a12cfda45d505665abd4f34504ec952d61c Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 17 Jun 2026 20:43:29 +0530 Subject: [PATCH 03/44] Record more RTC cancellation points. (#4600) There are several places the participant can drop off after initiating a connection attempt. Count those places as cancellation including when participant is closed due to specific reasons. Cancels should be discounted when determining RTC/ICE connectivity success/failure percentage. --- pkg/rtc/participant.go | 18 ++++++++++++++++++ pkg/service/roommanager.go | 9 +++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index f5f459e4e..1034c2275 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1413,12 +1413,30 @@ func (p *ParticipantImpl) IsReconnect() bool { return p.params.Reconnect } +func (p *ParticipantImpl) maybeRecordRTCanceled(closeReason types.ParticipantCloseReason) { + if p.State() >= livekit.ParticipantInfo_ACTIVE { + return + } + + if closeReason == types.ParticipantCloseReasonClientRequestLeave || + closeReason == types.ParticipantCloseReasonDuplicateIdentity || + closeReason == types.ParticipantCloseReasonRoomClosed || + closeReason == types.ParticipantCloseReasonMigrationRequested || + closeReason == types.ParticipantCloseReasonMigrationComplete || + // client closing signal connection too quickly, there is a time check to handle clients timing out and leaving without sending a leave message + (time.Since(p.params.SessionStartTime) < 3*time.Second && closeReason == types.ParticipantCloseReasonSignalSourceClose) { + prometheus.IncrementParticipantRtcCanceled(1) + } +} + func (p *ParticipantImpl) Close(sendLeave bool, reason types.ParticipantCloseReason, isExpectedToResume bool) error { if p.isClosed.Swap(true) { // already closed return nil } + p.maybeRecordRTCanceled(reason) + var sessionDuration time.Duration if activeAt := p.ActiveAt(); !activeAt.IsZero() { sessionDuration = time.Since(activeAt) diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 172a7752e..921c73e8c 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -296,6 +296,9 @@ func (r *RoomManager) StartSession( createRoom := pi.CreateRoom room, err := r.getOrCreateRoom(ctx, createRoom) if err != nil { + if pi.Identity != "" { + prometheus.IncrementParticipantRtcCanceled(1) + } return err } defer room.Release() @@ -371,9 +374,11 @@ func (r *RoomManager) StartSession( pi.ReconnectReason, ); err != nil { participant.GetLogger().Warnw("could not resume participant", err) + prometheus.IncrementParticipantRtcCanceled(1) return err } r.telemetry.ParticipantResumed(ctx, room.ToProto(), participant.ToProto(), r.currentNode.NodeID(), pi.ReconnectReason) + prometheus.IncrementParticipantRtcActive(1) go room.HandleSyncState(participant, pi.SyncState) @@ -527,6 +532,7 @@ func (r *RoomManager) StartSession( EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection, }) if err != nil { + prometheus.IncrementParticipantRtcCanceled(1) return err } iceConfig := r.setIceConfig(room.Name(), participant) @@ -542,6 +548,7 @@ func (r *RoomManager) StartSession( 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) return err } @@ -553,6 +560,7 @@ func (r *RoomManager) StartSession( participantServerClosers.Close() pLogger.Errorw("could not join register participant topic", err) _ = participant.Close(true, types.ParticipantCloseReasonMessageBusFailed, false) + prometheus.IncrementParticipantRtcCanceled(1) return err } @@ -563,6 +571,7 @@ func (r *RoomManager) StartSession( 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) return err } } From e7c63aa537dcde5ee00c7813af91e0265440d313 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 18 Jun 2026 00:08:39 +0530 Subject: [PATCH 04/44] Log subscription limit breaches (#4603) --- pkg/rtc/subscriptionmanager.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index d421d0805..566c8b91b 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -728,11 +728,13 @@ func (m *SubscriptionManager) hasCapacityForSubscription(kind livekit.TrackType) switch kind { case livekit.TrackType_VIDEO: if m.params.SubscriptionLimitVideo > 0 && m.subscribedVideoCount.Load() >= m.params.SubscriptionLimitVideo { + m.params.Logger.Infow("subcription limit exceeded for video", "limit", m.params.SubscriptionLimitVideo, "subscriptions", m.subscribedVideoCount.Load()) return false } case livekit.TrackType_AUDIO: if m.params.SubscriptionLimitAudio > 0 && m.subscribedAudioCount.Load() >= m.params.SubscriptionLimitAudio { + m.params.Logger.Infow("subcription limit exceeded for audio", "limit", m.params.SubscriptionLimitAudio, "subscriptions", m.subscribedAudioCount.Load()) return false } } From b882ccc86d6d12133bd95f758444020f48e059f2 Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Wed, 17 Jun 2026 12:35:59 -0700 Subject: [PATCH 05/44] service: cap all metadata at 512 KiB; enforce on join, agent dispatch, and embedded agents (#4602) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * service: enforce metadata size limit in CreateRoom, bump default to 512 KiB CreateRoom previously accepted any metadata size; only UpdateRoomMetadata rejected oversized payloads. Mirror the same CheckMetadataSize check at the CreateRoom API boundary so both entrypoints are bounded. Default MaxMetadataSize moves from 64000 to 512 * 1024 to match the practical needs of customers using room metadata for richer state. The limit remains configurable via the existing limits.max_metadata_size knob. * service: split room vs. participant metadata limit, enforce on join + agent dispatch LimitConfig.MaxMetadataSize was shared between room metadata and participant metadata. Last commit's bump to 512 KiB lifted both ceilings; this restores the participant ceiling to 64 KB and introduces a separate MaxRoomMetadataSize (default 512 KiB) for room metadata. Additional enforcement: - RoomManager.StartSession rejects joins whose JWT-grants metadata or attributes exceed the participant/attributes limits. The check was missing entirely from this path. - AgentDispatchService.CreateDispatch and the embedded CreateRoomRequest.Agents path now validate metadata and attributes against the common 64 KB ceilings (previously unbounded). NewAgentDispatchService gains a LimitConfig parameter; the two wire_gen callsites are updated. * service: collapse metadata size limit to single 512 KiB knob Reverts the LimitConfig split introduced in the previous commit: MaxRoomMetadataSize, CheckRoomMetadataSize, and the max_room_metadata_size yaml key are removed. MaxMetadataSize moves back to 512 * 1024 and gates all metadata uniformly — room (CreateRoom, UpdateRoomMetadata), participant (UpdateParticipant, signal UpdateMetadata, JWT grants on join), and agent dispatch (CreateDispatch + embedded RoomAgentDispatch). MaxAttributesSize stays at 64 KB and continues to gate participant and agent-dispatch attributes separately. Test cases consolidated under the single knob. * kb -> kib --- pkg/config/config.go | 4 +- pkg/service/agent_dispatch_service.go | 11 +++ pkg/service/roommanager.go | 9 ++ pkg/service/roomservice.go | 18 +++- pkg/service/roomservice_test.go | 119 ++++++++++++++++++++------ pkg/service/wire_gen.go | 2 +- 6 files changed, 133 insertions(+), 30 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 692c0af38..c77d7d241 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -440,8 +440,8 @@ var DefaultConfig = Config{ UpdateBatchTargetSize: 128 * 1024, }, Limit: LimitConfig{ - MaxMetadataSize: 64000, - MaxAttributesSize: 64000, + MaxMetadataSize: 512 * 1024, + MaxAttributesSize: 64 * 1024, MaxRoomNameLength: 256, MaxParticipantIdentityLength: 256, MaxParticipantNameLength: 256, diff --git a/pkg/service/agent_dispatch_service.go b/pkg/service/agent_dispatch_service.go index 49c820c52..b15bc55a3 100644 --- a/pkg/service/agent_dispatch_service.go +++ b/pkg/service/agent_dispatch_service.go @@ -18,6 +18,7 @@ import ( "context" "fmt" + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/protocol/agent" "github.com/livekit/protocol/livekit" @@ -29,6 +30,7 @@ import ( ) type AgentDispatchService struct { + limitConf config.LimitConfig agentDispatchClient rpc.TypedAgentDispatchInternalClient topicFormatter rpc.TopicFormatter roomAllocator RoomAllocator @@ -36,12 +38,14 @@ type AgentDispatchService struct { } func NewAgentDispatchService( + limitConf config.LimitConfig, agentDispatchClient rpc.TypedAgentDispatchInternalClient, topicFormatter rpc.TopicFormatter, roomAllocator RoomAllocator, router routing.MessageRouter, ) *AgentDispatchService { return &AgentDispatchService{ + limitConf: limitConf, agentDispatchClient: agentDispatchClient, topicFormatter: topicFormatter, roomAllocator: roomAllocator, @@ -60,6 +64,13 @@ func (ag *AgentDispatchService) CreateDispatch(ctx context.Context, req *livekit return nil, psrpc.NewError(psrpc.InvalidArgument, err) } + if !ag.limitConf.CheckMetadataSize(req.Metadata) { + return nil, ErrMetadataExceedsLimits + } + if !ag.limitConf.CheckAttributesSize(req.Attributes) { + return nil, ErrAttributeExceedsLimits + } + if ag.roomAllocator.AutoCreateEnabled(ctx) { err := ag.roomAllocator.SelectRoomNode(ctx, livekit.RoomName(req.Room), "") if err != nil { diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 921c73e8c..13ca67493 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -293,6 +293,15 @@ func (r *RoomManager) StartSession( ) 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 { diff --git a/pkg/service/roomservice.go b/pkg/service/roomservice.go index 3579eecc2..2cdf373a2 100644 --- a/pkg/service/roomservice.go +++ b/pkg/service/roomservice.go @@ -84,6 +84,19 @@ func (s *RoomService) CreateRoom(ctx context.Context, req *livekit.CreateRoomReq return nil, fmt.Errorf("%w: max length %d", ErrRoomNameExceedsLimits, s.limitConf.MaxRoomNameLength) } + if !s.limitConf.CheckMetadataSize(req.Metadata) { + return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize))) + } + + for _, ad := range req.Agents { + if !s.limitConf.CheckMetadataSize(ad.Metadata) { + return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize))) + } + if !s.limitConf.CheckAttributesSize(ad.Attributes) { + return nil, twirp.InvalidArgumentError(ErrAttributeExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxAttributesSize))) + } + } + err := s.roomAllocator.SelectRoomNode(ctx, livekit.RoomName(req.Name), livekit.NodeID(req.NodeId)) if err != nil { return nil, err @@ -320,9 +333,8 @@ func (s *RoomService) UpdateRoomMetadata(ctx context.Context, req *livekit.Updat RecordRequest(ctx, req) AppendLogFields(ctx, "room", req.Room, "size", len(req.Metadata)) - maxMetadataSize := int(s.limitConf.MaxMetadataSize) - if maxMetadataSize > 0 && len(req.Metadata) > maxMetadataSize { - return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(maxMetadataSize)) + if !s.limitConf.CheckMetadataSize(req.Metadata) { + return nil, twirp.InvalidArgumentError(ErrMetadataExceedsLimits.Error(), strconv.Itoa(int(s.limitConf.MaxMetadataSize))) } if err := EnsureAdminPermission(ctx, livekit.RoomName(req.Room)); err != nil { diff --git a/pkg/service/roomservice_test.go b/pkg/service/roomservice_test.go index d34d5103e..5b27f2fdf 100644 --- a/pkg/service/roomservice_test.go +++ b/pkg/service/roomservice_test.go @@ -47,43 +47,72 @@ func TestDeleteRoom(t *testing.T) { } func TestMetaDataLimits(t *testing.T) { - t.Run("metadata exceed limits", func(t *testing.T) { + adminCtx := func() context.Context { + return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{}}, "") + } + createCtx := func() context.Context { + return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{RoomCreate: true}}, "") + } + requireInvalidArg := func(t *testing.T, err error) { + t.Helper() + terr, ok := err.(twirp.Error) + require.True(t, ok, "expected twirp error, got %T (%v)", err, err) + require.Equal(t, twirp.InvalidArgument, terr.Code()) + } + + t.Run("metadata exceeds limit", func(t *testing.T) { svc := newTestRoomService(config.LimitConfig{MaxMetadataSize: 5}) - grant := &auth.ClaimGrants{ - Video: &auth.VideoGrant{}, - } - ctx := service.WithGrants(context.Background(), grant, "") - _, err := svc.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{ + + _, err := svc.UpdateParticipant(adminCtx(), &livekit.UpdateParticipantRequest{ Room: "testroom", Identity: "123", Metadata: "abcdefg", }) - terr, ok := err.(twirp.Error) - require.True(t, ok) - require.Equal(t, twirp.InvalidArgument, terr.Code()) + requireInvalidArg(t, err) - _, err = svc.UpdateRoomMetadata(ctx, &livekit.UpdateRoomMetadataRequest{ + _, err = svc.UpdateRoomMetadata(adminCtx(), &livekit.UpdateRoomMetadataRequest{ Room: "testroom", Metadata: "abcdefg", }) - terr, ok = err.(twirp.Error) - require.True(t, ok) - require.Equal(t, twirp.InvalidArgument, terr.Code()) + requireInvalidArg(t, err) + + _, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{ + Name: "testroom", + Metadata: "abcdefg", + }) + requireInvalidArg(t, err) + + _, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{ + Name: "testroom", + Agents: []*livekit.RoomAgentDispatch{ + {AgentName: "bot", Metadata: "abcdefg"}, + }, + }) + requireInvalidArg(t, err) + }) + + t.Run("embedded agent dispatch in CreateRoom exceeds attributes limit", func(t *testing.T) { + svc := newTestRoomService(config.LimitConfig{MaxAttributesSize: 5}) + _, err := svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{ + Name: "testroom", + Agents: []*livekit.RoomAgentDispatch{ + {AgentName: "bot", Attributes: map[string]string{"key": "abcdefg"}}, + }, + }) + requireInvalidArg(t, err) }) notExceedsLimitsSvc := map[string]*TestRoomService{ - "metadata exceeds limits": newTestRoomService(config.LimitConfig{MaxMetadataSize: 5}), - "metadata no limits": newTestRoomService(config.LimitConfig{}), // no limits + "metadata exceeds limits": newTestRoomService(config.LimitConfig{ + MaxMetadataSize: 5, + MaxAttributesSize: 5, + }), + "metadata no limits": newTestRoomService(config.LimitConfig{}), } - for n, s := range notExceedsLimitsSvc { - svc := s + for n, svc := range notExceedsLimitsSvc { t.Run(n, func(t *testing.T) { - grant := &auth.ClaimGrants{ - Video: &auth.VideoGrant{}, - } - ctx := service.WithGrants(context.Background(), grant, "") - _, err := svc.UpdateParticipant(ctx, &livekit.UpdateParticipantRequest{ + _, err := svc.UpdateParticipant(adminCtx(), &livekit.UpdateParticipantRequest{ Room: "testroom", Identity: "123", Metadata: "abc", @@ -92,18 +121,60 @@ func TestMetaDataLimits(t *testing.T) { require.True(t, ok) require.NotEqual(t, twirp.InvalidArgument, terr.Code()) - _, err = svc.UpdateRoomMetadata(ctx, &livekit.UpdateRoomMetadataRequest{ + _, err = svc.UpdateRoomMetadata(adminCtx(), &livekit.UpdateRoomMetadataRequest{ Room: "testroom", Metadata: "abc", }) terr, ok = err.(twirp.Error) require.True(t, ok) require.NotEqual(t, twirp.InvalidArgument, terr.Code()) - }) + _, err = svc.CreateRoom(createCtx(), &livekit.CreateRoomRequest{ + Name: "testroom", + Metadata: "abc", + Agents: []*livekit.RoomAgentDispatch{ + {AgentName: "bot", Metadata: "abc", Attributes: map[string]string{"k": "v"}}, + }, + }) + if err != nil { + terr, ok = err.(twirp.Error) + require.True(t, ok) + require.NotEqual(t, twirp.InvalidArgument, terr.Code()) + } + }) } } +func TestAgentDispatchMetadataLimits(t *testing.T) { + ctx := service.WithGrants(context.Background(), &auth.ClaimGrants{ + Video: &auth.VideoGrant{Room: "testroom", RoomAdmin: true}, + }, "") + + t.Run("metadata exceeds limits", func(t *testing.T) { + svc := newTestAgentDispatchService(config.LimitConfig{MaxMetadataSize: 5}) + _, err := svc.CreateDispatch(ctx, &livekit.CreateAgentDispatchRequest{ + Room: "testroom", + Metadata: "abcdefg", + }) + require.ErrorIs(t, err, service.ErrMetadataExceedsLimits) + }) + + t.Run("attributes exceeds limits", func(t *testing.T) { + svc := newTestAgentDispatchService(config.LimitConfig{MaxAttributesSize: 5}) + _, err := svc.CreateDispatch(ctx, &livekit.CreateAgentDispatchRequest{ + Room: "testroom", + Attributes: map[string]string{"key": "abcdefg"}, + }) + require.ErrorIs(t, err, service.ErrAttributeExceedsLimits) + }) +} + +func newTestAgentDispatchService(limitConf config.LimitConfig) *service.AgentDispatchService { + allocator := &servicefakes.FakeRoomAllocator{} + allocator.AutoCreateEnabledReturns(false) + return service.NewAgentDispatchService(limitConf, nil, rpc.NewTopicFormatter(), allocator, &routingfakes.FakeRouter{}) +} + func newTestRoomService(limitConf config.LimitConfig) *TestRoomService { router := &routingfakes.FakeRouter{} allocator := &servicefakes.FakeRoomAllocator{} diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 45ebcf4d2..b64fbb063 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -102,7 +102,7 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - agentDispatchService := NewAgentDispatchService(agentDispatchInternalClient, topicFormatter, roomAllocator, router) + agentDispatchService := NewAgentDispatchService(limitConfig, agentDispatchInternalClient, topicFormatter, roomAllocator, router) egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService) ingressConfig := getIngressConfig(conf) ingressClient, err := rpc.NewIngressClient(clientParams) From c6303bb15a17d2dc3e36a55f49f45fe80ac1cf5c Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 18 Jun 2026 11:58:39 +0530 Subject: [PATCH 06/44] Fix skipped packets accounting. (#4604) * Fix skipped packets accounting. No need to copy unskipped packet RTP header to skipped packet. That was causing padding bytes to be counted. Also use Header.PaddingSize as base PaddingSize is deprecated. * PaddingSize in header in utils --- pkg/sfu/buffer/buffer_base.go | 27 +++++++++++---------------- pkg/sfu/testutils/data.go | 4 ++-- 2 files changed, 13 insertions(+), 18 deletions(-) diff --git a/pkg/sfu/buffer/buffer_base.go b/pkg/sfu/buffer/buffer_base.go index b94326cff..68980ea05 100644 --- a/pkg/sfu/buffer/buffer_base.go +++ b/pkg/sfu/buffer/buffer_base.go @@ -742,25 +742,20 @@ func (b *BufferBase) HandleIncomingPacketLocked( b.processAudioSsrcLevelHeaderExtension(rtpPacket, arrivalTime) if len(skippedSeqs) > 0 { - skippedRtpPkt := rtp.Packet{ - Header: rtpPacket.Header, - } - skippedRtpPkt.Marker = false // Use the current highest timestamp to prevent the case of old sequence number and newer timestamp. // It is possible that the skipped packet is older. An example sequence // - Packet 10, skipped 6, 7, 9 -> Packet 8 is unknown at this point - // - Packet 11, skipped 8 -> this would cause sequence number be older, but using timestamp from Packet 11 will make time stamp diff +ve - skippedRtpPkt.Timestamp = b.rtpStats.HighestTimestamp() + // - Packet 11, skipped 8 -> this would cause sequence number to be older, but using timestamp from Packet 11 will make time stamp diff +ve + ts := b.rtpStats.HighestTimestamp() for _, sn := range skippedSeqs { - skippedRtpPkt.SequenceNumber = sn flowState := b.rtpStats.Update( arrivalTime, - skippedRtpPkt.Header.SequenceNumber, - skippedRtpPkt.Header.Timestamp, - skippedRtpPkt.Header.Marker, - skippedRtpPkt.Header.MarshalSize(), - len(skippedRtpPkt.Payload), - int(skippedRtpPkt.PaddingSize), + sn, + ts, + false, // no marker + 0, // no header for skipped packet, so 0 size + 0, // no payload + 0, // no padding ) if flowState.UnhandledReason == rtpstats.RTPFlowUnhandledReasonNone && !flowState.IsOutOfOrder { if err := b.snRangeMap.ExcludeRange(flowState.ExtSequenceNumber, flowState.ExtSequenceNumber+1); err != nil { @@ -790,7 +785,7 @@ func (b *BufferBase) HandleIncomingPacketLocked( rtpPacket.Header.Marker, rtpPacket.Header.MarshalSize(), len(rtpPacket.Payload), - int(rtpPacket.PaddingSize), + int(rtpPacket.Header.PaddingSize), ) switch flowState.UnhandledReason { case rtpstats.RTPFlowUnhandledReasonNone: @@ -808,7 +803,7 @@ func (b *BufferBase) HandleIncomingPacketLocked( rtpPacket.Header.Marker, rtpPacket.Header.MarshalSize(), len(rtpPacket.Payload), - int(rtpPacket.PaddingSize), + int(rtpPacket.Header.PaddingSize), ) default: return 0, fmt.Errorf("unhandled reason: %s", flowState.UnhandledReason.String()) @@ -866,7 +861,7 @@ func (b *BufferBase) HandleIncomingPacketLocked( "timestamp", rtpPacket.Timestamp, "extTimestamp", flowState.ExtTimestamp, "payloadSize", len(rtpPacket.Payload), - "paddingSize", rtpPacket.PaddingSize, + "paddingSize", rtpPacket.Header.PaddingSize, "rtpStats", b.rtpStats, "rtpStatsLite", b.rtpStatsLite, "snRangeMap", b.snRangeMap, diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index 34b1fca62..9b8a1386e 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -54,9 +54,9 @@ func GetTestExtPacket(params *TestExtPacketParams) (*buffer.ExtPacket, error) { SequenceNumber: params.SequenceNumber, Timestamp: params.Timestamp, SSRC: params.SSRC, + PaddingSize: params.PaddingSize, }, - Payload: make([]byte, params.PayloadSize), - PaddingSize: params.PaddingSize, + Payload: make([]byte, params.PayloadSize), } raw, err := packet.Marshal() From 35b5390c278274f0da8b3796c07e85741c45524d Mon Sep 17 00:00:00 2001 From: Denys Smirnov Date: Thu, 18 Jun 2026 13:29:21 +0200 Subject: [PATCH 07/44] Update protocol. (#4601) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1f3f4ead3..4b561b67d 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4 + github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index f5753898a..3ea04b90f 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4 h1:5YChJaYTXdVsz7zIZ12vOY2ToLVq3Bzs8xZxmv5SA+0= -github.com/livekit/protocol v1.46.9-0.20260616084954-6bd536c5c6d4/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b h1:7gH58XkJ0wtU+5d33Jkktmf4I3heKjQzDhwYHDSGmTo= +github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= From 9a7fe3cc689febe6f0e0558b90a04444c14cc4bf Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 18 Jun 2026 20:08:52 +0530 Subject: [PATCH 08/44] Do not log due to negative getting interpreted as large unsigned positive (#4605) --- pkg/sfu/connectionquality/scorer.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/connectionquality/scorer.go b/pkg/sfu/connectionquality/scorer.go index b415f75a0..471b291b6 100644 --- a/pkg/sfu/connectionquality/scorer.go +++ b/pkg/sfu/connectionquality/scorer.go @@ -482,7 +482,11 @@ func (q *qualityScorer) updateAtLocked(stat *windowStat, at time.Time) { ulgr.Debugw("quality rise") default: packets := stat.packets + stat.packetsPadding - if packets != 0 && ((stat.packetsLost-stat.packetsMissing-stat.packetsOutOfOrder)*100/packets) > 10 { + lost := stat.packetsLost - stat.packetsMissing - stat.packetsOutOfOrder + if int32(lost) < 0 { + lost = 0 + } + if packets != 0 && lost*100/packets > 10 { ulgr.Debugw("quality hold - high loss") } } From a011d995da758fd6016932a9a3c9bbacfa62bee4 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 18 Jun 2026 23:24:33 +0530 Subject: [PATCH 09/44] Do not call nil callback (#4607) --- pkg/rtc/participant.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 1034c2275..e65733d28 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -966,7 +966,9 @@ func (p *ParticipantImpl) GetTelemetryListener() types.ParticipantTelemetryListe func (p *ParticipantImpl) AddOnClose(key string, callback func(types.LocalParticipant)) { if p.isClosed.Load() { - go callback(p) + if callback != nil { + go callback(p) + } return } From cfedcc71d04180d6f76833940ec9f4f2e9f7bcaf Mon Sep 17 00:00:00 2001 From: CloudWebRTC Date: Fri, 19 Jun 2026 20:12:54 +0800 Subject: [PATCH 10/44] feat: acquire requested video layer directly at HIGH quality by default (#4595) * feat: acquire requested video layer directly at HIGH quality by default Two changes that together remove the visible low->high quality ramp for a new subscriber (both publisher-first and subscriber-first join orders): 1. Default a subscriber's initial video quality to HIGH on bind instead of LOW for adaptive stream, so the subscribed max layer is the top layer. Adaptive stream clients can still scale down afterwards based on viewport. 2. On initial layer acquisition the forwarder/selector latch directly onto the allocator's target (the requested top layer) instead of opportunistically latching onto the first lower key frame that arrives. A short initial-acquisition grace aims the target at the requested layer; if it does not show up in time, the target falls back to the highest layer seen so acquisition never stalls. Always on - no configuration flag. Co-Authored-By: Claude Opus 4.8 (1M context) * feat: gate start-at-desired-quality behind EnableStartAtDesiredQuality flag Put the "acquire requested video layer directly at HIGH quality" behavior behind a per-subscriber EnableStartAtDesiredQuality flag (default off, so the original low->high ramp-up is restored unless enabled). Plumbed from config.RTC.EnableStartAtDesiredQuality through ParticipantParams -> SubscribedTrack/DownTrack -> Forwarder -> simulcast selector, gating all three behavior changes: the HIGH default on bind, the forwarder's initial-acquisition grace, and the selector's direct-latch-onto-target. Co-Authored-By: Claude Opus 4.8 (1M context) * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * remove config. --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/rtc/mediatracksubscriptions.go | 19 ++--- pkg/rtc/participant.go | 5 ++ pkg/rtc/subscribedtrack.go | 9 ++- pkg/rtc/types/interfaces.go | 1 + .../typesfakes/fake_local_participant.go | 63 +++++++++++++++ pkg/sfu/downtrack.go | 11 +++ pkg/sfu/forwarder.go | 65 +++++++++++++++- pkg/sfu/forwarder_test.go | 42 ++++++++++ pkg/sfu/videolayerselector/base.go | 8 ++ pkg/sfu/videolayerselector/simulcast.go | 32 ++++++-- pkg/sfu/videolayerselector/simulcast_test.go | 77 +++++++++++++++++++ .../videolayerselector/videolayerselector.go | 1 + 12 files changed, 314 insertions(+), 19 deletions(-) create mode 100644 pkg/sfu/videolayerselector/simulcast_test.go diff --git a/pkg/rtc/mediatracksubscriptions.go b/pkg/rtc/mediatracksubscriptions.go index 69575fe6b..c8720eade 100644 --- a/pkg/rtc/mediatracksubscriptions.go +++ b/pkg/rtc/mediatracksubscriptions.go @@ -104,15 +104,16 @@ func (t *MediaTrackSubscriptions) AddSubscriber(sub types.LocalParticipant, wr * t.subscribedTracksMu.Unlock() subTrack, err := NewSubscribedTrack(SubscribedTrackParams{ - ReceiverConfig: t.params.ReceiverConfig, - SubscriberConfig: t.params.SubscriberConfig, - Subscriber: sub, - MediaTrack: t.params.MediaTrack, - AdaptiveStream: sub.GetAdaptiveStream(), - TelemetryListener: sub.GetTelemetryListener(), - WrappedReceiver: wr, - IsRelayed: t.params.IsRelayed, - OnDownTrackCreated: t.onDownTrackCreated, + 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) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index e65733d28..ebe59cc58 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -225,6 +225,7 @@ type ParticipantParams struct { EnableRTPStreamRestartDetection bool ForceBackupCodecPolicySimulcast bool DisableTransceiverReuseForE2EE bool + EnableStartAtDesiredQuality bool } type ParticipantImpl struct { @@ -505,6 +506,10 @@ func (p *ParticipantImpl) GetAdaptiveStream() bool { return p.params.AdaptiveStream } +func (p *ParticipantImpl) GetEnableStartAtDesiredQuality() bool { + return p.params.EnableStartAtDesiredQuality +} + func (p *ParticipantImpl) GetPacer() pacer.Pacer { return p.TransportManager.GetSubscriberPacer() } diff --git a/pkg/rtc/subscribedtrack.go b/pkg/rtc/subscribedtrack.go index 3a8a06148..9453bdf7d 100644 --- a/pkg/rtc/subscribedtrack.go +++ b/pkg/rtc/subscribedtrack.go @@ -48,6 +48,7 @@ type SubscribedTrackParams struct { Subscriber types.LocalParticipant MediaTrack types.MediaTrack AdaptiveStream bool + EnableStartAtDesiredQuality bool TelemetryListener types.ParticipantTelemetryListener WrappedReceiver *WrappedReceiver IsRelayed bool @@ -154,6 +155,7 @@ func NewSubscribedTrack(params SubscribedTrackParams) (*SubscribedTrack, error) RTCPWriter: params.Subscriber.WriteSubscriberRTCP, DisableSenderReportPassThrough: params.Subscriber.GetDisableSenderReportPassThrough(), SupportsCodecChange: params.Subscriber.SupportsCodecChange(), + EnableStartAtDesiredQuality: params.EnableStartAtDesiredQuality, Listener: s, }) if err != nil { @@ -212,7 +214,12 @@ func (t *SubscribedTrack) Bound(err error) { t.logger.Debugw("enabling subscriber track settings on bind", "settings", logger.Proto(t.settings)) } } else { - if t.params.AdaptiveStream { + 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} diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 194f529dd..ec10fb396 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -403,6 +403,7 @@ type LocalParticipant interface { GetReporter() roomobs.ParticipantSessionReporter GetReporterResolver() roomobs.ParticipantReporterResolver GetAdaptiveStream() bool + GetEnableStartAtDesiredQuality() bool ProtocolVersion() ProtocolVersion SupportsSyncStreamID() bool SupportsTransceiverReuse(mt MediaTrack) bool diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index 76c32d76b..9570a28c2 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -325,6 +325,16 @@ type FakeLocalParticipant struct { getDisableSenderReportPassThroughReturnsOnCall map[int]struct { result1 bool } + GetEnableStartAtDesiredQualityStub func() bool + getEnableStartAtDesiredQualityMutex sync.RWMutex + getEnableStartAtDesiredQualityArgsForCall []struct { + } + getEnableStartAtDesiredQualityReturns struct { + result1 bool + } + getEnableStartAtDesiredQualityReturnsOnCall map[int]struct { + result1 bool + } GetEnabledPublishCodecsStub func() []*livekit.Codec getEnabledPublishCodecsMutex sync.RWMutex getEnabledPublishCodecsArgsForCall []struct { @@ -3079,6 +3089,59 @@ func (fake *FakeLocalParticipant) GetDisableSenderReportPassThroughReturnsOnCall }{result1} } +func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQuality() bool { + fake.getEnableStartAtDesiredQualityMutex.Lock() + ret, specificReturn := fake.getEnableStartAtDesiredQualityReturnsOnCall[len(fake.getEnableStartAtDesiredQualityArgsForCall)] + fake.getEnableStartAtDesiredQualityArgsForCall = append(fake.getEnableStartAtDesiredQualityArgsForCall, struct { + }{}) + stub := fake.GetEnableStartAtDesiredQualityStub + fakeReturns := fake.getEnableStartAtDesiredQualityReturns + fake.recordInvocation("GetEnableStartAtDesiredQuality", []interface{}{}) + fake.getEnableStartAtDesiredQualityMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCallCount() int { + fake.getEnableStartAtDesiredQualityMutex.RLock() + defer fake.getEnableStartAtDesiredQualityMutex.RUnlock() + return len(fake.getEnableStartAtDesiredQualityArgsForCall) +} + +func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityCalls(stub func() bool) { + fake.getEnableStartAtDesiredQualityMutex.Lock() + defer fake.getEnableStartAtDesiredQualityMutex.Unlock() + fake.GetEnableStartAtDesiredQualityStub = stub +} + +func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturns(result1 bool) { + fake.getEnableStartAtDesiredQualityMutex.Lock() + defer fake.getEnableStartAtDesiredQualityMutex.Unlock() + fake.GetEnableStartAtDesiredQualityStub = nil + fake.getEnableStartAtDesiredQualityReturns = struct { + result1 bool + }{result1} +} + +func (fake *FakeLocalParticipant) GetEnableStartAtDesiredQualityReturnsOnCall(i int, result1 bool) { + fake.getEnableStartAtDesiredQualityMutex.Lock() + defer fake.getEnableStartAtDesiredQualityMutex.Unlock() + fake.GetEnableStartAtDesiredQualityStub = nil + if fake.getEnableStartAtDesiredQualityReturnsOnCall == nil { + fake.getEnableStartAtDesiredQualityReturnsOnCall = make(map[int]struct { + result1 bool + }) + } + fake.getEnableStartAtDesiredQualityReturnsOnCall[i] = struct { + result1 bool + }{result1} +} + func (fake *FakeLocalParticipant) GetEnabledPublishCodecs() []*livekit.Codec { fake.getEnabledPublishCodecsMutex.Lock() ret, specificReturn := fake.getEnabledPublishCodecsReturnsOnCall[len(fake.getEnabledPublishCodecsArgsForCall)] diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 38cf31569..b1a516799 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -312,6 +312,7 @@ type DownTrackParams struct { DisableSenderReportPassThrough bool SupportsCodecChange bool StripPacketTrailer bool + EnableStartAtDesiredQuality bool Listener DownTrackListener } @@ -463,6 +464,7 @@ func NewDownTrack(params DownTrackParams) (*DownTrack, error) { d.params.Logger, false, // skipReferenceTS false, // disableOpportunisticAllocation + d.params.EnableStartAtDesiredQuality, d.rtpStats, ) @@ -1026,6 +1028,15 @@ func (d *DownTrack) keyFrameRequester() { d.Receiver().SendPLI(layer, false) d.rtpStats.UpdateLayerLockPliAndTime(1) } + + // if the initial-acquisition grace expired without latching the requested layer, force a + // re-allocation so the target falls back to the highest layer actually seen (rather than + // stalling while waiting for a requested layer that never showed up) + if d.forwarder.MaybeExpireAcquireGrace() { + if sal := d.getStreamAllocatorListener(); sal != nil { + sal.OnAvailableLayersChanged(d) + } + } } } diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 4fac35962..3bfae7d4b 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -52,6 +52,15 @@ const ( ResumeBehindHighThresholdSeconds = float64(2.0) // 2 seconds LayerSwitchBehindThresholdSeconds = float64(0.05) // 50ms SwitchAheadThresholdSeconds = float64(0.025) // 25ms + + // While a subscriber is acquiring its first layer and the requested (max) layer has not been + // seen on the wire yet, aim straight for the requested layer for this long instead of latching + // onto a lower layer that is detected first. Avoids a visible low -> high quality ramp, + // notably when the subscriber joined before the publisher started (so layers are detected, and + // `maxSeen` climbs, gradually). If the requested layer does not show up within this window the + // grace expires and forwarding falls back to the highest layer actually seen. + // See Forwarder.opportunisticAlloc / withinAcquireGraceLocked / MaybeExpireAcquireGrace. + initialLayerAcquisitionGrace = time.Second ) var ( @@ -222,6 +231,7 @@ type Forwarder struct { logger logger.Logger skipReferenceTS bool disableOpportunisticAllocation bool + enableStartAtDesiredQuality bool rtpStats *rtpstats.RTPStatsSender muted bool @@ -230,6 +240,7 @@ type Forwarder struct { started bool preStartTime time.Time + acquireDeadline int64 // mono nanos; initial-acquisition grace deadline, 0 = inactive extFirstTS uint64 lastSSRC uint32 lastReferencePayloadType int8 @@ -256,6 +267,7 @@ func NewForwarder( logger logger.Logger, skipReferenceTS bool, disableOpportunisticAllocation bool, + enableStartAtDesiredQuality bool, rtpStats *rtpstats.RTPStatsSender, ) *Forwarder { f := &Forwarder{ @@ -264,6 +276,7 @@ func NewForwarder( logger: logger, skipReferenceTS: skipReferenceTS, disableOpportunisticAllocation: disableOpportunisticAllocation, + enableStartAtDesiredQuality: enableStartAtDesiredQuality, rtpStats: rtpStats, referenceLayerSpatial: buffer.InvalidLayerSpatial, lastAllocation: VideoAllocationDefault, @@ -276,6 +289,7 @@ func NewForwarder( if f.kind == webrtc.RTPCodecTypeVideo { f.vls.SetMaxTemporal(buffer.DefaultMaxLayerTemporal) } + f.vls.SetEnableStartAtDesiredQuality(enableStartAtDesiredQuality) return f } @@ -289,10 +303,36 @@ func (f *Forwarder) SetMaxPublishedLayer(maxPublishedLayer int32) bool { } f.vls.SetMaxSeenSpatial(maxPublishedLayer) + if f.enableStartAtDesiredQuality && !f.vls.GetCurrent().IsValid() { + // A (higher) layer just became available while nothing is being forwarded yet. + // (Re)start the initial-acquisition grace so the target aims for the requested layer + // instead of ramping up gradually as more layers are detected. See opportunisticAlloc. + f.acquireDeadline = mono.UnixNano() + initialLayerAcquisitionGrace.Nanoseconds() + } f.logger.Debugw("setting max published layer", "layer", maxPublishedLayer) return true } +// withinAcquireGraceLocked reports whether the initial-acquisition grace window is still open. +func (f *Forwarder) withinAcquireGraceLocked() bool { + return f.acquireDeadline != 0 && mono.UnixNano() < f.acquireDeadline +} + +// MaybeExpireAcquireGrace returns true once when the initial-acquisition grace has expired while +// the forwarder is still not streaming any layer. The caller should trigger a re-allocation so the +// target falls back from the requested layer to the highest layer actually seen, avoiding a stall +// if the requested layer never shows up. The deadline is cleared so it fires at most once. +func (f *Forwarder) MaybeExpireAcquireGrace() bool { + f.lock.Lock() + defer f.lock.Unlock() + + if f.acquireDeadline == 0 || mono.UnixNano() < f.acquireDeadline { + return false + } + f.acquireDeadline = 0 + return !f.vls.GetCurrent().IsValid() +} + func (f *Forwarder) SetMaxTemporalLayerSeen(maxTemporalLayerSeen int32) bool { f.lock.Lock() defer f.lock.Unlock() @@ -832,6 +872,17 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow return maxTemporal } + // inAcquireGrace reports that we are acquiring the first layer, the requested layer has not + // been seen on the wire yet, and the grace window is still open. While true, aim straight for + // the requested layer instead of the highest seen so far, so acquisition does not ramp up + // gradually as layers are detected (`maxSeen` climbs). See initialLayerAcquisitionGrace. + inAcquireGrace := func(maxSpatial int32) bool { + return !currentLayer.IsValid() && maxSeenLayer.Spatial < maxSpatial && f.withinAcquireGraceLocked() + } + + // set when opportunisticAlloc aimed the target at the requested layer due to the acquisition + // grace (as opposed to overshoot), so the key frame request can be pointed at it too + acquireGraceApplied := false opportunisticAlloc := func() { // opportunistically latch on to anything maxSpatial := maxLayer.Spatial @@ -839,8 +890,14 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow maxSpatial = maxSeenLayer.Spatial } + targetSpatial := min(maxSeenLayer.Spatial, maxSpatial) + if inAcquireGrace(maxSpatial) { + targetSpatial = maxSpatial + acquireGraceApplied = true + } + alloc.TargetLayer = buffer.VideoLayer{ - Spatial: min(maxSeenLayer.Spatial, maxSpatial), + Spatial: targetSpatial, Temporal: getMaxTemporal(), } } @@ -935,7 +992,11 @@ func (f *Forwarder) AllocateOptimal(availableLayers []int32, brs Bitrates, allow } else { // opportunistically latch on to anything opportunisticAlloc() - if requestLayerSpatial == buffer.InvalidLayerSpatial { + if acquireGraceApplied { + // in the acquisition grace, request a key frame for the requested layer we + // are waiting for (above what has been seen so far) + alloc.RequestLayerSpatial = alloc.TargetLayer.Spatial + } else if requestLayerSpatial == buffer.InvalidLayerSpatial { alloc.RequestLayerSpatial = maxLayerSpatialLimit } else { alloc.RequestLayerSpatial = requestLayerSpatial diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index cca1e69be..513fbe40e 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -39,6 +39,7 @@ func newForwarder(codec webrtc.RTPCodecCapability, kind webrtc.RTPCodecType) *Fo logger.GetLogger(), true, // skipReferenceTS true, // disableOpportunisticAllocation + true, // enableStartAtDesiredQuality nil, ) f.DetermineCodec(codec, nil, livekit.VideoLayer_MODE_UNUSED) @@ -2145,3 +2146,44 @@ func TestForwarderGetPaddingVP8(t *testing.T) { require.NoError(t, err) require.Equal(t, marshalledVP8, buf) } + +func TestForwarderInitialAcquisitionGrace(t *testing.T) { + f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + + // subscriber requested the top spatial layer + f.SetMaxSpatialLayer(buffer.DefaultMaxLayerSpatial) + f.SetMaxTemporalLayer(buffer.DefaultMaxLayerTemporal) + f.SetMaxTemporalLayerSeen(buffer.DefaultMaxLayerTemporal) + + bitrates := Bitrates{ + {2, 3, 0, 0}, + {4, 0, 0, 5}, + {0, 7, 0, 0}, + } + + // subscriber-first: only layer 1 has been seen so far and nothing is being forwarded yet + // (current invalid). this arms the initial-acquisition grace. + require.True(t, f.SetMaxPublishedLayer(1)) + f.lock.RLock() + require.True(t, f.withinAcquireGraceLocked()) + f.lock.RUnlock() + + // during the grace, the target aims straight at the requested layer (2) and requests a key + // frame for it, even though only layer 1 has been seen - so acquisition does not ramp up + // gradually as higher layers are detected + alloc := f.AllocateOptimal([]int32{0, 1}, bitrates, false, false) + require.Equal(t, int32(2), alloc.TargetLayer.Spatial) + require.Equal(t, int32(2), alloc.RequestLayerSpatial) + + // force the grace to expire while still not streaming: must signal that a re-allocation is + // needed so the target can fall back + f.acquireDeadline = 1 // a deadline far in the past + require.True(t, f.MaybeExpireAcquireGrace()) + require.False(t, f.MaybeExpireAcquireGrace()) // only fires once + + // after the grace, the target falls back to the highest layer actually seen (1) instead of + // stalling while waiting for a requested layer that never showed up + alloc = f.AllocateOptimal([]int32{0, 1}, bitrates, false, false) + require.Equal(t, int32(1), alloc.TargetLayer.Spatial) + require.Equal(t, int32(1), alloc.RequestLayerSpatial) +} diff --git a/pkg/sfu/videolayerselector/base.go b/pkg/sfu/videolayerselector/base.go index 181e5d3fb..9fc74c16a 100644 --- a/pkg/sfu/videolayerselector/base.go +++ b/pkg/sfu/videolayerselector/base.go @@ -35,6 +35,10 @@ type Base struct { currentLayer buffer.VideoLayer previousLayer buffer.VideoLayer + + // when set, on initial acquisition latch directly onto the target (requested) layer instead of + // opportunistically latching onto the first lower-layer key frame that arrives (see Simulcast.Select) + enableStartAtDesiredQuality bool } func NewBase(logger logger.Logger) *Base { @@ -66,6 +70,10 @@ func (b *Base) SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerS b.tls = tls } +func (b *Base) SetEnableStartAtDesiredQuality(enable bool) { + b.enableStartAtDesiredQuality = enable +} + func (b *Base) SetMax(maxLayer buffer.VideoLayer) { b.maxLayer = maxLayer } diff --git a/pkg/sfu/videolayerselector/simulcast.go b/pkg/sfu/videolayerselector/simulcast.go index 664b2a706..9f0770b7a 100644 --- a/pkg/sfu/videolayerselector/simulcast.go +++ b/pkg/sfu/videolayerselector/simulcast.go @@ -94,14 +94,32 @@ func (s *Simulcast) Select(extPkt *buffer.ExtPacket, layer int32) (result VideoL found := false reason := "" if extPkt.IsKeyFrame { - if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial { - reason = "upgrading layer" - found = true - } + if s.enableStartAtDesiredQuality && !isActive { + // Initial acquisition: latch directly onto the target layer instead of + // opportunistically latching onto the first key frame of any lower layer that + // happens to arrive first. This avoids a visible low-quality -> high-quality ramp + // (e.g. briefly decoding layer 0 before settling on a requested layer 2) for a + // subscriber that requested the higher layer. + // + // The target is chosen by the allocator: during the initial-acquisition grace it + // points at the requested layer (so we wait for it); if that layer never shows + // up the grace expires and the allocator drops the target to the highest layer + // actually seen, so we always end up latching onto a layer that is flowing. + if layer == s.targetLayer.Spatial { + reason = "acquiring target layer" + found = true + } + } else { + // default: opportunistically latch on to / step towards the target layer + if layer > s.currentLayer.Spatial && layer <= s.targetLayer.Spatial { + reason = "upgrading layer" + found = true + } - if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial { - reason = "downgrading layer" - found = true + if layer < s.currentLayer.Spatial && layer >= s.targetLayer.Spatial { + reason = "downgrading layer" + found = true + } } if found { diff --git a/pkg/sfu/videolayerselector/simulcast_test.go b/pkg/sfu/videolayerselector/simulcast_test.go new file mode 100644 index 000000000..878db5eec --- /dev/null +++ b/pkg/sfu/videolayerselector/simulcast_test.go @@ -0,0 +1,77 @@ +// 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 videolayerselector + +import ( + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/protocol/logger" +) + +func keyFrameOnLayer(spatial, temporal int32) *buffer.ExtPacket { + return &buffer.ExtPacket{ + VideoLayer: buffer.VideoLayer{Spatial: spatial, Temporal: temporal}, + Packet: &rtp.Packet{}, + IsKeyFrame: true, + } +} + +// On initial acquisition the selector must latch directly onto the target layer and ignore +// lower-layer key frames that arrive first, so a subscriber requesting the top layer does not +// briefly decode a lower layer (a visible quality ramp). +func TestSimulcastSelectAcquiresTargetLayerDirectly(t *testing.T) { + s := NewSimulcast(logger.GetLogger()) + s.SetEnableStartAtDesiredQuality(true) + s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2}) + s.SetMaxSeen(buffer.VideoLayer{Spatial: 2, Temporal: 2}) + s.SetTarget(buffer.VideoLayer{Spatial: 2, Temporal: 2}) + s.SetRequestSpatial(2) + s.SetCurrent(buffer.InvalidLayer) + + // lower-layer key frames arriving first must NOT be latched + require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected) + require.False(t, s.GetCurrent().IsValid()) + require.False(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected) + require.False(t, s.GetCurrent().IsValid()) + + // the target layer key frame latches directly + require.True(t, s.Select(keyFrameOnLayer(2, 2), 2).IsSelected) + require.Equal(t, int32(2), s.GetCurrent().Spatial) +} + +// Acquisition follows whatever target the allocator set: when the target is lowered (e.g. the +// acquisition grace expired and the allocator fell back to the highest layer seen), the selector +// latches that lower layer directly. This is the fallback path that prevents a stall when the +// originally requested layer never shows up. +func TestSimulcastSelectAcquiresLoweredTarget(t *testing.T) { + s := NewSimulcast(logger.GetLogger()) + s.SetEnableStartAtDesiredQuality(true) + s.SetMax(buffer.VideoLayer{Spatial: 2, Temporal: 2}) + s.SetMaxSeen(buffer.VideoLayer{Spatial: 1, Temporal: 2}) + // allocator dropped the target to the highest layer actually seen + s.SetTarget(buffer.VideoLayer{Spatial: 1, Temporal: 2}) + s.SetRequestSpatial(1) + s.SetCurrent(buffer.InvalidLayer) + + require.False(t, s.Select(keyFrameOnLayer(0, 2), 0).IsSelected) + require.False(t, s.GetCurrent().IsValid()) + + require.True(t, s.Select(keyFrameOnLayer(1, 2), 1).IsSelected) + require.Equal(t, int32(1), s.GetCurrent().Spatial) +} diff --git a/pkg/sfu/videolayerselector/videolayerselector.go b/pkg/sfu/videolayerselector/videolayerselector.go index 883e46aea..72a6e2c81 100644 --- a/pkg/sfu/videolayerselector/videolayerselector.go +++ b/pkg/sfu/videolayerselector/videolayerselector.go @@ -37,6 +37,7 @@ type VideoLayerSelector interface { IsOvershootOkay() bool SetTemporalLayerSelector(tls temporallayerselector.TemporalLayerSelector) + SetEnableStartAtDesiredQuality(enable bool) SetMax(maxLayer buffer.VideoLayer) SetMaxSpatial(layer int32) From a3a6b6de96d05022596a19fb1b6c0445efe9796d Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 21 Jun 2026 16:37:21 +0530 Subject: [PATCH 11/44] triviial: remove usused config. (#4611) noticed a config in deploy config while cleaning up some other usused config. small clean up. probably there is a bunch more that can be cleaned up, but doing a quick one as I noticed this. --- pkg/config/config.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index c77d7d241..4393f7f80 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -208,7 +208,6 @@ type RoomConfig struct { EnableRemoteUnmute bool `yaml:"enable_remote_unmute,omitempty"` PlayoutDelay PlayoutDelayConfig `yaml:"playout_delay,omitempty"` SyncStreams bool `yaml:"sync_streams,omitempty"` - CreateRoomEnabled bool `yaml:"create_room_enabled,omitempty"` CreateRoomTimeout time.Duration `yaml:"create_room_timeout,omitempty"` CreateRoomAttempts int `yaml:"create_room_attempts,omitempty"` // target room participant update batch chunk size in bytes @@ -434,7 +433,6 @@ var DefaultConfig = Config{ }, EmptyTimeout: 5 * 60, DepartureTimeout: 20, - CreateRoomEnabled: true, CreateRoomTimeout: 10 * time.Second, CreateRoomAttempts: 3, UpdateBatchTargetSize: 128 * 1024, From 13ce35fc8796a83a467f66b982ad8bd937861e61 Mon Sep 17 00:00:00 2001 From: CloudWebRTC Date: Mon, 22 Jun 2026 14:03:47 +0800 Subject: [PATCH 12/44] fix: Clear the enableStartAtDesiredQuality flags in MaybeExpireAcquireGrace. (#4613) --- pkg/sfu/forwarder.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 3bfae7d4b..398a2d2b7 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -330,6 +330,8 @@ func (f *Forwarder) MaybeExpireAcquireGrace() bool { return false } f.acquireDeadline = 0 + f.enableStartAtDesiredQuality = false + f.vls.SetEnableStartAtDesiredQuality(false) return !f.vls.GetCurrent().IsValid() } From f7085535da56d20307845c4b1e345133b75be775 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 22 Jun 2026 17:36:06 +0530 Subject: [PATCH 13/44] Tighten up publish latency stat. (#4615) Previously it was anchored to participant transitioning to `ACTIVE` if the add track request happened before that. But, that has a few issues 1.`ACTIVE` is for primary peer connection which could be subscriber peer connection. 2. `ACTIVE` also include data channel establishment. Switch to first connected time of publisher peer connection for that to get a more accurate measure of track publish time. --- pkg/rtc/participant.go | 8 ++++---- pkg/rtc/transport.go | 7 +++++++ pkg/rtc/transportmanager.go | 12 ++++++++++++ 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index ebe59cc58..c1f785d07 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -3248,11 +3248,11 @@ func (p *ParticipantImpl) mediaTrackReceived( mt = p.addMediaTrack(signalCid, ti) newTrack = true - // if the addTrackRequest is sent before participant active then it means the client tries to publish - // before fully connected, in this case we only record the time when the participant is active since + // if the addTrackRequest is sent before publisher peer connection is established, then it means the client tries to publish + // before fully connected, in this case we only record the time when publisher peer connection is established since // we want this metric to represent the time cost by publishing. - if activeAt := p.lastActiveAt.Load(); activeAt != nil && createdAt.Before(*activeAt) { - createdAt = *activeAt + if connectedAt := p.TransportManager.PublisherFirstConnectedAt(); !connectedAt.IsZero() && createdAt.Before(connectedAt) { + createdAt = connectedAt } pubTime = time.Since(createdAt) p.dirty.Store(true) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 0848ae137..be22bbc4f 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -1432,6 +1432,13 @@ func (t *PCTransport) HasEverConnected() bool { return !t.firstConnectedAt.IsZero() } +func (t *PCTransport) FirstConnectedAt() time.Time { + t.lock.RLock() + defer t.lock.RUnlock() + + return t.firstConnectedAt +} + func (t *PCTransport) GetICEConnectionInfo() *types.ICEConnectionInfo { return t.connectionDetails.GetInfo() } diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 528cd72c7..886217d7f 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -227,6 +227,10 @@ func (t *TransportManager) HasPublisherEverConnected() bool { return t.publisher.HasEverConnected() } +func (t *TransportManager) PublisherFirstConnectedAt() time.Time { + return t.publisher.FirstConnectedAt() +} + func (t *TransportManager) IsPublisherEstablished() bool { return t.publisher.IsEstablished() } @@ -267,6 +271,14 @@ func (t *TransportManager) HasSubscriberEverConnected() bool { } } +func (t *TransportManager) SubscriberFirstConnectedAt() time.Time { + if t.params.UseOneShotSignallingMode || t.params.UseSinglePeerConnection { + return t.publisher.FirstConnectedAt() + } else { + return t.subscriber.FirstConnectedAt() + } +} + func (t *TransportManager) AddTrackLocal( trackLocal webrtc.TrackLocal, params types.AddTrackParams, From 86a79f83fc03706054c0faae65e93fc8c7e9d7ea Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 22 Jun 2026 09:23:33 -0400 Subject: [PATCH 14/44] fix: report participant capabilities in ParticipantInfo (#4606) --- go.mod | 2 +- go.sum | 4 ++-- pkg/rtc/participant.go | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4b561b67d..18572dec2 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b + github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 3ea04b90f..3741db82c 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b h1:7gH58XkJ0wtU+5d33Jkktmf4I3heKjQzDhwYHDSGmTo= -github.com/livekit/protocol v1.47.1-0.20260617164816-2f8e4d6d263b/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db h1:oSCQ0bqPo/dsDIi3PQCoBGXmWXIsi59XE2yD5Gp6jh8= +github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index c1f785d07..2c17d0dd9 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -911,6 +911,7 @@ func (p *ParticipantImpl) ToProtoWithVersion() (*livekit.ParticipantInfo, utils. KindDetails: grants.GetKindDetails(), DisconnectReason: p.CloseReason().ToDisconnectReason(), ClientProtocol: clientProtocol, + Capabilities: p.params.ClientInfo.GetCapabilities(), } p.lock.RUnlock() From 1b69630a28c37c015e266d97537d382b7afcd7d9 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 22 Jun 2026 22:07:32 +0530 Subject: [PATCH 15/44] Prometheus metric for join latency. (#4616) * Prometheus metric for join latency. Also including a couple of other failures in the signal connection path and moving the signal connected to after all that. Not doing counters for the new signal failure paths. I should not have done for the other two I added a little while ago also ( validation failure and start participant failure) as those are not scalable to keep adding to node stats. Will probably remove those two from node stats later. Can add those counters if they are useful. * deprecate signal failed counters --- go.mod | 34 +++++++------- go.sum | 68 +++++++++++++-------------- pkg/service/rtcservice.go | 9 ++-- pkg/telemetry/prometheus/node.go | 72 ++++++++++++++--------------- pkg/telemetry/prometheus/packets.go | 68 +++++++++++++++------------ pkg/telemetry/prometheus/rooms.go | 13 ++++++ 6 files changed, 143 insertions(+), 121 deletions(-) diff --git a/go.mod b/go.mod index 18572dec2..938a14b22 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db + github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 @@ -37,13 +37,13 @@ require ( github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.2 github.com/pion/sctp v1.9.5 - github.com/pion/sdp/v3 v3.0.18 + github.com/pion/sdp/v3 v3.0.19 github.com/pion/transport/v4 v4.0.2 - github.com/pion/turn/v5 v5.0.8 + github.com/pion/turn/v5 v5.0.10 github.com/pion/webrtc/v4 v4.2.11 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 - github.com/redis/go-redis/v9 v9.20.0 + github.com/redis/go-redis/v9 v9.21.0 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.11.1 github.com/thoas/go-funk v0.9.3 @@ -54,8 +54,8 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/mod v0.36.0 - golang.org/x/sync v0.20.0 + golang.org/x/mod v0.37.0 + golang.org/x/sync v0.21.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -92,7 +92,7 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/time v0.15.0 // indirect ) @@ -134,22 +134,22 @@ require ( github.com/pion/logging v0.2.4 // indirect github.com/pion/mdns/v2 v2.1.0 // indirect github.com/pion/randutil v0.1.0 // indirect - github.com/pion/srtp/v3 v3.0.11 // indirect - github.com/pion/stun/v3 v3.1.4 + github.com/pion/srtp/v3 v3.0.12 // indirect + github.com/pion/stun/v3 v3.1.6 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.68.1 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/urfave/cli/v3 v3.9.0 github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - golang.org/x/tools v0.45.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.46.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect google.golang.org/grpc v1.81.1 // indirect ) diff --git a/go.sum b/go.sum index 3741db82c..37cbc525f 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db h1:oSCQ0bqPo/dsDIi3PQCoBGXmWXIsi59XE2yD5Gp6jh8= -github.com/livekit/protocol v1.47.1-0.20260619130638-81f9c58f49db/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52 h1:9obg71kYBNSegrEAkcqBebi9KxxFDyS7GXXcUkXAQVM= +github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= @@ -249,20 +249,20 @@ github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag= github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= -github.com/pion/sdp/v3 v3.0.18 h1:l0bAXazKHpepazVdp+tPYnrsy9dfh7ZbT8DxesH5ZnI= -github.com/pion/sdp/v3 v3.0.18/go.mod h1:ZREGo6A9ZygQ9XkqAj5xYCQtQpif0i6Pa81HOiAdqQ8= -github.com/pion/srtp/v3 v3.0.11 h1:GiESUr54/K4UuPigfq/CvWUed80JenQAHXn0C2MQQIQ= -github.com/pion/srtp/v3 v3.0.11/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= -github.com/pion/stun/v3 v3.1.4 h1:/7ZL0j0dmLroKOq4GfkyKQ6asByYqntwyHSp5sYLcGY= -github.com/pion/stun/v3 v3.1.4/go.mod h1:ET7PFiXo1nrD2ZNVpbEHDuT0kCPVXhKmyWdiePNMw/U= +github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= +github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= +github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= +github.com/pion/srtp/v3 v3.0.12/go.mod h1:EeZOi/sd6glM1EXapg051gdNWO9yWT1YSsgQ4SlJkns= +github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= +github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkYOM= github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= -github.com/pion/turn/v5 v5.0.8 h1:pZUCtmwWCMkrRKqh/8pL3WoGADXBe0/lOPkN7oqFjK8= -github.com/pion/turn/v5 v5.0.8/go.mod h1:1VwvxElZaOdJU0liJ/WUSm/Tsh+n2OxS5ISSDxgOWxU= +github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= +github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU= github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -274,14 +274,14 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.68.1 h1:omjRRl4QP4komogpXuhfeOiisQg7xdy8VM1UY+pStaY= -github.com/prometheus/common v0.68.1/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= -github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= -github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= +github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= @@ -351,12 +351,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= -golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q= -golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -371,12 +371,12 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -400,29 +400,29 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= -google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 h1:phvBWCAQMGN1945mp5fjCXP6jEF0+a0+4TjokS4sxNY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index d4f83e7cb..ff2d87b25 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -401,9 +401,6 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ return } - prometheus.IncrementParticipantJoin(1) - joinDuration = time.Since(startedAt) - pLogger = pLogger.WithValues("connID", cr.ConnectionID) if !pi.Reconnect && initialResponse.GetJoin() != nil { joinRoomID := livekit.RoomID(initialResponse.GetJoin().GetRoom().GetSid()) @@ -445,6 +442,7 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ // upgrade only once the basics are good to go conn, err := s.upgrader.Upgrade(w, r, nil) if err != nil { + prometheus.IncrementParticipantJoinUpgradeFail(1) resolveLogger(true) HandleError(w, r, http.StatusInternalServerError, err, getLoggerFields()...) return @@ -465,12 +463,17 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ pLogger.Debugw("sending initial response", "response", logger.Proto(initialResponse)) count, err := sigConn.WriteResponse(initialResponse) if err != nil { + prometheus.IncrementParticipantJoinWriteInitialResponseFail(1) resolveLogger(true) pLogger.Warnw("could not write initial response", err) return } signalStats.AddBytes(uint64(count), true) + prometheus.IncrementParticipantJoin(1) + joinDuration = time.Since(startedAt) + prometheus.RecordSessionJoinLatency(int(pi.Client.GetProtocol()), joinDuration) + pLogger.Debugw( "new client WS connected", "reconnect", pi.Reconnect, diff --git a/pkg/telemetry/prometheus/node.go b/pkg/telemetry/prometheus/node.go index c3a3fc3ca..0214d8e8e 100644 --- a/pkg/telemetry/prometheus/node.go +++ b/pkg/telemetry/prometheus/node.go @@ -149,43 +149,41 @@ func GetNodeStats(nodeStartedAt int64, prevStats []*livekit.NodeStats, rateInter promSysPacketGauge.WithLabelValues("dropped").Set(float64(sysDroppedPackets - sysDroppedPacketsStart)) stats := &livekit.NodeStats{ - StartedAt: nodeStartedAt, - UpdatedAt: time.Now().Unix(), - NumRooms: roomCurrent.Load(), - NumClients: participantCurrent.Load(), - NumTracksIn: trackPublishedCurrent.Load(), - NumTracksOut: trackSubscribedCurrent.Load(), - NumTrackPublishAttempts: trackPublishAttempts.Load(), - NumTrackPublishSuccess: trackPublishSuccess.Load(), - NumTrackPublishCancels: trackPublishCancels.Load(), - NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(), - NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(), - NumTrackSubscribeCancels: trackSubscribeCancels.Load(), - BytesIn: bytesIn.Load(), - BytesOut: bytesOut.Load(), - PacketsIn: packetsIn.Load(), - PacketsOut: packetsOut.Load(), - RetransmitBytesOut: retransmitBytes.Load(), - RetransmitPacketsOut: retransmitPackets.Load(), - NackTotal: nackTotal.Load(), - ParticipantSignalConnected: participantSignalConnected.Load(), - ParticipantSignalFailed: participantSignalFailed.Load(), - ParticipantSignalValidationFailed: participantSignalValidationFailed.Load(), - ParticipantRtcInit: participantRTCInit.Load(), - ParticipantRtcConnected: participantRTCConnected.Load(), - ParticipantRtcCanceled: participantRTCCanceled.Load(), - ParticipantRtcActive: participantRTCActive.Load(), - ForwardLatency: forwardLatency.Load(), - ForwardJitter: forwardJitter.Load(), - NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer - CpuLoad: float32(cpuStats.GetCPULoad()), - MemoryTotal: memTotal, - MemoryUsed: memUsed, - LoadAvgLast1Min: float32(loadAvg.Loadavg1), - LoadAvgLast5Min: float32(loadAvg.Loadavg5), - LoadAvgLast15Min: float32(loadAvg.Loadavg15), - SysPacketsOut: sysPackets, - SysPacketsDropped: sysDroppedPackets, + StartedAt: nodeStartedAt, + UpdatedAt: time.Now().Unix(), + NumRooms: roomCurrent.Load(), + NumClients: participantCurrent.Load(), + NumTracksIn: trackPublishedCurrent.Load(), + NumTracksOut: trackSubscribedCurrent.Load(), + NumTrackPublishAttempts: trackPublishAttempts.Load(), + NumTrackPublishSuccess: trackPublishSuccess.Load(), + NumTrackPublishCancels: trackPublishCancels.Load(), + NumTrackSubscribeAttempts: trackSubscribeAttempts.Load(), + NumTrackSubscribeSuccess: trackSubscribeSuccess.Load(), + NumTrackSubscribeCancels: trackSubscribeCancels.Load(), + BytesIn: bytesIn.Load(), + BytesOut: bytesOut.Load(), + PacketsIn: packetsIn.Load(), + PacketsOut: packetsOut.Load(), + RetransmitBytesOut: retransmitBytes.Load(), + RetransmitPacketsOut: retransmitPackets.Load(), + NackTotal: nackTotal.Load(), + ParticipantSignalConnected: participantSignalConnected.Load(), + ParticipantRtcInit: participantRTCInit.Load(), + ParticipantRtcConnected: participantRTCConnected.Load(), + ParticipantRtcCanceled: participantRTCCanceled.Load(), + ParticipantRtcActive: participantRTCActive.Load(), + ForwardLatency: forwardLatency.Load(), + ForwardJitter: forwardJitter.Load(), + NumCpus: uint32(cpuStats.NumCPU()), // this will round down to the nearest integer + CpuLoad: float32(cpuStats.GetCPULoad()), + MemoryTotal: memTotal, + MemoryUsed: memUsed, + LoadAvgLast1Min: float32(loadAvg.Loadavg1), + LoadAvgLast5Min: float32(loadAvg.Loadavg5), + LoadAvgLast15Min: float32(loadAvg.Loadavg15), + SysPacketsOut: sysPackets, + SysPacketsDropped: sysDroppedPackets, } for _, rateInterval := range rateIntervals { diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index a9f6684e1..c69afdaaa 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -36,22 +36,20 @@ const ( ) var ( - bytesIn atomic.Uint64 - bytesOut atomic.Uint64 - packetsIn atomic.Uint64 - packetsOut atomic.Uint64 - nackTotal atomic.Uint64 - retransmitBytes atomic.Uint64 - retransmitPackets atomic.Uint64 - participantSignalConnected atomic.Uint64 - participantSignalFailed atomic.Uint64 - participantSignalValidationFailed atomic.Uint64 - participantRTCConnected atomic.Uint64 - participantRTCInit atomic.Uint64 - participantRTCCanceled atomic.Uint64 - participantRTCActive atomic.Uint64 - forwardLatency atomic.Uint32 - forwardJitter atomic.Uint32 + bytesIn atomic.Uint64 + bytesOut atomic.Uint64 + packetsIn atomic.Uint64 + packetsOut atomic.Uint64 + nackTotal atomic.Uint64 + retransmitBytes atomic.Uint64 + retransmitPackets atomic.Uint64 + participantSignalConnected atomic.Uint64 + participantRTCConnected atomic.Uint64 + participantRTCInit atomic.Uint64 + participantRTCCanceled atomic.Uint64 + participantRTCActive atomic.Uint64 + forwardLatency atomic.Uint32 + forwardJitter atomic.Uint32 promPacketLabels = []string{"direction", "transmission", "country"} promPacketTotal *prometheus.CounterVec @@ -305,29 +303,39 @@ func IncrementParticipantJoin(join uint32) { func IncrementParticipantJoinFail(fail uint32) { if fail > 0 { - participantSignalFailed.Add(uint64(fail)) promParticipantJoin.WithLabelValues("signal_failed").Add(float64(fail)) } } func IncrementParticipantJoinValidationFail(validationFail uint32) { if validationFail > 0 { - participantSignalValidationFailed.Add(uint64(validationFail)) promParticipantJoin.WithLabelValues("signal_validation_failed").Add(float64(validationFail)) } } -func IncrementParticipantRtcInit(join uint32) { - if join > 0 { - participantRTCInit.Add(uint64(join)) - promParticipantJoin.WithLabelValues("rtc_init").Add(float64(join)) +func IncrementParticipantJoinUpgradeFail(upgradeFail uint32) { + if upgradeFail > 0 { + promParticipantJoin.WithLabelValues("signal_upgrade_failed").Add(float64(upgradeFail)) } } -func IncrementParticipantRtcConnected(join uint32) { - if join > 0 { - participantRTCConnected.Add(uint64(join)) - promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(join)) +func IncrementParticipantJoinWriteInitialResponseFail(writeInitialResponseFail uint32) { + if writeInitialResponseFail > 0 { + promParticipantJoin.WithLabelValues("signal_write_initial_response_failed").Add(float64(writeInitialResponseFail)) + } +} + +func IncrementParticipantRtcInit(init uint32) { + if init > 0 { + participantRTCInit.Add(uint64(init)) + promParticipantJoin.WithLabelValues("rtc_init").Add(float64(init)) + } +} + +func IncrementParticipantRtcConnected(connected uint32) { + if connected > 0 { + participantRTCConnected.Add(uint64(connected)) + promParticipantJoin.WithLabelValues("rtc_connected").Add(float64(connected)) } } @@ -338,10 +346,10 @@ func IncrementParticipantRtcActive(active uint32) { } } -func IncrementParticipantRtcCanceled(numCancels uint64) { - if numCancels > 0 { - participantRTCCanceled.Add(numCancels) - promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(numCancels)) +func IncrementParticipantRtcCanceled(canceled uint64) { + if canceled > 0 { + participantRTCCanceled.Add(canceled) + promParticipantJoin.WithLabelValues("rtc_canceled").Add(float64(canceled)) } } diff --git a/pkg/telemetry/prometheus/rooms.go b/pkg/telemetry/prometheus/rooms.go index 4001c24d9..d67a2c59d 100644 --- a/pkg/telemetry/prometheus/rooms.go +++ b/pkg/telemetry/prometheus/rooms.go @@ -46,6 +46,7 @@ var ( promTrackSubscribedCurrent *prometheus.GaugeVec promTrackPublishCounter *prometheus.CounterVec promTrackSubscribeCounter *prometheus.CounterVec + promSessionJoinLatency *prometheus.HistogramVec promSessionStartTime *prometheus.HistogramVec promSessionDuration *prometheus.HistogramVec promPubSubTime *prometheus.HistogramVec @@ -99,6 +100,13 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { Name: "subscribe_counter", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, }, []string{"state", "error"}) + promSessionJoinLatency = prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: livekitNamespace, + Subsystem: "session", + Name: "join_latency_ms", + ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, + Buckets: prometheus.ExponentialBucketsRange(10, 10000, 15), + }, []string{"protocol_version"}) promSessionStartTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "session", @@ -134,6 +142,7 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { prometheus.MustRegister(promTrackSubscribedCurrent) prometheus.MustRegister(promTrackPublishCounter) prometheus.MustRegister(promTrackSubscribeCounter) + prometheus.MustRegister(promSessionJoinLatency) prometheus.MustRegister(promSessionStartTime) prometheus.MustRegister(promSessionDuration) prometheus.MustRegister(promPubSubTime) @@ -271,6 +280,10 @@ func RecordTrackSubscribeCancels(numCancels int32) { promTrackSubscribeCounter.WithLabelValues("cancel", "").Add(float64(numCancels)) } +func RecordSessionJoinLatency(protocolVersion int, d time.Duration) { + promSessionJoinLatency.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) +} + func RecordSessionStartTime(protocolVersion int, d time.Duration) { promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) } From 4facbc582a791d136446f02ba5e436b662e0ec0f Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 23 Jun 2026 10:30:59 +0530 Subject: [PATCH 16/44] Move lock to addPendingTrack function. (#4617) Wrapping the function with the lock outside in the only invocation was not needed. --- pkg/rtc/participant.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 2c17d0dd9..f510cf5af 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1340,9 +1340,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { return } - p.pendingTracksLock.Lock() - ti := p.addPendingTrackLocked(req) - p.pendingTracksLock.Unlock() + ti := p.addPendingTrack(req) if ti == nil { return } @@ -2852,7 +2850,10 @@ func (p *ParticipantImpl) onSubscribedAudioCodecChange( return p.sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate) } -func (p *ParticipantImpl) addPendingTrackLocked(req *livekit.AddTrackRequest) *livekit.TrackInfo { +func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit.TrackInfo { + p.pendingTracksLock.Lock() + defer p.pendingTracksLock.Unlock() + if req.Sid != "" { track := p.GetPublishedTrack(livekit.TrackID(req.Sid)) if track == nil { From 6658dd5454c31441f3979c871bf268b6541cc3cb Mon Sep 17 00:00:00 2001 From: laosun Date: Tue, 23 Jun 2026 13:06:41 +0800 Subject: [PATCH 17/44] Echo offered audio payload types in single-PC subscriber answer (#4614) In single peer connection mode, when the server answers a subscriber's offer, configureSenderAudio set the sender codec preferences from the server MediaEngine's payload types. The answer could therefore advertise Opus on a payload type the offerer never offered (server PT 111 vs offered PT 109). Chrome tolerates this; Firefox decodes 0 samples (silence) -- packets are received but never decoded. The forwarded RTP already uses the offered PT, so only the answer SDP was inconsistent. This regressed in v1.12.0 once the single-PC MediaEngine became a union of publish+subscribe codecs. Parse the remote offer's audio rtpmap and remap the sender audio codec preferences to echo the offered payload types (RFC 3264 6.1) before SetCodecPreferences. Fixes #4599 Co-authored-by: laosun <14806343+cnvipstar@users.noreply.github.com> --- pkg/rtc/transport.go | 96 +++++++++++++++++++++++++++++++++------ pkg/rtc/transport_test.go | 28 +++++++++++- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index be22bbc4f..d94ef169b 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -1007,12 +1007,12 @@ func (t *PCTransport) queueOrConfigureSender( enableAudioNACK bool, ) { params := configureSenderParams{ - transceiver, - enabledCodecs, - rtcpFeedbackConfig, - !t.params.IsOfferer, - enableAudioStereo, - enableAudioNACK, + transceiver: transceiver, + enabledCodecs: enabledCodecs, + rtcpFeedbackConfig: rtcpFeedbackConfig, + filterOutH264HighProfile: !t.params.IsOfferer, + enableAudioStereo: enableAudioStereo, + enableAudioNACK: enableAudioNACK, } if !t.params.IsOfferer { t.sendersPendingConfigMu.Lock() @@ -1021,10 +1021,17 @@ func (t *PCTransport) queueOrConfigureSender( return } - configureSender(params) + // Offerer: no remote offer to echo payload types from. + configureSender(params, nil) } -func (t *PCTransport) processSendersPendingConfig() { +// processSendersPendingConfig configures the senders queued while answering the +// remote's offer (single peer connection mode). offerAudioPT (mime type -> the +// payload type the offer assigned) is parsed from the offer by the caller before +// SetRemoteDescription, so the answer echoes the offered payload types for audio +// codecs (and stays consistent with the forwarded RTP). It is nil when there is +// nothing to echo. +func (t *PCTransport) processSendersPendingConfig(offerAudioPT map[mime.MimeType]webrtc.PayloadType) { t.sendersPendingConfigMu.Lock() pending := t.sendersPendingConfig t.sendersPendingConfig = nil @@ -1037,7 +1044,7 @@ func (t *PCTransport) processSendersPendingConfig() { continue } - configureSender(p) + configureSender(p, offerAudioPT) } if len(unprocessed) != 0 { @@ -2347,11 +2354,19 @@ func (t *PCTransport) handleICEGatheringCompleteAnswerer() error { t.pendingRestartIceOffer = nil t.params.Logger.Debugw("accept remote restart ice offer after ICE gathering") + + // Parse the offer payload types before SetRemoteDescription so this does not + // race with pion's use of the same description. + var offerAudioPT map[mime.MimeType]webrtc.PayloadType + if parsed, err := offer.Unmarshal(); err == nil { + offerAudioPT = offerAudioPayloadTypes(parsed) + } + if err := t.setRemoteDescription(offer); err != nil { return err } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig() + t.processSendersPendingConfig(offerAudioPT) return t.createAndSendAnswer() } @@ -2896,7 +2911,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig() + t.processSendersPendingConfig(offerAudioPayloadTypes(parsed)) rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger) if len(rtxRepairs) > 0 { @@ -3081,7 +3096,7 @@ type configureSenderParams struct { enableAudioNACK bool } -func configureSender(params configureSenderParams) { +func configureSender(params configureSenderParams, offerAudioPT map[mime.MimeType]webrtc.PayloadType) { configureSenderCodecs( params.transceiver, params.enabledCodecs, @@ -3090,14 +3105,14 @@ func configureSender(params configureSenderParams) { ) if params.transceiver.Kind() == webrtc.RTPCodecTypeAudio { - configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK) + configureSenderAudio(params.transceiver, params.enableAudioStereo, params.enableAudioNACK, offerAudioPT) } } // configure subscriber transceiver for audio stereo and nack // pion doesn't support per transciver codec configuration, so the nack of this session will be disabled // forever once it is first disabled by a transceiver. -func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) { +func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool, offerAudioPT map[mime.MimeType]webrtc.PayloadType) { sender := tr.Sender() if sender == nil { return @@ -3121,12 +3136,65 @@ func configureSenderAudio(tr *webrtc.RTPTransceiver, stereo bool, nack bool) { } } } + // When answering a subscriber's offer (single peer connection mode), echo + // the payload type the offer assigned for this codec instead of the server's + // MediaEngine payload type. Otherwise the answer can advertise e.g. Opus on a + // PT that was never offered, which Firefox rejects (received packets decode to + // 0 samples / silence). The forwarded RTP already uses the offered PT. + if len(offerAudioPT) > 0 { + if pt, ok := offerAudioPT[mime.NormalizeMimeType(c.MimeType)]; ok { + c.PayloadType = pt + } + } configCodecs = append(configCodecs, c) } tr.SetCodecPreferences(configCodecs) } +// offerAudioPayloadTypes returns mime type -> payload type for the audio codecs +// in a remote offer, so the subscriber answer can echo the offered payload types +// (RFC 3264 6.1). The caller parses the offer before SetRemoteDescription, so this +// does not race with pion's use of the same description. +func offerAudioPayloadTypes(parsed *sdp.SessionDescription) map[mime.MimeType]webrtc.PayloadType { + if parsed == nil { + return nil + } + out := map[mime.MimeType]webrtc.PayloadType{} + for _, md := range parsed.MediaDescriptions { + if !strings.EqualFold(md.MediaName.Media, "audio") { + continue + } + for _, a := range md.Attributes { + if a.Key != "rtpmap" { + continue + } + // value e.g. "109 opus/48000/2" + fields := strings.Fields(a.Value) + if len(fields) < 2 { + continue + } + pt, err := strconv.Atoi(fields[0]) + if err != nil { + continue + } + codecName := fields[1] + if i := strings.Index(codecName, "/"); i >= 0 { + codecName = codecName[:i] + } + mt := mime.NormalizeMimeTypeCodec(codecName).ToMimeType() + if mt == mime.MimeTypeUnknown { + continue + } + out[mt] = webrtc.PayloadType(pt) + } + } + if len(out) == 0 { + return nil + } + return out +} + // In single peer connection mode, set up enebled codecs for sender. // The config provides config of direction. // For publisher peer connection those are publish enabled codecs diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index ab328d7c5..3c0ea425e 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -617,7 +617,7 @@ func TestConfigureAudioTransceiver(t *testing.T) { tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) require.NoError(t, err) - configureSenderAudio(tr, testcase.stereo, testcase.nack) + configureSenderAudio(tr, testcase.stereo, testcase.nack, nil) codecs := tr.Sender().GetParameters().Codecs for _, codec := range codecs { if mime.IsMimeTypeStringOpus(codec.MimeType) { @@ -636,6 +636,32 @@ func TestConfigureAudioTransceiver(t *testing.T) { } } +// When answering a subscriber offer, the sender's audio payload type must echo +// the payload type the offer assigned (RFC 3264), otherwise Firefox decodes no +// audio. See https://github.com/livekit/livekit/issues/4599. +func TestConfigureAudioTransceiverEchoesOfferPayloadType(t *testing.T) { + var me webrtc.MediaEngine + registerCodecs(&me, []*livekit.Codec{{Mime: mime.MimeTypeOpus.String()}}, RTCPFeedbackConfig{Audio: []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBNACK}}}, false) + pc, err := webrtc.NewAPI(webrtc.WithMediaEngine(&me)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + tr, err := pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) + require.NoError(t, err) + + // offer mapped Opus to a payload type different from the server MediaEngine's. + const offeredOpusPT = webrtc.PayloadType(109) + configureSenderAudio(tr, false, true, map[mime.MimeType]webrtc.PayloadType{mime.MimeTypeOpus: offeredOpusPT}) + + var found bool + for _, codec := range tr.Sender().GetParameters().Codecs { + if mime.IsMimeTypeStringOpus(codec.MimeType) { + require.Equal(t, offeredOpusPT, codec.PayloadType) + found = true + } + } + require.True(t, found, "opus codec must be present in sender preferences") +} + // In single-PC mode the publisher PC carries both publish and subscribe // directions. If the MediaEngine were built only from the publish codec list, // the SDP offer would not advertise some codecs in the m-section even though From 0cf53e2f0dd44616bc29439565f41aefeb85fbe5 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 23 Jun 2026 16:10:50 +0530 Subject: [PATCH 18/44] Add option to force drain rtcService/agentService connections. (#4618) When force: true, drain as fast as possible. --- pkg/agent/testutils/server.go | 4 ++-- pkg/service/agentservice.go | 30 ++++++++++++++++++++---------- pkg/service/rtcservice.go | 23 +++++++++++++++-------- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/pkg/agent/testutils/server.go b/pkg/agent/testutils/server.go index 090e37680..3f490918c 100644 --- a/pkg/agent/testutils/server.go +++ b/pkg/agent/testutils/server.go @@ -29,7 +29,7 @@ import ( type AgentService interface { HandleConnection(context.Context, agent.SignalConn, agent.WorkerRegistration) - DrainConnections(time.Duration) + DrainConnections(time.Duration, bool) } type TestServer struct { @@ -140,7 +140,7 @@ func (h *TestServer) SimulateAgentWorker(opts ...SimulatedWorkerOption) *AgentWo } func (h *TestServer) Close() { - h.DrainConnections(1) + h.DrainConnections(1, false) } var _ agent.SignalConn = (*AgentWorker)(nil) diff --git a/pkg/service/agentservice.go b/pkg/service/agentservice.go index 8481a1e3d..5f95f0722 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agentservice.go @@ -485,19 +485,29 @@ func (h *AgentHandler) CheckEnabled(ctx context.Context, req *rpc.CheckEnabledRe }, nil } -func (h *AgentHandler) DrainConnections(interval time.Duration) { - // jitter drain start - time.Sleep(time.Duration(rand.Int63n(int64(interval)))) +func (h *AgentHandler) DrainConnections(interval time.Duration, force bool) { + if !force { + // jitter drain start + time.Sleep(time.Duration(rand.Int63n(int64(interval)))) - t := time.NewTicker(interval) - defer t.Stop() + t := time.NewTicker(interval) + defer t.Stop() - h.mu.Lock() - defer h.mu.Unlock() + h.mu.Lock() + defer h.mu.Unlock() - for _, w := range h.workers { - w.Close() - <-t.C + for _, w := range h.workers { + w.Close() + <-t.C + } + } else { + // drain as quickly as possible when forced + h.mu.Lock() + defer h.mu.Unlock() + + for _, w := range h.workers { + w.Close() + } } } diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index ff2d87b25..84082f355 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -628,20 +628,27 @@ func (s *RTCService) serve(w http.ResponseWriter, r *http.Request, needsJoinRequ } } -func (s *RTCService) DrainConnections(interval time.Duration) { +func (s *RTCService) DrainConnections(interval time.Duration, force bool) { s.mu.Lock() conns := maps.Clone(s.connections) s.mu.Unlock() - // jitter drain start - time.Sleep(time.Duration(rand.Int63n(int64(interval)))) + if !force { + // jitter drain start + time.Sleep(time.Duration(rand.Int63n(int64(interval)))) - t := time.NewTicker(interval) - defer t.Stop() + t := time.NewTicker(interval) + defer t.Stop() - for c := range conns { - _ = c.Close() - <-t.C + for c := range conns { + _ = c.Close() + <-t.C + } + } else { + // drain as quickly as possible when forced + for c := range conns { + _ = c.Close() + } } } From 1faab0c48e7018aaf767efce3b885e4991f3e72a Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 24 Jun 2026 14:42:37 +0530 Subject: [PATCH 19/44] Add support for data blob (a. k. a. async participant attributes) (#4619) * Async attributes on participant. How it is different from existing participant attributes? 1. Async attribute can be added one at a time. 2. These are not included in `ParticipantInfo`. 3. Get an attribute bt participant identity and async attribute ID as and when needed. * clean up * get full definitions, not just ids * listener OnDataTrackSchema * name length config * data blob * deps * static check * Add missing request ID * Update protocol commit * Wire up StoreDataBlobResponse * Pass request ID through in GetDataBlobResponse * deps * atomic * sctp at 1.9.5 * remove proto clone --------- Co-authored-by: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> --- go.mod | 6 +- go.sum | 12 +- pkg/config/config.go | 39 +++ pkg/rtc/participant.go | 6 + pkg/rtc/participant_data_blob.go | 90 ++++++ pkg/rtc/participant_data_blob_handler.go | 113 +++++++ pkg/rtc/participant_data_blob_handler_test.go | 300 ++++++++++++++++++ pkg/rtc/participant_data_blob_test.go | 175 ++++++++++ pkg/rtc/participant_signal.go | 14 + pkg/rtc/room.go | 12 + pkg/rtc/signalling/interfaces.go | 2 + pkg/rtc/signalling/signalhandler.go | 6 + pkg/rtc/signalling/signalling.go | 16 + pkg/rtc/signalling/signallingunimplemented.go | 8 + pkg/rtc/types/interfaces.go | 14 + .../typesfakes/fake_local_participant.go | 285 +++++++++++++++++ .../fake_local_participant_listener.go | 78 +++++ pkg/rtc/types/typesfakes/fake_participant.go | 109 +++++++ pkg/service/roommanager.go | 1 + .../datachannel/datachannel_writer_test.go | 3 +- test/integration_helpers.go | 13 +- test/multinode_test.go | 127 ++++++++ test/singlenode_test.go | 296 +++++++++++++++++ 23 files changed, 1712 insertions(+), 13 deletions(-) create mode 100644 pkg/rtc/participant_data_blob.go create mode 100644 pkg/rtc/participant_data_blob_handler.go create mode 100644 pkg/rtc/participant_data_blob_handler_test.go create mode 100644 pkg/rtc/participant_data_blob_test.go diff --git a/go.mod b/go.mod index 938a14b22..89a90de11 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52 + github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 @@ -149,7 +149,7 @@ require ( golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/tools v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect google.golang.org/grpc v1.81.1 // indirect ) diff --git a/go.sum b/go.sum index 37cbc525f..29ca2a832 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52 h1:9obg71kYBNSegrEAkcqBebi9KxxFDyS7GXXcUkXAQVM= -github.com/livekit/protocol v1.48.1-0.20260622160555-777bf63c9d52/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 h1:Rj9/54oztXeioAwUkukXBwcPE/GxL97WylFd1m7V2pQ= +github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= @@ -419,10 +419,10 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k= -google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 h1:phvBWCAQMGN1945mp5fjCXP6jEF0+a0+4TjokS4sxNY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= +google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= diff --git a/pkg/config/config.go b/pkg/config/config.go index 4393f7f80..f85f6a841 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -91,6 +91,8 @@ type Config struct { EnableDataTracks bool `yaml:"enable_data_tracks,omitempty"` + EnableParticipantDataBlob bool `yaml:"enable_participant_data_blob,omitempty"` + API APIConfig `yaml:"api,omitempty"` } @@ -280,6 +282,8 @@ type RegionConfig struct { Lon float64 `yaml:"lon,omitempty"` } +// --------------------------------- + type LimitConfig struct { NumTracks int32 `yaml:"num_tracks,omitempty"` BytesPerSec float32 `yaml:"bytes_per_sec,omitempty"` @@ -291,6 +295,9 @@ type LimitConfig struct { MaxRoomNameLength int `yaml:"max_room_name_length,omitempty"` MaxParticipantIdentityLength int `yaml:"max_participant_identity_length,omitempty"` MaxParticipantNameLength int `yaml:"max_participant_name_length,omitempty"` + + MaxDataBlobKeyLength int `yaml:"max_data_blob_key_length,omitempty"` + MaxDataBlobSize uint32 `yaml:"max_data_blobs_size,omitempty"` } func (l LimitConfig) CheckRoomNameLength(name string) bool { @@ -321,6 +328,36 @@ func (l LimitConfig) CheckAttributesSize(attributes map[string]string) bool { return uint32(total) <= l.MaxAttributesSize } +func (l LimitConfig) CheckDataBlobKeyLength(key string) bool { + return l.MaxDataBlobKeyLength == 0 || len(key) <= l.MaxDataBlobKeyLength +} + +func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool { + if l.MaxDataBlobSize == 0 { + return true + } + + total := 0 + for _, dataBlob := range dataBlobs { + total += len(dataBlob.GetKey().String()) + len(dataBlob.Contents) + } + return uint32(total) <= l.MaxDataBlobSize +} + +func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *livekit.DataBlob) bool { + if l.MaxDataBlobSize == 0 { + return true + } + + total := 0 + for _, dataBlob := range dataBlobs { + total += len(dataBlob.Key.String()) + len(dataBlob.Contents) + } + return uint32(total+len(toAdd.GetKey().String())+len(toAdd.Contents)) <= l.MaxDataBlobSize +} + +// --------------------------------- + type IngressConfig struct { RTMPBaseURL string `yaml:"rtmp_base_url,omitempty"` WHIPBaseURL string `yaml:"whip_base_url,omitempty"` @@ -443,6 +480,8 @@ var DefaultConfig = Config{ MaxRoomNameLength: 256, MaxParticipantIdentityLength: 256, MaxParticipantNameLength: 256, + MaxDataBlobKeyLength: 256, + MaxDataBlobSize: 64000, }, Logging: LoggingConfig{ PionLevel: "error", diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index f510cf5af..1cf5bb5b2 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -225,6 +225,7 @@ type ParticipantParams struct { EnableRTPStreamRestartDetection bool ForceBackupCodecPolicySimulcast bool DisableTransceiverReuseForE2EE bool + EnableParticipantDataBlob bool EnableStartAtDesiredQuality bool } @@ -334,6 +335,8 @@ type ParticipantImpl struct { rpcLock sync.Mutex rpcPendingAcks map[string]*utils.DataChannelRpcPendingAckHandler rpcPendingResponses map[string]*utils.DataChannelRpcPendingResponseHandler + + dataBlob *ParticipantDataBlob } func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) { @@ -372,6 +375,9 @@ func NewParticipant(params ParticipantParams) (*ParticipantImpl, error) { telemetryGuard: &telemetry.ReferenceGuard{}, nextSubscribedDataTrackHandle: uint16(rand.Intn(256)), requireBroadcast: params.Grants.Metadata != "" || len(params.Grants.Attributes) != 0, + dataBlob: NewParticipantDataBlob(ParticipantDataBlobParams{ + Logger: params.Logger, + }), } p.setupSignalling() diff --git a/pkg/rtc/participant_data_blob.go b/pkg/rtc/participant_data_blob.go new file mode 100644 index 000000000..74512d569 --- /dev/null +++ b/pkg/rtc/participant_data_blob.go @@ -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 +} + +// ------------------------------- diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go new file mode 100644 index 000000000..e6ebcc8ed --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler.go @@ -0,0 +1,113 @@ +// 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 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() +} diff --git a/pkg/rtc/participant_data_blob_handler_test.go b/pkg/rtc/participant_data_blob_handler_test.go new file mode 100644 index 000000000..411a6a203 --- /dev/null +++ b/pkg/rtc/participant_data_blob_handler_test.go @@ -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) + }) +} diff --git a/pkg/rtc/participant_data_blob_test.go b/pkg/rtc/participant_data_blob_test.go new file mode 100644 index 000000000..a6d28fb20 --- /dev/null +++ b/pkg/rtc/participant_data_blob_test.go @@ -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() +} diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 3fa388adc..8f5c3dd2b 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -368,3 +368,17 @@ func (p *ParticipantImpl) SendDataTrackSubscriberHandles(handles map[uint32]*liv 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, + })) +} diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 86ddcd05e..66e922ec3 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1384,6 +1384,11 @@ func (r *Room) onUpdateDataSubscriptions(participant types.LocalParticipant, req } } +func (r *Room) onGetDataBlob(participant types.LocalParticipant, req *livekit.GetDataBlobRequest) { + publisher := r.GetParticipant(livekit.ParticipantIdentity(req.ParticipantIdentity)) + participant.ProcessGetDataBlobRequest(req, publisher) +} + func (r *Room) onLeave(p types.LocalParticipant, reason types.ParticipantCloseReason) { r.RemoveParticipant(p.Identity(), p.ID(), reason) } @@ -1996,6 +2001,13 @@ func (l *localParticipantListener) OnUpdateDataSubscriptions(p types.LocalPartic l.room.onUpdateDataSubscriptions(p, req) } +func (l *localParticipantListener) OnStoreDataBlob(_p types.LocalParticipant, _dataBlob *livekit.DataBlob) { +} + +func (l *localParticipantListener) OnGetDataBlob(p types.LocalParticipant, req *livekit.GetDataBlobRequest) { + l.room.onGetDataBlob(p, req) +} + func (l *localParticipantListener) OnSyncState(p types.LocalParticipant, state *livekit.SyncState) error { return l.room.onSyncState(p, state) } diff --git a/pkg/rtc/signalling/interfaces.go b/pkg/rtc/signalling/interfaces.go index 2470e9d31..99c3a0acc 100644 --- a/pkg/rtc/signalling/interfaces.go +++ b/pkg/rtc/signalling/interfaces.go @@ -62,4 +62,6 @@ type ParticipantSignalling interface { 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 } diff --git a/pkg/rtc/signalling/signalhandler.go b/pkg/rtc/signalling/signalhandler.go index 7e69aeee2..740e65899 100644 --- a/pkg/rtc/signalling/signalhandler.go +++ b/pkg/rtc/signalling/signalhandler.go @@ -151,6 +151,12 @@ func (s *signalhandler) HandleMessage(msg proto.Message) error { 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 diff --git a/pkg/rtc/signalling/signalling.go b/pkg/rtc/signalling/signalling.go index fffc2ba29..c13cee1be 100644 --- a/pkg/rtc/signalling/signalling.go +++ b/pkg/rtc/signalling/signalling.go @@ -258,3 +258,19 @@ func (s *signalling) SignalDataTrackSubscriberHandles(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, + }, + } +} diff --git a/pkg/rtc/signalling/signallingunimplemented.go b/pkg/rtc/signalling/signallingunimplemented.go index dca48779f..8210c6aac 100644 --- a/pkg/rtc/signalling/signallingunimplemented.go +++ b/pkg/rtc/signalling/signallingunimplemented.go @@ -127,3 +127,11 @@ func (u *signallingUnimplemented) SignalUnpublishDataTrackResponse(unpublishData 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 +} diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index ec10fb396..5d0c702ed 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -355,6 +355,9 @@ type Participant interface { HandleReceivedDataTrackMessage([]byte, *datatrack.Packet, int64) GetParticipantListener() ParticipantListener + + AddDataBlob(dataBlob *livekit.DataBlob) + GetDataBlob(key *livekit.DataBlobKey) *livekit.DataBlob } // ------------------------------------------------------- @@ -562,6 +565,9 @@ type LocalParticipant interface { HandlePublishDataTrackRequest(*livekit.PublishDataTrackRequest) HandleUnpublishDataTrackRequest(*livekit.UnpublishDataTrackRequest) HandleUpdateDataSubscription(*livekit.UpdateDataSubscription) + HandleStoreDataBlobRequest(*livekit.StoreDataBlobRequest) + HandleGetDataBlobRequest(*livekit.GetDataBlobRequest) + ProcessGetDataBlobRequest(*livekit.GetDataBlobRequest, Participant) HandleSignalMessage(msg proto.Message) error @@ -572,6 +578,8 @@ type LocalParticipant interface { ClearParticipantListener() GetNextSubscribedDataTrackHandle() uint16 + + GetAllDataBlob() []*livekit.DataBlob } // --------------------------------------------- @@ -621,6 +629,8 @@ type LocalParticipantListener interface { ) OnUpdateSubscriptionPermission(LocalParticipant, *livekit.SubscriptionPermission) error OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription) + OnStoreDataBlob(LocalParticipant, *livekit.DataBlob) + OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest) OnSyncState(LocalParticipant, *livekit.SyncState) error OnSimulateScenario(LocalParticipant, *livekit.SimulateScenario) error OnLeave(LocalParticipant, ParticipantCloseReason) @@ -652,6 +662,10 @@ func (*NullLocalParticipantListener) OnUpdateSubscriptionPermission(LocalPartici } func (*NullLocalParticipantListener) OnUpdateDataSubscriptions(LocalParticipant, *livekit.UpdateDataSubscription) { } +func (*NullLocalParticipantListener) OnStoreDataBlob(LocalParticipant, *livekit.DataBlob) { +} +func (*NullLocalParticipantListener) OnGetDataBlob(LocalParticipant, *livekit.GetDataBlobRequest) { +} func (*NullLocalParticipantListener) OnSyncState(LocalParticipant, *livekit.SyncState) error { return nil } diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index 9570a28c2..d4198c2de 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -33,6 +33,11 @@ type FakeLocalParticipant struct { activeAtReturnsOnCall map[int]struct { result1 time.Time } + AddDataBlobStub func(*livekit.DataBlob) + addDataBlobMutex sync.RWMutex + addDataBlobArgsForCall []struct { + arg1 *livekit.DataBlob + } AddOnCloseStub func(string, func(types.LocalParticipant)) addOnCloseMutex sync.RWMutex addOnCloseArgsForCall []struct { @@ -216,6 +221,16 @@ type FakeLocalParticipant struct { getAdaptiveStreamReturnsOnCall map[int]struct { result1 bool } + GetAllDataBlobStub func() []*livekit.DataBlob + getAllDataBlobMutex sync.RWMutex + getAllDataBlobArgsForCall []struct { + } + getAllDataBlobReturns struct { + result1 []*livekit.DataBlob + } + getAllDataBlobReturnsOnCall map[int]struct { + result1 []*livekit.DataBlob + } GetAnswerStub func() (webrtc.SessionDescription, uint32, error) getAnswerMutex sync.RWMutex getAnswerArgsForCall []struct { @@ -305,6 +320,17 @@ type FakeLocalParticipant struct { getCountryReturnsOnCall map[int]struct { result1 string } + GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob + getDataBlobMutex sync.RWMutex + getDataBlobArgsForCall []struct { + arg1 *livekit.DataBlobKey + } + getDataBlobReturns struct { + result1 *livekit.DataBlob + } + getDataBlobReturnsOnCall map[int]struct { + result1 *livekit.DataBlob + } GetDataTrackTransportStub func() types.DataTrackTransport getDataTrackTransportMutex sync.RWMutex getDataTrackTransportArgsForCall []struct { @@ -576,6 +602,11 @@ type FakeLocalParticipant struct { handleAnswerArgsForCall []struct { arg1 *livekit.SessionDescription } + HandleGetDataBlobRequestStub func(*livekit.GetDataBlobRequest) + handleGetDataBlobRequestMutex sync.RWMutex + handleGetDataBlobRequestArgsForCall []struct { + arg1 *livekit.GetDataBlobRequest + } HandleICERestartSDPFragmentStub func(string) (string, error) handleICERestartSDPFragmentMutex sync.RWMutex handleICERestartSDPFragmentArgsForCall []struct { @@ -689,6 +720,11 @@ type FakeLocalParticipant struct { handleSimulateScenarioReturnsOnCall map[int]struct { result1 error } + HandleStoreDataBlobRequestStub func(*livekit.StoreDataBlobRequest) + handleStoreDataBlobRequestMutex sync.RWMutex + handleStoreDataBlobRequestArgsForCall []struct { + arg1 *livekit.StoreDataBlobRequest + } HandleSyncStateStub func(*livekit.SyncState) error handleSyncStateMutex sync.RWMutex handleSyncStateArgsForCall []struct { @@ -986,6 +1022,12 @@ type FakeLocalParticipant struct { arg2 chan string arg3 chan error } + ProcessGetDataBlobRequestStub func(*livekit.GetDataBlobRequest, types.Participant) + processGetDataBlobRequestMutex sync.RWMutex + processGetDataBlobRequestArgsForCall []struct { + arg1 *livekit.GetDataBlobRequest + arg2 types.Participant + } ProtocolVersionStub func() types.ProtocolVersion protocolVersionMutex sync.RWMutex protocolVersionArgsForCall []struct { @@ -1594,6 +1636,38 @@ func (fake *FakeLocalParticipant) ActiveAtReturnsOnCall(i int, result1 time.Time }{result1} } +func (fake *FakeLocalParticipant) AddDataBlob(arg1 *livekit.DataBlob) { + fake.addDataBlobMutex.Lock() + fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct { + arg1 *livekit.DataBlob + }{arg1}) + stub := fake.AddDataBlobStub + fake.recordInvocation("AddDataBlob", []interface{}{arg1}) + fake.addDataBlobMutex.Unlock() + if stub != nil { + fake.AddDataBlobStub(arg1) + } +} + +func (fake *FakeLocalParticipant) AddDataBlobCallCount() int { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + return len(fake.addDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) { + fake.addDataBlobMutex.Lock() + defer fake.addDataBlobMutex.Unlock() + fake.AddDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + argsForCall := fake.addDataBlobArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) AddOnClose(arg1 string, arg2 func(types.LocalParticipant)) { fake.addOnCloseMutex.Lock() fake.addOnCloseArgsForCall = append(fake.addOnCloseArgsForCall, struct { @@ -2539,6 +2613,59 @@ func (fake *FakeLocalParticipant) GetAdaptiveStreamReturnsOnCall(i int, result1 }{result1} } +func (fake *FakeLocalParticipant) GetAllDataBlob() []*livekit.DataBlob { + fake.getAllDataBlobMutex.Lock() + ret, specificReturn := fake.getAllDataBlobReturnsOnCall[len(fake.getAllDataBlobArgsForCall)] + fake.getAllDataBlobArgsForCall = append(fake.getAllDataBlobArgsForCall, struct { + }{}) + stub := fake.GetAllDataBlobStub + fakeReturns := fake.getAllDataBlobReturns + fake.recordInvocation("GetAllDataBlob", []interface{}{}) + fake.getAllDataBlobMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetAllDataBlobCallCount() int { + fake.getAllDataBlobMutex.RLock() + defer fake.getAllDataBlobMutex.RUnlock() + return len(fake.getAllDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) GetAllDataBlobCalls(stub func() []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) GetAllDataBlobReturns(result1 []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = nil + fake.getAllDataBlobReturns = struct { + result1 []*livekit.DataBlob + }{result1} +} + +func (fake *FakeLocalParticipant) GetAllDataBlobReturnsOnCall(i int, result1 []*livekit.DataBlob) { + fake.getAllDataBlobMutex.Lock() + defer fake.getAllDataBlobMutex.Unlock() + fake.GetAllDataBlobStub = nil + if fake.getAllDataBlobReturnsOnCall == nil { + fake.getAllDataBlobReturnsOnCall = make(map[int]struct { + result1 []*livekit.DataBlob + }) + } + fake.getAllDataBlobReturnsOnCall[i] = struct { + result1 []*livekit.DataBlob + }{result1} +} + func (fake *FakeLocalParticipant) GetAnswer() (webrtc.SessionDescription, uint32, error) { fake.getAnswerMutex.Lock() ret, specificReturn := fake.getAnswerReturnsOnCall[len(fake.getAnswerArgsForCall)] @@ -2983,6 +3110,67 @@ func (fake *FakeLocalParticipant) GetCountryReturnsOnCall(i int, result1 string) }{result1} } +func (fake *FakeLocalParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob { + fake.getDataBlobMutex.Lock() + ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)] + fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct { + arg1 *livekit.DataBlobKey + }{arg1}) + stub := fake.GetDataBlobStub + fakeReturns := fake.getDataBlobReturns + fake.recordInvocation("GetDataBlob", []interface{}{arg1}) + fake.getDataBlobMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetDataBlobCallCount() int { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + return len(fake.getDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = stub +} + +func (fake *FakeLocalParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + argsForCall := fake.getDataBlobArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeLocalParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + fake.getDataBlobReturns = struct { + result1 *livekit.DataBlob + }{result1} +} + +func (fake *FakeLocalParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + if fake.getDataBlobReturnsOnCall == nil { + fake.getDataBlobReturnsOnCall = make(map[int]struct { + result1 *livekit.DataBlob + }) + } + fake.getDataBlobReturnsOnCall[i] = struct { + result1 *livekit.DataBlob + }{result1} +} + func (fake *FakeLocalParticipant) GetDataTrackTransport() types.DataTrackTransport { fake.getDataTrackTransportMutex.Lock() ret, specificReturn := fake.getDataTrackTransportReturnsOnCall[len(fake.getDataTrackTransportArgsForCall)] @@ -4428,6 +4616,38 @@ func (fake *FakeLocalParticipant) HandleAnswerArgsForCall(i int) *livekit.Sessio return argsForCall.arg1 } +func (fake *FakeLocalParticipant) HandleGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest) { + fake.handleGetDataBlobRequestMutex.Lock() + fake.handleGetDataBlobRequestArgsForCall = append(fake.handleGetDataBlobRequestArgsForCall, struct { + arg1 *livekit.GetDataBlobRequest + }{arg1}) + stub := fake.HandleGetDataBlobRequestStub + fake.recordInvocation("HandleGetDataBlobRequest", []interface{}{arg1}) + fake.handleGetDataBlobRequestMutex.Unlock() + if stub != nil { + fake.HandleGetDataBlobRequestStub(arg1) + } +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCallCount() int { + fake.handleGetDataBlobRequestMutex.RLock() + defer fake.handleGetDataBlobRequestMutex.RUnlock() + return len(fake.handleGetDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest)) { + fake.handleGetDataBlobRequestMutex.Lock() + defer fake.handleGetDataBlobRequestMutex.Unlock() + fake.HandleGetDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) HandleGetDataBlobRequestArgsForCall(i int) *livekit.GetDataBlobRequest { + fake.handleGetDataBlobRequestMutex.RLock() + defer fake.handleGetDataBlobRequestMutex.RUnlock() + argsForCall := fake.handleGetDataBlobRequestArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) HandleICERestartSDPFragment(arg1 string) (string, error) { fake.handleICERestartSDPFragmentMutex.Lock() ret, specificReturn := fake.handleICERestartSDPFragmentReturnsOnCall[len(fake.handleICERestartSDPFragmentArgsForCall)] @@ -5052,6 +5272,38 @@ func (fake *FakeLocalParticipant) HandleSimulateScenarioReturnsOnCall(i int, res }{result1} } +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequest(arg1 *livekit.StoreDataBlobRequest) { + fake.handleStoreDataBlobRequestMutex.Lock() + fake.handleStoreDataBlobRequestArgsForCall = append(fake.handleStoreDataBlobRequestArgsForCall, struct { + arg1 *livekit.StoreDataBlobRequest + }{arg1}) + stub := fake.HandleStoreDataBlobRequestStub + fake.recordInvocation("HandleStoreDataBlobRequest", []interface{}{arg1}) + fake.handleStoreDataBlobRequestMutex.Unlock() + if stub != nil { + fake.HandleStoreDataBlobRequestStub(arg1) + } +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCallCount() int { + fake.handleStoreDataBlobRequestMutex.RLock() + defer fake.handleStoreDataBlobRequestMutex.RUnlock() + return len(fake.handleStoreDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestCalls(stub func(*livekit.StoreDataBlobRequest)) { + fake.handleStoreDataBlobRequestMutex.Lock() + defer fake.handleStoreDataBlobRequestMutex.Unlock() + fake.HandleStoreDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) HandleStoreDataBlobRequestArgsForCall(i int) *livekit.StoreDataBlobRequest { + fake.handleStoreDataBlobRequestMutex.RLock() + defer fake.handleStoreDataBlobRequestMutex.RUnlock() + argsForCall := fake.handleStoreDataBlobRequestArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) HandleSyncState(arg1 *livekit.SyncState) error { fake.handleSyncStateMutex.Lock() ret, specificReturn := fake.handleSyncStateReturnsOnCall[len(fake.handleSyncStateArgsForCall)] @@ -6680,6 +6932,39 @@ func (fake *FakeLocalParticipant) PerformRpcArgsForCall(i int) (*livekit.Perform return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequest(arg1 *livekit.GetDataBlobRequest, arg2 types.Participant) { + fake.processGetDataBlobRequestMutex.Lock() + fake.processGetDataBlobRequestArgsForCall = append(fake.processGetDataBlobRequestArgsForCall, struct { + arg1 *livekit.GetDataBlobRequest + arg2 types.Participant + }{arg1, arg2}) + stub := fake.ProcessGetDataBlobRequestStub + fake.recordInvocation("ProcessGetDataBlobRequest", []interface{}{arg1, arg2}) + fake.processGetDataBlobRequestMutex.Unlock() + if stub != nil { + fake.ProcessGetDataBlobRequestStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCallCount() int { + fake.processGetDataBlobRequestMutex.RLock() + defer fake.processGetDataBlobRequestMutex.RUnlock() + return len(fake.processGetDataBlobRequestArgsForCall) +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestCalls(stub func(*livekit.GetDataBlobRequest, types.Participant)) { + fake.processGetDataBlobRequestMutex.Lock() + defer fake.processGetDataBlobRequestMutex.Unlock() + fake.ProcessGetDataBlobRequestStub = stub +} + +func (fake *FakeLocalParticipant) ProcessGetDataBlobRequestArgsForCall(i int) (*livekit.GetDataBlobRequest, types.Participant) { + fake.processGetDataBlobRequestMutex.RLock() + defer fake.processGetDataBlobRequestMutex.RUnlock() + argsForCall := fake.processGetDataBlobRequestArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipant) ProtocolVersion() types.ProtocolVersion { fake.protocolVersionMutex.Lock() ret, specificReturn := fake.protocolVersionReturnsOnCall[len(fake.protocolVersionArgsForCall)] diff --git a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go index 4cd0fef83..4c0a3a07a 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go @@ -42,6 +42,12 @@ type FakeLocalParticipantListener struct { arg1 types.Participant arg2 types.DataTrack } + OnGetDataBlobStub func(types.LocalParticipant, *livekit.GetDataBlobRequest) + onGetDataBlobMutex sync.RWMutex + onGetDataBlobArgsForCall []struct { + arg1 types.LocalParticipant + arg2 *livekit.GetDataBlobRequest + } OnLeaveStub func(types.LocalParticipant, types.ParticipantCloseReason) onLeaveMutex sync.RWMutex onLeaveArgsForCall []struct { @@ -82,6 +88,12 @@ type FakeLocalParticipantListener struct { onStateChangeArgsForCall []struct { arg1 types.LocalParticipant } + OnStoreDataBlobStub func(types.LocalParticipant, *livekit.DataBlob) + onStoreDataBlobMutex sync.RWMutex + onStoreDataBlobArgsForCall []struct { + arg1 types.LocalParticipant + arg2 *livekit.DataBlob + } OnSubscribeStatusChangedStub func(types.LocalParticipant, livekit.ParticipantID, bool) onSubscribeStatusChangedMutex sync.RWMutex onSubscribeStatusChangedArgsForCall []struct { @@ -331,6 +343,39 @@ func (fake *FakeLocalParticipantListener) OnDataTrackUnpublishedArgsForCall(i in return argsForCall.arg1, argsForCall.arg2 } +func (fake *FakeLocalParticipantListener) OnGetDataBlob(arg1 types.LocalParticipant, arg2 *livekit.GetDataBlobRequest) { + fake.onGetDataBlobMutex.Lock() + fake.onGetDataBlobArgsForCall = append(fake.onGetDataBlobArgsForCall, struct { + arg1 types.LocalParticipant + arg2 *livekit.GetDataBlobRequest + }{arg1, arg2}) + stub := fake.OnGetDataBlobStub + fake.recordInvocation("OnGetDataBlob", []interface{}{arg1, arg2}) + fake.onGetDataBlobMutex.Unlock() + if stub != nil { + fake.OnGetDataBlobStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobCallCount() int { + fake.onGetDataBlobMutex.RLock() + defer fake.onGetDataBlobMutex.RUnlock() + return len(fake.onGetDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobCalls(stub func(types.LocalParticipant, *livekit.GetDataBlobRequest)) { + fake.onGetDataBlobMutex.Lock() + defer fake.onGetDataBlobMutex.Unlock() + fake.OnGetDataBlobStub = stub +} + +func (fake *FakeLocalParticipantListener) OnGetDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.GetDataBlobRequest) { + fake.onGetDataBlobMutex.RLock() + defer fake.onGetDataBlobMutex.RUnlock() + argsForCall := fake.onGetDataBlobArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipantListener) OnLeave(arg1 types.LocalParticipant, arg2 types.ParticipantCloseReason) { fake.onLeaveMutex.Lock() fake.onLeaveArgsForCall = append(fake.onLeaveArgsForCall, struct { @@ -556,6 +601,39 @@ func (fake *FakeLocalParticipantListener) OnStateChangeArgsForCall(i int) types. return argsForCall.arg1 } +func (fake *FakeLocalParticipantListener) OnStoreDataBlob(arg1 types.LocalParticipant, arg2 *livekit.DataBlob) { + fake.onStoreDataBlobMutex.Lock() + fake.onStoreDataBlobArgsForCall = append(fake.onStoreDataBlobArgsForCall, struct { + arg1 types.LocalParticipant + arg2 *livekit.DataBlob + }{arg1, arg2}) + stub := fake.OnStoreDataBlobStub + fake.recordInvocation("OnStoreDataBlob", []interface{}{arg1, arg2}) + fake.onStoreDataBlobMutex.Unlock() + if stub != nil { + fake.OnStoreDataBlobStub(arg1, arg2) + } +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobCallCount() int { + fake.onStoreDataBlobMutex.RLock() + defer fake.onStoreDataBlobMutex.RUnlock() + return len(fake.onStoreDataBlobArgsForCall) +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobCalls(stub func(types.LocalParticipant, *livekit.DataBlob)) { + fake.onStoreDataBlobMutex.Lock() + defer fake.onStoreDataBlobMutex.Unlock() + fake.OnStoreDataBlobStub = stub +} + +func (fake *FakeLocalParticipantListener) OnStoreDataBlobArgsForCall(i int) (types.LocalParticipant, *livekit.DataBlob) { + fake.onStoreDataBlobMutex.RLock() + defer fake.onStoreDataBlobMutex.RUnlock() + argsForCall := fake.onStoreDataBlobArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + func (fake *FakeLocalParticipantListener) OnSubscribeStatusChanged(arg1 types.LocalParticipant, arg2 livekit.ParticipantID, arg3 bool) { fake.onSubscribeStatusChangedMutex.Lock() fake.onSubscribeStatusChangedArgsForCall = append(fake.onSubscribeStatusChangedArgsForCall, struct { diff --git a/pkg/rtc/types/typesfakes/fake_participant.go b/pkg/rtc/types/typesfakes/fake_participant.go index 19498c9c3..652c8bf14 100644 --- a/pkg/rtc/types/typesfakes/fake_participant.go +++ b/pkg/rtc/types/typesfakes/fake_participant.go @@ -13,6 +13,11 @@ import ( ) type FakeParticipant struct { + AddDataBlobStub func(*livekit.DataBlob) + addDataBlobMutex sync.RWMutex + addDataBlobArgsForCall []struct { + arg1 *livekit.DataBlob + } CanSkipBroadcastStub func() bool canSkipBroadcastMutex sync.RWMutex canSkipBroadcastArgsForCall []struct { @@ -78,6 +83,17 @@ type FakeParticipant struct { result1 float64 result2 bool } + GetDataBlobStub func(*livekit.DataBlobKey) *livekit.DataBlob + getDataBlobMutex sync.RWMutex + getDataBlobArgsForCall []struct { + arg1 *livekit.DataBlobKey + } + getDataBlobReturns struct { + result1 *livekit.DataBlob + } + getDataBlobReturnsOnCall map[int]struct { + result1 *livekit.DataBlob + } GetLoggerStub func() logger.Logger getLoggerMutex sync.RWMutex getLoggerArgsForCall []struct { @@ -361,6 +377,38 @@ type FakeParticipant struct { invocationsMutex sync.RWMutex } +func (fake *FakeParticipant) AddDataBlob(arg1 *livekit.DataBlob) { + fake.addDataBlobMutex.Lock() + fake.addDataBlobArgsForCall = append(fake.addDataBlobArgsForCall, struct { + arg1 *livekit.DataBlob + }{arg1}) + stub := fake.AddDataBlobStub + fake.recordInvocation("AddDataBlob", []interface{}{arg1}) + fake.addDataBlobMutex.Unlock() + if stub != nil { + fake.AddDataBlobStub(arg1) + } +} + +func (fake *FakeParticipant) AddDataBlobCallCount() int { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + return len(fake.addDataBlobArgsForCall) +} + +func (fake *FakeParticipant) AddDataBlobCalls(stub func(*livekit.DataBlob)) { + fake.addDataBlobMutex.Lock() + defer fake.addDataBlobMutex.Unlock() + fake.AddDataBlobStub = stub +} + +func (fake *FakeParticipant) AddDataBlobArgsForCall(i int) *livekit.DataBlob { + fake.addDataBlobMutex.RLock() + defer fake.addDataBlobMutex.RUnlock() + argsForCall := fake.addDataBlobArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeParticipant) CanSkipBroadcast() bool { fake.canSkipBroadcastMutex.Lock() ret, specificReturn := fake.canSkipBroadcastReturnsOnCall[len(fake.canSkipBroadcastArgsForCall)] @@ -692,6 +740,67 @@ func (fake *FakeParticipant) GetAudioLevelReturnsOnCall(i int, result1 float64, }{result1, result2} } +func (fake *FakeParticipant) GetDataBlob(arg1 *livekit.DataBlobKey) *livekit.DataBlob { + fake.getDataBlobMutex.Lock() + ret, specificReturn := fake.getDataBlobReturnsOnCall[len(fake.getDataBlobArgsForCall)] + fake.getDataBlobArgsForCall = append(fake.getDataBlobArgsForCall, struct { + arg1 *livekit.DataBlobKey + }{arg1}) + stub := fake.GetDataBlobStub + fakeReturns := fake.getDataBlobReturns + fake.recordInvocation("GetDataBlob", []interface{}{arg1}) + fake.getDataBlobMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeParticipant) GetDataBlobCallCount() int { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + return len(fake.getDataBlobArgsForCall) +} + +func (fake *FakeParticipant) GetDataBlobCalls(stub func(*livekit.DataBlobKey) *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = stub +} + +func (fake *FakeParticipant) GetDataBlobArgsForCall(i int) *livekit.DataBlobKey { + fake.getDataBlobMutex.RLock() + defer fake.getDataBlobMutex.RUnlock() + argsForCall := fake.getDataBlobArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeParticipant) GetDataBlobReturns(result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + fake.getDataBlobReturns = struct { + result1 *livekit.DataBlob + }{result1} +} + +func (fake *FakeParticipant) GetDataBlobReturnsOnCall(i int, result1 *livekit.DataBlob) { + fake.getDataBlobMutex.Lock() + defer fake.getDataBlobMutex.Unlock() + fake.GetDataBlobStub = nil + if fake.getDataBlobReturnsOnCall == nil { + fake.getDataBlobReturnsOnCall = make(map[int]struct { + result1 *livekit.DataBlob + }) + } + fake.getDataBlobReturnsOnCall[i] = struct { + result1 *livekit.DataBlob + }{result1} +} + func (fake *FakeParticipant) GetLogger() logger.Logger { fake.getLoggerMutex.Lock() ret, specificReturn := fake.getLoggerReturnsOnCall[len(fake.getLoggerArgsForCall)] diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 13ca67493..ce850df6e 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -538,6 +538,7 @@ func (r *RoomManager) StartSession( FireOnTrackBySdp: true, UseSinglePeerConnection: pi.UseSinglePeerConnection, EnableDataTracks: r.config.EnableDataTracks, + EnableParticipantDataBlob: r.config.EnableParticipantDataBlob, EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection, }) if err != nil { diff --git a/pkg/sfu/datachannel/datachannel_writer_test.go b/pkg/sfu/datachannel/datachannel_writer_test.go index 9b99b8929..3bdcefbf4 100644 --- a/pkg/sfu/datachannel/datachannel_writer_test.go +++ b/pkg/sfu/datachannel/datachannel_writer_test.go @@ -2,10 +2,11 @@ package datachannel import ( "context" - "sync/atomic" "testing" "time" + "go.uber.org/atomic" + "github.com/pion/datachannel" "github.com/pion/transport/v4/deadline" "github.com/stretchr/testify/require" diff --git a/test/integration_helpers.go b/test/integration_helpers.go index 3ccde58d9..367294e46 100644 --- a/test/integration_helpers.go +++ b/test/integration_helpers.go @@ -80,9 +80,13 @@ func setupSingleNodeTest(name string) (*service.LivekitServer, func()) { } func setupMultiNodeTest(name string) (*service.LivekitServer, *service.LivekitServer, func()) { + return setupMultiNodeTestWithConfig(name, nil) +} + +func setupMultiNodeTestWithConfig(name string, configUpdater func(*config.Config)) (*service.LivekitServer, *service.LivekitServer, func()) { logger.Infow("----------------STARTING TEST----------------", "test", name) - s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort) - s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort) + s1 := createMultiNodeServer(guid.New(nodeID1), defaultServerPort, configUpdater) + s2 := createMultiNodeServer(guid.New(nodeID2), secondServerPort, configUpdater) go s1.Start() go s2.Start() @@ -190,7 +194,7 @@ func createSingleNodeServer(configUpdater func(*config.Config)) *service.Livekit return s } -func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer { +func createMultiNodeServer(nodeID string, port uint32, configUpdater func(*config.Config)) *service.LivekitServer { var err error conf, err := config.NewConfig("", true, nil, nil) if err != nil { @@ -202,6 +206,9 @@ func createMultiNodeServer(nodeID string, port uint32) *service.LivekitServer { conf.Redis.Address = "localhost:6379" conf.Keys = map[string]string{testApiKey: testApiSecret} conf.EnableDataTracks = true + if configUpdater != nil { + configUpdater(conf) + } currentNode, err := routing.NewLocalNode(conf) if err != nil { diff --git a/test/multinode_test.go b/test/multinode_test.go index 9b4d5acea..21f4294c6 100644 --- a/test/multinode_test.go +++ b/test/multinode_test.go @@ -24,6 +24,7 @@ import ( "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/rtc" "github.com/livekit/livekit-server/pkg/testutils" "github.com/livekit/livekit-server/test/client" @@ -425,3 +426,129 @@ func TestCloseDisconnectedParticipantOnSignalClose(t *testing.T) { }) } } + +func TestMultiNodeDataBlob(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, _, finish := setupMultiNodeTestWithConfig("TestMultiNodeDataBlob", func(c *config.Config) { + c.EnableParticipantDataBlob = true + c.Limit.MaxDataBlobSize = 1024 + }) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + subCapture := &dataBlobCapture{} + + // publisher on node 1, subscriber on node 2 + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &client.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + sub := createRTCClient("sub", secondServerPort, testRTCServicePath, &client.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: subCapture.interceptor(), + }) + waitUntilConnected(t, pub, sub) + defer stopClients(pub, sub) + + // wait for both nodes to see each other so the get request routes correctly + testutils.WithTimeout(t, func() string { + if sub.GetRemoteParticipant(pub.ID()) == nil { + return "sub does not see pub yet" + } + return "" + }) + + key := &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-multinode", + }, + } + contents := []byte("multinode-content") + + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + RequestId: 1, + Blob: &livekit.DataBlob{ + Key: key, + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := pubCapture.takeStoreResponse() + if resp == nil { + return "publisher did not receive store response" + } + if resp.RequestId != 1 { + return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId) + } + if resp.Key == nil { + return "store response missing key" + } + if resp.Key.String() != key.String() { + return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String()) + } + return "" + }) + require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success") + + // subscriber on a different node asks for the blob; the request routes + // across nodes to the publisher. + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := subCapture.takeBlobResponse() + if resp == nil { + return "subscriber did not receive blob response" + } + if resp.Blob == nil { + return "blob response missing blob" + } + if resp.Blob.Key.String() != key.String() { + return fmt.Sprintf("expected data blob key %s, got %s", key.String(), resp.Blob.Key.String()) + } + if string(resp.Blob.Contents) != string(contents) { + return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents) + } + return "" + }) + + // requesting an unknown publisher identity should return NOT_FOUND + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "unknown-publisher", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for unknown publisher" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + }) + } +} diff --git a/test/singlenode_test.go b/test/singlenode_test.go index cf09473f3..6d88f4d8a 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -1526,3 +1526,299 @@ func TestTurnAuthFailure(t *testing.T) { }) } } + +// dataBlobCapture buffers RequestResponse, StoreDataBlobResponse, and GetDataBlobResponse messages +// sent to a test client so they can be asserted on. Other messages flow through to the +// default handler. +type dataBlobCapture struct { + mu sync.Mutex + requestResponses []*livekit.RequestResponse + storeResponses []*livekit.StoreDataBlobResponse + blobResponses []*livekit.GetDataBlobResponse +} + +func (c *dataBlobCapture) interceptor() testclient.SignalResponseInterceptor { + return func(msg *livekit.SignalResponse, next testclient.SignalResponseHandler) error { + switch m := msg.Message.(type) { + case *livekit.SignalResponse_RequestResponse: + c.mu.Lock() + c.requestResponses = append(c.requestResponses, m.RequestResponse) + c.mu.Unlock() + case *livekit.SignalResponse_StoreDataBlobResponse: + c.mu.Lock() + c.storeResponses = append(c.storeResponses, m.StoreDataBlobResponse) + c.mu.Unlock() + case *livekit.SignalResponse_GetDataBlobResponse: + c.mu.Lock() + c.blobResponses = append(c.blobResponses, m.GetDataBlobResponse) + c.mu.Unlock() + } + return next(msg) + } +} + +func (c *dataBlobCapture) takeRequestResponse() *livekit.RequestResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.requestResponses) == 0 { + return nil + } + rr := c.requestResponses[0] + c.requestResponses = c.requestResponses[1:] + return rr +} + +func (c *dataBlobCapture) takeStoreResponse() *livekit.StoreDataBlobResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.storeResponses) == 0 { + return nil + } + sr := c.storeResponses[0] + c.storeResponses = c.storeResponses[1:] + return sr +} + +func (c *dataBlobCapture) takeBlobResponse() *livekit.GetDataBlobResponse { + c.mu.Lock() + defer c.mu.Unlock() + if len(c.blobResponses) == 0 { + return nil + } + sr := c.blobResponses[0] + c.blobResponses = c.blobResponses[1:] + return sr +} + +func (c *dataBlobCapture) requestResponseCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return len(c.requestResponses) +} + +func setupDataBlobServer(t *testing.T, name string, enable bool) (*service.LivekitServer, func()) { + logger.Infow("----------------STARTING TEST----------------", "test", name) + s := createSingleNodeServer(func(c *config.Config) { + c.EnableParticipantDataBlob = enable + c.Limit.MaxDataBlobSize = 1024 + }) + go func() { + if err := s.Start(); err != nil { + logger.Errorw("server returned error", err) + } + }() + waitForServerToStart(s) + return s, func() { + s.Stop(true) + logger.Infow("----------------FINISHING TEST----------------", "test", name) + } +} + +func TestSingleNodeDataBlob(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, finish := setupDataBlobServer(t, "TestSingleNodeDataBlob", true) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + subCapture := &dataBlobCapture{} + + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + sub := createRTCClient("sub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: subCapture.interceptor(), + }) + waitUntilConnected(t, pub, sub) + defer stopClients(pub, sub) + + key := &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-1", + }, + } + contents := []byte("definition-bytes") + + // publisher stores a blob + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + RequestId: 1, + Blob: &livekit.DataBlob{ + Key: key, + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := pubCapture.takeStoreResponse() + if resp == nil { + return "publisher did not receive store response" + } + if resp.RequestId != 1 { + return fmt.Sprintf("expected store response request id 1, got %d", resp.RequestId) + } + if resp.Key == nil { + return "store response missing key" + } + if resp.Key.String() != key.String() { + return fmt.Sprintf("expected stored blob key %s, got %s", key.String(), resp.Key.String()) + } + return "" + }) + require.Equal(t, 0, pubCapture.requestResponseCount(), "publisher should not receive an error response on success") + + // subscriber asks for the blob + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + resp := subCapture.takeBlobResponse() + if resp == nil { + return "subscriber did not receive blob response" + } + if resp.Blob == nil { + return "blob response missing blob" + } + if resp.Blob.Key.String() != key.String() { + return fmt.Sprintf("expected blob key %s, got %s", key.String(), resp.Blob.Key.String()) + } + if string(resp.Blob.Contents) != string(contents) { + return fmt.Sprintf("expected contents %q, got %q", contents, resp.Blob.Contents) + } + return "" + }) + + // subscriber asks for an unknown blob on a known publisher + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "pub", + Key: &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "does-not-exist", + }, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for missing blob" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + + // subscriber asks for a blob on an unknown publisher identity + require.NoError(t, sub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_GetDataBlobRequest{ + GetDataBlobRequest: &livekit.GetDataBlobRequest{ + ParticipantIdentity: "unknown-publisher", + Key: key, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := subCapture.takeRequestResponse() + if rr == nil { + return "subscriber did not receive RequestResponse for unknown publisher" + } + if rr.Reason != livekit.RequestResponse_NOT_FOUND { + return fmt.Sprintf("expected NOT_FOUND, got %s", rr.Reason) + } + return "" + }) + + // publisher sends an invalid blob (empty key) + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Contents: contents, + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := pubCapture.takeRequestResponse() + if rr == nil { + return "publisher did not receive RequestResponse for invalid define" + } + if rr.Reason != livekit.RequestResponse_INVALID_REQUEST { + return fmt.Sprintf("expected INVALID_REQUEST, got %s", rr.Reason) + } + return "" + }) + }) + } +} + +func TestSingleNodeDataBlobDisabled(t *testing.T) { + if testing.Short() { + t.SkipNow() + return + } + + _, finish := setupDataBlobServer(t, "TestSingleNodeDataBlobDisabled", false) + defer finish() + + for _, testRTCServicePath := range testRTCServicePaths { + t.Run(fmt.Sprintf("testRTCServicePath=%s", testRTCServicePath.String()), func(t *testing.T) { + pubCapture := &dataBlobCapture{} + pub := createRTCClient("pub", defaultServerPort, testRTCServicePath, &testclient.Options{ + AutoSubscribe: true, + SignalResponseInterceptor: pubCapture.interceptor(), + }) + waitUntilConnected(t, pub) + defer stopClients(pub) + + require.NoError(t, pub.SendRequest(&livekit.SignalRequest{ + Message: &livekit.SignalRequest_StoreDataBlobRequest{ + StoreDataBlobRequest: &livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: &livekit.DataBlobKey{ + Key: &livekit.DataBlobKey_Generic{ + Generic: "blob-1", + }, + }, + Contents: []byte("definition-bytes"), + }, + }, + }, + })) + + testutils.WithTimeout(t, func() string { + rr := pubCapture.takeRequestResponse() + if rr == nil { + return "publisher did not receive RequestResponse" + } + if rr.Reason != livekit.RequestResponse_NOT_ALLOWED { + return fmt.Sprintf("expected NOT_ALLOWED, got %s", rr.Reason) + } + return "" + }) + }) + } +} From eb3de092f0980140b3abe07915775171d94949d0 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Thu, 25 Jun 2026 21:43:07 +0800 Subject: [PATCH 20/44] update pion/sctp (#4623) * update pion/sctp * webrtc & datachannel --- go.mod | 6 +++--- go.sum | 14 ++++++-------- 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/go.mod b/go.mod index 89a90de11..c91e34abc 100644 --- a/go.mod +++ b/go.mod @@ -30,17 +30,17 @@ require ( github.com/moby/moby/client v0.4.1 github.com/olekukonko/tablewriter v1.1.4 github.com/ory/dockertest/v4 v4.0.0 - github.com/pion/datachannel v1.6.0 + github.com/pion/datachannel v1.6.2 github.com/pion/dtls/v3 v3.1.4 github.com/pion/ice/v4 v4.2.7 github.com/pion/interceptor v0.1.45 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.2 - github.com/pion/sctp v1.9.5 + github.com/pion/sctp v1.10.2 github.com/pion/sdp/v3 v3.0.19 github.com/pion/transport/v4 v4.0.2 github.com/pion/turn/v5 v5.0.10 - github.com/pion/webrtc/v4 v4.2.11 + github.com/pion/webrtc/v4 v4.2.15 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.21.0 diff --git a/go.sum b/go.sum index 29ca2a832..040fa11dd 100644 --- a/go.sum +++ b/go.sum @@ -229,8 +229,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME= github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= -github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0= -github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk= +github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= +github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= @@ -247,8 +247,8 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.9.5 h1:QoSFB/drmAsmSeSFNQNI3xx010nW4HsycCZckRVWWag= -github.com/pion/sctp v1.9.5/go.mod h1:N20Dq6LY+JvJDAh9VVh1JELngb2rQ8dPgds5yBWiPgw= +github.com/pion/sctp v1.10.2 h1:6aezYsMrHAwpjJ6kUdyCiWPqZwgToT00ponT7seJ6a4= +github.com/pion/sctp v1.10.2/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= @@ -259,12 +259,10 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ= -github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ= github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= -github.com/pion/webrtc/v4 v4.2.11 h1:QUX1QZKlNIn4O7U5JxLPGP0sV5RTncZkzu9SPR3jVNU= -github.com/pion/webrtc/v4 v4.2.11/go.mod h1:s/rAiyy77GyRFrZMx+Ls6aua26dIBPudH8/ZHYbIRWY= +github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= +github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= From 23090163ce42cddad6c42336c12d54f3419a26a6 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 26 Jun 2026 20:01:32 +0530 Subject: [PATCH 21/44] Configurable migration wait duration for longer waits in simulation. (#4624) Only applies if it is more than the default 3 seconds. --- pkg/rtc/participant.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 1cf5bb5b2..915a7022e 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -227,6 +227,7 @@ type ParticipantParams struct { DisableTransceiverReuseForE2EE bool EnableParticipantDataBlob bool EnableStartAtDesiredQuality bool + MigrationWaitDuration time.Duration } type ParticipantImpl struct { @@ -1572,7 +1573,7 @@ func (p *ParticipantImpl) setupMigrationTimerLocked() { // to try and succeed. If not, close the subscriber peer connection // and help the remote side to narrow down its ICE candidate pool. // - p.migrationTimer = time.AfterFunc(migrationWaitDuration, func() { + p.migrationTimer = time.AfterFunc(max(p.params.MigrationWaitDuration, migrationWaitDuration), func() { p.clearMigrationTimer() if p.IsClosed() || p.IsDisconnected() { From a81f06b9603e474fc4d6142c91cf27684edb330e Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 27 Jun 2026 12:06:30 +0530 Subject: [PATCH 22/44] Release v1.13.2. (#4625) Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ version/version.go | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233153522..9f4f6bc55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.2] - 2026-06-27 + +### Added + +- Add Prometheus metrics for join latency and peer connection state (#4574, #4616) +- Preserve original expiry when refreshing token (#4580) +- Add grants expiry to Auth context (#4581) +- Add ability to run pprof on dedicated HTTP server (#4584) +- Add API to get latest node stats (#4589) +- Enforce subscription permission to data track (#4588) +- egress v2 api (#4592) +- agent: thread attributes map from dispatch to job (#4598) +- Log subscription limit breaches (#4603) +- Acquire requested video layer directly at HIGH quality by default (#4595) +- Report participant capabilities in ParticipantInfo (#4606) +- Add option to force drain rtcService/agentService connections (#4618) +- Add support for data blob (a. k. a. async participant attributes) (#4619) + +### Changed + +- Update dependencies: pion/sctp, DTLS v3.1.4, protocol (#4587, #4601, #4623) +- rtc: add RestartSessionTimer to re-anchor participant session duration (#4566) +- Record more RTC cancellation points (#4600) +- Cap all metadata at 512 KiB; enforce on join, agent dispatch, and embedded agents (#4602) +- Tighten up publish latency stat (#4615) +- Echo offered audio payload types in single-PC subscriber answer (#4614) + +### Fixed + +- Fix skipped packets accounting (#4604) +- Do not log due to negative getting interpreted as large unsigned positive (#4605) +- Do not call nil callback (#4607) + ## [1.13.1] - 2026-06-08 ### Fixed diff --git a/version/version.go b/version/version.go index fbc9dc1e2..3f1dbda2b 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.1" +const Version = "1.13.2" From bfb75d1fc30d53761236b28d465e48abf1a23cb6 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sun, 28 Jun 2026 09:28:18 +0200 Subject: [PATCH 23/44] feat: mock API server for testing server SDKs (#4627) * feat: mock API server for testing server SDKs * fixed lint --- .github/workflows/test-server-docker.yaml | 53 +++++ cmd/test-server/Dockerfile | 40 ++++ cmd/test-server/README.md | 84 ++++++++ cmd/test-server/handlers.go | 228 ++++++++++++++++++++ cmd/test-server/main.go | 247 ++++++++++++++++++++++ magefile.go | 5 + 6 files changed, 657 insertions(+) create mode 100644 .github/workflows/test-server-docker.yaml create mode 100644 cmd/test-server/Dockerfile create mode 100644 cmd/test-server/README.md create mode 100644 cmd/test-server/handlers.go create mode 100644 cmd/test-server/main.go diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml new file mode 100644 index 000000000..a0a854eaf --- /dev/null +++ b/.github/workflows/test-server-docker.yaml @@ -0,0 +1,53 @@ +# 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. + +# Builds and publishes the SDK conformance test server (cmd/test-server) to +# Docker Hub as livekit/test-server:latest on every push to master. The server +# SDK repos boot this image in their CI to run region-failover tests. +name: Release Test Server to Docker + +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to DockerHub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + file: cmd/test-server/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + # Each build overrides the latest tag so SDK CI always boots the + # current mock server. + tags: livekit/test-server:latest diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile new file mode 100644 index 000000000..0182aadea --- /dev/null +++ b/cmd/test-server/Dockerfile @@ -0,0 +1,40 @@ +# 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. + +# Mock LiveKit API server for SDK conformance testing. Build from the repo +# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +FROM golang:1.26-alpine AS builder + +ARG TARGETARCH + +WORKDIR /workspace + +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY cmd/ cmd/ +COPY pkg/ pkg/ +COPY version/ version/ + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server + +FROM alpine + +COPY --from=builder /workspace/livekit-test-server /livekit-test-server + +# region-0 (primary, 9999) + 3 fallback regions +EXPOSE 9999 10000 10001 10002 + +ENTRYPOINT ["/livekit-test-server"] diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md new file mode 100644 index 000000000..08dd75122 --- /dev/null +++ b/cmd/test-server/README.md @@ -0,0 +1,84 @@ +# LiveKit SDK test server + +A stateless, per-request programmable mock of the LiveKit server HTTP API. It +exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise +client-side behavior against one shared implementation, published as a Docker +image and booted by each SDK's CI. + +## Why it looks the way it does + +- **Stateless.** All behavior is selected by per-request `X-Lk-Mock-*` headers, + so the server holds no mutable state and tests run in parallel. +- **Multi-port = multi-region.** The process binds one listener per simulated + region (`--ports`). A port's position in the list is its **region index**; + index `0` is the primary the SDK is initially pointed at. `GET + /settings/regions` advertises all of them in order. +- **One header drives every attempt.** The SDK sends the same control header on + the initial request *and* every failover retry. Each listener decides what to + do from its **own** index, so a single `X-Lk-Mock-Fail-Regions: 0` makes the + primary fail while the first fallback succeeds — no coordination needed. +- **The whole API is mocked with populated responses.** Every RoomService, + Egress, Ingress, SIP, and Connector method returns a type-correct, populated + response: scalar fields that share a name with the request are echoed (e.g. + `name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder + values, and list endpoints return one element. Both protobuf and JSON Twirp + clients are supported. A client can override the response entirely with the + `X-Lk-Mock-Response` header (see below). Unregistered/future methods fall back + to an empty (all-default) message, which still decodes cleanly. + +## Running + +```bash +go run ./cmd/test-server # primary :9999, regions :10000-10002 +go run ./cmd/test-server --ports 9999,10000 # primary + one fallback + +# Docker +docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +docker run -p 9999-10002:9999-10002 livekit/test-server +``` + +| Flag | Env | Default | Meaning | +|---|---|---|---| +| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position | +| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` | +| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address | +| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix | + +## Control protocol + +Request headers (sent by the SDK on API calls; the SDK must forward +client-configured custom headers onto the `/settings/regions` fetch and every +failover retry): + +| Header | Default | Effect | +|---|---|---| +| `X-Lk-Mock-Fail-Regions` | — | comma list of region indices that fail this request, e.g. `0` or `0,1`. Each listener fails only if its own index is listed. | +| `X-Lk-Mock-Fail-Mode` | `status` | how a failing region fails: `status`, `drop` (close connection → transport error), `delay`. | +| `X-Lk-Mock-Fail-Status` | `503` | HTTP status when failing with `status`/`delay`. | +| `X-Lk-Mock-Fail-Twirp-Code` | derived from status | Twirp error code string in the failure body. | +| `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | +| `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | +| `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | + +Response headers: + +| Header | Meaning | +|---|---| +| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | + +## Common recipes + +| Goal | Headers | +|---|---| +| Happy path | _(none)_ → 200 from region `0` | +| Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | +| Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | +| All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | +| 4xx, no retry | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Status: 400` | +| Transport-error failover | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Mode: drop` | +| Region discovery unreachable | `X-Lk-Mock-Regions-Status: 500` | +| Custom response payload | `X-Lk-Mock-Response: {"sid":"RM_x","name":"my-room"}` | + +Note: SDK region failover normally only engages for `*.livekit.cloud` hosts. +Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its +forced-on value so failover engages against localhost. diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go new file mode 100644 index 000000000..f1568db95 --- /dev/null +++ b/cmd/test-server/handlers.go @@ -0,0 +1,228 @@ +// 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 main + +import ( + "io" + "net/http" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/protojson" +) + +// apiSpec captures the request and response message types for one Twirp method, +// so the mock can decode the incoming request and build a typed response. +type apiSpec struct { + newReq func() proto.Message + newResp func() proto.Message +} + +// ptrMsg constrains a pointer type that is also a proto.Message, letting reg +// construct fresh request/response values generically. +type ptrMsg[T any] interface { + *T + proto.Message +} + +// apiHandlers maps "./" to its message types. It +// covers the full LiveKit API surface; see init below. +var apiHandlers = map[string]apiSpec{} + +func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) { + apiHandlers[key] = apiSpec{ + newReq: func() proto.Message { return Req(new(ReqT)) }, + newResp: func() proto.Message { return Resp(new(RespT)) }, + } +} + +func init() { + // RoomService + reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom") + reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms") + reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom") + reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants") + reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant") + reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant") + reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack") + reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant") + reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions") + reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData") + reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata") + reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant") + reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant") + reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc") + + // Egress + reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress") + reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout") + reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream") + reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress") + reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress") + reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress") + reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress") + reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress") + reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress") + reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress") + + // Ingress + reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress") + reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress") + reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress") + reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress") + + // SIP + reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk") + reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk") + reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk") + reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk") + reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk") + reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk") + reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk") + reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk") + reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk") + reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk") + reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule") + reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule") + reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule") + reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule") + reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant") + reg[livekit.TransferSIPParticipantRequest, emptypb.Empty]("livekit.SIP/TransferSIPParticipant") + + // Connector + reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall") + reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall") + reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall") + reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall") + reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall") +} + +// writeAPIResponse serves a populated, type-correct response for a known API +// method. The response is the reflection-populated default unless the request +// carries an X-Lk-Mock-Response header (protojson), which overrides it +// entirely. Content type (protobuf vs JSON) mirrors the request. +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) { + json := strings.Contains(r.Header.Get("Content-Type"), "json") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + + key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") + spec, ok := apiHandlers[key] + if !ok { + // Unknown/future method: an empty body still decodes to a valid default + // message in every Twirp client. + writeEmptySuccess(w, json) + return + } + + body, _ := io.ReadAll(r.Body) + req := spec.newReq() + if json { + _ = protojson.Unmarshal(body, req) + } else { + _ = proto.Unmarshal(body, req) + } + + resp := spec.newResp() + if override := r.Header.Get(headerResponse); override != "" { + if err := protojson.Unmarshal([]byte(override), resp); err != nil { + // Malformed override: fall back to the populated default. + resp = spec.newResp() + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + } else { + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + + if json { + out, _ := protojson.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } else { + out, _ := proto.Marshal(resp) + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } +} + +func writeEmptySuccess(w http.ResponseWriter, json bool) { + if json { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + } else { + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + } +} + +// populateMessage fills a response message with plausible values: it echoes +// scalar fields that share a name with the request, assigns placeholder values +// to id/sid fields, and adds one element to repeated-message (list) fields so +// list endpoints return non-empty results. depth bounds list-element nesting. +func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) { + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + fd := fields.Get(i) + + // Echo a same-named scalar field from the request (e.g. name, metadata, + // identity, room, timeouts). + if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) { + if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil && + rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) { + m.Set(fd, req.Get(rf)) + continue + } + } + + // Give id/sid-like string fields a deterministic placeholder. + if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) { + n := string(fd.Name()) + if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") { + m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n))) + continue + } + } + + // Populate list endpoints with a single element so clients see results. + if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind { + list := m.Mutable(fd).List() + elem := list.NewElement() + populateMessage(elem.Message(), nil, depth-1) + list.Append(elem) + } + } +} + +func isScalarKind(k protoreflect.Kind) bool { + switch k { + case protoreflect.BoolKind, + protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind, + protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind, + protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind, + protoreflect.FloatKind, protoreflect.DoubleKind, + protoreflect.StringKind, protoreflect.BytesKind: + return true + default: + return false + } +} diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go new file mode 100644 index 000000000..66b756d47 --- /dev/null +++ b/cmd/test-server/main.go @@ -0,0 +1,247 @@ +// 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. + +// Command test-server is a programmable mock of the LiveKit server HTTP API, +// used by the server SDKs to test client behavior. See cmd/test-server/README.md. +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/protojson" +) + +// X-Lk-Mock-* request headers control the mock's behavior; see the README. +const ( + headerFailRegions = "X-Lk-Mock-Fail-Regions" + headerFailMode = "X-Lk-Mock-Fail-Mode" + headerFailStatus = "X-Lk-Mock-Fail-Status" + headerFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code" + headerDelayMs = "X-Lk-Mock-Delay-Ms" + headerRegionsStatus = "X-Lk-Mock-Regions-Status" + headerResponse = "X-Lk-Mock-Response" + // headerRegion is set on responses to the index of the region that served it. + headerRegion = "X-Lk-Mock-Region" +) + +const defaultDelayMs = 30_000 + +func main() { + portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002") + advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") + bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0") + twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp") + + ports, err := parsePorts(portsFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err) + os.Exit(1) + } + advertiseHost = strings.TrimRight(advertiseHost, "/") + + regions := &livekit.RegionSettings{} + for i, p := range ports { + regions.Regions = append(regions.Regions, &livekit.RegionInfo{ + Region: fmt.Sprintf("region-%d", i), + Url: fmt.Sprintf("%s:%d", advertiseHost, p), + Distance: int64(i), + }) + } + + errCh := make(chan error, len(ports)) + for i, p := range ports { + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", bindAddr, p), + Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix}, + } + go func() { errCh <- srv.ListenAndServe() }() + fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + select { + case err := <-errCh: + fmt.Fprintf(os.Stderr, "listener failed: %v\n", err) + os.Exit(1) + case <-sigCh: + fmt.Println("test-server: shutting down") + } +} + +type mockHandler struct { + regionIndex int + regions *livekit.RegionSettings + twirpPrefix string +} + +func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/settings/regions": + h.handleRegions(w, r) + case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"): + h.handleTwirp(w, r) + case r.URL.Path == "/" || r.URL.Path == "/_test/health": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + default: + http.NotFound(w, r) + } +} + +func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { + if status := parseStatus(r.Header.Get(headerRegionsStatus), 0); status != 0 && status != http.StatusOK { + w.WriteHeader(status) + return + } + body, err := protojson.Marshal(h.regions) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "max-age=0") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + _, _ = w.Write(body) +} + +func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { + if h.shouldFail(r) { + h.fail(w, r) + return + } + h.writeAPIResponse(w, r) +} + +func (h *mockHandler) shouldFail(r *http.Request) bool { + for _, part := range strings.Split(r.Header.Get(headerFailRegions), ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if idx, err := strconv.Atoi(part); err == nil && idx == h.regionIndex { + return true + } + } + return false +} + +func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) { + switch strings.ToLower(r.Header.Get(headerFailMode)) { + case "drop": + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() + return + } + } + w.WriteHeader(http.StatusServiceUnavailable) + case "delay": + delay := defaultDelayMs + if ms, err := strconv.Atoi(r.Header.Get(headerDelayMs)); err == nil && ms >= 0 { + delay = ms + } + time.Sleep(time.Duration(delay) * time.Millisecond) + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + default: + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + } +} + +func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { + code := r.Header.Get(headerFailTwirpCode) + if code == "" { + code = twirpCodeForStatus(status) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerRegion, "") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status)) +} + +func twirpCodeForStatus(status int) string { + switch { + case status == http.StatusBadRequest: + return "invalid_argument" + case status == http.StatusUnauthorized: + return "unauthenticated" + case status == http.StatusForbidden: + return "permission_denied" + case status == http.StatusNotFound: + return "not_found" + case status == http.StatusTooManyRequests: + return "resource_exhausted" + case status >= 500: + return "unavailable" + default: + return "internal" + } +} + +func parseStatus(s string, def int) int { + if s == "" { + return def + } + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 { + return v + } + return def +} + +func parsePorts(s string) ([]int, error) { + var ports []int + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("%q is not a port number", part) + } + ports = append(ports, v) + } + if len(ports) == 0 { + return nil, errors.New("at least one port is required") + } + return ports, nil +} + +// flagValue resolves a setting from a --flag, then an environment variable, then a default. +func flagValue(flag, env, def string) string { + prefix := flag + "=" + for i, arg := range os.Args[1:] { + if arg == flag { + if i+2 <= len(os.Args[1:]) { + return os.Args[1:][i+1] + } + } + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + } + if v := os.Getenv(env); v != "" { + return v + } + return def +} diff --git a/magefile.go b/magefile.go index 4423b01c9..d4d42fa68 100644 --- a/magefile.go +++ b/magefile.go @@ -177,6 +177,11 @@ func TestAll() error { return mageutil.Run(context.Background(), "go test ./... -count=1 -timeout=4m -v") } +// runs the SDK test server (cmd/test-server) in the foreground +func TestServer() error { + return mageutil.Run(context.Background(), "go run ./cmd/test-server") +} + // runs golangci-lint func Lint() error { if _, err := exec.LookPath("golangci-lint"); err != nil { From 930a2b6ad770df4ac7dc4e3e07b2162dff94e42c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 28 Jun 2026 14:57:35 -0700 Subject: [PATCH 24/44] Update module github.com/urfave/cli/v3 to v3.10.0 (#4612) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c91e34abc..57c4003fb 100644 --- a/go.mod +++ b/go.mod @@ -140,7 +140,7 @@ require ( github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/procfs v0.20.1 // indirect - github.com/urfave/cli/v3 v3.9.0 + github.com/urfave/cli/v3 v3.10.0 github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect diff --git a/go.sum b/go.sum index 040fa11dd..d8ab175ef 100644 --- a/go.sum +++ b/go.sum @@ -303,8 +303,8 @@ github.com/twitchtv/twirp v8.1.3+incompatible h1:+F4TdErPgSUbMZMwp13Q/KgDVuI7HJX github.com/twitchtv/twirp v8.1.3+incompatible/go.mod h1:RRJoFSAmTEh2weEqWtpPE3vFK5YBhA6bqp2l1kfCC5A= github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c h1:XbG4n3OWA1PcRTpbBA22E2ChPLvJCuwYRXO12tIyVL0= github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c/go.mod h1:gwANdYmo9R8LLwGnyDFWK2PMsaXXX2HhAvCnb/UhZsM= -github.com/urfave/cli/v3 v3.9.0 h1:AV9lIiPv3ukYnxunaCUsHnEozptYmDN2F0+yWqLMn/c= -github.com/urfave/cli/v3 v3.9.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.10.0 h1:0aU8yOObVDMkM13Cj4G+zb4P0PdeJMec65f81Ak1ioM= +github.com/urfave/cli/v3 v3.10.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= From 2aec61c11b545ea5d9575224ee7e9f1dbe95a243 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 29 Jun 2026 10:33:51 +0800 Subject: [PATCH 25/44] update webrtc to fix interop issue with bundled datachannel (#4631) --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 57c4003fb..84378d367 100644 --- a/go.mod +++ b/go.mod @@ -36,11 +36,11 @@ require ( github.com/pion/interceptor v0.1.45 github.com/pion/rtcp v1.2.16 github.com/pion/rtp v1.10.2 - github.com/pion/sctp v1.10.2 + github.com/pion/sctp v1.10.3 github.com/pion/sdp/v3 v3.0.19 github.com/pion/transport/v4 v4.0.2 github.com/pion/turn/v5 v5.0.10 - github.com/pion/webrtc/v4 v4.2.15 + github.com/pion/webrtc/v4 v4.2.16 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.21.0 diff --git a/go.sum b/go.sum index d8ab175ef..3a0b13d74 100644 --- a/go.sum +++ b/go.sum @@ -247,8 +247,8 @@ github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.10.2 h1:6aezYsMrHAwpjJ6kUdyCiWPqZwgToT00ponT7seJ6a4= -github.com/pion/sctp v1.10.2/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI= +github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= github.com/pion/sdp/v3 v3.0.19/go.mod h1:dE5WOSlzXrtiE/iuZqe9n+AcEbOjtAd3k5m5NtlV/qU= github.com/pion/srtp/v3 v3.0.12 h1:U7V17bckl7sI4mb3sepiojByDuBY0wNCqQE+6IlQBbc= @@ -261,8 +261,8 @@ github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6 github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= -github.com/pion/webrtc/v4 v4.2.15 h1:Ir/MauNFCfg+kgyBYPQLiGdVWFlzEcLxqtuzAkYkky0= -github.com/pion/webrtc/v4 v4.2.15/go.mod h1:CPTcyLfIzC4scOkQ4UY4pj6WvbUGhcNLIpK28cP5h6M= +github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q= +github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= From ad76898f0351b5d2bfe336079ed6a5909034974b Mon Sep 17 00:00:00 2001 From: David Zhao Date: Mon, 29 Jun 2026 08:19:05 +0200 Subject: [PATCH 26/44] support auth checks with mock server (#4629) * support auth checks with mock server * simplify unit tests --- cmd/test-server/README.md | 37 ++++++- cmd/test-server/auth.go | 212 ++++++++++++++++++++++++++++++++++++ cmd/test-server/handlers.go | 50 ++++++--- cmd/test-server/main.go | 21 ++-- 4 files changed, 299 insertions(+), 21 deletions(-) create mode 100644 cmd/test-server/auth.go diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 08dd75122..1c4eb247a 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -59,6 +59,7 @@ failover retry): | `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | | `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | | `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | +| `X-Lk-Mock-Skip-Auth` | — | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | Response headers: @@ -66,11 +67,45 @@ Response headers: |---|---| | `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | +## Permission enforcement + +Every API method requires the same token grants the real LiveKit server checks +(see `pkg/service/auth.go`), so the mock doubles as a conformance check that an +SDK attaches the right permissions automatically. Tokens are parsed and verified +with the protocol's own `auth` helpers — the same code path the real server uses +— against the mock's configured API secret (default `secret`, matching +`livekit-server --dev`; override with `--api-secret` / `LK_TEST_SERVER_API_SECRET`). + +- Missing, malformed, or wrongly-signed `Authorization` → `401 unauthenticated`. +- Validly-signed token without the required grant → `403 permission_denied`. +- `roomAdmin`-scoped methods also require the token's `room` to match the + request's room; `ForwardParticipant`/`MoveParticipant` additionally require + `destinationRoom` to match. + +SDKs exercising permissions should sign tokens with the same API secret the mock +is configured with (`secret` by default). + +| Grant | Methods | +|---|---| +| `video.roomCreate` | `CreateRoom`, `DeleteRoom`, all `Connector` calls | +| `video.roomList` | `ListRooms` | +| `video.roomRecord` | all `Egress` methods | +| `video.ingressAdmin` | all `Ingress` methods | +| `video.roomAdmin` (+ `room`) | room participant/data/metadata methods, `AgentDispatchService` methods | +| `video.roomAdmin` (+ `room` + `destinationRoom`) | `ForwardParticipant`, `MoveParticipant` | +| `sip.admin` | SIP trunk & dispatch-rule CRUD | +| `sip.call` | `CreateSIPParticipant`; `TransferSIPParticipant` (also needs `roomAdmin`) | + +Send `X-Lk-Mock-Skip-Auth: true` to bypass enforcement for tests that aren't +about permissions. + ## Common recipes | Goal | Headers | |---|---| -| Happy path | _(none)_ → 200 from region `0` | +| Happy path | valid token with the method's grant → 200 from region `0` | +| Bypass auth (failover tests) | `X-Lk-Mock-Skip-Auth: true` | +| Missing-permission error | token without the required grant → 403 | | Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | | Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | | All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | diff --git a/cmd/test-server/auth.go b/cmd/test-server/auth.go new file mode 100644 index 000000000..d526e6777 --- /dev/null +++ b/cmd/test-server/auth.go @@ -0,0 +1,212 @@ +// 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 main + +import ( + "net/http" + "strings" + + "github.com/livekit/protocol/auth" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// The mock enforces the same token permissions the real LiveKit server requires +// for each API method (see pkg/service/auth.go in livekit/livekit). The point is +// to verify that SDKs attach the correct grants automatically. Tokens are parsed +// and verified with the protocol's own auth helpers (the same code the server +// uses), against the mock's configured API secret (default "secret", matching +// `livekit-server --dev`); set --api-secret / LK_TEST_SERVER_API_SECRET to change it. +// +// Set X-Lk-Mock-Skip-Auth: true to bypass enforcement for tests that aren't +// about permissions (e.g. region-failover tests using a placeholder token). + +// perm describes the grants a method requires. roomAdmin additionally requires +// the token's room to match the request's room; destRoom further requires the +// token's destinationRoom to match the request's destination_room. +type perm struct { + roomCreate bool + roomList bool + roomRecord bool + ingressAdmin bool + roomAdmin bool + destRoom bool + sipAdmin bool + sipCall bool +} + +// methodPerms maps "./" to its required grants, +// matching the Ensure*Permission checks in the real server's services. +var methodPerms = map[string]perm{ + // RoomService + "livekit.RoomService/CreateRoom": {roomCreate: true}, + "livekit.RoomService/DeleteRoom": {roomCreate: true}, + "livekit.RoomService/ListRooms": {roomList: true}, + "livekit.RoomService/ListParticipants": {roomAdmin: true}, + "livekit.RoomService/GetParticipant": {roomAdmin: true}, + "livekit.RoomService/RemoveParticipant": {roomAdmin: true}, + "livekit.RoomService/MutePublishedTrack": {roomAdmin: true}, + "livekit.RoomService/UpdateParticipant": {roomAdmin: true}, + "livekit.RoomService/UpdateSubscriptions": {roomAdmin: true}, + "livekit.RoomService/SendData": {roomAdmin: true}, + "livekit.RoomService/UpdateRoomMetadata": {roomAdmin: true}, + "livekit.RoomService/ForwardParticipant": {destRoom: true}, + "livekit.RoomService/MoveParticipant": {destRoom: true}, + "livekit.RoomService/PerformRpc": {roomAdmin: true}, + + // Egress — all require record permission + "livekit.Egress/StartEgress": {roomRecord: true}, + "livekit.Egress/StartRoomCompositeEgress": {roomRecord: true}, + "livekit.Egress/StartWebEgress": {roomRecord: true}, + "livekit.Egress/StartParticipantEgress": {roomRecord: true}, + "livekit.Egress/StartTrackCompositeEgress": {roomRecord: true}, + "livekit.Egress/StartTrackEgress": {roomRecord: true}, + "livekit.Egress/UpdateLayout": {roomRecord: true}, + "livekit.Egress/UpdateStream": {roomRecord: true}, + "livekit.Egress/ListEgress": {roomRecord: true}, + "livekit.Egress/StopEgress": {roomRecord: true}, + + // Ingress — all require ingress admin + "livekit.Ingress/CreateIngress": {ingressAdmin: true}, + "livekit.Ingress/UpdateIngress": {ingressAdmin: true}, + "livekit.Ingress/ListIngress": {ingressAdmin: true}, + "livekit.Ingress/DeleteIngress": {ingressAdmin: true}, + + // SIP — trunk/dispatch administration requires sip.admin + "livekit.SIP/CreateSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/CreateSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/UpdateSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/UpdateSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/GetSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/GetSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPInboundTrunk": {sipAdmin: true}, + "livekit.SIP/ListSIPOutboundTrunk": {sipAdmin: true}, + "livekit.SIP/DeleteSIPTrunk": {sipAdmin: true}, + "livekit.SIP/CreateSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/UpdateSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/ListSIPDispatchRule": {sipAdmin: true}, + "livekit.SIP/DeleteSIPDispatchRule": {sipAdmin: true}, + // Placing a call requires sip.call; transfer also requires room admin. + "livekit.SIP/CreateSIPParticipant": {sipCall: true}, + "livekit.SIP/TransferSIPParticipant": {sipCall: true, roomAdmin: true}, + + // AgentDispatch — room admin scoped to the dispatch's room + "livekit.AgentDispatchService/CreateDispatch": {roomAdmin: true}, + "livekit.AgentDispatchService/DeleteDispatch": {roomAdmin: true}, + "livekit.AgentDispatchService/ListDispatch": {roomAdmin: true}, + + // Connector (cloud) — initiating a call requires room create + "livekit.Connector/DialWhatsAppCall": {roomCreate: true}, + "livekit.Connector/DisconnectWhatsAppCall": {roomCreate: true}, + "livekit.Connector/ConnectWhatsAppCall": {roomCreate: true}, + "livekit.Connector/AcceptWhatsAppCall": {roomCreate: true}, + "livekit.Connector/ConnectTwilioCall": {roomCreate: true}, +} + +// authorize enforces the permissions a method requires. It returns the HTTP +// status and Twirp error code to send (0, "" means authorized / not enforced). +func (h *mockHandler) authorize(key string, r *http.Request, req proto.Message) (int, string) { + if strings.EqualFold(r.Header.Get(headerSkipAuth), "true") { + return 0, "" + } + p, known := methodPerms[key] + if !known { + return 0, "" // unknown/future method: don't enforce + } + + grants, err := h.verifyToken(r.Header.Get("Authorization")) + if err != nil { + // Missing, malformed, or improperly-signed token, like the real server. + return http.StatusUnauthorized, "unauthenticated" + } + if !p.satisfiedBy(grants, req) { + return http.StatusForbidden, "permission_denied" + } + return 0, "" +} + +// verifyToken parses and verifies a "Bearer " header using the protocol's +// own auth helpers (the same path the real server uses), returning the grants. +func (h *mockHandler) verifyToken(authorization string) (*auth.ClaimGrants, error) { + token := strings.TrimSpace(strings.TrimPrefix(authorization, "Bearer ")) + v, err := auth.ParseAPIToken(token) + if err != nil { + return nil, err + } + _, grants, err := v.Verify(h.apiSecret) + if err != nil { + return nil, err + } + return grants, nil +} + +func (p perm) satisfiedBy(g *auth.ClaimGrants, req proto.Message) bool { + v, s := g.Video, g.SIP + if p.roomCreate && (v == nil || !v.RoomCreate) { + return false + } + if p.roomList && (v == nil || !v.RoomList) { + return false + } + if p.roomRecord && (v == nil || !v.RoomRecord) { + return false + } + if p.ingressAdmin && (v == nil || !v.IngressAdmin) { + return false + } + if p.sipAdmin && (s == nil || !s.Admin) { + return false + } + if p.sipCall && (s == nil || !s.Call) { + return false + } + if p.roomAdmin || p.destRoom { + if v == nil || !v.RoomAdmin { + return false + } + if room := requestRoom(req); room != "" && v.Room != room { + return false + } + } + if p.destRoom { + if dest := requestString(req, "destination_room"); dest != "" && v.DestinationRoom != dest { + return false + } + } + return true +} + +// requestRoom reads the room name a request targets, trying the common "room" +// and "room_name" fields. +func requestRoom(req proto.Message) string { + if v := requestString(req, "room"); v != "" { + return v + } + return requestString(req, "room_name") +} + +func requestString(req proto.Message, field string) string { + if req == nil { + return "" + } + m := req.ProtoReflect() + fd := m.Descriptor().Fields().ByName(protoreflect.Name(field)) + if fd == nil || fd.Kind() != protoreflect.StringKind || fd.IsList() { + return "" + } + return m.Get(fd).String() +} diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go index f1568db95..7de083bc4 100644 --- a/cmd/test-server/handlers.go +++ b/cmd/test-server/handlers.go @@ -114,31 +114,55 @@ func init() { reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall") } +// serveAPI handles a Twirp call end to end: decode the request, enforce the +// method's required permissions (like the real server's auth middleware), apply +// any region-failure injection, then serve a populated response. +func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) { + json := strings.Contains(r.Header.Get("Content-Type"), "json") + key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") + spec, known := apiHandlers[key] + + // Decode the request up front — needed both to enforce room-scoped grants + // and to build the echoed response. + var req proto.Message + if known { + body, _ := io.ReadAll(r.Body) + req = spec.newReq() + if json { + _ = protojson.Unmarshal(body, req) + } else { + _ = proto.Unmarshal(body, req) + } + } + + // Permission enforcement comes first, mirroring the real server. + if status, code := h.authorize(key, r, req); status != 0 { + writeTwirpErrorCode(w, status, code, "mock: "+code) + return + } + + if h.shouldFail(r) { + h.fail(w, r) + return + } + + h.writeAPIResponse(w, r, json, known, req, spec) +} + // writeAPIResponse serves a populated, type-correct response for a known API // method. The response is the reflection-populated default unless the request // carries an X-Lk-Mock-Response header (protojson), which overrides it // entirely. Content type (protobuf vs JSON) mirrors the request. -func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) { - json := strings.Contains(r.Header.Get("Content-Type"), "json") +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request, json, known bool, req proto.Message, spec apiSpec) { w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) - key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") - spec, ok := apiHandlers[key] - if !ok { + if !known { // Unknown/future method: an empty body still decodes to a valid default // message in every Twirp client. writeEmptySuccess(w, json) return } - body, _ := io.ReadAll(r.Body) - req := spec.newReq() - if json { - _ = protojson.Unmarshal(body, req) - } else { - _ = proto.Unmarshal(body, req) - } - resp := spec.newResp() if override := r.Header.Get(headerResponse); override != "" { if err := protojson.Unmarshal([]byte(override), resp); err != nil { diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 66b756d47..07449539d 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -40,6 +40,8 @@ const ( headerDelayMs = "X-Lk-Mock-Delay-Ms" headerRegionsStatus = "X-Lk-Mock-Regions-Status" headerResponse = "X-Lk-Mock-Response" + // headerSkipAuth disables permission enforcement for a request. + headerSkipAuth = "X-Lk-Mock-Skip-Auth" // headerRegion is set on responses to the index of the region that served it. headerRegion = "X-Lk-Mock-Region" ) @@ -51,6 +53,9 @@ func main() { advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0") twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp") + // API secret used to verify request tokens for permission enforcement. + // Defaults to the `livekit-server --dev` secret. + apiSecret := flagValue("--api-secret", "LK_TEST_SERVER_API_SECRET", "secret") ports, err := parsePorts(portsFlag) if err != nil { @@ -72,7 +77,7 @@ func main() { for i, p := range ports { srv := &http.Server{ Addr: fmt.Sprintf("%s:%d", bindAddr, p), - Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix}, + Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix, apiSecret: apiSecret}, } go func() { errCh <- srv.ListenAndServe() }() fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p) @@ -93,6 +98,7 @@ type mockHandler struct { regionIndex int regions *livekit.RegionSettings twirpPrefix string + apiSecret string } func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -126,11 +132,7 @@ func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { } func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { - if h.shouldFail(r) { - h.fail(w, r) - return - } - h.writeAPIResponse(w, r) + h.serveAPI(w, r) } func (h *mockHandler) shouldFail(r *http.Request) bool { @@ -173,10 +175,15 @@ func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { if code == "" { code = twirpCodeForStatus(status) } + writeTwirpErrorCode(w, status, code, fmt.Sprintf("mock failure (status %d)", status)) +} + +// writeTwirpErrorCode writes a Twirp JSON error with an explicit code and message. +func writeTwirpErrorCode(w http.ResponseWriter, status int, code, msg string) { w.Header().Set("Content-Type", "application/json") w.Header().Set(headerRegion, "") w.WriteHeader(status) - _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status)) + _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, msg) } func twirpCodeForStatus(status int) string { From a47e21b6cb945aabee88c98650366cef8cbf7a99 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:44:08 -0700 Subject: [PATCH 27/44] Data track schema metadata (#4622) * Async attributes on participant. How it is different from existing participant attributes? 1. Async attribute can be added one at a time. 2. These are not included in `ParticipantInfo`. 3. Get an attribute bt participant identity and async attribute ID as and when needed. * clean up * get full definitions, not just ids * listener OnDataTrackSchema * name length config * data blob * deps * static check * Add missing request ID * Update protocol commit * Wire up StoreDataBlobResponse * Pass request ID through in GetDataBlobResponse * Pin protocol for schema metadata * Pass through schema and frame encoding * Support custom encoding identifiers * Rename config key * Increase default length to 32 * Make log messages more generic * Use getters with built-in null check * Do not bump deps * Rename function * Use protocol v1.48.1 release --------- Co-authored-by: boks1971 --- go.mod | 2 +- go.sum | 4 +-- pkg/config/config.go | 37 +++++++++++++++++++----- pkg/rtc/participant_data_blob_handler.go | 10 +++++++ pkg/rtc/participant_data_track.go | 15 ++++++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/go.mod b/go.mod index 84378d367..06e261c39 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 + github.com/livekit/protocol v1.48.1 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 3a0b13d74..4e0188b7a 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63 h1:Rj9/54oztXeioAwUkukXBwcPE/GxL97WylFd1m7V2pQ= -github.com/livekit/protocol v1.48.1-0.20260623210753-2e1bfd81dd63/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.48.1 h1:vFnMqGeknJo6jDwW4wU+x5KuxgA95fPuz7MriAnLzCc= +github.com/livekit/protocol v1.48.1/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= diff --git a/pkg/config/config.go b/pkg/config/config.go index f85f6a841..90135c781 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -298,6 +298,8 @@ type LimitConfig struct { MaxDataBlobKeyLength int `yaml:"max_data_blob_key_length,omitempty"` MaxDataBlobSize uint32 `yaml:"max_data_blobs_size,omitempty"` + + MaxDataTrackCustomEncodingLength int `yaml:"max_data_track_custom_encoding_length,omitempty"` } func (l LimitConfig) CheckRoomNameLength(name string) bool { @@ -332,6 +334,26 @@ func (l LimitConfig) CheckDataBlobKeyLength(key string) bool { return l.MaxDataBlobKeyLength == 0 || len(key) <= l.MaxDataBlobKeyLength } +func (l LimitConfig) CheckDataTrackCustomEncodingLength(identifier string) bool { + return l.MaxDataTrackCustomEncodingLength == 0 || len(identifier) <= l.MaxDataTrackCustomEncodingLength +} + +func (l LimitConfig) CheckDataTrackFrameEncoding(encoding *livekit.DataTrackFrameEncoding) bool { + custom, ok := encoding.GetValue().(*livekit.DataTrackFrameEncoding_Custom) + if !ok { + return true + } + return len(custom.Custom) != 0 && l.CheckDataTrackCustomEncodingLength(custom.Custom) +} + +func (l LimitConfig) CheckDataTrackSchemaID(schema *livekit.DataTrackSchemaId) bool { + custom, ok := schema.GetEncoding().GetValue().(*livekit.DataTrackSchemaEncoding_Custom) + if !ok { + return true + } + return len(custom.Custom) != 0 && l.CheckDataTrackCustomEncodingLength(custom.Custom) +} + func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool { if l.MaxDataBlobSize == 0 { return true @@ -475,13 +497,14 @@ var DefaultConfig = Config{ UpdateBatchTargetSize: 128 * 1024, }, Limit: LimitConfig{ - MaxMetadataSize: 512 * 1024, - MaxAttributesSize: 64 * 1024, - MaxRoomNameLength: 256, - MaxParticipantIdentityLength: 256, - MaxParticipantNameLength: 256, - MaxDataBlobKeyLength: 256, - MaxDataBlobSize: 64000, + MaxMetadataSize: 512 * 1024, + MaxAttributesSize: 64 * 1024, + MaxRoomNameLength: 256, + MaxParticipantIdentityLength: 256, + MaxParticipantNameLength: 256, + MaxDataBlobKeyLength: 256, + MaxDataBlobSize: 64000, + MaxDataTrackCustomEncodingLength: 32, }, Logging: LoggingConfig{ PionLevel: "error", diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go index e6ebcc8ed..8c19ee765 100644 --- a/pkg/rtc/participant_data_blob_handler.go +++ b/pkg/rtc/participant_data_blob_handler.go @@ -41,6 +41,16 @@ func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobR 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, diff --git a/pkg/rtc/participant_data_track.go b/pkg/rtc/participant_data_track.go index dbda1ede0..df9854744 100644 --- a/pkg/rtc/participant_data_track.go +++ b/pkg/rtc/participant_data_track.go @@ -60,6 +60,19 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData 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 := "" @@ -95,6 +108,8 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData 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), From fcc46d3c45497142d8fe2c6c5756da2ab7d800c3 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:33:55 -0700 Subject: [PATCH 28/44] Use camel case log name in `DataBlobKey` (#4633) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 06e261c39..1fcc70246 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.48.1 + github.com/livekit/protocol v1.48.2 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 4e0188b7a..c969726a2 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.48.1 h1:vFnMqGeknJo6jDwW4wU+x5KuxgA95fPuz7MriAnLzCc= -github.com/livekit/protocol v1.48.1/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.48.2 h1:1Jv1Eckf2jMN7SgJm7fQkSRQoMkGpmuN63mFeh3gd1U= +github.com/livekit/protocol v1.48.2/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= From 46e5caedbe0d53217d1b4c84398deb687737f6b6 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Wed, 1 Jul 2026 17:06:49 +0800 Subject: [PATCH 29/44] Report average bitrates for whip ingress (#4634) --- pkg/service/roommanager_service.go | 78 +++++++++++++++++++----------- 1 file changed, 50 insertions(+), 28 deletions(-) diff --git a/pkg/service/roommanager_service.go b/pkg/service/roommanager_service.go index 0b5ce6d79..5986fa997 100644 --- a/pkg/service/roommanager_service.go +++ b/pkg/service/roommanager_service.go @@ -12,6 +12,7 @@ import ( "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/pkg/sfu/rtpstats" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -204,52 +205,73 @@ func (s whipService) sendConnectionNotify(ctx context.Context, participant types } func getMediaStateForParticipant(participant types.Participant) (*livekit.InputVideoState, *livekit.InputAudioState) { - pParticipant := participant.ToProto() - var video *livekit.InputVideoState var audio *livekit.InputAudioState - for _, v := range pParticipant.Tracks { - if v == nil { + for _, t := range participant.GetPublishedTracks() { + if t == nil { continue } - if v.Type != livekit.TrackType_VIDEO { + ti := t.ToProto() + if ti == nil { continue } - video = &livekit.InputVideoState{} + switch t.Kind() { + case livekit.TrackType_VIDEO: + if video != nil { + continue + } - video.MimeType = v.MimeType - video.Height = v.Height - video.Width = v.Width + video = &livekit.InputVideoState{ + MimeType: ti.MimeType, + Width: ti.Width, + Height: ti.Height, + AverageBitrate: trackAverageBitrate(t), + } - break - } + case livekit.TrackType_AUDIO: + if audio != nil { + continue + } - for _, a := range pParticipant.Tracks { - if a == nil { - continue + channels := uint32(1) + if ti.Stereo { + channels = 2 + } + + audio = &livekit.InputAudioState{ + MimeType: ti.MimeType, + Channels: channels, + AverageBitrate: trackAverageBitrate(t), + } } - - if a.Type != livekit.TrackType_AUDIO { - continue - } - - audio = &livekit.InputAudioState{} - - audio.MimeType = a.MimeType - audio.Channels = 1 - if a.Stereo { - audio.Channels = 2 - } - - break } return video, audio } +func trackAverageBitrate(t types.MediaTrack) uint32 { + var allStats []*livekit.RTPStats + for _, r := range t.Receivers() { + if r == nil { + continue + } + + if s := r.GetTrackStats(); s != nil { + allStats = append(allStats, s) + } + } + + agg := rtpstats.AggregateRTPStats(allStats) + if agg == nil { + return 0 + } + + return uint32(agg.Bitrate) +} + // ------------------------------------------- type whipParticipantService struct { From d1b031a9dd4a8e511348ad4f657960edd49f221d Mon Sep 17 00:00:00 2001 From: Benjamin Pracht Date: Thu, 2 Jul 2026 14:07:21 +0100 Subject: [PATCH 30/44] Stop WHIP session notifier when participant leaves (#4637) The WHIP connection-notify loop kept issuing RPCs after the participant had left the room. Guard sendConnectionNotify against a closed participant (returning ErrParticipantNotFound) and treat that error as a clean loop exit. Also reorder DeleteSession so the participant is removed before its WHIP OnClose entry is cleared. Adds tests covering loop termination on participant leave, context cancellation, and the closed-participant guard. Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/service/roommanager_service.go | 13 ++- pkg/service/roommanager_service_test.go | 125 ++++++++++++++++++++++++ 2 files changed, 133 insertions(+), 5 deletions(-) create mode 100644 pkg/service/roommanager_service_test.go diff --git a/pkg/service/roommanager_service.go b/pkg/service/roommanager_service.go index 5986fa997..d8ea05433 100644 --- a/pkg/service/roommanager_service.go +++ b/pkg/service/roommanager_service.go @@ -20,9 +20,8 @@ import ( "github.com/livekit/psrpc" ) -const ( - whipSessionNotifyInterval = 10 * time.Second -) +// whipSessionNotifyInterval is a var (rather than a const) so tests can shorten it. +var whipSessionNotifyInterval = 10 * time.Second type whipService struct { *RoomManager @@ -181,7 +180,7 @@ func (s whipService) notifySession(ctx context.Context, participant types.Partic case <-ticker.C: err := s.sendConnectionNotify(ctx, participant) if err != nil { - if errors.Is(err, context.Canceled) { + if errors.Is(err, context.Canceled) || errors.Is(err, ErrParticipantNotFound) { return nil } } @@ -193,6 +192,10 @@ func (s whipService) notifySession(ctx context.Context, participant types.Partic } func (s whipService) sendConnectionNotify(ctx context.Context, participant types.Participant) error { + if participant.IsClosed() { + return ErrParticipantNotFound + } + video, audio := getMediaStateForParticipant(participant) _, err := s.ingressRpcCli.WHIPRTCConnectionNotify(ctx, string(participant.ID()), &rpc.WHIPRTCConnectionNotifyRequest{ @@ -345,12 +348,12 @@ func (r whipParticipantService) DeleteSession(ctx context.Context, req *rpc.WHIP lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) if lp != nil { - lp.AddOnClose(types.ParticipantCloseKeyWHIP, nil) room.RemoveParticipant( lp.Identity(), lp.ID(), types.ParticipantCloseReasonClientRequestLeave, ) + lp.AddOnClose(types.ParticipantCloseKeyWHIP, nil) } return &emptypb.Empty{}, nil diff --git a/pkg/service/roommanager_service_test.go b/pkg/service/roommanager_service_test.go new file mode 100644 index 000000000..e0d588080 --- /dev/null +++ b/pkg/service/roommanager_service_test.go @@ -0,0 +1,125 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" +) + +// fakeIngressHandlerClient records WHIPRTCConnectionNotify calls. It embeds the +// interface so only the method under test needs to be implemented; any other +// call would panic (and we assert none happen). +type fakeIngressHandlerClient struct { + rpc.IngressHandlerClient + notifyCount atomic.Int32 +} + +func (f *fakeIngressHandlerClient) WHIPRTCConnectionNotify( + _ context.Context, + _ string, + _ *rpc.WHIPRTCConnectionNotifyRequest, + _ ...psrpc.RequestOption, +) (*emptypb.Empty, error) { + f.notifyCount.Inc() + return &emptypb.Empty{}, nil +} + +// TestWhipNotifySessionStopsWhenParticipantLeaves verifies the notifier loop +// terminates once the WHIP participant leaves the room (i.e. IsClosed becomes +// true), and stops issuing further connection notifications. +func TestWhipNotifySessionStopsWhenParticipantLeaves(t *testing.T) { + origInterval := whipSessionNotifyInterval + whipSessionNotifyInterval = 5 * time.Millisecond + t.Cleanup(func() { whipSessionNotifyInterval = origInterval }) + + var closed atomic.Bool + participant := &typesfakes.FakeParticipant{} + participant.IsClosedStub = func() bool { return closed.Load() } + participant.IDReturns(livekit.ParticipantID("PA_test")) + participant.ToProtoReturns(&livekit.ParticipantInfo{}) + + cli := &fakeIngressHandlerClient{} + s := whipService{ingressRpcCli: cli} + + done := make(chan error, 1) + go func() { + done <- s.notifySession(context.Background(), participant) + }() + + // while the participant is connected the loop should keep notifying + require.Eventually(t, func() bool { + return cli.notifyCount.Load() > 0 + }, time.Second, time.Millisecond, "expected notifications while participant is connected") + + // the participant leaves the room + closed.Store(true) + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("notifySession did not stop after the participant left the room") + } + + // no further notifications should be attempted after it stops + countAtStop := cli.notifyCount.Load() + time.Sleep(50 * time.Millisecond) + require.Equal(t, countAtStop, cli.notifyCount.Load(), "should not notify after the participant left") +} + +// TestWhipNotifySessionStopsOnContextCancel verifies the loop exits when the +// aliveCtx (cancelled from the participant's OnClose callback) is done. +func TestWhipNotifySessionStopsOnContextCancel(t *testing.T) { + origInterval := whipSessionNotifyInterval + whipSessionNotifyInterval = 5 * time.Millisecond + t.Cleanup(func() { whipSessionNotifyInterval = origInterval }) + + participant := &typesfakes.FakeParticipant{} + participant.IsClosedReturns(false) + participant.IDReturns(livekit.ParticipantID("PA_test")) + participant.ToProtoReturns(&livekit.ParticipantInfo{}) + + cli := &fakeIngressHandlerClient{} + s := whipService{ingressRpcCli: cli} + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + done <- s.notifySession(ctx, participant) + }() + + cancel() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("notifySession did not stop after context was cancelled") + } +} + +// TestWhipSendConnectionNotifySkipsClosedParticipant verifies the guard that +// short-circuits the RPC (and drives loop termination) for a closed participant. +func TestWhipSendConnectionNotifySkipsClosedParticipant(t *testing.T) { + participant := &typesfakes.FakeParticipant{} + participant.IsClosedReturns(true) + participant.IDReturns(livekit.ParticipantID("PA_test")) + participant.ToProtoReturns(&livekit.ParticipantInfo{}) + + cli := &fakeIngressHandlerClient{} + s := whipService{ingressRpcCli: cli} + + err := s.sendConnectionNotify(context.Background(), participant) + require.ErrorIs(t, err, ErrParticipantNotFound) + require.Zero(t, cli.notifyCount.Load(), "should not issue an RPC for a closed participant") +} From 8f6a9cb8b735549f0c5770df8ea70ac51f860ecb Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 3 Jul 2026 11:03:59 +0530 Subject: [PATCH 31/44] Release v1.13.3. (#4640) Co-authored-by: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ version/version.go | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f4f6bc55..9ad30c3cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.3] - 2026-07-03 + +### Added + +- feat: mock API server for testing server SDKs (#4627) +- support auth checks with mock server (#4629) +- Data track schema metadata (#4622) +- Report average bitrates for whip ingress (#4634) + +### Changed + +- Update webrtc to fix interop issue with bundled datachannel (#4631) +- Update module github.com/urfave/cli/v3 to v3.10.0 (#4612) +- Use camel case log name in `DataBlobKey` (#4633) + +### Fixed + +- Stop WHIP session notifier when participant leaves (#4637) + ## [1.13.2] - 2026-06-27 ### Added diff --git a/version/version.go b/version/version.go index 3f1dbda2b..2ee36a37b 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.2" +const Version = "1.13.3" From 00348c1299b77aeca8678152f7d3faab76fdebbd Mon Sep 17 00:00:00 2001 From: David Zhao Date: Fri, 3 Jul 2026 13:17:59 +0200 Subject: [PATCH 32/44] simpler mock protocol (#4641) enabling us to build a comprehensive set of API tests for our clients --- cmd/test-server/README.md | 74 ++++++++++++-------- cmd/test-server/auth.go | 20 ++++-- cmd/test-server/config.go | 132 ++++++++++++++++++++++++++++++++++++ cmd/test-server/handlers.go | 54 ++++++++++++--- cmd/test-server/main.go | 71 ++++++------------- 5 files changed, 256 insertions(+), 95 deletions(-) create mode 100644 cmd/test-server/config.go diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 1c4eb247a..02991a934 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -7,23 +7,27 @@ image and booted by each SDK's CI. ## Why it looks the way it does -- **Stateless.** All behavior is selected by per-request `X-Lk-Mock-*` headers, - so the server holds no mutable state and tests run in parallel. +- **Stateless.** All behavior is selected by a single per-request `X-Lk-Mock` + header (a JSON object), so the server holds no mutable state and tests run in + parallel. - **Multi-port = multi-region.** The process binds one listener per simulated region (`--ports`). A port's position in the list is its **region index**; index `0` is the primary the SDK is initially pointed at. `GET /settings/regions` advertises all of them in order. - **One header drives every attempt.** The SDK sends the same control header on the initial request *and* every failover retry. Each listener decides what to - do from its **own** index, so a single `X-Lk-Mock-Fail-Regions: 0` makes the - primary fail while the first fallback succeeds — no coordination needed. + do from its **own** index, so a single `X-Lk-Mock: {"failRegions":[0]}` makes + the primary fail while the first fallback succeeds — no coordination needed. +- **Realistic latency.** Methods that block in the real server block here too: + `CreateSIPParticipant` with `wait_until_answered` and `TransferSIPParticipant` + take ~11s before responding, so SDKs can exercise their timeouts. - **The whole API is mocked with populated responses.** Every RoomService, Egress, Ingress, SIP, and Connector method returns a type-correct, populated response: scalar fields that share a name with the request are echoed (e.g. `name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder values, and list endpoints return one element. Both protobuf and JSON Twirp clients are supported. A client can override the response entirely with the - `X-Lk-Mock-Response` header (see below). Unregistered/future methods fall back + `response` field (see below). Unregistered/future methods fall back to an empty (all-default) message, which still decodes cleanly. ## Running @@ -46,20 +50,31 @@ docker run -p 9999-10002:9999-10002 livekit/test-server ## Control protocol -Request headers (sent by the SDK on API calls; the SDK must forward -client-configured custom headers onto the `/settings/regions` fetch and every -failover retry): +All behavior is driven by a single `X-Lk-Mock` request header whose value is a +JSON object. The SDK sends the same header on API calls, on the +`/settings/regions` fetch, and on every failover retry (it must forward +client-configured custom headers onto all of them). Omit the header — or any +field — for normal behavior. Every field is optional: -| Header | Default | Effect | +| Field | Default | Effect | |---|---|---| -| `X-Lk-Mock-Fail-Regions` | — | comma list of region indices that fail this request, e.g. `0` or `0,1`. Each listener fails only if its own index is listed. | -| `X-Lk-Mock-Fail-Mode` | `status` | how a failing region fails: `status`, `drop` (close connection → transport error), `delay`. | -| `X-Lk-Mock-Fail-Status` | `503` | HTTP status when failing with `status`/`delay`. | -| `X-Lk-Mock-Fail-Twirp-Code` | derived from status | Twirp error code string in the failure body. | -| `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | -| `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | -| `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | -| `X-Lk-Mock-Skip-Auth` | — | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | +| `failRegions` | — | array of region indices that fail this request, e.g. `[0]` or `[0,1]`. Each listener fails only if its own index is listed. | +| `failMode` | `status` | how a failing region fails: `status` (write a Twirp error), or `drop` (close the connection → transport error). | +| `failStatus` | `503` | HTTP status for a `status`-mode failure. | +| `failTwirpCode` | derived from status | Twirp error code string in the failure body. | +| `delayMs` | — | delay (ms) before responding, on success or failure. Overrides a method's natural latency — use it for timeout tests, or set it to skip a SIP method's built-in ~11s wait. | +| `regionsStatus` | `200` | override the status of `GET /settings/regions`. | +| `response` | — | the response message for the called method (a JSON object, protojson-shaped); replaces the populated default, giving full control over the returned payload. | +| `skipAuth` | `false` | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | + +Example: `X-Lk-Mock: {"skipAuth":true,"failRegions":[0],"failStatus":400}` + +> **Deprecated:** the older per-setting headers — `X-Lk-Mock-Fail-Regions`, +> `X-Lk-Mock-Fail-Mode` (incl. the `delay` mode), `X-Lk-Mock-Fail-Status`, +> `X-Lk-Mock-Fail-Twirp-Code`, `X-Lk-Mock-Delay-Ms`, `X-Lk-Mock-Regions-Status`, +> `X-Lk-Mock-Response`, `X-Lk-Mock-Skip-Auth` — are still honored for existing +> clients and will be removed later. When `X-Lk-Mock` is also present, its fields +> take precedence per-field. New clients should use `X-Lk-Mock` only. Response headers: @@ -96,23 +111,24 @@ is configured with (`secret` by default). | `sip.admin` | SIP trunk & dispatch-rule CRUD | | `sip.call` | `CreateSIPParticipant`; `TransferSIPParticipant` (also needs `roomAdmin`) | -Send `X-Lk-Mock-Skip-Auth: true` to bypass enforcement for tests that aren't +Send `X-Lk-Mock: {"skipAuth":true}` to bypass enforcement for tests that aren't about permissions. ## Common recipes -| Goal | Headers | +| Goal | `X-Lk-Mock` value | |---|---| -| Happy path | valid token with the method's grant → 200 from region `0` | -| Bypass auth (failover tests) | `X-Lk-Mock-Skip-Auth: true` | -| Missing-permission error | token without the required grant → 403 | -| Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | -| Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | -| All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | -| 4xx, no retry | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Status: 400` | -| Transport-error failover | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Mode: drop` | -| Region discovery unreachable | `X-Lk-Mock-Regions-Status: 500` | -| Custom response payload | `X-Lk-Mock-Response: {"sid":"RM_x","name":"my-room"}` | +| Happy path | (no header) — valid token with the method's grant → 200 from region `0` | +| Bypass auth (failover tests) | `{"skipAuth":true}` | +| Missing-permission error | (no header) — token without the required grant → 403 | +| Failover succeeds on region 1 | `{"failRegions":[0]}` | +| Exhaust to region 2 | `{"failRegions":[0,1]}` | +| All regions down | `{"failRegions":[0,1,2,3]}` | +| 4xx, no retry | `{"failRegions":[0],"failStatus":400}` | +| Transport-error failover | `{"failRegions":[0],"failMode":"drop"}` | +| Timeout test | `{"delayMs":30000}` | +| Region discovery unreachable | `{"regionsStatus":500}` | +| Custom response payload | `{"response":{"sid":"RM_x","name":"my-room"}}` | Note: SDK region failover normally only engages for `*.livekit.cloud` hosts. Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its diff --git a/cmd/test-server/auth.go b/cmd/test-server/auth.go index d526e6777..6e270cd00 100644 --- a/cmd/test-server/auth.go +++ b/cmd/test-server/auth.go @@ -31,8 +31,8 @@ import ( // uses), against the mock's configured API secret (default "secret", matching // `livekit-server --dev`); set --api-secret / LK_TEST_SERVER_API_SECRET to change it. // -// Set X-Lk-Mock-Skip-Auth: true to bypass enforcement for tests that aren't -// about permissions (e.g. region-failover tests using a placeholder token). +// Set `"skipAuth": true` in the X-Lk-Mock header to bypass enforcement for tests +// that aren't about permissions (e.g. region-failover tests with a placeholder token). // perm describes the grants a method requires. roomAdmin additionally requires // the token's room to match the request's room; destRoom further requires the @@ -119,8 +119,8 @@ var methodPerms = map[string]perm{ // authorize enforces the permissions a method requires. It returns the HTTP // status and Twirp error code to send (0, "" means authorized / not enforced). -func (h *mockHandler) authorize(key string, r *http.Request, req proto.Message) (int, string) { - if strings.EqualFold(r.Header.Get(headerSkipAuth), "true") { +func (h *mockHandler) authorize(key string, r *http.Request, cfg *mockConfig, req proto.Message) (int, string) { + if cfg.SkipAuth { return 0, "" } p, known := methodPerms[key] @@ -210,3 +210,15 @@ func requestString(req proto.Message, field string) string { } return m.Get(fd).String() } + +func requestBool(req proto.Message, field string) bool { + if req == nil { + return false + } + m := req.ProtoReflect() + fd := m.Descriptor().Fields().ByName(protoreflect.Name(field)) + if fd == nil || fd.Kind() != protoreflect.BoolKind || fd.IsList() { + return false + } + return m.Get(fd).Bool() +} diff --git a/cmd/test-server/config.go b/cmd/test-server/config.go new file mode 100644 index 000000000..2e1aefe40 --- /dev/null +++ b/cmd/test-server/config.go @@ -0,0 +1,132 @@ +// 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 main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" +) + +const ( + // headerMock carries the whole mock control config as a JSON object; see + // mockConfig and the README. Sent by the SDK on API calls (and forwarded onto + // the /settings/regions fetch and every failover retry). + headerMock = "X-Lk-Mock" + // headerRegion is set on responses to the index of the region that served it. + headerRegion = "X-Lk-Mock-Region" +) + +// Deprecated: the individual X-Lk-Mock-* control headers predate the unified +// X-Lk-Mock JSON header. They are still honored for existing clients; new +// clients should send X-Lk-Mock instead. When X-Lk-Mock is present its fields +// take precedence over any legacy header. +const ( + legacyHeaderFailRegions = "X-Lk-Mock-Fail-Regions" + legacyHeaderFailMode = "X-Lk-Mock-Fail-Mode" + legacyHeaderFailStatus = "X-Lk-Mock-Fail-Status" + legacyHeaderFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code" + legacyHeaderDelayMs = "X-Lk-Mock-Delay-Ms" + legacyHeaderRegionsStatus = "X-Lk-Mock-Regions-Status" + legacyHeaderResponse = "X-Lk-Mock-Response" + legacyHeaderSkipAuth = "X-Lk-Mock-Skip-Auth" +) + +// legacyDefaultDelayMs is the sleep used by the deprecated "delay" fail mode when +// no X-Lk-Mock-Delay-Ms is given (long enough to trip client timeouts). +const legacyDefaultDelayMs = 30_000 + +// mockConfig is the JSON value of the X-Lk-Mock request header. Every field is +// optional; the zero value means "behave normally". A single object keeps the +// control protocol simple — the SDK serializes one struct instead of juggling a +// header per knob. +type mockConfig struct { + // FailRegions lists region indices that should fail this request. A listener + // fails only if its own region index appears here, so one config can make the + // primary fail while a fallback succeeds. + FailRegions []int `json:"failRegions,omitempty"` + // FailMode selects how a failing region fails: "status" (default) writes a + // Twirp error; "drop" closes the connection to force a transport error. + // ("delay" is a deprecated legacy mode; new clients use DelayMs instead.) + FailMode string `json:"failMode,omitempty"` + // FailStatus is the HTTP status for a "status"-mode failure (default 503). + FailStatus int `json:"failStatus,omitempty"` + // FailTwirpCode overrides the Twirp error code string in the failure body + // (default derived from FailStatus). + FailTwirpCode string `json:"failTwirpCode,omitempty"` + // DelayMs delays the response by this many milliseconds before returning, + // whether the region succeeds or fails. It overrides a method's natural + // latency (see methodLatency): set it high for timeout tests, or to 0 to skip + // a SIP method's built-in wait. Nil means "use the natural latency". + DelayMs *int `json:"delayMs,omitempty"` + // RegionsStatus overrides the HTTP status of GET /settings/regions (default 200). + RegionsStatus int `json:"regionsStatus,omitempty"` + // Response is the protojson of the response message for the called method; it + // replaces the populated default, giving full control over the payload. + Response json.RawMessage `json:"response,omitempty"` + // SkipAuth disables permission enforcement for this request (for tests that + // aren't about authz, e.g. failover tests with a placeholder token). + SkipAuth bool `json:"skipAuth,omitempty"` + + // legacyDelayMs is the sleep used by the deprecated "delay" fail mode. It is + // populated only from the legacy X-Lk-Mock-Delay-Ms header, never from JSON. + legacyDelayMs int +} + +// parseMockConfig builds the request's config. Deprecated individual X-Lk-Mock-* +// headers form the base; the unified X-Lk-Mock JSON header (if present) is +// overlaid on top, so its fields win per-field while absent fields keep the +// legacy value. +func parseMockConfig(r *http.Request) mockConfig { + cfg := parseLegacyConfig(r) + if v := r.Header.Get(headerMock); v != "" { + // Unmarshal overwrites only the fields present in the JSON; the unexported + // legacyDelayMs is untouched. + _ = json.Unmarshal([]byte(v), &cfg) + } + return cfg +} + +// parseLegacyConfig reads the deprecated per-setting headers into a config. +func parseLegacyConfig(r *http.Request) mockConfig { + var cfg mockConfig + for _, part := range strings.Split(r.Header.Get(legacyHeaderFailRegions), ",") { + if idx, err := strconv.Atoi(strings.TrimSpace(part)); err == nil { + cfg.FailRegions = append(cfg.FailRegions, idx) + } + } + cfg.FailMode = r.Header.Get(legacyHeaderFailMode) + cfg.FailStatus = parseStatus(r.Header.Get(legacyHeaderFailStatus)) + cfg.FailTwirpCode = r.Header.Get(legacyHeaderFailTwirpCode) + cfg.RegionsStatus = parseStatus(r.Header.Get(legacyHeaderRegionsStatus)) + if resp := r.Header.Get(legacyHeaderResponse); resp != "" { + cfg.Response = json.RawMessage(resp) + } + cfg.SkipAuth = strings.EqualFold(r.Header.Get(legacyHeaderSkipAuth), "true") + cfg.legacyDelayMs = legacyDefaultDelayMs + if ms, err := strconv.Atoi(r.Header.Get(legacyHeaderDelayMs)); err == nil && ms >= 0 { + cfg.legacyDelayMs = ms + } + return cfg +} + +// parseStatus returns a valid HTTP status from s, or 0 if absent/invalid. +func parseStatus(s string) int { + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 { + return v + } + return 0 +} diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go index 7de083bc4..7878b7856 100644 --- a/cmd/test-server/handlers.go +++ b/cmd/test-server/handlers.go @@ -19,6 +19,7 @@ import ( "net/http" "strconv" "strings" + "time" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" @@ -135,25 +136,58 @@ func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) { } } + cfg := parseMockConfig(r) + // Permission enforcement comes first, mirroring the real server. - if status, code := h.authorize(key, r, req); status != 0 { + if status, code := h.authorize(key, r, &cfg, req); status != 0 { writeTwirpErrorCode(w, status, code, "mock: "+code) return } - if h.shouldFail(r) { - h.fail(w, r) + // Delay before responding (success or failure). An explicit delayMs overrides + // the method's natural latency — e.g. CreateSIPParticipant blocking until the + // callee answers. + delay := methodLatency(key, req) + if cfg.DelayMs != nil { + delay = time.Duration(*cfg.DelayMs) * time.Millisecond + } + if delay > 0 { + time.Sleep(delay) + } + + if h.shouldFail(&cfg) { + h.fail(w, &cfg) return } - h.writeAPIResponse(w, r, json, known, req, spec) + h.writeAPIResponse(w, json, known, req, spec, &cfg) } +// methodLatency returns the realistic time a method blocks before responding, so +// the mock approximates the real server's behavior. CreateSIPParticipant blocks +// until the callee answers when wait_until_answered is set; TransferSIPParticipant +// always blocks until the transfer (REFER) completes. +func methodLatency(key string, req proto.Message) time.Duration { + switch key { + case "livekit.SIP/CreateSIPParticipant": + if requestBool(req, "wait_until_answered") { + return sipAnswerLatency + } + case "livekit.SIP/TransferSIPParticipant": + return sipAnswerLatency + } + return 0 +} + +// sipAnswerLatency is how long a SIP call takes to be answered/transferred in the +// mock — long enough to exercise client-side timeouts around these calls. +const sipAnswerLatency = 11 * time.Second + // writeAPIResponse serves a populated, type-correct response for a known API -// method. The response is the reflection-populated default unless the request -// carries an X-Lk-Mock-Response header (protojson), which overrides it -// entirely. Content type (protobuf vs JSON) mirrors the request. -func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request, json, known bool, req proto.Message, spec apiSpec) { +// method. The response is the reflection-populated default unless the mock +// config carries a `response` (protojson), which overrides it entirely. Content +// type (protobuf vs JSON) mirrors the request. +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, json, known bool, req proto.Message, spec apiSpec, cfg *mockConfig) { w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) if !known { @@ -164,8 +198,8 @@ func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request, j } resp := spec.newResp() - if override := r.Header.Get(headerResponse); override != "" { - if err := protojson.Unmarshal([]byte(override), resp); err != nil { + if len(cfg.Response) > 0 { + if err := protojson.Unmarshal(cfg.Response, resp); err != nil { // Malformed override: fall back to the populated default. resp = spec.newResp() populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 07449539d..5d54885b6 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -22,6 +22,7 @@ import ( "net/http" "os" "os/signal" + "slices" "strconv" "strings" "syscall" @@ -31,23 +32,6 @@ import ( "github.com/livekit/protocol/utils/protojson" ) -// X-Lk-Mock-* request headers control the mock's behavior; see the README. -const ( - headerFailRegions = "X-Lk-Mock-Fail-Regions" - headerFailMode = "X-Lk-Mock-Fail-Mode" - headerFailStatus = "X-Lk-Mock-Fail-Status" - headerFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code" - headerDelayMs = "X-Lk-Mock-Delay-Ms" - headerRegionsStatus = "X-Lk-Mock-Regions-Status" - headerResponse = "X-Lk-Mock-Response" - // headerSkipAuth disables permission enforcement for a request. - headerSkipAuth = "X-Lk-Mock-Skip-Auth" - // headerRegion is set on responses to the index of the region that served it. - headerRegion = "X-Lk-Mock-Region" -) - -const defaultDelayMs = 30_000 - func main() { portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002") advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") @@ -116,8 +100,9 @@ func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { - if status := parseStatus(r.Header.Get(headerRegionsStatus), 0); status != 0 && status != http.StatusOK { - w.WriteHeader(status) + cfg := parseMockConfig(r) + if cfg.RegionsStatus != 0 && cfg.RegionsStatus != http.StatusOK { + w.WriteHeader(cfg.RegionsStatus) return } body, err := protojson.Marshal(h.regions) @@ -135,21 +120,12 @@ func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { h.serveAPI(w, r) } -func (h *mockHandler) shouldFail(r *http.Request) bool { - for _, part := range strings.Split(r.Header.Get(headerFailRegions), ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - if idx, err := strconv.Atoi(part); err == nil && idx == h.regionIndex { - return true - } - } - return false +func (h *mockHandler) shouldFail(cfg *mockConfig) bool { + return slices.Contains(cfg.FailRegions, h.regionIndex) } -func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) { - switch strings.ToLower(r.Header.Get(headerFailMode)) { +func (h *mockHandler) fail(w http.ResponseWriter, cfg *mockConfig) { + switch strings.ToLower(cfg.FailMode) { case "drop": if hj, ok := w.(http.Hijacker); ok { if conn, _, err := hj.Hijack(); err == nil { @@ -158,20 +134,21 @@ func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) { } } w.WriteHeader(http.StatusServiceUnavailable) + return case "delay": - delay := defaultDelayMs - if ms, err := strconv.Atoi(r.Header.Get(headerDelayMs)); err == nil && ms >= 0 { - delay = ms - } - time.Sleep(time.Duration(delay) * time.Millisecond) - writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) - default: - writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + // Deprecated legacy mode: sleep, then status-fail. New clients should set + // DelayMs (which delays every response) instead. + time.Sleep(time.Duration(cfg.legacyDelayMs) * time.Millisecond) } + status := cfg.FailStatus + if status < 100 || status > 599 { + status = http.StatusServiceUnavailable + } + writeTwirpError(w, cfg, status) } -func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { - code := r.Header.Get(headerFailTwirpCode) +func writeTwirpError(w http.ResponseWriter, cfg *mockConfig, status int) { + code := cfg.FailTwirpCode if code == "" { code = twirpCodeForStatus(status) } @@ -205,16 +182,6 @@ func twirpCodeForStatus(status int) string { } } -func parseStatus(s string, def int) int { - if s == "" { - return def - } - if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 { - return v - } - return def -} - func parsePorts(s string) ([]int, error) { var ports []int for _, part := range strings.Split(s, ",") { From 0bc73fd0daf5b312bdc06706fd391efd6018931e Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sat, 4 Jul 2026 21:47:02 +0200 Subject: [PATCH 33/44] added mocking function for SIP dialing methods (#4642) --- cmd/test-server/README.md | 3 +++ cmd/test-server/config.go | 14 ++++++++++++++ cmd/test-server/handlers.go | 28 ++++++++++++++++++++++++++++ cmd/test-server/main.go | 20 ++++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 02991a934..2f13f00da 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -66,6 +66,7 @@ field — for normal behavior. Every field is optional: | `regionsStatus` | `200` | override the status of `GET /settings/regions`. | | `response` | — | the response message for the called method (a JSON object, protojson-shaped); replaces the populated default, giving full control over the returned payload. | | `skipAuth` | `false` | `true` disables permission enforcement for the request (use for tests that aren't about authz, e.g. failover tests with a placeholder token). | +| `sipStatus` | — | fail a SIP dial method (`CreateSIPParticipant`/`TransferSIPParticipant`) with a SIP status, e.g. `{"code":486,"status":"Busy Here"}` (`status` optional). The Twirp error code and `sip_status_code`/`sip_status`/`error_details` metadata are derived from it exactly as the real server does. Composes with `delayMs` to simulate "ring, then fail". | Example: `X-Lk-Mock: {"skipAuth":true,"failRegions":[0],"failStatus":400}` @@ -129,6 +130,8 @@ about permissions. | Timeout test | `{"delayMs":30000}` | | Region discovery unreachable | `{"regionsStatus":500}` | | Custom response payload | `{"response":{"sid":"RM_x","name":"my-room"}}` | +| SIP busy signal | `{"sipStatus":{"code":486,"status":"Busy Here"}}` | +| SIP carrier decline | `{"sipStatus":{"code":603}}` | Note: SDK region failover normally only engages for `*.livekit.cloud` hosts. Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its diff --git a/cmd/test-server/config.go b/cmd/test-server/config.go index 2e1aefe40..bca4ee251 100644 --- a/cmd/test-server/config.go +++ b/cmd/test-server/config.go @@ -80,12 +80,26 @@ type mockConfig struct { // SkipAuth disables permission enforcement for this request (for tests that // aren't about authz, e.g. failover tests with a placeholder token). SkipAuth bool `json:"skipAuth,omitempty"` + // SIPStatus, when set on a SIP dial method (CreateSIPParticipant / + // TransferSIPParticipant), fails the call with this SIP status. The Twirp + // error code and metadata (sip_status_code, sip_status, error_details) are + // derived from it exactly as the real server does, so the SDK sees an + // identical error. Composes with DelayMs to simulate "ring, then fail". + SIPStatus *sipStatusConfig `json:"sipStatus,omitempty"` // legacyDelayMs is the sleep used by the deprecated "delay" fail mode. It is // populated only from the legacy X-Lk-Mock-Delay-Ms header, never from JSON. legacyDelayMs int } +// sipStatusConfig is a SIP response to inject; see mockConfig.SIPStatus. +type sipStatusConfig struct { + // Code is the SIP response code, e.g. 486 (Busy Here) or 603 (Decline). + Code int `json:"code"` + // Status is the SIP reason phrase; defaults to the code's canonical name. + Status string `json:"status,omitempty"` +} + // parseMockConfig builds the request's config. Deprecated individual X-Lk-Mock-* // headers form the base; the unified X-Lk-Mock JSON header (if present) is // overlaid on top, so its fields win per-field while absent fields keep the diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go index 7878b7856..9d14fd2b3 100644 --- a/cmd/test-server/handlers.go +++ b/cmd/test-server/handlers.go @@ -27,6 +27,7 @@ import ( "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils/protojson" + "github.com/livekit/protocol/utils/xtwirp" ) // apiSpec captures the request and response message types for one Twirp method, @@ -155,6 +156,13 @@ func (h *mockHandler) serveAPI(w http.ResponseWriter, r *http.Request) { time.Sleep(delay) } + // A SIP dial that fails carries a SIP status; the Twirp code and metadata are + // derived from it exactly as the real server does. + if cfg.SIPStatus != nil && isSIPDialMethod(key) { + h.failSIP(w, &cfg) + return + } + if h.shouldFail(&cfg) { h.fail(w, &cfg) return @@ -183,6 +191,26 @@ func methodLatency(key string, req proto.Message) time.Duration { // mock — long enough to exercise client-side timeouts around these calls. const sipAnswerLatency = 11 * time.Second +// isSIPDialMethod reports whether key places a call that can fail with a SIP status. +func isSIPDialMethod(key string) bool { + switch key { + case "livekit.SIP/CreateSIPParticipant", "livekit.SIP/TransferSIPParticipant": + return true + } + return false +} + +// failSIP fails the request with the configured SIP status, mirroring the real +// server: the status maps to a Twirp error code and attaches sip_status_code, +// sip_status, and error_details metadata via xtwirp. +func (h *mockHandler) failSIP(w http.ResponseWriter, cfg *mockConfig) { + st := &livekit.SIPStatus{ + Code: livekit.SIPStatusCode(cfg.SIPStatus.Code), + Status: cfg.SIPStatus.Status, + } + writeTwirpErr(w, xtwirp.ToError(st)) +} + // writeAPIResponse serves a populated, type-correct response for a known API // method. The response is the reflection-populated default unless the mock // config carries a `response` (protojson), which overrides it entirely. Content diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 5d54885b6..7850a4c7c 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -17,6 +17,7 @@ package main import ( + "encoding/json" "errors" "fmt" "net/http" @@ -28,6 +29,8 @@ import ( "syscall" "time" + "github.com/twitchtv/twirp" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils/protojson" ) @@ -163,6 +166,23 @@ func writeTwirpErrorCode(w http.ResponseWriter, status int, code, msg string) { _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, msg) } +// writeTwirpErr writes a full Twirp JSON error — code, message, and metadata — +// using the HTTP status Twirp derives from the error code. +func writeTwirpErr(w http.ResponseWriter, terr twirp.Error) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerRegion, "") + w.WriteHeader(twirp.ServerHTTPStatusFromErrorCode(terr.Code())) + _ = json.NewEncoder(w).Encode(struct { + Code string `json:"code"` + Msg string `json:"msg"` + Meta map[string]string `json:"meta,omitempty"` + }{ + Code: string(terr.Code()), + Msg: terr.Msg(), + Meta: terr.MetaMap(), + }) +} + func twirpCodeForStatus(status int) string { switch { case status == http.StatusBadRequest: From bf777e65132e393aa286bccf0463d6fd8c6d31be Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 5 Jul 2026 16:08:10 +0530 Subject: [PATCH 34/44] Make IsConnectionCanceled available at LocalParticipant interface. (#4643) Can be used to keep track of pariticpants failing connection in a room by checking this when room closes the participant. --- pkg/rtc/participant.go | 14 ++-- pkg/rtc/types/interfaces.go | 1 + .../typesfakes/fake_local_participant.go | 72 +++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 915a7022e..780deea9c 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1427,19 +1427,23 @@ func (p *ParticipantImpl) IsReconnect() bool { } func (p *ParticipantImpl) maybeRecordRTCanceled(closeReason types.ParticipantCloseReason) { - if p.State() >= livekit.ParticipantInfo_ACTIVE { + if p.HasConnected() { return } - if closeReason == types.ParticipantCloseReasonClientRequestLeave || + if p.IsConnectionCanceled(closeReason) { + prometheus.IncrementParticipantRtcCanceled(1) + } +} + +func (p *ParticipantImpl) IsConnectionCanceled(closeReason types.ParticipantCloseReason) bool { + return closeReason == types.ParticipantCloseReasonClientRequestLeave || closeReason == types.ParticipantCloseReasonDuplicateIdentity || closeReason == types.ParticipantCloseReasonRoomClosed || closeReason == types.ParticipantCloseReasonMigrationRequested || closeReason == types.ParticipantCloseReasonMigrationComplete || // client closing signal connection too quickly, there is a time check to handle clients timing out and leaving without sending a leave message - (time.Since(p.params.SessionStartTime) < 3*time.Second && closeReason == types.ParticipantCloseReasonSignalSourceClose) { - prometheus.IncrementParticipantRtcCanceled(1) - } + (time.Since(p.params.SessionStartTime) < 3*time.Second && closeReason == types.ParticipantCloseReasonSignalSourceClose) } func (p *ParticipantImpl) Close(sendLeave bool, reason types.ParticipantCloseReason, isExpectedToResume bool) error { diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 5d0c702ed..c47802ce5 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -414,6 +414,7 @@ type LocalParticipant interface { IsReady() bool ActiveAt() time.Time Disconnected() <-chan struct{} + IsConnectionCanceled(closeReason ParticipantCloseReason) bool IsIdle() bool SubscriberAsPrimary() bool GetClientInfo() *livekit.ClientInfo diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index d4198c2de..b7461f9ff 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -841,6 +841,17 @@ type FakeLocalParticipant struct { isClosedReturnsOnCall map[int]struct { result1 bool } + IsConnectionCanceledStub func(types.ParticipantCloseReason) bool + isConnectionCanceledMutex sync.RWMutex + isConnectionCanceledArgsForCall []struct { + arg1 types.ParticipantCloseReason + } + isConnectionCanceledReturns struct { + result1 bool + } + isConnectionCanceledReturnsOnCall map[int]struct { + result1 bool + } IsDependentStub func() bool isDependentMutex sync.RWMutex isDependentArgsForCall []struct { @@ -5946,6 +5957,67 @@ func (fake *FakeLocalParticipant) IsClosedReturnsOnCall(i int, result1 bool) { }{result1} } +func (fake *FakeLocalParticipant) IsConnectionCanceled(arg1 types.ParticipantCloseReason) bool { + fake.isConnectionCanceledMutex.Lock() + ret, specificReturn := fake.isConnectionCanceledReturnsOnCall[len(fake.isConnectionCanceledArgsForCall)] + fake.isConnectionCanceledArgsForCall = append(fake.isConnectionCanceledArgsForCall, struct { + arg1 types.ParticipantCloseReason + }{arg1}) + stub := fake.IsConnectionCanceledStub + fakeReturns := fake.isConnectionCanceledReturns + fake.recordInvocation("IsConnectionCanceled", []interface{}{arg1}) + fake.isConnectionCanceledMutex.Unlock() + if stub != nil { + return stub(arg1) + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) IsConnectionCanceledCallCount() int { + fake.isConnectionCanceledMutex.RLock() + defer fake.isConnectionCanceledMutex.RUnlock() + return len(fake.isConnectionCanceledArgsForCall) +} + +func (fake *FakeLocalParticipant) IsConnectionCanceledCalls(stub func(types.ParticipantCloseReason) bool) { + fake.isConnectionCanceledMutex.Lock() + defer fake.isConnectionCanceledMutex.Unlock() + fake.IsConnectionCanceledStub = stub +} + +func (fake *FakeLocalParticipant) IsConnectionCanceledArgsForCall(i int) types.ParticipantCloseReason { + fake.isConnectionCanceledMutex.RLock() + defer fake.isConnectionCanceledMutex.RUnlock() + argsForCall := fake.isConnectionCanceledArgsForCall[i] + return argsForCall.arg1 +} + +func (fake *FakeLocalParticipant) IsConnectionCanceledReturns(result1 bool) { + fake.isConnectionCanceledMutex.Lock() + defer fake.isConnectionCanceledMutex.Unlock() + fake.IsConnectionCanceledStub = nil + fake.isConnectionCanceledReturns = struct { + result1 bool + }{result1} +} + +func (fake *FakeLocalParticipant) IsConnectionCanceledReturnsOnCall(i int, result1 bool) { + fake.isConnectionCanceledMutex.Lock() + defer fake.isConnectionCanceledMutex.Unlock() + fake.IsConnectionCanceledStub = nil + if fake.isConnectionCanceledReturnsOnCall == nil { + fake.isConnectionCanceledReturnsOnCall = make(map[int]struct { + result1 bool + }) + } + fake.isConnectionCanceledReturnsOnCall[i] = struct { + result1 bool + }{result1} +} + func (fake *FakeLocalParticipant) IsDependent() bool { fake.isDependentMutex.Lock() ret, specificReturn := fake.isDependentReturnsOnCall[len(fake.isDependentArgsForCall)] From a3c6208a74b49d7b62832c83b0950526be250ae2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 01:59:12 -0700 Subject: [PATCH 35/44] Update actions/checkout action to v7 (#4646) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/buildtest.yaml | 2 +- .github/workflows/docker.yaml | 2 +- .github/workflows/release.yaml | 2 +- .github/workflows/test-server-docker.yaml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index 284ecf87a..eabddd15a 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -28,7 +28,7 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - uses: shogo82148/actions-setup-redis@2f3253b148c73d7a0682eae73e862b777a4fa74e # v1 with: redis-version: "6.x" diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 1ee39fe6f..1c0f64802 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -29,7 +29,7 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Docker meta id: meta uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 78b7ef75a..0fe7bb6c6 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -28,7 +28,7 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Fetch all tags run: git fetch --force --tags diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml index a0a854eaf..184f0de04 100644 --- a/.github/workflows/test-server-docker.yaml +++ b/.github/workflows/test-server-docker.yaml @@ -30,7 +30,7 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 From a4bea6981a2f061843233946fe946cebab29ee5b Mon Sep 17 00:00:00 2001 From: Denys Smirnov Date: Wed, 8 Jul 2026 19:27:00 +0200 Subject: [PATCH 36/44] Update protocol. (#4648) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 1fcc70246..41d00052e 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.48.2 + github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index c969726a2..f3c2342d6 100644 --- a/go.sum +++ b/go.sum @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.48.2 h1:1Jv1Eckf2jMN7SgJm7fQkSRQoMkGpmuN63mFeh3gd1U= -github.com/livekit/protocol v1.48.2/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594 h1:O5vKcr5UdK9ZFXQbFhwfZBs+C1Z2M8IV0fc0xQ/simo= +github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= From cb46452b5d579561cda114d03e53fe9afe4cc769 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 9 Jul 2026 12:23:33 +0530 Subject: [PATCH 37/44] Export migration to LocalParticipant interface. (#4652) Can be used during delayed egress start. --- pkg/rtc/participant.go | 4 ++ pkg/rtc/types/interfaces.go | 1 + .../typesfakes/fake_local_participant.go | 63 +++++++++++++++++++ pkg/service/wire_gen.go | 18 +++--- 4 files changed, 77 insertions(+), 9 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 780deea9c..9cba516cf 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1426,6 +1426,10 @@ func (p *ParticipantImpl) IsReconnect() bool { return p.params.Reconnect } +func (p *ParticipantImpl) IsMigration() bool { + return p.params.Migration +} + func (p *ParticipantImpl) maybeRecordRTCanceled(closeReason types.ParticipantCloseReason) { if p.HasConnected() { return diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index c47802ce5..484f707e2 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -527,6 +527,7 @@ type LocalParticipant interface { dataTracks []*livekit.PublishDataTrackResponse, ) IsReconnect() bool + IsMigration() bool MoveToRoom(params MoveToRoomParams) UpdateMediaRTT(rtt uint32) diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index b7461f9ff..6996a81e3 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -882,6 +882,16 @@ type FakeLocalParticipant struct { isIdleReturnsOnCall map[int]struct { result1 bool } + IsMigrationStub func() bool + isMigrationMutex sync.RWMutex + isMigrationArgsForCall []struct { + } + isMigrationReturns struct { + result1 bool + } + isMigrationReturnsOnCall map[int]struct { + result1 bool + } IsPublisherStub func() bool isPublisherMutex sync.RWMutex isPublisherArgsForCall []struct { @@ -6177,6 +6187,59 @@ func (fake *FakeLocalParticipant) IsIdleReturnsOnCall(i int, result1 bool) { }{result1} } +func (fake *FakeLocalParticipant) IsMigration() bool { + fake.isMigrationMutex.Lock() + ret, specificReturn := fake.isMigrationReturnsOnCall[len(fake.isMigrationArgsForCall)] + fake.isMigrationArgsForCall = append(fake.isMigrationArgsForCall, struct { + }{}) + stub := fake.IsMigrationStub + fakeReturns := fake.isMigrationReturns + fake.recordInvocation("IsMigration", []interface{}{}) + fake.isMigrationMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) IsMigrationCallCount() int { + fake.isMigrationMutex.RLock() + defer fake.isMigrationMutex.RUnlock() + return len(fake.isMigrationArgsForCall) +} + +func (fake *FakeLocalParticipant) IsMigrationCalls(stub func() bool) { + fake.isMigrationMutex.Lock() + defer fake.isMigrationMutex.Unlock() + fake.IsMigrationStub = stub +} + +func (fake *FakeLocalParticipant) IsMigrationReturns(result1 bool) { + fake.isMigrationMutex.Lock() + defer fake.isMigrationMutex.Unlock() + fake.IsMigrationStub = nil + fake.isMigrationReturns = struct { + result1 bool + }{result1} +} + +func (fake *FakeLocalParticipant) IsMigrationReturnsOnCall(i int, result1 bool) { + fake.isMigrationMutex.Lock() + defer fake.isMigrationMutex.Unlock() + fake.IsMigrationStub = nil + if fake.isMigrationReturnsOnCall == nil { + fake.isMigrationReturnsOnCall = make(map[int]struct { + result1 bool + }) + } + fake.isMigrationReturnsOnCall[i] = struct { + result1 bool + }{result1} +} + func (fake *FakeLocalParticipant) IsPublisher() bool { fake.isPublisherMutex.Lock() ret, specificReturn := fake.isPublisherReturnsOnCall[len(fake.isPublisherArgsForCall)] diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index b64fbb063..0da441132 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -86,23 +86,23 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live } rtcEgressLauncher := NewEgressLauncher(egressClient, ioInfoService, objectStore) topicFormatter := rpc.NewTopicFormatter() - roomClient, err := rpc.NewTypedRoomClient(clientParams) + v, err := rpc.NewTypedRoomClient(clientParams) if err != nil { return nil, err } - participantClient, err := rpc.NewTypedParticipantClient(clientParams) + v2, err := rpc.NewTypedParticipantClient(clientParams) if err != nil { return nil, err } - roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, roomClient, participantClient) + roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, v, v2) if err != nil { return nil, err } - agentDispatchInternalClient, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) + v3, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) if err != nil { return nil, err } - agentDispatchService := NewAgentDispatchService(limitConfig, agentDispatchInternalClient, topicFormatter, roomAllocator, router) + agentDispatchService := NewAgentDispatchService(limitConfig, v3, topicFormatter, roomAllocator, router) egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService) ingressConfig := getIngressConfig(conf) ingressClient, err := rpc.NewIngressClient(clientParams) @@ -117,11 +117,11 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live } sipService := NewSIPService(sipConfig, nodeID, messageBus, sipClient, sipStore, roomService, telemetryService) rtcService := NewRTCService(conf, roomAllocator, router, telemetryService) - whipParticipantClient, err := rpc.NewTypedWHIPParticipantClient(clientParams) + v4, err := rpc.NewTypedWHIPParticipantClient(clientParams) if err != nil { return nil, err } - serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, whipParticipantClient) + serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, v4) if err != nil { return nil, err } @@ -146,8 +146,8 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - authHandler := getTURNAuthHandlerFunc(turnAuthHandler) - server, err := newInProcessTurnServer(conf, authHandler) + v5 := getTURNAuthHandlerFunc(turnAuthHandler) + server, err := newInProcessTurnServer(conf, v5) if err != nil { return nil, err } From aff752a4bcaa5fc4bab15af7c7dbb9637ecd1809 Mon Sep 17 00:00:00 2001 From: lukasIO Date: Thu, 9 Jul 2026 16:07:37 +0200 Subject: [PATCH 38/44] Add signal tests to test-server (#4653) * add signal tests to test-server * update action enum --- cmd/test-server/README.md | 74 ++++++ cmd/test-server/main.go | 4 + cmd/test-server/signal.go | 406 +++++++++++++++++++++++++++++ cmd/test-server/signal_test.go | 451 +++++++++++++++++++++++++++++++++ 4 files changed, 935 insertions(+) create mode 100644 cmd/test-server/signal.go create mode 100644 cmd/test-server/signal_test.go diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md index 2f13f00da..00649f8fb 100644 --- a/cmd/test-server/README.md +++ b/cmd/test-server/README.md @@ -83,6 +83,80 @@ Response headers: |---|---| | `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | +## Signal connection (WebSocket) mocking + +The mock also speaks enough of the LiveKit signal protocol for SDKs to run +end-to-end signal-connection tests (connect, keepalive, reconnect, leave, and +the failure/timeout modes a client must classify). Signal behavior is selected +by a participant attribute (`lk.mock`) in the access token (see below) — the +WebSocket client can't set request headers, so it can't carry a control header. +Selecting via the token means parallel tests need no shared +state. + +Endpoints (both protocol versions are supported and behave identically): + +| Path | Purpose | +|---|---| +| `/rtc`, `/rtc/v1` | WebSocket signal connection | +| `/rtc/validate`, `/rtc/v1/validate` | HTTP validate (the client fetches this when the WS fails to open) | + +- The access token is read from the `access_token` query param (or a + `Bearer` Authorization header) and verified against the API secret. A + missing/malformed/expired/wrongly-signed token makes `validate` return + **401** (and the WS refuse the upgrade). +- Wire format is **binary protobuf**: `SignalRequest` in, `SignalResponse` out. +- The v1 embedded publisher offer (`join_request` connection param) is + **ignored** — no valid offer is required. +- Keepalive uses a short `pingTimeout=3s` / `pingInterval=1s` in the join so + timeout tests run fast. + +**Mode selection is via a participant attribute.** After the token is verified, +the server reads the `lk.mock` entry from the token's `attributes` claim +(`ClaimGrants.Attributes`, a `map[string]string`). The value of that attribute +is a stringified JSON control object whose `signal` field picks the behavior. +The `lk.mock` namespace is the attribute **key** (dot notation, matching +LiveKit's convention for internal attributes), so the value has no inner parent: + +``` +attribute key: lk.mock +attribute value: {"signal":"no_pong"} +``` + +The control object also accepts an optional `leaveAction` field — a +`LeaveRequest_Action`, given either as the number (`0`=DISCONNECT, `1`=RESUME, +`2`=RECONNECT) or the enum name (`"RECONNECT"`, case-insensitive) — that sets +the `action` on the `LeaveRequest` the leave-sending modes emit +(`leave_when_connected`, `leave_first_message`, `leave_during_reconnect`). When +absent it defaults to `0` (DISCONNECT). Examples: + +``` +attribute value: {"signal":"leave_when_connected","leaveAction":"RECONNECT"} +``` + +If the `lk.mock` attribute is absent/empty, its value is unparseable, or its +`signal` is unknown, the mode defaults to `happy`. Both the WS handlers and the +validate handlers read the mode from this same attribute. + +Behavior modes (any unknown/absent `signal` = `happy`): + +| `signal` value | Effect | +|---|---| +| `happy` | validate → 200; WS sends `JoinResponse` (or `ReconnectResponse` if `reconnect=1`), pongs pings, closes cleanly (1000) on client `LeaveRequest` | +| `validate_500` | validate → 500; WS refuses upgrade with 500 | +| `validate_service_not_found` | validate → 404 with a body *without* the room marker (client → serviceNotFound); WS refuses with 404 | +| `room_not_found` | validate → 404 with body `requested room does not exist` (client → notAllowed); WS refuses with 404 | +| `no_first_message` | WS accepted, server sends nothing (client hits connect timeout) | +| `no_pong` | WS sends the join, then never pongs (client hits ping timeout) | +| `close_before_join` | WS upgrade succeeds, then ~50ms later a clean close (code 1011, empty reason) *before* any first message — unexpected closure during connect | +| `close_when_connected` | WS sends join, then ~200ms later closes with code 1011 | +| `drop_when_connected` | WS sends join, then ~200ms later abruptly drops the TCP connection with no close handshake — client observes an abnormal closure (code 1006) | +| `leave_when_connected` | WS sends join, then ~200ms later sends a `LeaveRequest` | +| `leave_first_message` | WS sends a `LeaveRequest` as the first (and only) message | +| `leave_during_reconnect` | on a `reconnect=1` connection, sends `LeaveRequest` first; otherwise behaves like `happy` | + +`LeaveRequest`s carry `reason=SERVER_SHUTDOWN` and `action` from the control's +optional `leaveAction` (default `DISCONNECT (0)`). + ## Permission enforcement Every API method requires the same token grants the real LiveKit server checks diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go index 7850a4c7c..7a2e4f52d 100644 --- a/cmd/test-server/main.go +++ b/cmd/test-server/main.go @@ -92,6 +92,10 @@ func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch { case r.URL.Path == "/settings/regions": h.handleRegions(w, r) + case isValidatePath(r.URL.Path): + h.handleValidate(w, r) + case isSignalPath(r.URL.Path): + h.handleSignal(w, r) case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"): h.handleTwirp(w, r) case r.URL.Path == "/" || r.URL.Path == "/_test/health": diff --git a/cmd/test-server/signal.go b/cmd/test-server/signal.go new file mode 100644 index 000000000..439d669e7 --- /dev/null +++ b/cmd/test-server/signal.go @@ -0,0 +1,406 @@ +// 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 main + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" +) + +// Signal endpoints (/rtc, /rtc/v1 and their /validate counterparts) let SDKs +// exercise end-to-end WebSocket signal behavior. Per-connection behavior is +// selected by the `lk.mock` participant attribute (see signalControl), so tests +// need no shared state. The client fetches validate only when the WS fails to +// open, so validate-error modes refuse the upgrade with the matching status and +// let that fetch return the definitive status/body. + +const ( + // Short keepalive (seconds, sent in the JoinResponse) so timeout tests run fast. + signalPingInterval = 1 + signalPingTimeout = 3 + + // Delay after join before close_when_connected / leave_when_connected act, + // giving the client time to mark the connection established. + connectedDelay = 200 * time.Millisecond +) + +// Behavior modes, selected by the `lk.mock` attribute's `signal` field. Any +// unknown/absent signal behaves as the happy path. +const ( + // Validate-endpoint modes (the WS upgrade is refused with the same status + // so the client falls back to the validate fetch). + modeValidate500 = "validate_500" // validate → 500 + modeServiceNotFound = "validate_service_not_found" // validate → 404, generic body + modeRoomNotFound = "room_not_found" // validate → 404, "requested room does not exist" + + // WebSocket signal modes (validate → 200; behavior is on the WS). + modeHappy = "happy" // join, pong, clean close on client leave + modeNoFirstMessage = "no_first_message" // accept WS, send nothing + modeNoPong = "no_pong" // send join, never pong + modeCloseBeforeJoin = "close_before_join" // clean close 1011 before any first message + modeCloseWhenConnected = "close_when_connected" // send join, then clean close 1011 + modeDropWhenConnected = "drop_when_connected" // send join, then abrupt TCP drop (1006) + modeLeaveWhenConnected = "leave_when_connected" // send join, then LeaveRequest + modeLeaveFirstMessage = "leave_first_message" // LeaveRequest as first message + modeLeaveDuringReconnect = "leave_during_reconnect" // on reconnect=1, LeaveRequest first +) + +const signalControlAttribute = "lk.mock" + +// signalControl is the JSON value of the `lk.mock` attribute: +// {"signal":"","leaveAction":}. leaveAction is optional +// (a LeaveRequest_Action, given as the number or the enum name e.g. +// "RECONNECT"; absent/0 = DISCONNECT) and sets the action on emitted leaves. +type signalControl struct { + Signal string `json:"signal"` + LeaveAction leaveActionValue `json:"leaveAction"` +} + +// leaveActionValue is a LeaveRequest_Action that unmarshals from either a JSON +// number (2) or an enum name ("RECONNECT", case-insensitive). Anything +// unrecognized decodes to 0 (DISCONNECT) rather than failing the whole control. +type leaveActionValue livekit.LeaveRequest_Action + +func (v *leaveActionValue) UnmarshalJSON(b []byte) error { + var n int32 + if json.Unmarshal(b, &n) == nil { + *v = leaveActionValue(n) + return nil + } + var s string + if err := json.Unmarshal(b, &s); err != nil { + return err + } + *v = leaveActionValue(livekit.LeaveRequest_Action_value[strings.ToUpper(s)]) + return nil +} + +// parseSignalControl parses the `lk.mock` attribute value; absent/invalid → zero. +func parseSignalControl(grants *auth.ClaimGrants) signalControl { + if grants == nil { + return signalControl{} + } + raw := grants.Attributes[signalControlAttribute] + if raw == "" { + return signalControl{} + } + var ctrl signalControl + if err := json.Unmarshal([]byte(raw), &ctrl); err != nil { + return signalControl{} + } + return ctrl +} + +// signalMode returns the mode from the `lk.mock` `signal` field; unknown/absent → happy. +func signalMode(grants *auth.ClaimGrants) string { + switch ctrl := parseSignalControl(grants); ctrl.Signal { + case modeValidate500, modeServiceNotFound, modeRoomNotFound, + modeHappy, modeNoFirstMessage, modeNoPong, + modeCloseBeforeJoin, modeCloseWhenConnected, modeDropWhenConnected, + modeLeaveWhenConnected, modeLeaveFirstMessage, modeLeaveDuringReconnect: + return ctrl.Signal + default: + return modeHappy + } +} + +func isSignalPath(path string) bool { + return path == "/rtc" || path == "/rtc/v1" +} + +func isValidatePath(path string) bool { + return path == "/rtc/validate" || path == "/rtc/v1/validate" +} + +var signalUpgrader = websocket.Upgrader{ + EnableCompression: true, + // Auth is via the access token, so allow any origin. + CheckOrigin: func(r *http.Request) bool { return true }, +} + +// verifySignalToken reads the access token (access_token query param or Bearer +// header) and verifies it against the mock's API secret. +func (h *mockHandler) verifySignalToken(r *http.Request) (*auth.ClaimGrants, error) { + token := r.FormValue("access_token") + if token == "" { + token = strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + } + v, err := auth.ParseAPIToken(token) + if err != nil { + return nil, err + } + _, grants, err := v.Verify(h.apiSecret) + if err != nil { + return nil, err + } + return grants, nil +} + +func grantRoom(grants *auth.ClaimGrants) string { + if grants == nil || grants.Video == nil { + return "" + } + return grants.Video.Room +} + +// handleValidate verifies the JWT (bad/expired/missing → 401), then returns the +// status the mode dictates. +func (h *mockHandler) handleValidate(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + + grants, err := h.verifySignalToken(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("invalid token: " + err.Error())) + return + } + + switch signalMode(grants) { + case modeValidate500: + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + case modeServiceNotFound: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("404 page not found")) + case modeRoomNotFound: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("requested room does not exist")) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("success")) + } +} + +// handleSignal verifies the token, applies validate-error modes by refusing the +// upgrade, else upgrades and runs the selected behavior. The v1 publisher offer +// is ignored. +func (h *mockHandler) handleSignal(w http.ResponseWriter, r *http.Request) { + grants, err := h.verifySignalToken(r) + if err != nil { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte("invalid token")) + return + } + + mode := signalMode(grants) + + switch mode { + case modeValidate500: + w.WriteHeader(http.StatusInternalServerError) + return + case modeServiceNotFound, modeRoomNotFound: + w.WriteHeader(http.StatusNotFound) + return + } + + reconnect := r.URL.Query().Get("reconnect") == "1" + + conn, err := signalUpgrader.Upgrade(w, r, nil) + if err != nil { + return + } + defer func() { _ = conn.Close() }() + + h.runSignal(conn, mode, reconnect, grants) +} + +// runSignal drives one WebSocket connection according to mode. +func (h *mockHandler) runSignal(conn *websocket.Conn, mode string, reconnect bool, grants *auth.ClaimGrants) { + writeResp := func(msg *livekit.SignalResponse) error { + payload, err := proto.Marshal(msg) + if err != nil { + return err + } + return conn.WriteMessage(websocket.BinaryMessage, payload) + } + + leaveAction := livekit.LeaveRequest_Action(parseSignalControl(grants).LeaveAction) + + // drainUntilClosed reads/discards until the peer closes, keeping the socket open. + drainUntilClosed := func() { + for { + if _, _, err := conn.ReadMessage(); err != nil { + return + } + } + } + + // Modes that decide the very first message. + switch mode { + case modeNoFirstMessage: + drainUntilClosed() + return + case modeCloseBeforeJoin: + time.Sleep(50 * time.Millisecond) + msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "") + _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) + return + case modeLeaveFirstMessage: + _ = writeResp(leaveResponse(leaveAction)) + drainUntilClosed() + return + case modeLeaveDuringReconnect: + if reconnect { + _ = writeResp(leaveResponse(leaveAction)) + drainUntilClosed() + return + } + // Non-reconnect connections fall through to the happy path. + } + + // First message: reconnect response on a resume, join otherwise. + if reconnect { + if err := writeResp(reconnectResponse(h.regionIndex)); err != nil { + return + } + } else { + if err := writeResp(joinResponse(h.regionIndex, grants)); err != nil { + return + } + } + + // Post-join behaviors. + switch mode { + case modeCloseWhenConnected: + time.Sleep(connectedDelay) + msg := websocket.FormatCloseMessage(websocket.CloseInternalServerErr, "mock close_when_connected") + _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) + return + case modeDropWhenConnected: + time.Sleep(connectedDelay) + _ = conn.UnderlyingConn().Close() + return + case modeLeaveWhenConnected: + time.Sleep(connectedDelay) + _ = writeResp(leaveResponse(leaveAction)) + } + + // Read loop: pong to pings (unless no_pong), clean close on client leave. + for { + mt, payload, err := conn.ReadMessage() + if err != nil { + return + } + if mt != websocket.BinaryMessage { + continue + } + req := &livekit.SignalRequest{} + if err := proto.Unmarshal(payload, req); err != nil { + continue + } + switch m := req.Message.(type) { + case *livekit.SignalRequest_Ping: + if mode != modeNoPong { + _ = writeResp(&livekit.SignalResponse{ + Message: &livekit.SignalResponse_Pong{Pong: time.Now().UnixMilli()}, + }) + } + case *livekit.SignalRequest_PingReq: + if mode != modeNoPong { + _ = writeResp(&livekit.SignalResponse{ + Message: &livekit.SignalResponse_PongResp{ + PongResp: &livekit.Pong{ + LastPingTimestamp: m.PingReq.Timestamp, + Timestamp: time.Now().UnixMilli(), + }, + }, + }) + } + case *livekit.SignalRequest_Leave: + msg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") + _ = conn.WriteControl(websocket.CloseMessage, msg, time.Now().Add(time.Second)) + return + } + } +} + +func serverInfo(regionIndex int) *livekit.ServerInfo { + return &livekit.ServerInfo{ + Edition: livekit.ServerInfo_Standard, + Version: "mock", + Protocol: 15, + Region: regionName(regionIndex), + NodeId: "MOCK_NODE", + } +} + +func regionName(regionIndex int) string { + return "region-" + strconv.Itoa(regionIndex) +} + +// joinResponse builds the initial JoinResponse (non-zero ping config so the +// client arms keepalive). +func joinResponse(regionIndex int, grants *auth.ClaimGrants) *livekit.SignalResponse { + room := grantRoom(grants) + identity := "mock-participant" + name := "" + if grants != nil { + if grants.Identity != "" { + identity = grants.Identity + } + name = grants.Name + } + return &livekit.SignalResponse{ + Message: &livekit.SignalResponse_Join{ + Join: &livekit.JoinResponse{ + Room: &livekit.Room{ + Sid: "RM_MOCK", + Name: room, + }, + Participant: &livekit.ParticipantInfo{ + Sid: "PA_MOCK", + Identity: identity, + Name: name, + State: livekit.ParticipantInfo_JOINED, + }, + PingInterval: signalPingInterval, + PingTimeout: signalPingTimeout, + ServerInfo: serverInfo(regionIndex), + ServerVersion: "mock", + ServerRegion: regionName(regionIndex), + }, + }, + } +} + +func reconnectResponse(regionIndex int) *livekit.SignalResponse { + return &livekit.SignalResponse{ + Message: &livekit.SignalResponse_Reconnect{ + Reconnect: &livekit.ReconnectResponse{ + ServerInfo: serverInfo(regionIndex), + }, + }, + } +} + +func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse { + return &livekit.SignalResponse{ + Message: &livekit.SignalResponse_Leave{ + Leave: &livekit.LeaveRequest{ + Reason: livekit.DisconnectReason_SERVER_SHUTDOWN, + Action: action, + }, + }, + } +} diff --git a/cmd/test-server/signal_test.go b/cmd/test-server/signal_test.go new file mode 100644 index 000000000..3ccd97a95 --- /dev/null +++ b/cmd/test-server/signal_test.go @@ -0,0 +1,451 @@ +// 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 main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" + "google.golang.org/protobuf/proto" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" +) + +const testSecret = "secret" + +func newTestServer() *httptest.Server { + return httptest.NewServer(&mockHandler{regionIndex: 0, apiSecret: testSecret}) +} + +// mintToken signs a token whose `lk.mock` attribute selects mode (empty → happy). +func mintToken(t *testing.T, mode string) string { + t.Helper() + if mode == "" { + return mintTokenControl(t, nil) + } + return mintTokenControl(t, &signalControl{Signal: mode}) +} + +// mintTokenControl signs a token carrying ctrl as the `lk.mock` attribute (nil → none). +func mintTokenControl(t *testing.T, ctrl *signalControl) string { + t.Helper() + at := auth.NewAccessToken("APItest", testSecret). + SetIdentity("tester"). + SetValidFor(time.Hour). + SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true}) + if ctrl != nil { + raw, err := json.Marshal(ctrl) + if err != nil { + t.Fatalf("marshal control: %v", err) + } + at.SetAttributes(map[string]string{signalControlAttribute: string(raw)}) + } + tok, err := at.ToJWT() + if err != nil { + t.Fatalf("mint token: %v", err) + } + return tok +} + +// mintTokenAttr signs a token whose `lk.mock` attribute is the given raw value. +func mintTokenAttr(t *testing.T, attrValue string) string { + t.Helper() + at := auth.NewAccessToken("APItest", testSecret). + SetIdentity("tester"). + SetValidFor(time.Hour). + SetVideoGrant(&auth.VideoGrant{Room: "test-room", RoomJoin: true}). + SetAttributes(map[string]string{signalControlAttribute: attrValue}) + tok, err := at.ToJWT() + if err != nil { + t.Fatalf("mint token: %v", err) + } + return tok +} + +func wsURL(base, path, token string) string { + u := strings.Replace(base, "http://", "ws://", 1) + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return u + path + sep + "access_token=" + token +} + +func dial(t *testing.T, base, path, token string) *websocket.Conn { + t.Helper() + c, _, err := websocket.DefaultDialer.Dial(wsURL(base, path, token), nil) + if err != nil { + t.Fatalf("dial %s: %v", path, err) + } + return c +} + +func readResp(t *testing.T, c *websocket.Conn, timeout time.Duration) *livekit.SignalResponse { + t.Helper() + _ = c.SetReadDeadline(time.Now().Add(timeout)) + mt, payload, err := c.ReadMessage() + if err != nil { + t.Fatalf("read: %v", err) + } + if mt != websocket.BinaryMessage { + t.Fatalf("expected binary message, got %d", mt) + } + resp := &livekit.SignalResponse{} + if err := proto.Unmarshal(payload, resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return resp +} + +func writeReq(t *testing.T, c *websocket.Conn, req *livekit.SignalRequest) { + t.Helper() + payload, err := proto.Marshal(req) + if err != nil { + t.Fatalf("marshal req: %v", err) + } + if err := c.WriteMessage(websocket.BinaryMessage, payload); err != nil { + t.Fatalf("write req: %v", err) + } +} + +func TestHappyJoinAndPingPong(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "happy")) + defer c.Close() + + resp := readResp(t, c, 2*time.Second) + join := resp.GetJoin() + if join == nil { + t.Fatalf("first message not join: %T", resp.Message) + } + if join.PingTimeout == 0 || join.PingInterval == 0 { + t.Fatalf("ping timeout/interval must be non-zero: %d/%d", join.PingTimeout, join.PingInterval) + } + if join.ServerInfo == nil || join.Room == nil || join.Participant == nil { + t.Fatalf("join missing serverInfo/room/participant") + } + if join.Room.Name != "test-room" { + t.Fatalf("room name = %q, want test-room", join.Room.Name) + } + + // pingReq -> pongResp echoing timestamp + writeReq(t, c, &livekit.SignalRequest{ + Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 12345}}, + }) + pr := readResp(t, c, 2*time.Second) + if pr.GetPongResp() == nil || pr.GetPongResp().LastPingTimestamp != 12345 { + t.Fatalf("expected pongResp echoing 12345, got %+v", pr.Message) + } + + // legacy ping -> pong + writeReq(t, c, &livekit.SignalRequest{Message: &livekit.SignalRequest_Ping{Ping: 999}}) + pong := readResp(t, c, 2*time.Second) + if pong.GetPong() == 0 { + t.Fatalf("expected pong, got %+v", pong.Message) + } +} + +func TestReconnectResponse(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "happy")) + defer c.Close() + resp := readResp(t, c, 2*time.Second) + if resp.GetReconnect() == nil { + t.Fatalf("expected reconnect response, got %T", resp.Message) + } +} + +func TestV1PathHappy(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc/v1", mintToken(t, "happy")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("v1 first message not join") + } +} + +func TestNoPong(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "no_pong")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + writeReq(t, c, &livekit.SignalRequest{ + Message: &livekit.SignalRequest_PingReq{PingReq: &livekit.Ping{Timestamp: 1}}, + }) + _ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if _, _, err := c.ReadMessage(); err == nil { + t.Fatal("expected no pong (timeout), but got a message") + } +} + +func TestLeaveFirstMessage(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_first_message")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetLeave() == nil { + t.Fatal("expected leave as first message") + } +} + +func TestCloseWhenConnected(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "close_when_connected")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err := c.ReadMessage() + ce, ok := err.(*websocket.CloseError) + if !ok { + t.Fatalf("expected close error, got %v", err) + } + if ce.Code != websocket.CloseInternalServerErr { + t.Fatalf("expected close code 1011, got %d", ce.Code) + } +} + +func TestLeaveWhenConnected(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "leave_when_connected")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + leave := readResp(t, c, 2*time.Second).GetLeave() + if leave == nil { + t.Fatal("expected leave after join") + } + // Default action is DISCONNECT. + if leave.Action != livekit.LeaveRequest_DISCONNECT { + t.Fatalf("default leave action = %v, want DISCONNECT", leave.Action) + } +} + +func TestLeaveActionOverride(t *testing.T) { + srv := newTestServer() + defer srv.Close() + tok := mintTokenControl(t, &signalControl{ + Signal: "leave_when_connected", + LeaveAction: leaveActionValue(livekit.LeaveRequest_RECONNECT), + }) + c := dial(t, srv.URL, "/rtc", tok) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + leave := readResp(t, c, 2*time.Second).GetLeave() + if leave == nil { + t.Fatal("expected leave after join") + } + if leave.Action != livekit.LeaveRequest_RECONNECT { + t.Fatalf("leave action = %v, want RECONNECT", leave.Action) + } +} + +func TestLeaveActionByName(t *testing.T) { + srv := newTestServer() + defer srv.Close() + // leaveAction may be the enum name instead of the number. + tok := mintTokenAttr(t, `{"signal":"leave_when_connected","leaveAction":"RECONNECT"}`) + c := dial(t, srv.URL, "/rtc", tok) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + leave := readResp(t, c, 2*time.Second).GetLeave() + if leave == nil { + t.Fatal("expected leave after join") + } + if leave.Action != livekit.LeaveRequest_RECONNECT { + t.Fatalf("leave action = %v, want RECONNECT", leave.Action) + } +} + +func TestNoFirstMessage(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "no_first_message")) + defer c.Close() + _ = c.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + if _, _, err := c.ReadMessage(); err == nil { + t.Fatal("expected no first message (timeout)") + } +} + +func TestCloseBeforeJoin(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "close_before_join")) + defer c.Close() + // First read must be the close, never a join. + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err := c.ReadMessage() + ce, ok := err.(*websocket.CloseError) + if !ok { + t.Fatalf("expected close error before any message, got %v", err) + } + if ce.Code != websocket.CloseInternalServerErr { + t.Fatalf("expected close code 1011, got %d", ce.Code) + } + if ce.Text != "" { + t.Fatalf("expected empty close reason, got %q", ce.Text) + } +} + +func TestDropWhenConnected(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc", mintToken(t, "drop_when_connected")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join") + } + // Abrupt TCP drop → abnormal closure (1006 to a browser): a read error that + // is not a normal (1000) close. + _ = c.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _, err := c.ReadMessage() + if err == nil { + t.Fatal("expected read error after abrupt drop") + } + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + t.Fatalf("expected abnormal (non-1000) closure, got %v", err) + } +} + +func getStatusBody(t *testing.T, url string) (int, string) { + t.Helper() + resp, err := http.Get(url) + if err != nil { + t.Fatalf("get %s: %v", url, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +func TestValidateModes(t *testing.T) { + srv := newTestServer() + defer srv.Close() + + // happy → 200 + if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "happy")); st != 200 { + t.Fatalf("happy validate = %d, want 200", st) + } + // v1 validate happy → 200 + if st, _ := getStatusBody(t, srv.URL+"/rtc/v1/validate?access_token="+mintToken(t, "happy")); st != 200 { + t.Fatalf("v1 happy validate = %d, want 200", st) + } + // validate_500 → 500 + if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_500")); st != 500 { + t.Fatalf("validate_500 = %d, want 500", st) + } + // validate_service_not_found → 404, no marker + st, body := getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "validate_service_not_found")) + if st != 404 || strings.Contains(body, "requested room does not exist") { + t.Fatalf("service_not_found = %d body=%q", st, body) + } + // room_not_found → 404 with marker + st, body = getStatusBody(t, srv.URL+"/rtc/validate?access_token="+mintToken(t, "room_not_found")) + if st != 404 || !strings.Contains(body, "requested room does not exist") { + t.Fatalf("room_not_found = %d body=%q", st, body) + } + // bad token → 401 + if st, _ := getStatusBody(t, srv.URL+"/rtc/validate?access_token=not-a-jwt"); st != 401 { + t.Fatalf("bad token validate = %d, want 401", st) + } + // missing token → 401 + if st, _ := getStatusBody(t, srv.URL+"/rtc/validate"); st != 401 { + t.Fatalf("missing token validate = %d, want 401", st) + } +} + +func TestValidateErrorModesRefuseWS(t *testing.T) { + srv := newTestServer() + defer srv.Close() + // Validate-error modes must refuse the upgrade so the client falls back to validate. + _, resp, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "validate_500")), nil) + if err == nil { + t.Fatal("expected WS dial to fail for validate_500") + } + if resp == nil || resp.StatusCode != 500 { + t.Fatalf("expected 500 on WS refuse, got %v", resp) + } +} + +func TestValidateCORSHeader(t *testing.T) { + srv := newTestServer() + defer srv.Close() + // ACAO must be present on every status so a browser fetch can read it. + cases := map[string]string{ + "happy": mintToken(t, "happy"), // 200 + "bad": "bad", // 401 + "room_not_found": mintToken(t, "room_not_found"), // 404 + "validate_500": mintToken(t, "validate_500"), // 500 + } + for name, tok := range cases { + resp, err := http.Get(srv.URL + "/rtc/validate?access_token=" + tok) + if err != nil { + t.Fatalf("%s: get: %v", name, err) + } + got := resp.Header.Get("Access-Control-Allow-Origin") + resp.Body.Close() + if got != "*" { + t.Fatalf("%s (status %d): ACAO = %q, want *", name, resp.StatusCode, got) + } + } +} + +func TestWSCrossOrigin(t *testing.T) { + srv := newTestServer() + defer srv.Close() + // A mismatched browser Origin must still upgrade (CheckOrigin allows any). + hdr := http.Header{} + hdr.Set("Origin", "http://localhost:5173") + c, _, err := websocket.DefaultDialer.Dial(wsURL(srv.URL, "/rtc", mintToken(t, "happy")), hdr) + if err != nil { + t.Fatalf("cross-origin dial: %v", err) + } + defer c.Close() + if readResp(t, c, 2*time.Second).GetJoin() == nil { + t.Fatal("expected join on cross-origin WS") + } +} + +func TestLeaveDuringReconnect(t *testing.T) { + srv := newTestServer() + defer srv.Close() + c := dial(t, srv.URL, "/rtc?reconnect=1", mintToken(t, "leave_during_reconnect")) + defer c.Close() + if readResp(t, c, 2*time.Second).GetLeave() == nil { + t.Fatal("expected leave on reconnect") + } +} From 19c3d00fc97ef4debc60a8a01340611990c0decc Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 10 Jul 2026 15:56:58 +0530 Subject: [PATCH 39/44] Add option to exclude local IPv6 candidates. (#4657) Could be useful option to try in certain conditions where flakey IPv6 infrastructure is suspected for connection issues. --- pkg/rtc/participant.go | 2 + pkg/rtc/transport.go | 109 ++++++++++++++++++------------------ pkg/rtc/transportmanager.go | 3 + pkg/rtc/utils.go | 5 ++ pkg/service/wire_gen.go | 18 +++--- 5 files changed, 75 insertions(+), 62 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 9cba516cf..6ce46b2a6 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -228,6 +228,7 @@ type ParticipantParams struct { EnableParticipantDataBlob bool EnableStartAtDesiredQuality bool MigrationWaitDuration time.Duration + ExcludeIPv6LocalCandidates bool } type ParticipantImpl struct { @@ -2057,6 +2058,7 @@ func (p *ParticipantImpl) setupTransportManager() error { UseOneShotSignallingMode: p.params.UseOneShotSignallingMode, FireOnTrackBySdp: p.params.FireOnTrackBySdp, EnableDataTracks: p.params.EnableDataTracks, + ExcludeIPv6LocalCandidates: p.params.ExcludeIPv6LocalCandidates, } if p.params.SyncStreams && p.params.PlayoutDelay.GetEnabled() && p.params.ClientInfo.isFirefox() { // we will disable playout delay for Firefox if the user is expecting diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index d94ef169b..27ff8a3a3 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -319,6 +319,7 @@ type TransportParams struct { IsSendSide bool AllowPlayoutDelay bool UseOneShotSignallingMode bool + ExcludeIPv6LocalCandidates bool FireOnTrackBySdp bool DataChannelMaxBufferedAmount uint64 DatachannelSlowThreshold int @@ -1712,31 +1713,26 @@ func (t *PCTransport) GetAnswer() (webrtc.SessionDescription, uint32, error) { cld := t.pc.CurrentLocalDescription() - // add local candidates to ICE connection details - parsed, err := cld.Unmarshal() - if err == nil { - addLocalICECandidates := func(attrs []sdp.Attribute) { - for _, a := range attrs { - if a.IsICECandidate() { - c, err := ice.UnmarshalCandidate(a.Value) - if err != nil { - continue - } - t.connectionDetails.AddLocalICECandidate(c, false, false) - } - } - } + preferTCP := t.preferTCP.Load() + if t.isCandidateFilterActive(preferTCP) { + t.params.Logger.Debugw("local answer (unfiltered)", "sdp", cld.SDP) + } - addLocalICECandidates(parsed.Attributes) - for _, m := range parsed.MediaDescriptions { - addLocalICECandidates(m.Attributes) - } + // + // Filter after setting local description as pion expects the answer + // to match between CreateAnswer and SetLocalDescription. + // Filtered answer is sent to remote so that remote does not + // see filtered candidates. + // + filteredAnswer := t.filterCandidates(*cld, preferTCP, true) + if t.isCandidateFilterActive(preferTCP) { + t.params.Logger.Debugw("local answer (filtered)", "sdp", filteredAnswer.SDP) } answerId := t.remoteOfferId.Load() t.localAnswerId.Store(answerId) - return *cld, answerId, nil + return filteredAnswer, answerId, nil } func (t *PCTransport) GetICESessionUfrag() (string, error) { @@ -1899,13 +1895,13 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e t.connectionDetails.AddRemoteICECandidate(c, false, false, false) } - ans, err := t.pc.CreateAnswer(nil) + answer, err := t.pc.CreateAnswer(nil) if err != nil { t.params.Logger.Warnw("could not create answer", err) return "", err } - if err = t.pc.SetLocalDescription(ans); err != nil { + if err = t.pc.SetLocalDescription(answer); err != nil { t.params.Logger.Warnw("could not set local description", err) return "", err } @@ -1915,31 +1911,30 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e cld := t.pc.CurrentLocalDescription() + preferTCP := t.preferTCP.Load() + if t.isCandidateFilterActive(preferTCP) { + t.params.Logger.Debugw("local answer (unfiltered)", "sdp", cld.SDP) + } + + // + // Filter after setting local description as pion expects the answer + // to match between CreateAnswer and SetLocalDescription. + // Filtered answer is sent to remote so that remote does not + // see filtered candidates. + // + filteredAnswer := t.filterCandidates(*cld, preferTCP, true) + if t.isCandidateFilterActive(preferTCP) { + t.params.Logger.Debugw("local answer (filtered)", "sdp", filteredAnswer.SDP) + } + // add local candidates to ICE connection details - parsedAnswer, err := cld.Unmarshal() + parsedFilteredAnswer, err := filteredAnswer.Unmarshal() if err != nil { t.params.Logger.Warnw("could not parse local description", err) return "", err } - addLocalICECandidates := func(attrs []sdp.Attribute) { - for _, a := range attrs { - if a.IsICECandidate() { - c, err := ice.UnmarshalCandidate(a.Value) - if err != nil { - continue - } - t.connectionDetails.AddLocalICECandidate(c, false, false) - } - } - } - - addLocalICECandidates(parsedAnswer.Attributes) - for _, m := range parsedAnswer.MediaDescriptions { - addLocalICECandidates(m.Attributes) - } - - parsedFragmentAnswer, err := lksdp.ExtractSDPFragment(parsedAnswer) + parsedFragmentAnswer, err := lksdp.ExtractSDPFragment(parsedFilteredAnswer) if err != nil { t.params.Logger.Warnw("could not extract SDP fragment", err) return "", err @@ -2402,9 +2397,15 @@ func (t *PCTransport) handleLocalICECandidate(e event) error { filtered := false if c != nil { if t.preferTCP.Load() && c.Protocol != webrtc.ICEProtocolTCP { - t.params.Logger.Debugw("filtering out local candidate", "candidate", c.String()) + t.params.Logger.Debugw("filtering out local candidate, TCP prefered", "candidate", c.String()) filtered = true } + if !filtered && t.params.ExcludeIPv6LocalCandidates { + if IsIPv6(c.Address) { + t.params.Logger.Debugw("filtering out local candidate, IPv6 excluded", "candidate", c.String()) + filtered = true + } + } t.connectionDetails.AddLocalCandidate(c, filtered, true) } @@ -2469,6 +2470,10 @@ func (t *PCTransport) setNegotiationState(state transport.NegotiationState) { } } +func (t *PCTransport) isCandidateFilterActive(preferTCP bool) bool { + return preferTCP || t.params.ExcludeIPv6LocalCandidates +} + func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP, isLocal bool) webrtc.SessionDescription { parsed, err := sd.Unmarshal() if err != nil { @@ -2486,12 +2491,10 @@ func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP, filteredAttrs = append(filteredAttrs, a) continue } - excluded := preferTCP && !c.NetworkType().IsTCP() - if !excluded { - if !t.params.Config.UseMDNS && types.IsICECandidateMDNS(c) { - excluded = true - } - } + excluded := + (preferTCP && !c.NetworkType().IsTCP()) || + (t.params.ExcludeIPv6LocalCandidates && isLocal && c.NetworkType().IsIPv6()) || + (!t.params.Config.UseMDNS && types.IsICECandidateMDNS(c)) if !excluded { filteredAttrs = append(filteredAttrs, a) } @@ -2634,7 +2637,7 @@ func (t *PCTransport) createAndSendOffer(options *webrtc.OfferOptions) error { } preferTCP := t.preferTCP.Load() - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("local offer (unfiltered)", "sdp", offer.SDP) } @@ -2662,7 +2665,7 @@ func (t *PCTransport) createAndSendOffer(options *webrtc.OfferOptions) error { // see filtered candidates. // offer = t.filterCandidates(offer, preferTCP, true) - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("local offer (filtered)", "sdp", offer.SDP) } @@ -2728,11 +2731,11 @@ func (t *PCTransport) isRemoteOfferRestartICE(parsed *sdp.SessionDescription) (s func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error { // filter before setting remote description so that pion does not see filtered remote candidates preferTCP := t.preferTCP.Load() - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (unfiltered)", "type", sd.Type, "sdp", sd.SDP) } sd = t.filterCandidates(sd, preferTCP, false) - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (filtered)", "type", sd.Type, "sdp", sd.SDP) } @@ -2792,7 +2795,7 @@ func (t *PCTransport) createAndSendAnswer() error { } preferTCP := t.preferTCP.Load() - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("local answer (unfiltered)", "sdp", answer.SDP) } @@ -2808,7 +2811,7 @@ func (t *PCTransport) createAndSendAnswer() error { // see filtered candidates. // answer = t.filterCandidates(answer, preferTCP, true) - if preferTCP { + if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("local answer (filtered)", "sdp", answer.SDP) } diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 886217d7f..6259d1a7c 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -98,6 +98,7 @@ type TransportManagerParams struct { UseOneShotSignallingMode bool FireOnTrackBySdp bool EnableDataTracks bool + ExcludeIPv6LocalCandidates bool } type TransportManager struct { @@ -168,6 +169,7 @@ func NewTransportManager(params TransportManagerParams) (*TransportManager, erro DatachannelLossyTargetLatency: params.DatachannelLossyTargetLatency, FireOnTrackBySdp: params.FireOnTrackBySdp, EnableDataTracks: params.EnableDataTracks, + ExcludeIPv6LocalCandidates: params.ExcludeIPv6LocalCandidates, }) if err != nil { return nil, err @@ -194,6 +196,7 @@ func NewTransportManager(params TransportManagerParams) (*TransportManager, erro Handler: TransportManagerTransportHandler{params.SubscriberHandler, t, lgr}, FireOnTrackBySdp: params.FireOnTrackBySdp, EnableDataTracks: params.EnableDataTracks, + ExcludeIPv6LocalCandidates: params.ExcludeIPv6LocalCandidates, }) if err != nil { return nil, err diff --git a/pkg/rtc/utils.go b/pkg/rtc/utils.go index 83d9b786d..fab143262 100644 --- a/pkg/rtc/utils.go +++ b/pkg/rtc/utils.go @@ -173,6 +173,11 @@ func MaybeTruncateIP(addr string) string { return addr[:len(addr)-3] + "..." } +func IsIPv6(addr string) bool { + ipAddr := net.ParseIP(addr) + return ipAddr != nil && ipAddr.To4() == nil +} + func ChunkProtoBatch[T proto.Message](batch []T, target int) [][]T { var chunks [][]T var start, size int diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 0da441132..b64fbb063 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -86,23 +86,23 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live } rtcEgressLauncher := NewEgressLauncher(egressClient, ioInfoService, objectStore) topicFormatter := rpc.NewTopicFormatter() - v, err := rpc.NewTypedRoomClient(clientParams) + roomClient, err := rpc.NewTypedRoomClient(clientParams) if err != nil { return nil, err } - v2, err := rpc.NewTypedParticipantClient(clientParams) + participantClient, err := rpc.NewTypedParticipantClient(clientParams) if err != nil { return nil, err } - roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, v, v2) + roomService, err := NewRoomService(limitConfig, apiConfig, router, roomAllocator, objectStore, rtcEgressLauncher, topicFormatter, roomClient, participantClient) if err != nil { return nil, err } - v3, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) + agentDispatchInternalClient, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) if err != nil { return nil, err } - agentDispatchService := NewAgentDispatchService(limitConfig, v3, topicFormatter, roomAllocator, router) + agentDispatchService := NewAgentDispatchService(limitConfig, agentDispatchInternalClient, topicFormatter, roomAllocator, router) egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService) ingressConfig := getIngressConfig(conf) ingressClient, err := rpc.NewIngressClient(clientParams) @@ -117,11 +117,11 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live } sipService := NewSIPService(sipConfig, nodeID, messageBus, sipClient, sipStore, roomService, telemetryService) rtcService := NewRTCService(conf, roomAllocator, router, telemetryService) - v4, err := rpc.NewTypedWHIPParticipantClient(clientParams) + whipParticipantClient, err := rpc.NewTypedWHIPParticipantClient(clientParams) if err != nil { return nil, err } - serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, v4) + serviceWHIPService, err := NewWHIPService(conf, router, roomAllocator, clientParams, topicFormatter, whipParticipantClient) if err != nil { return nil, err } @@ -146,8 +146,8 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - v5 := getTURNAuthHandlerFunc(turnAuthHandler) - server, err := newInProcessTurnServer(conf, v5) + authHandler := getTURNAuthHandlerFunc(turnAuthHandler) + server, err := newInProcessTurnServer(conf, authHandler) if err != nil { return nil, err } From 424c7a602abad6808fc0426e7b0d88dac1923f05 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 11 Jul 2026 00:09:16 +0530 Subject: [PATCH 40/44] Record final RTC state as success/failure. (#4658) Leave out canceled attempts. Should make it easier to do percentages. Not putting these in node stats yet. Will observe in prom before using it in node stats. --- pkg/rtc/participant.go | 19 +++++++++++-------- pkg/telemetry/prometheus/packets.go | 12 ++++++++++++ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 6ce46b2a6..d6130cfce 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1431,13 +1431,15 @@ func (p *ParticipantImpl) IsMigration() bool { return p.params.Migration } -func (p *ParticipantImpl) maybeRecordRTCanceled(closeReason types.ParticipantCloseReason) { +func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReason) { if p.HasConnected() { - return - } - - if p.IsConnectionCanceled(closeReason) { - prometheus.IncrementParticipantRtcCanceled(1) + prometheus.IncrementParticipantRtcSuccess(1) + } else { + if p.IsConnectionCanceled(closeReason) { + prometheus.IncrementParticipantRtcCanceled(1) + } else { + prometheus.IncrementParticipantRtcFailure(1) + } } } @@ -1447,7 +1449,8 @@ func (p *ParticipantImpl) IsConnectionCanceled(closeReason types.ParticipantClos closeReason == types.ParticipantCloseReasonRoomClosed || closeReason == types.ParticipantCloseReasonMigrationRequested || closeReason == types.ParticipantCloseReasonMigrationComplete || - // client closing signal connection too quickly, there is a time check to handle clients timing out and leaving without sending a leave message + // client closing signal connection too quickly, a quick close could be an indication of client leaving before a timeout + // something longer could be clients timing out without sending a leave message and is usually a sign of failed connection (time.Since(p.params.SessionStartTime) < 3*time.Second && closeReason == types.ParticipantCloseReasonSignalSourceClose) } @@ -1457,7 +1460,7 @@ func (p *ParticipantImpl) Close(sendLeave bool, reason types.ParticipantCloseRea return nil } - p.maybeRecordRTCanceled(reason) + p.recordRTCState(reason) var sessionDuration time.Duration if activeAt := p.ActiveAt(); !activeAt.IsZero() { diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index c69afdaaa..6ca9ce436 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -353,6 +353,18 @@ func IncrementParticipantRtcCanceled(canceled uint64) { } } +func IncrementParticipantRtcSuccess(success uint64) { + if success > 0 { + promParticipantJoin.WithLabelValues("rtc_success").Add(float64(success)) + } +} + +func IncrementParticipantRtcFailure(failure uint64) { + if failure > 0 { + promParticipantJoin.WithLabelValues("rtc_failure").Add(float64(failure)) + } +} + func AddConnection(direction Direction) { promConnections.WithLabelValues(string(direction)).Add(1) } From 44323799bc970cc8dafe5c5235bd37d1d6b18074 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 11 Jul 2026 16:18:57 +0530 Subject: [PATCH 41/44] Omit rtx/out-of-order packets from forwarding delay measurement. (#4659) They could inflate because of the burst of NACK responses living in the queue for a longer time. --- pkg/sfu/receiver_base.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index d056d252a..5bfcaf3e5 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -1036,7 +1036,13 @@ func (r *ReceiverBase) forwardRTP( } // track delay/jitter - if writeCount.Load() > 0 && r.forwardStats != nil && !extPkt.IsBuffered { + // + // Out-of-order packets (retransmissions/late arrivals) are excluded. They + // tend to arrive in bursts (e.g. a NACK triggers a batch of retransmissions + // delivered back-to-back) which the single forwarder goroutine drains + // serially, inflating the measured transit for the tail of the burst. That + // reflects loss recovery rather than steady-state forwarding health. + if writeCount.Load() > 0 && r.forwardStats != nil && !extPkt.IsBuffered && !extPkt.IsOutOfOrder { if latency, isHigh := r.forwardStats.Update(extPkt.Arrival, mono.UnixNano()); isHigh { r.params.Logger.Debugw( "high forwarding latency", From 345bc5eeb0aeeb0d9d3fe57a6786bf34a0a7aa37 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 12 Jul 2026 12:46:46 +0530 Subject: [PATCH 42/44] Move ForwardStats aggregation off the packet forwarding path (#4660) * Move ForwardStats aggregation off the packet forwarding path ForwardStats is a process-wide singleton and its Update runs for every forwarded packet. It previously took a shared mutex, updated a windowed aggregate, and observed into a global Prometheus histogram on every call. Update now only buffers the transit sample into a sharded lock-free ring (one atomic add to reserve a slot, one atomic store to publish). A background worker drains the ring every summary interval, observes each sample into the histogram (per-packet fidelity retained) and folds the interval summary into a window ring for the latency/jitter gauges. Under sustained overload the oldest excess samples are dropped and counted, and the count is logged. Ring slots are atomic.Int64 so producer store / consumer load are synchronized (race-clean). Capacity is numShards*shardCap = 131072 samples; at the default 50ms summary interval that sustains ~2.6M samples/s before dropping. Benchmark: BenchmarkForwardStatsUpdate (0 allocs/op both), Update per call: cores before after speedup 1 ~8.6 ns ~1.5 ns ~6x 8 ~175 ns ~22 ns ~8x The before path (mutex + windowed Welford aggregate + per-packet histogram observe) degrades ~20x from 1 to 8 cores under contention; the after path does not block and stays an order of magnitude lower under load. Co-Authored-By: Claude Opus 4.8 (1M context) * Fix ring publish race and report-path double-flush Addresses two review findings on the forward-stats sample buffer. 1. Publish race (reserve-before-store): push reserved a ring slot by advancing writeIdx and stored the value afterwards, so drain could read a slot the producer had reserved but not yet written, getting a stale/zero value; the producer's later store then landed behind the read cursor and was lost. Each slot now carries a publish epoch. push stores the value and then publishes seq = index+1. drain reads a slot only when seq == r+1; a reserved-but-unpublished slot at the cursor stops the drain and is picked up on the next call, so no sample is read stale or lost. A slot overwritten during the read is detected on re-check and counted as dropped. The windowed latency/jitter stats did not permanently drift even before this change (report recomputes from a fixed-size ring that overwrites, not discounts, old buckets, so any error aged out within the report window), but these values feed the capacity manager, so the buffer is made exact. 2. Report-path double-flush: run() flushed on both the summary tick and the report tick, and every flush advances the window ring, so the ring cycled faster than intended and the effective window was shorter than configured (~5% at 50ms/1s/1m). The report tick no longer flushes; the summary ticker keeps the ring current to within one summary interval. Benchmark, Update per call (0 allocs/op), 1 and 8 cores: after fixes: ~5.5 ns / ~29 ns before fixes (this branch): ~1.5 ns / ~22 ns baseline (mutex + per-packet histogram): ~8.6 ns / ~175 ns The publish epoch adds one atomic store to push; the path stays non-blocking, zero-allocation, and well below the baseline under contention. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- pkg/sfu/forwardstats.go | 304 +++++++++++++++++++++++-------- pkg/sfu/forwardstats_test.go | 334 +++++++++++++++++++++++++++++++++++ 2 files changed, 565 insertions(+), 73 deletions(-) create mode 100644 pkg/sfu/forwardstats_test.go diff --git a/pkg/sfu/forwardstats.go b/pkg/sfu/forwardstats.go index dd41d0bb0..34bb188a4 100644 --- a/pkg/sfu/forwardstats.go +++ b/pkg/sfu/forwardstats.go @@ -1,13 +1,12 @@ package sfu import ( - "sync" + "math" "time" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" - "github.com/livekit/protocol/utils/mono" + "go.uber.org/atomic" ) const ( @@ -15,97 +14,256 @@ const ( cSkewFactor = 10 ) -type ForwardStats struct { - lock sync.Mutex - latency *utils.LatencyAggregate - lowest int64 - highest int64 - lastUpdateAt int64 - closeCh chan struct{} +const ( + // A summary interval's worth of samples across all tracks must fit without + // dropping (ForwardStats is a singleton). Shard count spreads the per-packet + // atomic; shard capacity bounds memory (numShards*shardCap*16 bytes = 2MiB). + forwardSampleNumShards = 16 + forwardSampleShardCap = 8192 + forwardSampleShardMask = forwardSampleShardCap - 1 + forwardSampleShardSel = forwardSampleNumShards - 1 +) + +// forwardSampleShard is a ring of transit samples with multiple producers and a +// single consumer. A producer reserves a slot, stores the value, then publishes +// the slot's epoch (reserved index + 1). The consumer reads a slot only once its +// epoch marks the value committed for that index. +type forwardSampleShard struct { + writeIdx atomic.Uint64 // advanced by producers to reserve a slot + readIdx uint64 // consumer-only cursor + ring [forwardSampleShardCap]atomic.Int64 + seq [forwardSampleShardCap]atomic.Uint64 // per-slot publish epoch } -func NewForwardStats(latencyUpdateInterval, reportInterval, latencyWindowLength time.Duration) *ForwardStats { - s := &ForwardStats{ - latency: utils.NewLatencyAggregate(latencyUpdateInterval, latencyWindowLength), - lowest: time.Second.Nanoseconds(), - closeCh: make(chan struct{}), +// forwardSampleBuffer holds per-packet transit samples produced on the packet +// path and consumed by the background worker, which performs metric emission. +type forwardSampleBuffer struct { + shards [forwardSampleNumShards]forwardSampleShard + dropped atomic.Uint64 +} + +// push records a sample: reserve a slot, store the value, then publish the +// slot's epoch. The shard is selected from arrival time bits. +func (b *forwardSampleBuffer) push(arrival, transitNs int64) { + sh := &b.shards[(uint64(arrival)>>6)&forwardSampleShardSel] + i := sh.writeIdx.Add(1) - 1 + slot := i & forwardSampleShardMask + sh.ring[slot].Store(transitNs) + sh.seq[slot].Store(i + 1) +} + +// drain passes every committed sample to fn and advances the read cursor. Only +// the background worker calls this. +// +// A slot holds index r's value once its epoch equals r+1. If the slot at the +// cursor is still uncommitted (a producer reserved it but has not published), +// draining stops and resumes from there on the next call, so no sample is read +// stale or skipped. When producers get a shard's capacity ahead, or overwrite a +// slot before it is read, the affected samples are counted as dropped. +func (b *forwardSampleBuffer) drain(fn func(transitNs int64)) { + for si := range b.shards { + sh := &b.shards[si] + w := sh.writeIdx.Load() + r := sh.readIdx + if w-r > forwardSampleShardCap { + b.dropped.Add(w - r - forwardSampleShardCap) + r = w - forwardSampleShardCap + } + for r < w { + slot := r & forwardSampleShardMask + if sh.seq[slot].Load() < r+1 { + // reserved but not yet published; resume here next drain + break + } + v := sh.ring[slot].Load() + if sh.seq[slot].Load() != r+1 { + // overwritten by a newer sample during the read; original lost + b.dropped.Add(1) + r++ + continue + } + fn(v) + r++ + } + sh.readIdx = r + } +} + +func (b *forwardSampleBuffer) takeDropped() uint64 { + return b.dropped.Swap(0) +} + +// forwardSummary is a mergeable summary of forwarding transit over an interval. +// The sum of squares is kept in microseconds so it does not overflow int64. +type forwardSummary struct { + count int64 + sumUs int64 + sumSqUs int64 + minNs int64 + maxNs int64 +} + +func (s forwardSummary) addSample(transitNs int64) forwardSummary { + us := transitNs / 1000 + if s.count == 0 { + return forwardSummary{count: 1, sumUs: us, sumSqUs: us * us, minNs: transitNs, maxNs: transitNs} } - go s.report(reportInterval) + s.count++ + s.sumUs += us + s.sumSqUs += us * us + if transitNs < s.minNs { + s.minNs = transitNs + } + if transitNs > s.maxNs { + s.maxNs = transitNs + } return s } +func (s forwardSummary) merge(o forwardSummary) forwardSummary { + if o.count == 0 { + return s + } + if s.count == 0 { + return o + } + return forwardSummary{ + count: s.count + o.count, + sumUs: s.sumUs + o.sumUs, + sumSqUs: s.sumSqUs + o.sumSqUs, + minNs: min(s.minNs, o.minNs), + maxNs: max(s.maxNs, o.maxNs), + } +} + +func (s forwardSummary) meanStdDev() (mean, stdDev time.Duration) { + if s.count == 0 { + return 0, 0 + } + + meanUs := float64(s.sumUs) / float64(s.count) + mean = time.Duration(meanUs * float64(time.Microsecond)) + if s.count < 2 { + return mean, 0 + } + + // sample variance (divisor count-1) + m2 := float64(s.sumSqUs) - float64(s.sumUs)*meanUs + varUs2 := m2 / float64(s.count-1) + if varUs2 < 0 { + // floating point rounding can push a (near-zero) variance slightly negative + varUs2 = 0 + } + stdDev = time.Duration(math.Sqrt(varUs2) * float64(time.Microsecond)) + return mean, stdDev +} + +type ForwardStats struct { + samples forwardSampleBuffer + + // ring of per-summary-interval summaries covering the report window. + // only touched by the background worker, so it needs no locking. + ring []forwardSummary + ringHead int + ringLen int + + summaryInterval time.Duration + reportInterval time.Duration + + closeCh chan struct{} +} + +func NewForwardStats(summaryInterval, reportInterval, reportWindow time.Duration) *ForwardStats { + ringCap := int((reportWindow + summaryInterval - 1) / summaryInterval) + if ringCap < 1 { + ringCap = 1 + } + + s := &ForwardStats{ + ring: make([]forwardSummary, ringCap), + summaryInterval: summaryInterval, + reportInterval: reportInterval, + closeCh: make(chan struct{}), + } + + go s.run() + return s +} + +// Update records a forwarded packet's transit latency. It buffers the sample +// and returns the transit and whether it exceeds the high-latency threshold. +// The sample is aggregated and emitted by the background worker. func (s *ForwardStats) Update(arrival, left int64) (int64, bool) { transit := left - arrival - isHighForwardingLatency := time.Duration(transit) > cHighForwardingLatency - - s.lock.Lock() - s.latency.Update(time.Duration(arrival), float64(transit)) - s.lowest = min(transit, s.lowest) - s.highest = max(transit, s.highest) - s.lastUpdateAt = arrival - s.lock.Unlock() - - prometheus.RecordForwardLatencySample(transit) - return transit, isHighForwardingLatency -} - -func (s *ForwardStats) GetStats(shortDuration time.Duration) (time.Duration, time.Duration) { - s.lock.Lock() - // a dummy sample to flush the pipe to current time - now := mono.UnixNano() - if (now - s.lastUpdateAt) > shortDuration.Nanoseconds() { - s.latency.Update(time.Duration(now), 0) - } - - wLong := s.latency.Summarize() - - lowest := s.lowest - s.lowest = time.Second.Nanoseconds() - - highest := s.highest - s.highest = 0 - s.lock.Unlock() - - latencyLong, jitterLong := time.Duration(wLong.Mean()), time.Duration(wLong.StdDev()) - if jitterLong > latencyLong*cSkewFactor { - logger.Infow( - "high jitter in forwarding path", - "lowest", time.Duration(lowest), - "highest", time.Duration(highest), - "countLong", wLong.Count(), - "latencyLong", latencyLong, - "jitterLong", jitterLong, - ) - } - return latencyLong, jitterLong -} - -func (s *ForwardStats) GetShortStats(shortDuration time.Duration) (time.Duration, time.Duration) { - s.lock.Lock() - wShort := s.latency.SummarizeLast(shortDuration) - s.lock.Unlock() - - return time.Duration(wShort.Mean()), time.Duration(wShort.StdDev()) + s.samples.push(arrival, transit) + return transit, time.Duration(transit) > cHighForwardingLatency } func (s *ForwardStats) Stop() { close(s.closeCh) } -func (s *ForwardStats) report(reportInterval time.Duration) { - ticker := time.NewTicker(reportInterval) - defer ticker.Stop() +func (s *ForwardStats) run() { + summaryTicker := time.NewTicker(s.summaryInterval) + defer summaryTicker.Stop() + reportTicker := time.NewTicker(s.reportInterval) + defer reportTicker.Stop() for { select { case <-s.closeCh: return - case <-ticker.C: - latencyLong, jitterLong := s.GetStats(reportInterval) - prometheus.RecordForwardJitter(uint32(jitterLong.Nanoseconds())) - prometheus.RecordForwardLatency(uint32(latencyLong.Nanoseconds())) + case <-summaryTicker.C: + s.flush() + + case <-reportTicker.C: + // the summary ticker keeps the window ring current to within one + // summary interval; report over it without advancing the ring. + s.report() } } } + +// flush drains the buffered samples, observes each into the Prometheus +// histogram, and folds the interval summary into the window ring used for the +// latency/jitter gauges. +func (s *ForwardStats) flush() { + var summ forwardSummary + s.samples.drain(func(transitNs int64) { + prometheus.RecordForwardLatencySample(transitNs) + summ = summ.addSample(transitNs) + }) + + s.ring[s.ringHead] = summ + s.ringHead = (s.ringHead + 1) % len(s.ring) + if s.ringLen < len(s.ring) { + s.ringLen++ + } +} + +func (s *ForwardStats) report() { + var w forwardSummary + for i := 0; i < s.ringLen; i++ { + w = w.merge(s.ring[i]) + } + + latency, jitter := w.meanStdDev() + if dropped := s.samples.takeDropped(); dropped > 0 { + logger.Warnw("forward stats sample buffer overflow", nil, "dropped", dropped) + } + if w.count > 0 && jitter > latency*cSkewFactor { + logger.Infow( + "high jitter in forwarding path", + "lowest", time.Duration(w.minNs), + "highest", time.Duration(w.maxNs), + "count", w.count, + "latency", latency, + "jitter", jitter, + ) + } + + prometheus.RecordForwardJitter(uint32(jitter.Nanoseconds())) + prometheus.RecordForwardLatency(uint32(latency.Nanoseconds())) +} diff --git a/pkg/sfu/forwardstats_test.go b/pkg/sfu/forwardstats_test.go new file mode 100644 index 000000000..e51b612e1 --- /dev/null +++ b/pkg/sfu/forwardstats_test.go @@ -0,0 +1,334 @@ +package sfu + +import ( + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + + "github.com/livekit/livekit-server/pkg/telemetry/prometheus" + "github.com/livekit/protocol/livekit" +) + +// initPrometheus initializes the global forward-latency collectors so that the +// worker's metric emission has non-nil targets. Init returns early if already +// initialized, so it is safe to call from multiple tests. +func initPrometheus(t *testing.T) { + t.Helper() + require.NoError(t, prometheus.Init("test", livekit.NodeType_SERVER)) +} + +// --------------------------------------------------------------------------- +// forwardSummary +// --------------------------------------------------------------------------- + +func TestForwardSummary_AddSample(t *testing.T) { + var s forwardSummary + + // empty summary + require.Equal(t, int64(0), s.count) + + // microsecond-aligned transits so the /1000 truncation is exact + s = s.addSample(3000) // 3us + s = s.addSample(1000) // 1us + s = s.addSample(2000) // 2us + + require.Equal(t, int64(3), s.count) + require.Equal(t, int64(1+2+3), s.sumUs) + require.Equal(t, int64(1+4+9), s.sumSqUs) + require.Equal(t, int64(1000), s.minNs) + require.Equal(t, int64(3000), s.maxNs) +} + +func TestForwardSummary_Merge(t *testing.T) { + var empty forwardSummary + a := forwardSummary{}.addSample(1000).addSample(2000) + b := forwardSummary{}.addSample(5000).addSample(3000) + + // merging with empty is identity, in both directions + require.Equal(t, a, a.merge(empty)) + require.Equal(t, a, empty.merge(a)) + + m := a.merge(b) + require.Equal(t, int64(4), m.count) + require.Equal(t, a.sumUs+b.sumUs, m.sumUs) + require.Equal(t, a.sumSqUs+b.sumSqUs, m.sumSqUs) + require.Equal(t, int64(1000), m.minNs) + require.Equal(t, int64(5000), m.maxNs) +} + +func TestForwardSummary_MeanStdDev(t *testing.T) { + // empty -> zero + mean, stdDev := forwardSummary{}.meanStdDev() + require.Zero(t, mean) + require.Zero(t, stdDev) + + // single sample -> mean set, stddev zero (needs >= 2 for variance) + mean, stdDev = forwardSummary{}.addSample(4000).meanStdDev() + require.Equal(t, 4*time.Microsecond, mean) + require.Zero(t, stdDev) + + // identical samples -> zero variance + s := forwardSummary{}.addSample(2000).addSample(2000).addSample(2000) + mean, stdDev = s.meanStdDev() + require.Equal(t, 2*time.Microsecond, mean) + require.Zero(t, stdDev) + + // known dataset [1us, 2us, 3us]: mean 2us, sample variance 1us^2 -> stddev 1us + s = forwardSummary{}.addSample(1000).addSample(2000).addSample(3000) + mean, stdDev = s.meanStdDev() + require.Equal(t, 2*time.Microsecond, mean) + require.InDelta(t, float64(time.Microsecond), float64(stdDev), float64(50*time.Nanosecond)) +} + +// --------------------------------------------------------------------------- +// forwardSampleBuffer +// --------------------------------------------------------------------------- + +func shardOf(arrival int64) int { + return int((uint64(arrival) >> 6) & forwardSampleShardSel) +} + +// arrivalForShard returns the n-th arrival value that maps to a fixed shard. +// Incrementing arrival by (1<<10) advances (arrival>>6) by 16, leaving the low +// 4 selection bits unchanged. +func arrivalForShard(n int) int64 { + return int64(n) << 10 +} + +func TestForwardSampleBuffer_PushDrain(t *testing.T) { + var b forwardSampleBuffer + + const n = 1000 + for i := 0; i < n; i++ { + b.push(int64(i), int64((i+1)*1000)) + } + + got := map[int64]int{} + total := 0 + b.drain(func(v int64) { + got[v]++ + total++ + }) + require.Equal(t, n, total) + require.Equal(t, uint64(0), b.dropped.Load()) + for i := 0; i < n; i++ { + require.Equal(t, 1, got[int64((i+1)*1000)], "sample %d missing", i) + } + + // draining again yields nothing (read cursor advanced) + total = 0 + b.drain(func(v int64) { total++ }) + require.Equal(t, 0, total) +} + +func TestForwardSampleBuffer_Overflow(t *testing.T) { + var b forwardSampleBuffer + + const extra = 100 + const n = forwardSampleShardCap + extra + + // pin every push to a single shard so it overflows + for i := 0; i < n; i++ { + b.push(arrivalForShard(i), int64(i)*1000) + } + require.Equal(t, 0, shardOf(arrivalForShard(0))) + require.Equal(t, shardOf(arrivalForShard(0)), shardOf(arrivalForShard(n-1))) + + var drained []int64 + b.drain(func(v int64) { drained = append(drained, v) }) + + // exactly a shard's worth survives; the oldest `extra` are dropped and counted + require.Len(t, drained, forwardSampleShardCap) + require.Equal(t, uint64(extra), b.dropped.Load()) + + // survivors are the most recent cap samples, in order + for j, v := range drained { + require.Equal(t, int64(extra+j)*1000, v) + } +} + +func TestForwardSampleBuffer_DefersUncommitted(t *testing.T) { + var b forwardSampleBuffer + sh := &b.shards[0] + + // simulate a producer that reserved index 0 but has not published its value + sh.writeIdx.Store(1) + + got := 0 + b.drain(func(int64) { got++ }) + require.Equal(t, 0, got, "uncommitted slot must not be read") + require.Equal(t, uint64(0), sh.readIdx, "cursor must not advance past an uncommitted slot") + require.Equal(t, uint64(0), b.dropped.Load()) + + // producer publishes the value; next drain picks it up + sh.ring[0].Store(1234) + sh.seq[0].Store(1) + + var vals []int64 + b.drain(func(v int64) { vals = append(vals, v) }) + require.Equal(t, []int64{1234}, vals) + require.Equal(t, uint64(1), sh.readIdx) + require.Equal(t, uint64(0), b.dropped.Load()) +} + +func TestForwardSampleBuffer_Concurrent(t *testing.T) { + var b forwardSampleBuffer + var stop atomic.Bool + + var consumed int64 + done := make(chan struct{}) + go func() { + defer close(done) + for !stop.Load() { + b.drain(func(int64) { consumed++ }) + time.Sleep(time.Millisecond) + } + b.drain(func(int64) { consumed++ }) // final sweep + }() + + const producers = 8 + const perProducer = 100_000 + var wg sync.WaitGroup + for p := 0; p < producers; p++ { + wg.Add(1) + go func(seed int64) { + defer wg.Done() + for i := int64(0); i < perProducer; i++ { + b.push(seed*7+i, (i%50)*int64(time.Microsecond)) + } + }(int64(p)) + } + wg.Wait() + stop.Store(true) + <-done + + // with a consumer keeping pace no samples should be lost + require.Equal(t, int64(producers*perProducer), consumed+int64(b.dropped.Load())) +} + +// --------------------------------------------------------------------------- +// ForwardStats +// --------------------------------------------------------------------------- + +func TestForwardStats_Update(t *testing.T) { + s := &ForwardStats{ring: make([]forwardSummary, 1)} + + // below threshold + transit, isHigh := s.Update(1000, 1000+int64(5*time.Millisecond)) + require.Equal(t, int64(5*time.Millisecond), transit) + require.False(t, isHigh) + + // above threshold + transit, isHigh = s.Update(1000, 1000+int64(25*time.Millisecond)) + require.Equal(t, int64(25*time.Millisecond), transit) + require.True(t, isHigh) + + // exactly at threshold is not "high" (strictly greater) + _, isHigh = s.Update(0, int64(cHighForwardingLatency)) + require.False(t, isHigh) +} + +func TestForwardStats_Flush(t *testing.T) { + initPrometheus(t) + + s := &ForwardStats{ring: make([]forwardSummary, 4)} + + for i := 0; i < 10; i++ { + s.Update(0, int64((i+1)*1000)) // 1us..10us + } + + s.flush() + + require.Equal(t, 1, s.ringLen) + summ := s.ring[0] + require.Equal(t, int64(10), summ.count) + require.Equal(t, int64(1000), summ.minNs) + require.Equal(t, int64(10000), summ.maxNs) + require.Equal(t, uint64(0), s.samples.dropped.Load()) + + // a subsequent flush with no new samples appends an empty summary + s.flush() + require.Equal(t, 2, s.ringLen) + require.Equal(t, int64(0), s.ring[1].count) +} + +func TestForwardStats_ReportWindow(t *testing.T) { + initPrometheus(t) + + // window of 3 summary buckets + s := &ForwardStats{ring: make([]forwardSummary, 3)} + + s.Update(0, 1000) + s.flush() + s.Update(0, 3000) + s.flush() + + // report merges the whole window without panicking and reflects both samples + var w forwardSummary + for i := 0; i < s.ringLen; i++ { + w = w.merge(s.ring[i]) + } + require.Equal(t, int64(2), w.count) + require.Equal(t, int64(1000), w.minNs) + require.Equal(t, int64(3000), w.maxNs) + + require.NotPanics(t, s.report) +} + +func TestForwardStats_Lifecycle(t *testing.T) { + initPrometheus(t) + + s := NewForwardStats(5*time.Millisecond, 20*time.Millisecond, 100*time.Millisecond) + for i := 0; i < 1000; i++ { + s.Update(int64(i), int64(i)+int64(time.Millisecond)) + } + time.Sleep(60 * time.Millisecond) // let the worker flush/report a few times + require.NotPanics(t, s.Stop) +} + +func TestNewForwardStats_RingSizing(t *testing.T) { + // ringCap = ceil(window / summaryInterval) + s := NewForwardStats(100*time.Millisecond, time.Second, time.Second) + require.Equal(t, 10, len(s.ring)) + s.Stop() + + // rounds up a partial interval + s = NewForwardStats(100*time.Millisecond, time.Second, 250*time.Millisecond) + require.Equal(t, 3, len(s.ring)) + s.Stop() + + // never smaller than one bucket, even if window < summaryInterval + s = NewForwardStats(time.Second, time.Second, 100*time.Millisecond) + require.Equal(t, 1, len(s.ring)) + s.Stop() +} + +// --------------------------------------------------------------------------- +// benchmark: per-packet cost of Update (run with -cpu 1,8). +// --------------------------------------------------------------------------- + +// benchArrival advances the arrival timestamp by 64ns per packet so that +// consecutive packets from one goroutine map to successive shards +// ((arrival>>6)&mask increments each step). A distinct per-goroutine base +// spreads goroutines across shards. +func benchArrival(base, i int64) int64 { + return base + i*64 +} + +func BenchmarkForwardStatsUpdate(b *testing.B) { + s := &ForwardStats{ring: make([]forwardSummary, 1)} + + var gid atomic.Int64 + b.RunParallel(func(pb *testing.PB) { + base := gid.Add(1) * 1_000_003 + var i int64 + for pb.Next() { + i++ + arrival := benchArrival(base, i) + s.Update(arrival, arrival+int64(2*time.Millisecond)) + } + }) +} From 788b01bc5c32acdb292ec4ec8e0bf2cd15eefc76 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 12 Jul 2026 14:34:12 +0530 Subject: [PATCH 43/44] protocol deps to get parallel exec alloc reduction (#4662) --- go.mod | 44 ++++++++++++++--------------- go.sum | 88 +++++++++++++++++++++++++++++----------------------------- 2 files changed, 66 insertions(+), 66 deletions(-) diff --git a/go.mod b/go.mod index 41d00052e..b68de126a 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 - github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594 + github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6 github.com/livekit/psrpc v0.7.2 github.com/mackerelio/go-osstat v0.2.7 github.com/magefile/mage v1.17.2 @@ -31,15 +31,15 @@ require ( github.com/olekukonko/tablewriter v1.1.4 github.com/ory/dockertest/v4 v4.0.0 github.com/pion/datachannel v1.6.2 - github.com/pion/dtls/v3 v3.1.4 + github.com/pion/dtls/v3 v3.1.5 github.com/pion/ice/v4 v4.2.7 github.com/pion/interceptor v0.1.45 - github.com/pion/rtcp v1.2.16 - github.com/pion/rtp v1.10.2 + github.com/pion/rtcp v1.2.17 + github.com/pion/rtp v1.10.3 github.com/pion/sctp v1.10.3 github.com/pion/sdp/v3 v3.0.19 github.com/pion/transport/v4 v4.0.2 - github.com/pion/turn/v5 v5.0.10 + github.com/pion/turn/v5 v5.0.12 github.com/pion/webrtc/v4 v4.2.16 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.23.2 @@ -54,8 +54,8 @@ require ( go.uber.org/atomic v1.11.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 - golang.org/x/mod v0.37.0 - golang.org/x/sync v0.21.0 + golang.org/x/mod v0.38.0 + golang.org/x/sync v0.22.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -92,12 +92,12 @@ require ( go.opentelemetry.io/otel/trace v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 // indirect golang.org/x/time v0.15.0 // indirect ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 // indirect buf.build/go/protovalidate v1.2.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect cel.dev/expr v0.25.2 // indirect @@ -111,15 +111,15 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/go-logr/logr v1.4.3 // indirect - github.com/google/cel-go v0.28.1 // indirect + github.com/google/cel-go v0.29.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/subcommands v1.2.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/josharian/native v1.1.0 // indirect - github.com/klauspost/compress v1.18.6 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/klauspost/compress v1.19.0 // indirect + github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/lithammer/shortuuid/v4 v4.2.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mdlayher/netlink v1.11.2 // indirect @@ -138,18 +138,18 @@ require ( github.com/pion/stun/v3 v3.1.6 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.69.0 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/common v0.70.0 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/urfave/cli/v3 v3.10.0 github.com/wlynxg/anet v0.0.5 // indirect github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect - golang.org/x/crypto v0.53.0 // indirect - golang.org/x/net v0.56.0 // indirect - golang.org/x/sys v0.46.0 // indirect - golang.org/x/text v0.38.0 // indirect - golang.org/x/tools v0.46.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d // indirect - google.golang.org/grpc v1.81.1 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.40.0 // indirect + golang.org/x/tools v0.48.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect + google.golang.org/grpc v1.82.0 // indirect ) diff --git a/go.sum b/go.sum index f3c2342d6..9427e7447 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 h1:s6hzCXtND/ICdGPTMGk7C+/BFlr2Jg5GyH0NKf4XGXg= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1 h1:fXh8CsdNpjRr8R5vFdqtIxPt/Lno2IIJlYOdZBIZn0w= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260709200747-435963d16310.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0= buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= @@ -85,8 +85,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM= -github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= +github.com/google/cel-go v0.29.2 h1:ZtDxkeiMmz0mxbKDYiNkE5Lk7V5edMRcaaDf2jX002k= +github.com/google/cel-go v0.29.2/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -139,10 +139,10 @@ github.com/jsimonetti/rtnetlink v0.0.0-20211022192332-93da33804786 h1:N527AHMa79 github.com/jsimonetti/rtnetlink v0.0.0-20211022192332-93da33804786/go.mod h1:v4hqbTdfQngbVSZJVWUhGE/lbTFf9jb+ygmNUDQMuOs= github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw= github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= +github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= @@ -160,8 +160,8 @@ github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5AT github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0 h1:XHNNzebIKZRkLimla/hFGrAIX5EMWHctrgt3hLw7s+I= github.com/livekit/mediatransportutil v0.0.0-20260608063931-a3417d38cda0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594 h1:O5vKcr5UdK9ZFXQbFhwfZBs+C1Z2M8IV0fc0xQ/simo= -github.com/livekit/protocol v1.49.1-0.20260706161809-d5b489c31594/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= +github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6 h1:ANFafwVDRMeB3bGva/C9BFcWp/iAvSCwbTYWWVuners= +github.com/livekit/protocol v1.49.1-0.20260712085342-8a3c109dc3c6/go.mod h1:jO+y05AU9Ec4JswDyuzKCZ4bhziOS0CzMqgnbj60Dzs= github.com/livekit/psrpc v0.7.2 h1:6oZ+NODJ2pLyaT6VqDq1F4Qc/3TpDUSpyphj/P9MhQc= github.com/livekit/psrpc v0.7.2/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/mackerelio/go-osstat v0.2.7 h1:TCavZi10wF49bT6iQZ9eT2keGZQpC69MTDfdJej5e94= @@ -231,8 +231,8 @@ github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoP github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8= github.com/pion/datachannel v1.6.2 h1:7EXQ8TH3vTouBUdRWYbcX2edSx9Yj6k5zl5P+qyxEPc= github.com/pion/datachannel v1.6.2/go.mod h1:pzbdAZvyGtXbcHM1hBbsFaOTf40lZizU/dNlvVOak6E= -github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= -github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/ice/v4 v4.2.7 h1:zDEbC6MiEdhQpF8TxBOTws+NU6ZgGpveHrQq4Lc1kao= github.com/pion/ice/v4 v4.2.7/go.mod h1:9SNPaq0c7El/ki8leJzyCkK10zsskprR3zTNbO3monY= github.com/pion/interceptor v0.1.45 h1:6PUo/5829bIfRFIPPJQzuDn8EjxRTSB/CSD7QVCOaqo= @@ -243,10 +243,10 @@ github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY= github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A= github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= -github.com/pion/rtcp v1.2.16 h1:fk1B1dNW4hsI78XUCljZJlC4kZOPk67mNRuQ0fcEkSo= -github.com/pion/rtcp v1.2.16/go.mod h1:/as7VKfYbs5NIb4h6muQ35kQF/J0ZVNz2Z3xKoCBYOo= -github.com/pion/rtp v1.10.2 h1:l+f6tTDcAH6xwepaAoW791ddhuYsJlqRATOzirO04Mo= -github.com/pion/rtp v1.10.2/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= +github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= +github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtp v1.10.3 h1:r5nJQdtM9Dc4ZYxtTcPPz7PIFArKJIf/DMlIUxU7+1c= +github.com/pion/rtp v1.10.3/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pion/sctp v1.10.3 h1:1gBtLMA9lmwNuJkZSZJCdD5/Hz4yJs+7dAqi6ZY97QI= github.com/pion/sctp v1.10.3/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= github.com/pion/sdp/v3 v3.0.19 h1:1VMKs3gIkTQV5M3hNKfTAPrDXSNrYtOlmOD8+mSZUGQ= @@ -259,8 +259,8 @@ github.com/pion/transport/v3 v3.1.1 h1:Tr684+fnnKlhPceU+ICdrw6KKkTms+5qHMgw6bIkY github.com/pion/transport/v3 v3.1.1/go.mod h1:+c2eewC5WJQHiAA46fkMMzoYZSuGzA/7E2FPrOYHctQ= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pion/turn/v5 v5.0.10 h1:mOMZjudflXpte5OsCnXztpUKwNXcpXIAzMBnq9TXOSQ= -github.com/pion/turn/v5 v5.0.10/go.mod h1:u3XjBqy2Z4+NhCUpDoOSsNuQDrPLvKStlCGWk6sTQ1E= +github.com/pion/turn/v5 v5.0.12 h1:6+b69ivQQXSlyfkp2AKripqD2k3W32qXK8QzCzpJWPI= +github.com/pion/turn/v5 v5.0.12/go.mod h1:CQACsRDJtjQ+6RSrGHrS2PCIerLwbW3uqXRqOvtjAFg= github.com/pion/webrtc/v4 v4.2.16 h1:oK1GAg0TWJtZWYB8J/BgTgGWPoV2148gQWocH12vr3Q= github.com/pion/webrtc/v4 v4.2.16/go.mod h1:y4HjLAkX90LH+C/qPqGOUgz8RA8CbDj3Iar3d+2hdKQ= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -272,10 +272,10 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= -github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/common v0.70.0 h1:bcpru3tWPVnxGnETLgOV5jbp/JRXgYEyv65CuBLAMMI= +github.com/prometheus/common v0.70.0/go.mod h1:S/SFasQmgGiYH6C81LKCtYa8QACgthGg5zxL2udV7SY= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= github.com/puzpuzpuz/xsync/v4 v4.5.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo= github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= @@ -349,12 +349,12 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= -golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597 h1:qLvzZeaANDgyVOA8pyHCOStGlXn0rseXma+GQjeuv2g= +golang.org/x/exp v0.0.0-20260709172345-9ea1abe57597/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -369,12 +369,12 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190411185658-b44545bcd369/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -398,31 +398,31 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= -golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d h1:xr2lwHI91bn3UiXcnyzRMQjp2LRiM8wEHzwUaE0YhTs= -google.golang.org/genproto/googleapis/api v0.0.0-20260622175928-b703f567277d/go.mod h1:O0ZOWSrfWfJ+Z5HbwZ+wNtHsg/vk1k2C/w67eww8PfQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d h1:mpAgMyM9vQHxycBlDq50y1VHpfSfVwzXvrQKtYbXuUY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260622175928-b703f567277d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 h1:admdQBe8jR3VWhBsUrAOaF2Qw6K/+p5pSm1GN8+6Fw4= +google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800/go.mod h1:FPk7EXUKMtImne7AmknoYjT4QXqKIzzRbeQIXzLk6fQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 h1:qEHAMpSaUhtD0p3NbEEI83HwNGFxEwaSJ1G9PLnCBZE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 2bd3b9d67fb1ef4cb7c7134c4ad26594862da571 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 12 Jul 2026 21:46:14 +0530 Subject: [PATCH 44/44] Add a method to get forward stats via API method. (#4664) Use by cloud simulated tracks. Introduces a lock, but it is used only in the background worker (where it will be almost always uncontested) and when API GetStats is accessed. Does not touch the per-packet Update path. --- pkg/sfu/forwardstats.go | 47 ++++++++++++++++++++++++++++++++---- pkg/sfu/forwardstats_test.go | 31 ++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 5 deletions(-) diff --git a/pkg/sfu/forwardstats.go b/pkg/sfu/forwardstats.go index 34bb188a4..2ad8497a7 100644 --- a/pkg/sfu/forwardstats.go +++ b/pkg/sfu/forwardstats.go @@ -2,6 +2,7 @@ package sfu import ( "math" + "sync" "time" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" @@ -163,7 +164,9 @@ type ForwardStats struct { samples forwardSampleBuffer // ring of per-summary-interval summaries covering the report window. - // only touched by the background worker, so it needs no locking. + // written by the background worker (flush) and read both by the worker + // (report) and by external callers (GetStats), so it is guarded by lock. + lock sync.Mutex ring []forwardSummary ringHead int ringLen int @@ -236,18 +239,52 @@ func (s *ForwardStats) flush() { summ = summ.addSample(transitNs) }) + s.lock.Lock() s.ring[s.ringHead] = summ s.ringHead = (s.ringHead + 1) % len(s.ring) if s.ringLen < len(s.ring) { s.ringLen++ } + s.lock.Unlock() +} + +// summarize merges the ring summaries covering the most recent window. A +// window <= 0 (or >= the report window) covers the entire ring. +func (s *ForwardStats) summarize(window time.Duration) forwardSummary { + s.lock.Lock() + defer s.lock.Unlock() + + n := s.ringLen + if window > 0 && s.summaryInterval > 0 { + want := int((window + s.summaryInterval - 1) / s.summaryInterval) + if want < 1 { + want = 1 + } + if want < n { + n = want + } + } + + // walk backwards from the most recent entry (ringHead-1) over n entries. + var w forwardSummary + for i := 0; i < n; i++ { + idx := (s.ringHead - 1 - i + len(s.ring)) % len(s.ring) + w = w.merge(s.ring[idx]) + } + return w +} + +// GetStats returns the mean latency and jitter (std dev) of the forwarding +// transit over the most recent duration. The duration is rounded up to a whole +// number of summary intervals (the smallest bucket span that covers it). A +// duration <= 0, or one that meets/exceeds the report window, covers the full +// window. +func (s *ForwardStats) GetStats(duration time.Duration) (time.Duration, time.Duration) { + return s.summarize(duration).meanStdDev() } func (s *ForwardStats) report() { - var w forwardSummary - for i := 0; i < s.ringLen; i++ { - w = w.merge(s.ring[i]) - } + w := s.summarize(0) latency, jitter := w.meanStdDev() if dropped := s.samples.takeDropped(); dropped > 0 { diff --git a/pkg/sfu/forwardstats_test.go b/pkg/sfu/forwardstats_test.go index e51b612e1..152404978 100644 --- a/pkg/sfu/forwardstats_test.go +++ b/pkg/sfu/forwardstats_test.go @@ -278,6 +278,37 @@ func TestForwardStats_ReportWindow(t *testing.T) { require.NotPanics(t, s.report) } +func TestForwardStats_GetStats(t *testing.T) { + initPrometheus(t) + + // 5 buckets, each covering one 100ms summary interval. + s := &ForwardStats{ring: make([]forwardSummary, 5), summaryInterval: 100 * time.Millisecond} + + // fold five 100ms buckets, one sample each: 1ms, 2ms, 3ms, 4ms, 5ms. + for i := 1; i <= 5; i++ { + s.Update(0, int64(i)*int64(time.Millisecond)) + s.flush() + } + require.Equal(t, 5, s.ringLen) + + // a duration <= 0 covers the whole window: mean of 1..5ms == 3ms. + latency, jitter := s.GetStats(0) + require.InDelta(t, float64(3*time.Millisecond), float64(latency), float64(50*time.Microsecond)) + require.Greater(t, jitter, time.Duration(0)) + + // a duration meeting/exceeding the window also covers it. + fullLatency, _ := s.GetStats(time.Second) + require.InDelta(t, float64(3*time.Millisecond), float64(fullLatency), float64(50*time.Microsecond)) + + // ~200ms rounds up to the two most recent buckets (4ms, 5ms): mean == 4.5ms. + shortLatency, _ := s.GetStats(200 * time.Millisecond) + require.InDelta(t, float64(4500*time.Microsecond), float64(shortLatency), float64(50*time.Microsecond)) + + // a sub-interval duration still yields at least the most recent bucket (5ms). + lastLatency, _ := s.GetStats(time.Nanosecond) + require.InDelta(t, float64(5*time.Millisecond), float64(lastLatency), float64(50*time.Microsecond)) +} + func TestForwardStats_Lifecycle(t *testing.T) { initPrometheus(t)