From 54496c765d11e4f2c53c90b4d625471b187c59a7 Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Fri, 28 Aug 2026 23:41:59 -0700 Subject: [PATCH 001/130] report a reason on room-ended telemetry (#4815) * report a reason on room-ended telemetry Room.Close already took a ParticipantCloseReason, but OnClose was func() with no arguments, so the reason was structurally dropped before it could reach RoomEnded. Add types.RoomCloseReason and carry it through Close -> OnClose -> RoomEnded, where it lands on both the analytics event and the room_finished webhook. Room.Close now takes the room reason and derives the participant reason from it, so the two can never disagree. ToParticipantCloseReason maps each reason to exactly what its call site passed before, so no participant-facing behaviour changes; a table test pins that mapping. * fix(test): update webhook test for RoomCloseReason test/ was outside the packages checked before pushing, so this Close call site was missed. Also assert the reason reaches the room_finished webhook, which the unit test cannot cover since it runs with a nil notifier. --- go.mod | 2 +- go.sum | 4 +- pkg/rtc/room.go | 15 +++-- pkg/rtc/room_test.go | 35 ++++++---- pkg/rtc/types/interfaces.go | 67 +++++++++++++++++++ pkg/service/roommanager.go | 8 +-- pkg/telemetry/events.go | 16 +++-- pkg/telemetry/events_test.go | 15 +++++ .../telemetryfakes/fake_telemetry_service.go | 18 ++--- pkg/telemetry/telemetryservice.go | 5 +- test/webhook_test.go | 3 +- 11 files changed, 143 insertions(+), 45 deletions(-) diff --git a/go.mod b/go.mod index 75b011646..5fdc81a0a 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.50.5-0.20260828115123-c6d0e234b41d + github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7 github.com/livekit/psrpc v0.7.5 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 21843541e..fcd5ce44d 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.50.5-0.20260828115123-c6d0e234b41d h1:qqObuGUWqdu1LbuexhZrrx2J7I25ZliwKRn/4ITu348= -github.com/livekit/protocol v1.50.5-0.20260828115123-c6d0e234b41d/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= +github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7 h1:g2rZnglnweby428hsYCrtbW/3CFfQInM1JTsRuU8qhE= +github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 74f7f3059..92bc9218f 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -139,7 +139,7 @@ type Room struct { onParticipantChanged func(p types.Participant) onRoomUpdated func() - onClose func() + onClose func(types.RoomCloseReason) simulationLock sync.Mutex disconnectSignalOnResumeParticipants map[livekit.ParticipantIdentity]time.Time @@ -802,12 +802,12 @@ func (r *Room) CloseIfEmpty() { r.lock.Unlock() if elapsed >= int64(timeout) { - r.Close(types.ParticipantCloseReasonRoomClosed) + r.Close(types.RoomCloseReasonIdleTimeout) r.logger.Infow("closing idle room", "reason", reason) } } -func (r *Room) Close(reason types.ParticipantCloseReason) { +func (r *Room) Close(reason types.RoomCloseReason) { r.lock.Lock() select { case <-r.closed: @@ -819,19 +819,20 @@ func (r *Room) Close(reason types.ParticipantCloseReason) { close(r.closed) r.lock.Unlock() - r.logger.Infow("closing room") + r.logger.Infow("closing room", "reason", reason) + participantCloseReason := reason.ToParticipantCloseReason() for _, p := range r.GetParticipants() { - _ = p.Close(true, reason, false) + _ = p.Close(true, participantCloseReason, false) } r.protoProxy.Stop() if r.onClose != nil { - r.onClose() + r.onClose(reason) } } -func (r *Room) OnClose(f func()) { +func (r *Room) OnClose(f func(types.RoomCloseReason)) { r.onClose = f } diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 0d69710d3..4f76d0705 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -377,8 +377,10 @@ func TestRoomClosure(t *testing.T) { t.Run("room closes after participant leaves", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) p := rm.GetParticipants()[0] rm.lock.Lock() @@ -392,6 +394,7 @@ func TestRoomClosure(t *testing.T) { rm.CloseIfEmpty() require.Len(t, rm.GetParticipants(), 0) require.True(t, isClosed) + require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason) require.Equal(t, ErrRoomClosed, rm.Join(p, nil, nil, iceServersForRoom)) }) @@ -399,19 +402,24 @@ func TestRoomClosure(t *testing.T) { t.Run("room does not close before empty timeout", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 0}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) require.NotZero(t, rm.protoRoom.EmptyTimeout) rm.CloseIfEmpty() require.False(t, isClosed) + require.Equal(t, types.RoomCloseReasonUnknown, closeReason) }) t.Run("room closes after empty timeout", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 0}) isClosed := false - rm.OnClose(func() { + var closeReason types.RoomCloseReason + rm.OnClose(func(reason types.RoomCloseReason) { isClosed = true + closeReason = reason }) rm.lock.Lock() rm.protoRoom.EmptyTimeout = 1 @@ -420,6 +428,7 @@ func TestRoomClosure(t *testing.T) { time.Sleep(1010 * time.Millisecond) rm.CloseIfEmpty() require.True(t, isClosed) + require.Equal(t, types.RoomCloseReasonIdleTimeout, closeReason) }) } @@ -461,7 +470,7 @@ func TestActiveSpeakers(t *testing.T) { audioUpdateDuration := (audioUpdateInterval + 10) * time.Millisecond t.Run("participant should not be getting audio updates (protocol 2)", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1, protocol: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) require.Empty(t, rm.GetActiveSpeakers()) @@ -473,7 +482,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("speakers should be sorted by loudness", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) p2 := participants[1].(*typesfakes.FakeLocalParticipant) @@ -488,7 +497,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("participants are getting audio updates (protocol 3+)", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) time.Sleep(time.Millisecond) // let the first update cycle run @@ -527,7 +536,7 @@ func TestActiveSpeakers(t *testing.T) { t.Run("audio level is smoothed", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, protocol: 3, audioSmoothIntervals: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) @@ -621,7 +630,7 @@ func TestDataChannel(t *testing.T) { mode := mode t.Run(modeNames[mode], func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 3}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) lpl := rm.LocalParticipantListener() @@ -667,7 +676,7 @@ func TestDataChannel(t *testing.T) { mode := mode t.Run(modeNames[mode], func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 4}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) lpl := rm.LocalParticipantListener() @@ -713,7 +722,7 @@ func TestDataChannel(t *testing.T) { t.Run("publishing disallowed", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) participants := rm.GetParticipants() p := participants[0].(*typesfakes.FakeLocalParticipant) @@ -743,7 +752,7 @@ func TestDataChannel(t *testing.T) { func TestHiddenParticipants(t *testing.T) { t.Run("other participants don't receive hidden updates", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2, numHidden: 1}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) pNew := NewMockParticipant("new", types.CurrentProtocol, false, false, rm.LocalParticipantListener()) rm.Join(pNew, nil, nil, iceServersForRoom) @@ -775,7 +784,7 @@ func TestHiddenParticipants(t *testing.T) { func TestRoomUpdate(t *testing.T) { t.Run("updates are sent when participant joined", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) p1 := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) require.Equal(t, 0, p1.SendRoomUpdateCallCount()) @@ -791,7 +800,7 @@ func TestRoomUpdate(t *testing.T) { t.Run("participants should receive metadata update", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 2}) - defer rm.Close(types.ParticipantCloseReasonNone) + defer rm.Close(types.RoomCloseReasonUnknown) rm.SetMetadata("test metadata...") diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index a67b19ffd..0393177a9 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -227,6 +227,8 @@ func (p ParticipantCloseReason) ToDisconnectReason() livekit.DisconnectReason { } } +// --------------------------------------------- + // IsIntentionalDisconnect reports whether a disconnect reason represents an // intentional/expected closure (client leaving, admin action, room teardown, // migration, etc.) as opposed to a connection failure. @@ -292,6 +294,7 @@ func (s SignallingCloseReason) String() string { } // --------------------------------------------- + const ( ParticipantCloseKeyNormal = "normal" ParticipantCloseKeyWHIP = "whip" @@ -299,6 +302,70 @@ const ( // --------------------------------------------- +type RoomCloseReason int + +const ( + RoomCloseReasonUnknown RoomCloseReason = iota + RoomCloseReasonAPIDelete + RoomCloseReasonIdleTimeout + RoomCloseReasonServerShutdown + RoomCloseReasonSuperseded + RoomCloseReasonOpenFailed +) + +func (r RoomCloseReason) String() string { + switch r { + case RoomCloseReasonUnknown: + return "UNKNOWN" + case RoomCloseReasonAPIDelete: + return "API_DELETE" + case RoomCloseReasonIdleTimeout: + return "IDLE_TIMEOUT" + case RoomCloseReasonServerShutdown: + return "SERVER_SHUTDOWN" + case RoomCloseReasonSuperseded: + return "SUPERSEDED" + case RoomCloseReasonOpenFailed: + return "OPEN_FAILED" + default: + return fmt.Sprintf("%d", int(r)) + } +} + +func (r RoomCloseReason) ToProto() livekit.RoomEndReason { + switch r { + case RoomCloseReasonAPIDelete: + return livekit.RoomEndReason_ROOM_END_API_DELETE + case RoomCloseReasonIdleTimeout: + return livekit.RoomEndReason_ROOM_END_IDLE_TIMEOUT + case RoomCloseReasonServerShutdown: + return livekit.RoomEndReason_ROOM_END_SERVER_SHUTDOWN + case RoomCloseReasonSuperseded: + return livekit.RoomEndReason_ROOM_END_SUPERSEDED + case RoomCloseReasonOpenFailed: + return livekit.RoomEndReason_ROOM_END_OPEN_FAILED + default: + return livekit.RoomEndReason_ROOM_END_UNKNOWN + } +} + +// ToParticipantCloseReason gives the reason participants are closed with when the +// room closes for this reason, so the two can never disagree. +func (r RoomCloseReason) ToParticipantCloseReason() ParticipantCloseReason { + switch r { + case RoomCloseReasonAPIDelete: + return ParticipantCloseReasonServiceRequestDeleteRoom + case RoomCloseReasonIdleTimeout, RoomCloseReasonSuperseded: + return ParticipantCloseReasonRoomClosed + case RoomCloseReasonServerShutdown: + return ParticipantCloseReasonRoomManagerStop + default: + return ParticipantCloseReasonNone + } +} + +// --------------------------------------------- + //counterfeiter:generate . Participant type Participant interface { ID() livekit.ParticipantID diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 57282b3c3..e25fae883 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -246,7 +246,7 @@ func (r *RoomManager) Stop() { r.lock.RUnlock() for _, room := range rooms { - room.Close(types.ParticipantCloseReasonRoomManagerStop) + room.Close(types.RoomCloseReasonServerShutdown) } r.roomManagerServer.Kill() @@ -692,12 +692,12 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C return nil, err } - newRoom.OnClose(func() { + newRoom.OnClose(func(reason types.RoomCloseReason) { killRoomServer() killDispServer() roomInfo := newRoom.ToProto() - r.telemetry.RoomEnded(ctx, roomInfo) + r.telemetry.RoomEnded(ctx, roomInfo, reason.ToProto()) prometheus.RoomEnded(time.Unix(roomInfo.CreationTime, 0)) if err := r.deleteRoom(ctx, roomName); err != nil { newRoom.Logger().Errorw("could not delete room", err) @@ -943,7 +943,7 @@ func (r *RoomManager) DeleteRoom(ctx context.Context, req *livekit.DeleteRoomReq } } else { room.Logger().Infow("deleting room") - room.Close(types.ParticipantCloseReasonServiceRequestDeleteRoom) + room.Close(types.RoomCloseReasonAPIDelete) } return &livekit.DeleteRoomResponse{}, nil } diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 989dd870e..2231b6a2b 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -57,18 +57,20 @@ func (t *telemetryService) RoomStarted(ctx context.Context, room *livekit.Room) }) } -func (t *telemetryService) RoomEnded(ctx context.Context, room *livekit.Room) { +func (t *telemetryService) RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) { t.enqueue(func() { t.NotifyEvent(ctx, &livekit.WebhookEvent{ - Event: webhook.EventRoomFinished, - Room: room, + Event: webhook.EventRoomFinished, + Room: room, + RoomEndReason: reason, }) t.SendEvent(ctx, &livekit.AnalyticsEvent{ - Type: livekit.AnalyticsEventType_ROOM_ENDED, - Timestamp: timestamppb.Now(), - RoomId: room.Sid, - Room: room, + Type: livekit.AnalyticsEventType_ROOM_ENDED, + Timestamp: timestamppb.Now(), + RoomId: room.Sid, + Room: room, + RoomEndReason: reason, }) }) } diff --git a/pkg/telemetry/events_test.go b/pkg/telemetry/events_test.go index 03fca0e4a..ae275c89f 100644 --- a/pkg/telemetry/events_test.go +++ b/pkg/telemetry/events_test.go @@ -243,3 +243,18 @@ func Test_OnTrackSubscribed_EventIsSent(t *testing.T) { require.Equal(t, publisherInfo.Identity, eventTrackSubscribed.Publisher.Identity) } + +func Test_OnRoomEnded_ReasonIsSent(t *testing.T) { + fixture := createFixture() + + room := &livekit.Room{Sid: "RoomSid", Name: "RoomName"} + fixture.sut.RoomEnded(context.Background(), room, livekit.RoomEndReason_ROOM_END_API_DELETE) + + fixture.flush() + + require.Equal(t, 1, fixture.analytics.SendEventCallCount()) + _, event := fixture.analytics.SendEventArgsForCall(0) + require.Equal(t, livekit.AnalyticsEventType_ROOM_ENDED, event.Type) + require.Equal(t, room.Sid, event.RoomId) + require.Equal(t, livekit.RoomEndReason_ROOM_END_API_DELETE, event.RoomEndReason) +} diff --git a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go index 25a241bef..aed5e7cf3 100644 --- a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go +++ b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go @@ -129,11 +129,12 @@ type FakeTelemetryService struct { arg1 context.Context arg2 *livekit.ReportInfo } - RoomEndedStub func(context.Context, *livekit.Room) + RoomEndedStub func(context.Context, *livekit.Room, livekit.RoomEndReason) roomEndedMutex sync.RWMutex roomEndedArgsForCall []struct { arg1 context.Context arg2 *livekit.Room + arg3 livekit.RoomEndReason } RoomProjectReporterStub func(context.Context) roomobs.ProjectReporter roomProjectReporterMutex sync.RWMutex @@ -880,17 +881,18 @@ func (fake *FakeTelemetryService) ReportArgsForCall(i int) (context.Context, *li return argsForCall.arg1, argsForCall.arg2 } -func (fake *FakeTelemetryService) RoomEnded(arg1 context.Context, arg2 *livekit.Room) { +func (fake *FakeTelemetryService) RoomEnded(arg1 context.Context, arg2 *livekit.Room, arg3 livekit.RoomEndReason) { fake.roomEndedMutex.Lock() fake.roomEndedArgsForCall = append(fake.roomEndedArgsForCall, struct { arg1 context.Context arg2 *livekit.Room - }{arg1, arg2}) + arg3 livekit.RoomEndReason + }{arg1, arg2, arg3}) stub := fake.RoomEndedStub - fake.recordInvocation("RoomEnded", []interface{}{arg1, arg2}) + fake.recordInvocation("RoomEnded", []interface{}{arg1, arg2, arg3}) fake.roomEndedMutex.Unlock() if stub != nil { - fake.RoomEndedStub(arg1, arg2) + fake.RoomEndedStub(arg1, arg2, arg3) } } @@ -900,17 +902,17 @@ func (fake *FakeTelemetryService) RoomEndedCallCount() int { return len(fake.roomEndedArgsForCall) } -func (fake *FakeTelemetryService) RoomEndedCalls(stub func(context.Context, *livekit.Room)) { +func (fake *FakeTelemetryService) RoomEndedCalls(stub func(context.Context, *livekit.Room, livekit.RoomEndReason)) { fake.roomEndedMutex.Lock() defer fake.roomEndedMutex.Unlock() fake.RoomEndedStub = stub } -func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, *livekit.Room) { +func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, *livekit.Room, livekit.RoomEndReason) { fake.roomEndedMutex.RLock() defer fake.roomEndedMutex.RUnlock() argsForCall := fake.roomEndedArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } func (fake *FakeTelemetryService) RoomProjectReporter(arg1 context.Context) roomobs.ProjectReporter { diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index bdf9c820c..b3fd92b8d 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -35,7 +35,7 @@ type TelemetryService interface { // events RoomStarted(ctx context.Context, room *livekit.Room) - RoomEnded(ctx context.Context, room *livekit.Room) + RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) // ParticipantJoined - a participant establishes signal connection to a room ParticipantJoined(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientInfo *livekit.ClientInfo, clientMeta *livekit.AnalyticsClientMeta, shouldSendEvent bool, guard *ReferenceGuard) @@ -105,7 +105,8 @@ type NullTelemetryService struct { func (n NullTelemetryService) TrackStats(roomID livekit.RoomID, roomName livekit.RoomName, key StatsKey, stat *livekit.AnalyticsStat) { } func (n NullTelemetryService) RoomStarted(ctx context.Context, room *livekit.Room) {} -func (n NullTelemetryService) RoomEnded(ctx context.Context, room *livekit.Room) {} +func (n NullTelemetryService) RoomEnded(ctx context.Context, room *livekit.Room, reason livekit.RoomEndReason) { +} func (n NullTelemetryService) ParticipantJoined(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientInfo *livekit.ClientInfo, clientMeta *livekit.AnalyticsClientMeta, shouldSendEvent bool, guard *ReferenceGuard) { } func (n NullTelemetryService) ParticipantActive(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, clientMeta *livekit.AnalyticsClientMeta, isMigration bool, isWarp bool, guard *ReferenceGuard) { diff --git a/test/webhook_test.go b/test/webhook_test.go index 739c47179..7fff2d226 100644 --- a/test/webhook_test.go +++ b/test/webhook_test.go @@ -111,7 +111,7 @@ func TestWebhooks(t *testing.T) { // room closed rm := server.RoomManager().GetRoom(context.Background(), testRoom) - rm.Close(types.ParticipantCloseReasonNone) + rm.Close(types.RoomCloseReasonAPIDelete) testutils.WithTimeout(t, func() string { if ts.GetEvent(webhook.EventRoomFinished) == nil { return "did not receive RoomFinished" @@ -119,6 +119,7 @@ func TestWebhooks(t *testing.T) { return "" }) require.Equal(t, testRoom, ts.GetEvent(webhook.EventRoomFinished).Room.Name) + require.Equal(t, livekit.RoomEndReason_ROOM_END_API_DELETE, ts.GetEvent(webhook.EventRoomFinished).RoomEndReason) }) } } From 5528c464a0075424ffd251d0bc0bd93c4057ac9d Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 29 Aug 2026 12:58:22 +0530 Subject: [PATCH 002/130] telemetry: support roomID change for a participant (#4816) * telemetry: support roomID change for a participant A room can get a new id while participants are connected. Key stats workers as map[roomID]map[participantID] so moving a room is a single map splice, and add reKeyRoom/RoomIDChanged to do the move. Stats collected before the change are sealed off with the room they were collected in so they stay attributed to it. Co-Authored-By: Claude Opus 5 (1M context) * telemetry: close superseded worker on re-key collision Only one worker can be keyed at (room, participant). If a re-key lands on a room that already has a worker for the same participant, keep the one already filed there and close the superseded one so it drains and is reaped instead of lingering in the flush list. Co-Authored-By: Claude Opus 5 (1M context) * telemetry: hand references to the successor on force close A ReferenceGuard records that it activated some worker, not which one, so a superseded worker cannot just drop its references - the survivor would be left with references it never sees released and would never close. Hand them over instead. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- pkg/telemetry/events.go | 12 ++ pkg/telemetry/stats_test.go | 127 +++++++++++++++ pkg/telemetry/statsworker.go | 147 ++++++++++++++++-- pkg/telemetry/statsworker_test.go | 44 ++++++ .../telemetryfakes/fake_telemetry_service.go | 41 +++++ pkg/telemetry/telemetryservice.go | 100 ++++++++++-- 6 files changed, 441 insertions(+), 30 deletions(-) diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 2231b6a2b..fa6257173 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -184,6 +184,18 @@ func (t *telemetryService) ParticipantResumed( }) } +// RoomIDChanged re-keys the room's stats workers. +// +// NOTE: this shares the queue with the stats and participant events it races with, so +// ops raised before the id changed (carrying `prevRoomID`) are applied before the +// re-key and ops raised after it (carrying the new id) are applied after. Callers +// should raise this as soon as the room starts reporting the new id. +func (t *telemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) { + t.enqueue(func() { + t.reKeyRoom(prevRoomID, livekit.RoomID(room.Sid), livekit.RoomName(room.Name)) + }) +} + func (t *telemetryService) ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, diff --git a/pkg/telemetry/stats_test.go b/pkg/telemetry/stats_test.go index 4729d99dd..f4f9d3dbe 100644 --- a/pkg/telemetry/stats_test.go +++ b/pkg/telemetry/stats_test.go @@ -626,6 +626,133 @@ func Test_BothDownstreamAndUpstreamStatsAreSentTogether(t *testing.T) { require.Equal(t, livekit.StreamType_DOWNSTREAM, stats[1].Kind) } +func Test_RoomIDChangeReKeysStatsWorkers(t *testing.T) { + fixture := createFixture() + + // prepare + room := &livekit.Room{Sid: "RoomSid", Name: "RoomName"} + partSID := livekit.ParticipantID("part1") + participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} + trackID := livekit.TrackID("trackID") + guard := &telemetry.ReferenceGuard{} + fixture.sut.ParticipantJoined(context.Background(), room, participantInfo, nil, nil, true, guard) + + stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} + fixture.sut.TrackStats(livekit.RoomID(room.Sid), livekit.RoomName(room.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) + + // do - the room restarts and gets a new id + restartedRoom := &livekit.Room{Sid: "RestartedSid", Name: "RoomName"} + fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(room.Sid), restartedRoom) + + // stats reported with the new id reach the same worker + stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} + fixture.sut.TrackStats(livekit.RoomID(restartedRoom.Sid), livekit.RoomName(restartedRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) + + fixture.flush() + + // one worker, one flush, but two stats - each attributed to the session it was collected in + require.Equal(t, 1, fixture.analytics.SendStatsCallCount()) + _, stats := fixture.analytics.SendStatsArgsForCall(0) + require.Equal(t, 2, len(stats)) + + byRoom := map[string]*livekit.AnalyticsStat{} + for _, stat := range stats { + require.Equal(t, string(partSID), stat.ParticipantId) + byRoom[stat.RoomId] = stat + } + require.Len(t, byRoom, 2) + require.Equal(t, uint64(33), byRoom[room.Sid].Streams[0].PrimaryBytes) + require.Equal(t, uint64(44), byRoom[restartedRoom.Sid].Streams[0].PrimaryBytes) + + // the worker moved rather than being duplicated, so closing it out drains everything + fixture.sut.ParticipantLeft(context.Background(), restartedRoom, participantInfo, true, guard) + fixture.flush() + require.Equal(t, 1, fixture.analytics.SendStatsCallCount()) +} + +// a forwarded participant is in more than one room at a time under the same participant +// id, so re-keying one of those rooms must leave the other alone +func Test_RoomIDChangeLeavesForwardedParticipantAlone(t *testing.T) { + fixture := createFixture() + + // prepare - the same participant id in a source room and a forwarding destination room + sourceRoom := &livekit.Room{Sid: "SourceSid", Name: "SourceRoom"} + destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"} + partSID := livekit.ParticipantID("part1") + participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} + trackID := livekit.TrackID("trackID") + fixture.sut.ParticipantJoined(context.Background(), sourceRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) + fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) + + // do - only the destination room restarts + restartedDest := &livekit.Room{Sid: "RestartedDestSid", Name: "DestRoom"} + fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(destRoom.Sid), restartedDest) + + stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} + fixture.sut.TrackStats(livekit.RoomID(sourceRoom.Sid), livekit.RoomName(sourceRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) + stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} + fixture.sut.TrackStats(livekit.RoomID(restartedDest.Sid), livekit.RoomName(restartedDest.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) + + fixture.flush() + + // the source room's worker is untouched, the destination room's worker moved + byRoom := map[string]*livekit.AnalyticsStat{} + for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ { + _, stats := fixture.analytics.SendStatsArgsForCall(i) + for _, stat := range stats { + byRoom[stat.RoomId] = stat + } + } + require.Len(t, byRoom, 2) + require.Equal(t, uint64(33), byRoom[sourceRoom.Sid].Streams[0].PrimaryBytes) + require.Equal(t, sourceRoom.Name, byRoom[sourceRoom.Sid].RoomName) + require.Equal(t, uint64(44), byRoom[restartedDest.Sid].Streams[0].PrimaryBytes) + require.Equal(t, restartedDest.Name, byRoom[restartedDest.Sid].RoomName) +} + +// a re-key should never land on a room that already has workers, but if it does only +// one worker can be keyed at (room, participant) and the superseded one must not be +// left unreachable in the flush list +func Test_RoomIDChangeParticipantCollision(t *testing.T) { + fixture := createFixture() + + // prepare - the same participant id in the room being re-keyed and in its destination + prevRoom := &livekit.Room{Sid: "PrevSid", Name: "PrevRoom"} + destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"} + partSID := livekit.ParticipantID("part1") + participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} + trackID := livekit.TrackID("trackID") + fixture.sut.ParticipantJoined(context.Background(), prevRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) + fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) + + stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} + fixture.sut.TrackStats(livekit.RoomID(prevRoom.Sid), livekit.RoomName(prevRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) + + // do + fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(prevRoom.Sid), destRoom) + + // the superseded worker drains what it collected under the room it was in + fixture.flush() + byRoom := map[string]*livekit.AnalyticsStat{} + for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ { + _, stats := fixture.analytics.SendStatsArgsForCall(i) + for _, stat := range stats { + byRoom[stat.RoomId] = stat + } + } + require.Equal(t, uint64(33), byRoom[prevRoom.Sid].Streams[0].PrimaryBytes) + + // the worker already keyed at the destination wins and keeps receiving stats + stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} + fixture.sut.TrackStats(livekit.RoomID(destRoom.Sid), livekit.RoomName(destRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) + + fixture.flush() + _, stats := fixture.analytics.SendStatsArgsForCall(fixture.analytics.SendStatsCallCount() - 1) + require.Equal(t, 1, len(stats)) + require.Equal(t, destRoom.Sid, stats[0].RoomId) + require.Equal(t, uint64(44), stats[0].Streams[0].PrimaryBytes) +} + func (f *telemetryServiceFixture) flush() { time.Sleep(time.Millisecond * 500) f.sut.FlushStats() diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 59429a0d9..2271c3e9f 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -52,6 +52,18 @@ func (s *ReferenceCount) Release(guard *ReferenceGuard) bool { return s.count == 0 } +// Take hands over every reference held, leaving none behind. +func (s *ReferenceCount) Take() int { + count := s.count + s.count = 0 + return count +} + +// Absorb takes on references handed over from elsewhere. +func (s *ReferenceCount) Absorb(count int) { + s.count += count +} + func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error { e.AddInt("count", s.count) return nil @@ -59,19 +71,39 @@ func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error { // ---------------------------------------- +// statsBatch is stats collected while the worker was in one room +type statsBatch struct { + roomID livekit.RoomID + roomName livekit.RoomName + incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat + outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat +} + +func (b statsBatch) isEmpty() bool { + return len(b.incomingPerTrack) == 0 && len(b.outgoingPerTrack) == 0 +} + +// ---------------------------------------- + // StatsWorker handles participant stats type StatsWorker struct { next *StatsWorker ctx context.Context t TelemetryService - roomID livekit.RoomID - roomName livekit.RoomName participantID livekit.ParticipantID participantIdentity livekit.ParticipantIdentity - isConnected bool - lock sync.RWMutex + lock sync.RWMutex + // the room a worker belongs to can change mid-session, so it is mutable state + // guarded by `lock`. it is kept in sync with the key the worker is filed under in + // telemetryService.workers, see telemetryService.reKeyRoom. + roomID livekit.RoomID + roomName livekit.RoomName + // batches sealed off by a room change, they carry the room they were collected + // in and go out on the next flush + sealed []statsBatch + isConnected bool outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat refCount ReferenceCount @@ -115,6 +147,51 @@ func (s *StatsWorker) ParticipantID() livekit.ParticipantID { return s.participantID } +func (s *StatsWorker) RoomID() livekit.RoomID { + s.lock.RLock() + defer s.lock.RUnlock() + + return s.roomID +} + +// SetRoom re-points the worker at a room. +// +// Stats collected so far are sealed off rather than re-stamped - a room id changes +// because the previous session ended, so what was collected under it belongs to it. +// Sealing keeps the re-key free of any sending, the sealed stats go out on the next +// flush like every other stat. +func (s *StatsWorker) SetRoom(roomID livekit.RoomID, roomName livekit.RoomName) { + s.lock.Lock() + defer s.lock.Unlock() + + if s.roomID == roomID && s.roomName == roomName { + return + } + + if batch := s.sealStatsLocked(); !batch.isEmpty() { + s.sealed = append(s.sealed, batch) + } + + s.roomID = roomID + s.roomName = roomName +} + +// sealStatsLocked hands over everything collected since the last seal, stamped +// with the room it was collected in +func (s *StatsWorker) sealStatsLocked() statsBatch { + batch := statsBatch{ + roomID: s.roomID, + roomName: s.roomName, + incomingPerTrack: s.incomingPerTrack, + outgoingPerTrack: s.outgoingPerTrack, + } + + s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) + s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) + + return batch +} + func (s *StatsWorker) SetConnected() { s.lock.Lock() s.isConnected = true @@ -132,19 +209,24 @@ func (s *StatsWorker) Flush(now time.Time, closeWait time.Duration) bool { ts := timestamppb.New(now) s.lock.Lock() - stats := make([]*livekit.AnalyticsStat, 0, len(s.incomingPerTrack)+len(s.outgoingPerTrack)) - - incomingPerTrack := s.incomingPerTrack - s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) - - outgoingPerTrack := s.outgoingPerTrack - s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) + // anything sealed off by a room change goes out along with the current batch, + // each stamped with the room it was collected in + batches := append(s.sealed, s.sealStatsLocked()) + s.sealed = nil closed := !s.closedAt.IsZero() && now.Sub(s.closedAt) > closeWait s.lock.Unlock() - stats = s.collectStats(ts, livekit.StreamType_UPSTREAM, incomingPerTrack, stats) - stats = s.collectStats(ts, livekit.StreamType_DOWNSTREAM, outgoingPerTrack, stats) + numTracks := 0 + for _, batch := range batches { + numTracks += len(batch.incomingPerTrack) + len(batch.outgoingPerTrack) + } + + stats := make([]*livekit.AnalyticsStat, 0, numTracks) + for _, batch := range batches { + stats = s.collectStats(ts, batch, livekit.StreamType_UPSTREAM, stats) + stats = s.collectStats(ts, batch, livekit.StreamType_DOWNSTREAM, stats) + } if len(stats) > 0 { s.t.SendStats(s.ctx, stats) } @@ -167,6 +249,34 @@ func (s *StatsWorker) Close(guard *ReferenceGuard) bool { return ok } +// ForceClose closes the worker irrespective of outstanding references. Used when a +// worker can no longer be reached through the worker map, so that it drains and is +// reaped instead of lingering in the flush list forever. +// +// Its references are handed over to `successor`, the worker that can be reached in its +// place, so that whoever holds one still has a live worker to close. A ReferenceGuard +// records that it activated some worker, not which one, so leaving them behind would +// strand the successor with references it can never see released. +func (s *StatsWorker) ForceClose(successor *StatsWorker) bool { + s.lock.Lock() + if !s.closedAt.IsZero() { + s.lock.Unlock() + return false + } + + s.closedAt = time.Now() + count := s.refCount.Take() + s.lock.Unlock() + + if successor != nil && count != 0 { + successor.lock.Lock() + successor.refCount.Absorb(count) + successor.lock.Unlock() + } + + return true +} + func (s *StatsWorker) Closed(guard *ReferenceGuard) bool { s.lock.Lock() defer s.lock.Unlock() @@ -179,10 +289,15 @@ func (s *StatsWorker) Closed(guard *ReferenceGuard) bool { func (s *StatsWorker) collectStats( ts *timestamppb.Timestamp, + batch statsBatch, streamType livekit.StreamType, - perTrack map[livekit.TrackID][]*livekit.AnalyticsStat, stats []*livekit.AnalyticsStat, ) []*livekit.AnalyticsStat { + perTrack := batch.incomingPerTrack + if streamType == livekit.StreamType_DOWNSTREAM { + perTrack = batch.outgoingPerTrack + } + for trackID, analyticsStats := range perTrack { coalesced := coalesce(analyticsStats) if coalesced == nil { @@ -192,9 +307,9 @@ func (s *StatsWorker) collectStats( coalesced.TimeStamp = ts coalesced.TrackId = string(trackID) coalesced.Kind = streamType - coalesced.RoomId = string(s.roomID) + coalesced.RoomId = string(batch.roomID) coalesced.ParticipantId = string(s.participantID) - coalesced.RoomName = string(s.roomName) + coalesced.RoomName = string(batch.roomName) stats = append(stats, coalesced) } return stats diff --git a/pkg/telemetry/statsworker_test.go b/pkg/telemetry/statsworker_test.go index f7ae48816..f89992c94 100644 --- a/pkg/telemetry/statsworker_test.go +++ b/pkg/telemetry/statsworker_test.go @@ -16,4 +16,48 @@ func TestStatsWorker(t *testing.T) { require.True(t, w.Close(&g1)) require.True(t, w.Closed(&g1)) }) + + // a ReferenceGuard records that it activated some worker, not which one, so a + // superseded worker has to hand its references to the one reachable in its place + t.Run("force close hands references to the successor", func(t *testing.T) { + t.Run("a guard shared by both workers", func(t *testing.T) { + // the second worker never got a reference, the guard was already activated + var g ReferenceGuard + superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g) + survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &g) + require.Equal(t, 1, superseded.refCount.count) + require.Equal(t, 0, survivor.refCount.count) + + require.True(t, superseded.ForceClose(survivor)) + require.Equal(t, 0, superseded.refCount.count) + require.Equal(t, 1, survivor.refCount.count) + + // without the hand over this would leave the survivor at -1 and never closed + require.True(t, survivor.Close(&g)) + require.True(t, survivor.Closed(&g)) + }) + + t.Run("a guard per worker", func(t *testing.T) { + var gSuperseded, gSurvivor ReferenceGuard + superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &gSuperseded) + survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &gSurvivor) + + require.True(t, superseded.ForceClose(survivor)) + require.Equal(t, 2, survivor.refCount.count) + + // the superseded worker's owner departs, it must not close the survivor early + require.False(t, survivor.Close(&gSuperseded)) + require.True(t, survivor.Close(&gSurvivor)) + }) + + t.Run("closing an already closed worker holds on to its references", func(t *testing.T) { + var g ReferenceGuard + superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g) + survivor := newStatsWorker(t.Context(), nil, "", "", "", "", nil) + + require.True(t, superseded.ForceClose(nil)) + require.False(t, superseded.ForceClose(survivor)) + require.Equal(t, 0, survivor.refCount.count) + }) + }) } diff --git a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go index aed5e7cf3..12d542127 100644 --- a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go +++ b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go @@ -136,6 +136,13 @@ type FakeTelemetryService struct { arg2 *livekit.Room arg3 livekit.RoomEndReason } + RoomIDChangedStub func(context.Context, livekit.RoomID, *livekit.Room) + roomIDChangedMutex sync.RWMutex + roomIDChangedArgsForCall []struct { + arg1 context.Context + arg2 livekit.RoomID + arg3 *livekit.Room + } RoomProjectReporterStub func(context.Context) roomobs.ProjectReporter roomProjectReporterMutex sync.RWMutex roomProjectReporterArgsForCall []struct { @@ -915,6 +922,40 @@ func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } +func (fake *FakeTelemetryService) RoomIDChanged(arg1 context.Context, arg2 livekit.RoomID, arg3 *livekit.Room) { + fake.roomIDChangedMutex.Lock() + fake.roomIDChangedArgsForCall = append(fake.roomIDChangedArgsForCall, struct { + arg1 context.Context + arg2 livekit.RoomID + arg3 *livekit.Room + }{arg1, arg2, arg3}) + stub := fake.RoomIDChangedStub + fake.recordInvocation("RoomIDChanged", []interface{}{arg1, arg2, arg3}) + fake.roomIDChangedMutex.Unlock() + if stub != nil { + fake.RoomIDChangedStub(arg1, arg2, arg3) + } +} + +func (fake *FakeTelemetryService) RoomIDChangedCallCount() int { + fake.roomIDChangedMutex.RLock() + defer fake.roomIDChangedMutex.RUnlock() + return len(fake.roomIDChangedArgsForCall) +} + +func (fake *FakeTelemetryService) RoomIDChangedCalls(stub func(context.Context, livekit.RoomID, *livekit.Room)) { + fake.roomIDChangedMutex.Lock() + defer fake.roomIDChangedMutex.Unlock() + fake.RoomIDChangedStub = stub +} + +func (fake *FakeTelemetryService) RoomIDChangedArgsForCall(i int) (context.Context, livekit.RoomID, *livekit.Room) { + fake.roomIDChangedMutex.RLock() + defer fake.roomIDChangedMutex.RUnlock() + argsForCall := fake.roomIDChangedArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 +} + func (fake *FakeTelemetryService) RoomProjectReporter(arg1 context.Context) roomobs.ProjectReporter { fake.roomProjectReporterMutex.Lock() ret, specificReturn := fake.roomProjectReporterReturnsOnCall[len(fake.roomProjectReporterArgsForCall)] diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index b3fd92b8d..c8e31efd0 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -19,6 +19,7 @@ import ( "sync" "time" + "github.com/livekit/livekit-server/pkg/telemetry/prometheus" "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" @@ -45,6 +46,9 @@ type TelemetryService interface { ParticipantResumed(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, nodeID livekit.NodeID, reason livekit.ReconnectReason) // ParticipantLeft - the participant leaves the room, only sent if ParticipantActive has been called before ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard) + // RoomIDChanged - the room kept its session, but got a different id (a provisional room id + // replaced by the resolved one), re-keys the stats workers of every participant in the room + RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) // TrackPublishRequested - a publication attempt has been received TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) // TrackPublished - a publication attempt has been successful @@ -115,6 +119,8 @@ func (n NullTelemetryService) ParticipantResumed(ctx context.Context, room *live } func (n NullTelemetryService) ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard) { } +func (n NullTelemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) { +} func (n NullTelemetryService) TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) { } func (n NullTelemetryService) TrackPublished(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) { @@ -166,11 +172,6 @@ const ( telemetryStatsUpdateInterval = time.Second * 30 ) -type statsWorkerKey struct { - roomID livekit.RoomID - participantID livekit.ParticipantID -} - type telemetryService struct { AnalyticsService @@ -178,7 +179,7 @@ type telemetryService struct { jobsQueue *utils.OpsQueue workersMu sync.RWMutex - workers map[statsWorkerKey]*StatsWorker + workers map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker workerList *StatsWorker flushMu sync.Mutex @@ -194,7 +195,7 @@ func NewTelemetryService(notifier webhook.QueuedNotifier, analytics AnalyticsSer FlushOnStop: true, Logger: logger.GetLogger(), }), - workers: make(map[statsWorkerKey]*StatsWorker), + workers: make(map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker), } t.jobsQueue.Start() @@ -243,9 +244,12 @@ func (t *telemetryService) FlushStats() { if reap != nil { t.workersMu.Lock() for reap != nil { - key := statsWorkerKey{reap.roomID, reap.participantID} - if reap == t.workers[key] { - delete(t.workers, key) + roomID := reap.RoomID() + if roomWorkers := t.workers[roomID]; reap == roomWorkers[reap.participantID] { + delete(roomWorkers, reap.participantID) + if len(roomWorkers) == 0 { + delete(t.workers, roomID) + } } reap = reap.next } @@ -267,7 +271,7 @@ func (t *telemetryService) getWorker(roomID livekit.RoomID, participantID liveki t.workersMu.RLock() defer t.workersMu.RUnlock() - worker, ok = t.workers[statsWorkerKey{roomID, participantID}] + worker, ok = t.workers[roomID][participantID] return } @@ -282,8 +286,8 @@ func (t *telemetryService) getOrCreateWorker( t.workersMu.Lock() defer t.workersMu.Unlock() - key := statsWorkerKey{roomID, participantID} - worker, ok := t.workers[key] + roomWorkers := t.workers[roomID] + worker, ok := roomWorkers[participantID] if ok && !worker.Closed(guard) { return worker, true } @@ -306,7 +310,11 @@ func (t *telemetryService) getOrCreateWorker( worker.SetConnected() } - t.workers[key] = worker + if roomWorkers == nil { + roomWorkers = make(map[livekit.ParticipantID]*StatsWorker) + t.workers[roomID] = roomWorkers + } + roomWorkers[participantID] = worker worker.next = t.workerList t.workerList = worker @@ -314,6 +322,70 @@ func (t *telemetryService) getOrCreateWorker( return worker, false } +// reKeyRoom files every one of a room's stats workers under `roomID` instead of +// `prevRoomID`. +// +// A room can be restarted while participants are connected and reporting stats, which +// gives it a new id. As every worker of the room moves at once, the move is a single map +// splice - the workers themselves are untouched and keep their place in the flush list. +// Each worker then seals off what it collected under `prevRoomID` so those stats stay +// attributed to the session that ended (see StatsWorker.SetRoom). +func (t *telemetryService) reKeyRoom(prevRoomID livekit.RoomID, roomID livekit.RoomID, roomName livekit.RoomName) { + if prevRoomID == roomID { + return + } + + t.workersMu.Lock() + defer t.workersMu.Unlock() + + roomWorkers := t.workers[prevRoomID] + if len(roomWorkers) == 0 { + delete(t.workers, prevRoomID) + return + } + delete(t.workers, prevRoomID) + + existing := t.workers[roomID] + if existing == nil { + t.workers[roomID] = roomWorkers + } else { + // should not happen as a room id is only ever replaced by a freshly minted one + logger.Warnw( + "telemetry re-keying room into an existing entry", nil, + "prevRoomID", prevRoomID, + "room", roomName, + "roomID", roomID, + "numWorkers", len(roomWorkers), + "numExistingWorkers", len(existing), + ) + } + + for participantID, worker := range roomWorkers { + if existing != nil { + if survivor, ok := existing[participantID]; ok { + // only one worker can be keyed at (room, participant) and the one already + // filed there wins, close the superseded one so that it drains and is + // reaped instead of lingering in the flush list unreachable + if worker.ForceClose(survivor) { + prometheus.SubParticipant() + } + continue + } + existing[participantID] = worker + } + + worker.SetRoom(roomID, roomName) + } + + logger.Infow( + "telemetry re-keyed room", + "prevRoomID", prevRoomID, + "room", roomName, + "roomID", roomID, + "numWorkers", len(roomWorkers), + ) +} + func (t *telemetryService) LocalRoomState(ctx context.Context, info *livekit.AnalyticsNodeRooms) { t.enqueue(func() { t.SendNodeRoomStates(ctx, info) From c934aa29b75cf47fc83110d4b44c4055dd4f48d9 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 29 Aug 2026 16:46:13 +0530 Subject: [PATCH 003/130] log telemetry guard details (#4817) * log telemetry guard details * nil guard --- pkg/telemetry/events.go | 1 + pkg/telemetry/statsworker.go | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index fa6257173..657ea69e8 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -216,6 +216,7 @@ func (t *telemetryService) ParticipantLeft(ctx context.Context, "participant", participant.Identity, "participantID", participant.Sid, "worker", worker, + "guard", guard, ) } } diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 2271c3e9f..21da5240a 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -18,6 +18,7 @@ import ( "context" "sync" "time" + "unsafe" "go.uber.org/zap/zapcore" "google.golang.org/protobuf/types/known/timestamppb" @@ -32,6 +33,17 @@ type ReferenceGuard struct { activated, released bool } +func (r *ReferenceGuard) MarshalLogObject(e zapcore.ObjectEncoder) error { + if r != nil { + e.AddUintptr("self", uintptr(unsafe.Pointer(r))) + e.AddBool("activated", r.activated) + e.AddBool("released", r.released) + } + return nil +} + +// ---------------------------------------- + type ReferenceCount struct { count int } From 8b38f61fa1b2d95b1d002a1b7c4e23b8651f3879 Mon Sep 17 00:00:00 2001 From: Viacheslav Sergeev Date: Sun, 30 Aug 2026 16:09:35 +0200 Subject: [PATCH 004/130] Remove duplication within cpuload and sysload (#4818) * Removed duplication within cpuload and sysload * spelling fix --- pkg/routing/selector/cpuload.go | 18 +++++------------- pkg/routing/selector/nodesfilter.go | 21 +++++++++++++++++++++ pkg/routing/selector/sysload.go | 15 +++------------ 3 files changed, 29 insertions(+), 25 deletions(-) create mode 100644 pkg/routing/selector/nodesfilter.go diff --git a/pkg/routing/selector/cpuload.go b/pkg/routing/selector/cpuload.go index f84b4a3ce..3c31236f1 100644 --- a/pkg/routing/selector/cpuload.go +++ b/pkg/routing/selector/cpuload.go @@ -27,21 +27,13 @@ type CPULoadSelector struct { } func (s *CPULoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) { - nodes = GetAvailableNodes(nodes) - if len(nodes) == 0 { - return nil, ErrNoAvailableNodes + nodes, err := FilterNodesByCriteria(nodes, s.CPULoadLimit, func(node *livekit.Node) float32 { + return node.Stats.CpuLoad + }) + if err != nil { + return nil, err } - nodesLowLoad := make([]*livekit.Node, 0) - for _, node := range nodes { - stats := node.Stats - if stats.CpuLoad < s.CPULoadLimit { - nodesLowLoad = append(nodesLowLoad, node) - } - } - if len(nodesLowLoad) > 0 { - nodes = nodesLowLoad - } return nodes, nil } diff --git a/pkg/routing/selector/nodesfilter.go b/pkg/routing/selector/nodesfilter.go new file mode 100644 index 000000000..3dd49d3b5 --- /dev/null +++ b/pkg/routing/selector/nodesfilter.go @@ -0,0 +1,21 @@ +package selector + +import "github.com/livekit/protocol/livekit" + +func FilterNodesByCriteria(nodes []*livekit.Node, criteriaThreshold float32, calculateCriteriaFunc func(*livekit.Node) float32) ([]*livekit.Node, error) { + nodes = GetAvailableNodes(nodes) + if len(nodes) == 0 { + return nil, ErrNoAvailableNodes + } + + filteredNodes := make([]*livekit.Node, 0) + for _, node := range nodes { + if calculateCriteriaFunc(node) < criteriaThreshold { + filteredNodes = append(filteredNodes, node) + } + } + if len(filteredNodes) > 0 { + nodes = filteredNodes + } + return nodes, nil +} diff --git a/pkg/routing/selector/sysload.go b/pkg/routing/selector/sysload.go index 285fca493..88af4a19c 100644 --- a/pkg/routing/selector/sysload.go +++ b/pkg/routing/selector/sysload.go @@ -27,20 +27,11 @@ type SystemLoadSelector struct { } func (s *SystemLoadSelector) filterNodes(nodes []*livekit.Node) ([]*livekit.Node, error) { - nodes = GetAvailableNodes(nodes) - if len(nodes) == 0 { - return nil, ErrNoAvailableNodes + nodes, err := FilterNodesByCriteria(nodes, s.SysloadLimit, GetNodeSysload) + if err != nil { + return nil, err } - nodesLowLoad := make([]*livekit.Node, 0) - for _, node := range nodes { - if GetNodeSysload(node) < s.SysloadLimit { - nodesLowLoad = append(nodesLowLoad, node) - } - } - if len(nodesLowLoad) > 0 { - nodes = nodesLowLoad - } return nodes, nil } From a1f46b310a690c47578d73474222a65f7767059d Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 31 Aug 2026 11:14:15 +0800 Subject: [PATCH 005/130] Do not escape the ICE server URI in the WHIP Link header (#4795) The link target was passed through url.PathEscape, which turns the '?' of turn:host:3478?transport=udp into %3F. The target of a Link header field is a URI reference and "?transport=" is part of TURN URI syntax , not a query string, so a publisher that percent-decodes the target ends up with an unparsable host. Co-authored-by: Claude Opus 5 --- pkg/service/whipservice.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pkg/service/whipservice.go b/pkg/service/whipservice.go index 7f5f20fd9..b2d5d7f7a 100644 --- a/pkg/service/whipservice.go +++ b/pkg/service/whipservice.go @@ -20,7 +20,6 @@ import ( "fmt" "io" "net/http" - "net/url" "strings" "github.com/pion/webrtc/v4" @@ -269,7 +268,7 @@ func (s *WHIPService) handleCreate(w http.ResponseWriter, r *http.Request) { for _, iceServer := range res.IceServers { for _, iceURL := range iceServer.Urls { iceServerLink := &linkheader.Link{ - URL: url.PathEscape(iceURL), + URL: iceURL, Rel: "ice-server", Params: map[string]string{}, } From 460014023fe5df504bdfff51cbb47ddbfdd1e6fd Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:10:59 +0530 Subject: [PATCH 006/130] Update github.com/livekit/protocol digest to a469dd4 (#4820) 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 5fdc81a0a..0647bb3d7 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7 + github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b github.com/livekit/psrpc v0.7.5 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index fcd5ce44d..4be634370 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7 h1:g2rZnglnweby428hsYCrtbW/3CFfQInM1JTsRuU8qhE= -github.com/livekit/protocol v1.50.5-0.20260829061921-678f2494b3a7/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= +github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b h1:OOqCnFt5AZRVdSjYzaOzCR9KiB9IeeTPMGls+C1Ti2g= +github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= From 60e08cb19fd2dc40d9019a728a171768f6317b47 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 31 Aug 2026 15:29:34 +0800 Subject: [PATCH 007/130] update ice fork for warp (#4824) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 0647bb3d7..9c2d915b9 100644 --- a/go.mod +++ b/go.mod @@ -167,4 +167,4 @@ replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-w replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1 -replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.1 +replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.2 diff --git a/go.sum b/go.sum index 4be634370..78af2b233 100644 --- a/go.sum +++ b/go.sum @@ -158,8 +158,8 @@ github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2A github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= github.com/livekit/dtls/v3 v3.1.5-warp.1 h1:5jo2qQreDnUrgw6Al1F+9xVyXHvWQ2IaQkjF53FHEyU= github.com/livekit/dtls/v3 v3.1.5-warp.1/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= -github.com/livekit/ice/v4 v4.4.0-warp.1 h1:P9Y1f02VVx6kkRJxY3jzoS7HnblFbYkgMwiu6iOqCOU= -github.com/livekit/ice/v4 v4.4.0-warp.1/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= +github.com/livekit/ice/v4 v4.4.0-warp.2 h1:AYfwxksKNs2LGgLIifsrB/K4ixaQDuURxBgrA5dJBo4= +github.com/livekit/ice/v4 v4.4.0-warp.2/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= From 0aae7a4894006a37a535326d199835969e357780 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 1 Sep 2026 13:59:04 +0530 Subject: [PATCH 008/130] fix: hold signal messages until the ReconnectResponse goes out (#4827) * fix: hold signal messages until the ReconnectResponse goes out Clients take the ReconnectResponse as the first message on a resumed or migrated in signal connection, anything ahead of it is dropped. Hold messages back until it has been written. Co-Authored-By: Claude Opus 5 * fix: keep queued participant updates on a path that always flushes Queue only while the participant is not ready, that queue is always drained by the join or reconnect response. Log a dropped SDP, it leaves the negotiation waiting until the state machine recovers it. Co-Authored-By: Claude Opus 5 * fix: flush queued updates whenever the connection opens Queue participant updates while the handshake is pending again, and give the signaller a hook that fires when the connection opens, on an explicit open and on the handshake window expiring, so the queue always drains. Co-Authored-By: Claude Opus 5 * fix: drive the handshake window off a timer and read the gate atomically The window ran only when something asked whether the handshake was pending, so a connection with nothing else to send held its queue. Reading the gate under the lock the flush takes closes the race where an update queued just after a flush. Co-Authored-By: Claude Opus 5 * fix: ignore a handshake timeout from a wait that has ended Stop cannot cancel a timeout that is already running, so tag each wait and let a timeout act only on its own. Count opens atomically in the test, the timer fires on its own goroutine. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- pkg/rtc/participant.go | 14 +- pkg/rtc/participant_internal_test.go | 152 +++++++++++++++++ pkg/rtc/participant_signal.go | 58 ++++++- pkg/rtc/signalling/interfaces.go | 7 + pkg/rtc/signalling/signallerasync.go | 52 ++++-- pkg/rtc/signalling/signallerasyncbase.go | 106 ++++++++++++ pkg/rtc/signalling/signallerasyncbase_test.go | 157 ++++++++++++++++++ pkg/rtc/signalling/signallerunimplemented.go | 6 + 8 files changed, 532 insertions(+), 20 deletions(-) create mode 100644 pkg/rtc/signalling/signallerasyncbase_test.go diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index cdf7168de..17b07dac3 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -261,6 +261,10 @@ type ParticipantImpl struct { state atomic.Value // livekit.ParticipantInfo_State disconnected chan struct{} + // a migrating in participant resumes on a reconnect response, the client takes it + // only as the first message on the resumed signal connection + reconnectResponseSent atomic.Bool + grants atomic.Pointer[auth.ClaimGrants] isPublisher atomic.Bool @@ -612,9 +616,10 @@ func (p *ParticipantImpl) IsReady() bool { state := p.State() // when migrating, there is no JoinResponse, state transitions from JOINING -> ACTIVE -> DISCONNECTED - // so JOINING is considered ready. + // so JOINING is considered ready. The ReconnectResponse takes the place of the JoinResponse + // as the message the resumed signal connection opens with, so readiness waits for it. if p.params.Migration { - return state != livekit.ParticipantInfo_DISCONNECTED + return state != livekit.ParticipantInfo_DISCONNECTED && p.reconnectResponseSent.Load() } // when not migrating, there is a JoinResponse, state transitions from JOINING -> JOINED -> ACTIVE -> DISCONNECTED @@ -2071,8 +2076,9 @@ func (p *ParticipantImpl) setupSignalling() { Participant: p, }) p.signaller = signalling.NewSignallerAsync(signalling.SignallerAsyncParams{ - Logger: p.params.Logger, - Participant: p, + Logger: p.params.Logger, + Participant: p, + OnHandshakeOpened: p.flushQueuedUpdates, }) } diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 2cde9181c..3a32808ac 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -783,6 +783,7 @@ type participantOpts struct { publisher bool clientConf *livekit.ClientConfiguration clientInfo *livekit.ClientInfo + migration bool } func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *participantOpts) *ParticipantImpl { @@ -837,6 +838,7 @@ func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *p VersionGenerator: utils.NewDefaultTimedVersionGenerator(), ParticipantListener: &typesfakes.FakeLocalParticipantListener{}, ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{}, + Migration: opts.migration, }) p.isPublisher.Store(opts.publisher) p.updateState(livekit.ParticipantInfo_ACTIVE) @@ -895,3 +897,153 @@ func TestUnsequencedReliableDataBufferedWhileJoining(t *testing.T) { require.Empty(t, p.reliableDataInfo.joiningUnsequencedMessages) }) } + +func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) { + // a migrating in participant resumes on a ReconnectResponse, the client takes it only + // as the first message on the resumed signal connection, so everything else waits + newMigratingParticipant := func() (*ParticipantImpl, *routingfakes.FakeMessageSink) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + migration: true, + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "2.15.2"}, + }) + return p, p.params.Sink.(*routingfakes.FakeMessageSink) + } + + t.Run("not ready until the response is sent", func(t *testing.T) { + p, sink := newMigratingParticipant() + require.False(t, p.IsReady()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{LastMessageSeq: 21}, + )) + require.True(t, p.IsReady()) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, res.GetReconnect()) + require.EqualValues(t, 21, res.GetReconnect().LastMessageSeq) + }) + + t.Run("the response goes out first", func(t *testing.T) { + p, sink := newMigratingParticipant() + + // a room update while waiting is dropped, it is re-sent after the migration + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + // participant updates while waiting are queued + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{}, + )) + + require.Equal(t, 2, sink.WriteMessageCallCount()) + first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, first.GetReconnect()) + second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse) + require.NotNil(t, second.GetUpdate()) + require.Len(t, second.GetUpdate().Participants, 1) + require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid) + }) + + t.Run("a client that cannot handle the response is ready right away", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + migration: true, + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: "1.6.2"}, + }) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_UNKNOWN, + &livekit.ReconnectResponse{}, + )) + require.True(t, p.IsReady()) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + require.Equal(t, 1, sink.WriteMessageCallCount()) + }) +} + +func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) { + // a resumed connection has to open with the ReconnectResponse too, and the + // participant is ready throughout, so the signaller holds messages back + newResumedParticipant := func(version string) (*ParticipantImpl, *routingfakes.FakeMessageSink) { + p := newParticipantForTestWithOpts("test", &participantOpts{ + protocolVersion: 17, + clientInfo: &livekit.ClientInfo{Sdk: livekit.ClientInfo_JS, Version: version}, + }) + require.True(t, p.IsReady()) + + sink := &routingfakes.FakeMessageSink{} + p.SwapResponseSink(sink, types.SignallingCloseReasonResume) + return p, sink + } + + t.Run("the response goes out first", func(t *testing.T) { + p, sink := newResumedParticipant("2.15.2") + + // a room update in this window is dropped, the resume path re-sends room state + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + // participant updates are queued + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, + &livekit.ReconnectResponse{LastMessageSeq: 7}, + )) + + require.Equal(t, 2, sink.WriteMessageCallCount()) + first := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, first.GetReconnect()) + require.EqualValues(t, 7, first.GetReconnect().LastMessageSeq) + second := sink.WriteMessageArgsForCall(1).(*livekit.SignalResponse) + require.NotNil(t, second.GetUpdate()) + require.EqualValues(t, "PA_other", second.GetUpdate().Participants[0].Sid) + }) + + t.Run("a connection that opens without a response still delivers the queue", func(t *testing.T) { + p, sink := newResumedParticipant("2.15.2") + + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_other", + Identity: "other", + Version: 1, + }})) + require.Zero(t, sink.WriteMessageCallCount()) + + // no ReconnectResponse written, this is what the handshake window does on expiry + p.signaller.OpenHandshake() + + require.Equal(t, 1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + require.NotNil(t, res.GetUpdate()) + require.EqualValues(t, "PA_other", res.GetUpdate().Participants[0].Sid) + }) + + t.Run("a client that cannot handle the response is not held back", func(t *testing.T) { + p, sink := newResumedParticipant("1.6.2") + + require.NoError(t, p.HandleReconnectAndSendResponse( + livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, + &livekit.ReconnectResponse{}, + )) + require.Zero(t, sink.WriteMessageCallCount()) + + require.NoError(t, p.SendRoomUpdate(&livekit.Room{Name: "test"})) + require.Equal(t, 1, sink.WriteMessageCallCount()) + }) +} diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 8f5c3dd2b..57f03f305 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -81,7 +81,9 @@ func (p *ParticipantImpl) SendParticipantUpdate(participantsToUpdate []*livekit. return nil } - if !p.IsReady() { + // read under the lock the flush takes, so an update either queues before the flush + // or goes out after the connection has opened + if !p.IsReady() || p.signaller.HandshakePending() { // queue up updates p.queuedUpdates = append(p.queuedUpdates, participantsToUpdate...) p.updateLock.Unlock() @@ -177,11 +179,23 @@ func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit p.TransportManager.HandleClientReconnect(reconnectReason) if !p.params.ClientInfo.CanHandleReconnectResponse() { - return nil + // no ReconnectResponse opens this connection, so nothing to hold back + return p.reconnectResponseSentAndFlush() } - if err := p.signaller.WriteMessage(p.signalling.SignalReconnectResponse(reconnectResponse)); err != nil { + + // send reconnect response + err := p.signaller.WriteMessage(p.signalling.SignalReconnectResponse(reconnectResponse)) + + // mark sent after sending the message, so that nothing could slip through before + // ReconnectResponse is sent. Marked on a failed write too: the sink is gone in that + // case, and leaving it unmarked would hold back every message after it. + flushErr := p.reconnectResponseSentAndFlush() + if err != nil { return err } + if flushErr != nil { + return flushErr + } if p.params.ProtocolVersion.SupportsDisconnectedUpdate() { return p.sendDisconnectUpdatesForReconnect() @@ -190,6 +204,44 @@ func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit return nil } +// reconnectResponseSentAndFlush makes a migrating in participant ready and sends what was +// queued up while the connection was waiting for its ReconnectResponse. +func (p *ParticipantImpl) reconnectResponseSentAndFlush() error { + // a successful write opens the connection by itself, this covers the paths that do + // not write one, i. e. a client that cannot handle it and a failed write + p.signaller.OpenHandshake() + + p.updateLock.Lock() + p.reconnectResponseSent.Store(true) + queuedUpdates := p.queuedUpdates + p.queuedUpdates = nil + p.updateLock.Unlock() + + if len(queuedUpdates) > 0 { + return p.SendParticipantUpdate(queuedUpdates) + } + + return nil +} + +// flushQueuedUpdates sends the updates queued while the connection was closed for the +// handshake. Called when the connection opens, including when it opens without a +// ReconnectResponse having been written. +func (p *ParticipantImpl) flushQueuedUpdates() { + p.updateLock.Lock() + queuedUpdates := p.queuedUpdates + p.queuedUpdates = nil + p.updateLock.Unlock() + + if len(queuedUpdates) == 0 { + return + } + + if err := p.SendParticipantUpdate(queuedUpdates); err != nil { + p.params.Logger.Warnw("could not send queued participant updates", err) + } +} + func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { lastSignalAt := p.TransportManager.LastSeenSignalAt() var disconnectedParticipants []*livekit.ParticipantInfo diff --git a/pkg/rtc/signalling/interfaces.go b/pkg/rtc/signalling/interfaces.go index 99c3a0acc..2f8f59138 100644 --- a/pkg/rtc/signalling/interfaces.go +++ b/pkg/rtc/signalling/interfaces.go @@ -32,6 +32,13 @@ type ParticipantSignaller interface { GetResponseSink() routing.MessageSink CloseSignalConnection(reason types.SignallingCloseReason) + // HandshakePending reports whether a resumed connection is still waiting for the + // ReconnectResponse it has to open with + HandshakePending() bool + // OpenHandshake lets messages flow on a resumed connection that does not open with + // a ReconnectResponse + OpenHandshake() + WriteMessage(msg proto.Message) error } diff --git a/pkg/rtc/signalling/signallerasync.go b/pkg/rtc/signalling/signallerasync.go index 0dc0853ee..c82dbc582 100644 --- a/pkg/rtc/signalling/signallerasync.go +++ b/pkg/rtc/signalling/signallerasync.go @@ -31,8 +31,9 @@ import ( var _ ParticipantSignaller = (*signallerAsync)(nil) type SignallerAsyncParams struct { - Logger logger.Logger - Participant types.LocalParticipant + Logger logger.Logger + Participant types.LocalParticipant + OnHandshakeOpened func() } type signallerAsync struct { @@ -43,8 +44,11 @@ type signallerAsync struct { func NewSignallerAsync(params SignallerAsyncParams) ParticipantSignaller { return &signallerAsync{ - params: params, - signallerAsyncBase: newSignallerAsyncBase(signallerAsyncBaseParams{Logger: params.Logger}), + params: params, + signallerAsyncBase: newSignallerAsyncBase(signallerAsyncBaseParams{ + Logger: params.Logger, + OnHandshakeOpened: params.OnHandshakeOpened, + }), } } @@ -69,17 +73,35 @@ func (s *signallerAsync) WriteMessage(msg proto.Message) error { return nil } - if !s.params.Participant.IsReady() { - if typed, ok := msg.(*livekit.SignalResponse); !ok { - s.params.Logger.Warnw( - "unknown message type", nil, - "messageType", fmt.Sprintf("%T", msg), - ) - } else { - if typed.GetJoin() == nil { - return nil + // a signal connection opens with a join response, or with a reconnect response when + // it is resumed or migrated in. The client reads that first message as the handshake, + // so nothing may go out ahead of it. + isHandshake := false + isSdp := false + if typed, ok := msg.(*livekit.SignalResponse); !ok { + s.params.Logger.Warnw( + "unknown message type", nil, + "messageType", fmt.Sprintf("%T", msg), + ) + } else { + isHandshake = typed.GetJoin() != nil || typed.GetReconnect() != nil + isSdp = typed.GetOffer() != nil || typed.GetAnswer() != nil + } + + if !isHandshake && (!s.params.Participant.IsReady() || s.HandshakePending()) { + logFunc := s.params.Logger.Debugw + if isSdp { + // a dropped SDP leaves the negotiation waiting for the peer until the + // negotiation state machine recovers it, so make it visible + logFunc = func(msg string, keysAndValues ...any) { + s.params.Logger.Infow(msg, keysAndValues...) } } + logFunc( + "dropping message, connection has not opened yet", + "messageType", getMessageType(msg), + ) + return nil } sink := s.GetResponseSink() @@ -108,6 +130,10 @@ func (s *signallerAsync) WriteMessage(msg proto.Message) error { return err } } else { + if isHandshake { + // the connection is open, hold nothing back any more + s.OpenHandshake() + } s.params.Logger.Debugw("sent signal response", "response", logger.Proto(msg)) } return nil diff --git a/pkg/rtc/signalling/signallerasyncbase.go b/pkg/rtc/signalling/signallerasyncbase.go index 0b4d2b665..28bfc968d 100644 --- a/pkg/rtc/signalling/signallerasyncbase.go +++ b/pkg/rtc/signalling/signallerasyncbase.go @@ -16,6 +16,7 @@ package signalling import ( "sync" + "time" "github.com/livekit/protocol/logger" @@ -25,8 +26,16 @@ import ( type signallerAsyncBaseParams struct { Logger logger.Logger + // called when the connection opens, i. e. when messages held back for the + // handshake may be sent + OnHandshakeOpened func() } +// how long a resumed connection holds messages back waiting for its ReconnectResponse. +// A path that resumes without sending one lets messages flow after this instead of +// holding them back for the rest of the session. +const handshakeWindow = 5 * time.Second + type signallerAsyncBase struct { signallerUnimplemented @@ -34,6 +43,13 @@ type signallerAsyncBase struct { resSinkMu sync.Mutex resSink routing.MessageSink + // set while a resumed connection holds messages back waiting for its + // ReconnectResponse, the timer opens the connection if none is written. + // handshakeGeneration tells a timeout whether it belongs to the current wait, a + // Stop cannot cancel a callback that has already started running. + handshakePending bool + handshakeGeneration uint32 + handshakeTimer *time.Timer } func newSignallerAsyncBase(params signallerAsyncBaseParams) *signallerAsyncBase { @@ -46,8 +62,24 @@ func (s *signallerAsyncBase) SwapResponseSink(sink routing.MessageSink, reason t s.resSinkMu.Lock() oldSink := s.resSink s.resSink = sink + // a resumed connection has to open with the ReconnectResponse, the client takes it + // only as the first message it reads + opened := false + switch { + case sink == nil: + // the connection is gone, keep anything queued for the next one + s.disarmHandshakeLocked() + case reason == types.SignallingCloseReasonResume: + s.armHandshakeLocked() + default: + opened = s.disarmHandshakeLocked() + } s.resSinkMu.Unlock() + if opened { + s.notifyHandshakeOpened() + } + if oldSink != nil { if sink != nil { s.params.Logger.Debugw( @@ -67,6 +99,80 @@ func (s *signallerAsyncBase) SwapResponseSink(sink routing.MessageSink, reason t } } +// HandshakePending is a plain read, so a caller can decide to hold a message back +// while holding its own lock +func (s *signallerAsyncBase) HandshakePending() bool { + s.resSinkMu.Lock() + defer s.resSinkMu.Unlock() + + return s.handshakePending +} + +func (s *signallerAsyncBase) OpenHandshake() { + s.resSinkMu.Lock() + opened := s.disarmHandshakeLocked() + s.resSinkMu.Unlock() + + if opened { + s.notifyHandshakeOpened() + } +} + +func (s *signallerAsyncBase) armHandshakeLocked() { + s.stopHandshakeTimerLocked() + + s.handshakePending = true + s.handshakeGeneration++ + generation := s.handshakeGeneration + // the connection opens on its own if nothing writes a ReconnectResponse, a path + // that resumes without one should not hold messages back for the whole session + s.handshakeTimer = time.AfterFunc(handshakeWindow, func() { s.onHandshakeTimeout(generation) }) +} + +// disarmHandshakeLocked reports whether it opened a connection that was holding +// messages back +func (s *signallerAsyncBase) disarmHandshakeLocked() bool { + s.stopHandshakeTimerLocked() + + wasPending := s.handshakePending + s.handshakePending = false + // a timeout of the wait that just ended is stale + s.handshakeGeneration++ + return wasPending +} + +func (s *signallerAsyncBase) stopHandshakeTimerLocked() { + if s.handshakeTimer != nil { + s.handshakeTimer.Stop() + s.handshakeTimer = nil + } +} + +func (s *signallerAsyncBase) onHandshakeTimeout(generation uint32) { + s.resSinkMu.Lock() + if generation != s.handshakeGeneration { + // the wait this timeout was armed for has ended, a later one may be in progress + s.resSinkMu.Unlock() + return + } + opened := s.disarmHandshakeLocked() + s.resSinkMu.Unlock() + + if !opened { + return + } + + s.params.Logger.Warnw("resumed connection did not open with a ReconnectResponse", nil) + s.notifyHandshakeOpened() +} + +// notifyHandshakeOpened runs without resSinkMu held, the callback sends messages +func (s *signallerAsyncBase) notifyHandshakeOpened() { + if s.params.OnHandshakeOpened != nil { + s.params.OnHandshakeOpened() + } +} + func (s *signallerAsyncBase) GetResponseSink() routing.MessageSink { s.resSinkMu.Lock() defer s.resSinkMu.Unlock() diff --git a/pkg/rtc/signalling/signallerasyncbase_test.go b/pkg/rtc/signalling/signallerasyncbase_test.go new file mode 100644 index 000000000..1d91a4c7e --- /dev/null +++ b/pkg/rtc/signalling/signallerasyncbase_test.go @@ -0,0 +1,157 @@ +package signalling + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/atomic" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/routing/routingfakes" + "github.com/livekit/livekit-server/pkg/rtc/types" +) + +func newTestSignallerBase(onHandshakeOpened func()) *signallerAsyncBase { + return newSignallerAsyncBase(signallerAsyncBaseParams{ + Logger: logger.GetLogger(), + OnHandshakeOpened: onHandshakeOpened, + }) +} + +// currentHandshakeGeneration is what a timeout armed right now would be tagged with +func currentHandshakeGeneration(s *signallerAsyncBase) uint32 { + s.resSinkMu.Lock() + defer s.resSinkMu.Unlock() + + return s.handshakeGeneration +} + +func TestHandshakeGate(t *testing.T) { + t.Run("a resumed connection holds messages back", func(t *testing.T) { + s := newTestSignallerBase(nil) + require.False(t, s.HandshakePending()) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.OpenHandshake() + require.False(t, s.HandshakePending()) + }) + + t.Run("other sink swaps do not hold messages back", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown) + require.False(t, s.HandshakePending()) + }) + + t.Run("closing the connection clears the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose) + require.False(t, s.HandshakePending()) + }) + + t.Run("the handshake window opens the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + require.False(t, s.HandshakePending()) + }) + + t.Run("a swap to a fresh connection opens the gate", func(t *testing.T) { + s := newTestSignallerBase(nil) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.True(t, s.HandshakePending()) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonUnknown) + require.False(t, s.HandshakePending()) + }) + + t.Run("a timeout of a wait that has ended does nothing", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + stale := currentHandshakeGeneration(s) + + // the client resumes again while the first timeout is running, Stop cannot + // cancel a callback that has already started + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.onHandshakeTimeout(stale) + + require.True(t, s.HandshakePending()) + require.Zero(t, opened.Load()) + + // and the wait in progress still opens on its own timeout + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + require.False(t, s.HandshakePending()) + require.EqualValues(t, 1, opened.Load()) + }) +} + +func TestHandshakeGateNotifiesOnOpen(t *testing.T) { + t.Run("on an explicit open", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.Zero(t, opened.Load()) + + s.OpenHandshake() + require.EqualValues(t, 1, opened.Load()) + + // only the transition notifies + s.OpenHandshake() + require.EqualValues(t, 1, opened.Load()) + }) + + t.Run("on the handshake window expiring", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.onHandshakeTimeout(currentHandshakeGeneration(s)) + + require.False(t, s.HandshakePending()) + require.EqualValues(t, 1, opened.Load()) + }) + + t.Run("the window fires without anything else touching the gate", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + require.Eventually(t, func() bool { + return !s.HandshakePending() && opened.Load() == 1 + }, 2*handshakeWindow, 100*time.Millisecond) + }) + + t.Run("not on a connection close", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.SwapResponseSink(&routingfakes.FakeMessageSink{}, types.SignallingCloseReasonResume) + s.CloseSignalConnection(types.SignallingCloseReasonParticipantClose) + + require.False(t, s.HandshakePending()) + require.Zero(t, opened.Load()) + }) + + t.Run("not when the gate was never armed", func(t *testing.T) { + var opened atomic.Int32 + s := newTestSignallerBase(func() { opened.Inc() }) + + s.OpenHandshake() + require.Zero(t, opened.Load()) + }) +} diff --git a/pkg/rtc/signalling/signallerunimplemented.go b/pkg/rtc/signalling/signallerunimplemented.go index 3592fd857..ae160a74f 100644 --- a/pkg/rtc/signalling/signallerunimplemented.go +++ b/pkg/rtc/signalling/signallerunimplemented.go @@ -34,6 +34,12 @@ func (u *signallerUnimplemented) GetResponseSink() routing.MessageSink { func (u *signallerUnimplemented) CloseSignalConnection(reason types.SignallingCloseReason) {} +func (u *signallerUnimplemented) HandshakePending() bool { + return false +} + +func (u *signallerUnimplemented) OpenHandshake() {} + func (u *signallerUnimplemented) WriteMessage(msg proto.Message) error { return nil } From 91f33da004ff46c9449437948411de89646bee8b Mon Sep 17 00:00:00 2001 From: Benjamin Pracht Date: Tue, 1 Sep 2026 12:11:14 -0700 Subject: [PATCH 009/130] ingress: add opt-in support for udp:// URL pull ingress (#4810) * ingress: add opt-in support for udp:// URL pull ingress URL pull ingress previously accepted only http, https and srt source URLs. Add udp:// as an accepted scheme, gated behind a new `ingress.enable_udp_url_pull` config option that defaults to false, so unauthenticated UDP sources are not reachable unless the operator opts in. Also add a counterfeiter fake for IngressLauncher and a test covering the scheme validation matrix. The ingress handler binds a local socket on the caller supplied address and port instead of connecting out like the http and srt sources do. Spell out what that means for operators in both the config field doc comment and config-sample.yaml: caller controlled local port binding, unauthenticated and spoofable input, and multicast relaying of traffic on the handler's local network. Co-authored-by: Claude Opus 5 (1M context) --- config-sample.yaml | 9 ++ pkg/config/config.go | 13 ++ pkg/service/ingress.go | 14 ++- pkg/service/ingress_test.go | 96 ++++++++++++++ .../servicefakes/fake_ingress_launcher.go | 118 ++++++++++++++++++ 5 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 pkg/service/ingress_test.go create mode 100644 pkg/service/servicefakes/fake_ingress_launcher.go diff --git a/config-sample.yaml b/config-sample.yaml index 5c1c50a42..f07299972 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -337,6 +337,15 @@ keys: # rtmp_base_url: "rtmp://my.domain.com/live" # # Prefix used to generate WHIP URLs for WHIP ingress. # whip_base_url: "http://my.domain.com/whip" +# # Allow URL pull ingress from udp:// source URLs. Disabled by default. +# # Only enable this if you trust both the callers allowed to create ingresses and the network the +# # ingress handlers run on. Unlike an http or srt source url, a udp source url doesn't make the +# # handler connect out to the url host: the handler binds a local socket on the address and port +# # taken from the url, and joins the multicast group if one is given. This lets the caller choose +# # which local port the handler binds, feed the session unauthenticated and easily spoofed traffic, +# # and make the handler join arbitrary multicast groups and republish whatever it receives into a +# # room, using the ingress as a relay for streams on the handler's local network. +# enable_udp_url_pull: false # Region of the current node. Required if using regionaware node selector # region: us-west-2 diff --git a/pkg/config/config.go b/pkg/config/config.go index 2ef6a3efe..b46756adb 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -439,6 +439,19 @@ func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *liveki type IngressConfig struct { RTMPBaseURL string `yaml:"rtmp_base_url,omitempty"` WHIPBaseURL string `yaml:"whip_base_url,omitempty"` + // Allow URL pull ingresses with a udp:// source URL. Disabled by default, and should only be + // enabled if both the callers allowed to create ingresses and the network the ingress handlers + // run on are trusted. Unlike an http or srt source url, a udp source url doesn't make the ingress + // handler connect out to the url host: the handler binds a local socket on the address and port + // taken from the url, and joins the multicast group if one is given. This lets the caller: + // - Choose which local port the handler binds, potentially colliding with other services on + // the host. + // - Feed the session unauthenticated traffic. UDP is connectionless, so any host able to reach + // that port can inject media, or spoof the sender address to disrupt a legitimate feed. + // - Make the handler join arbitrary multicast groups and republish whatever it receives into a + // room, using the ingress as a relay for streams on the handler's local network the caller has + // no direct access to. + EnableUDPURLPull bool `yaml:"enable_udp_url_pull,omitempty"` } type SIPConfig struct{} diff --git a/pkg/service/ingress.go b/pkg/service/ingress.go index 6441756fb..528309c68 100644 --- a/pkg/service/ingress.go +++ b/pkg/service/ingress.go @@ -19,8 +19,6 @@ import ( "fmt" "net/url" - "github.com/livekit/livekit-server/pkg/config" - "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/protocol/ingress" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -28,8 +26,12 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/telemetry" ) +//counterfeiter:generate . IngressLauncher type IngressLauncher interface { LaunchPullIngress(ctx context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error) } @@ -133,7 +135,13 @@ func (s *IngressService) CreateIngressWithUrl(ctx context.Context, urlStr string if err != nil { return nil, psrpc.NewError(psrpc.InvalidArgument, err) } - if urlObj.Scheme != "http" && urlObj.Scheme != "https" && urlObj.Scheme != "srt" { + switch urlObj.Scheme { + case "http", "https", "srt": + case "udp": + if !s.conf.EnableUDPURLPull { + return nil, ingress.ErrInvalidIngress("udp url pull is not enabled") + } + default: return nil, ingress.ErrInvalidIngress(fmt.Sprintf("invalid url scheme %s", urlObj.Scheme)) } // Marshall the URL again for sanitization diff --git a/pkg/service/ingress_test.go b/pkg/service/ingress_test.go new file mode 100644 index 000000000..86d29d2e2 --- /dev/null +++ b/pkg/service/ingress_test.go @@ -0,0 +1,96 @@ +// 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 service_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/service/servicefakes" +) + +func TestCreateURLPullIngressScheme(t *testing.T) { + newService := func(enableUDP bool) (*service.IngressService, *servicefakes.FakeIngressLauncher) { + launcher := &servicefakes.FakeIngressLauncher{} + launcher.LaunchPullIngressCalls(func(_ context.Context, info *livekit.IngressInfo) (*livekit.IngressInfo, error) { + return info, nil + }) + + svc := service.NewIngressServiceWithIngressLauncher( + &config.IngressConfig{EnableUDPURLPull: enableUDP}, + "nodeID", + nil, + nil, + &servicefakes.FakeIngressStore{}, + nil, + nil, + launcher, + ) + return svc, launcher + } + + adminCtx := func() context.Context { + return service.WithGrants(context.Background(), &auth.ClaimGrants{Video: &auth.VideoGrant{IngressAdmin: true}}, "") + } + + createReq := func(url string) *livekit.CreateIngressRequest { + return &livekit.CreateIngressRequest{ + InputType: livekit.IngressInput_URL_INPUT, + Url: url, + RoomName: "testroom", + ParticipantIdentity: "ingress", + } + } + + t.Run("udp rejected when disabled", func(t *testing.T) { + svc, launcher := newService(false) + + _, err := svc.CreateIngress(adminCtx(), createReq("udp://1.2.3.4:1234")) + require.Error(t, err) + require.Contains(t, err.Error(), "udp url pull is not enabled") + require.Zero(t, launcher.LaunchPullIngressCallCount()) + }) + + t.Run("udp accepted when enabled", func(t *testing.T) { + svc, launcher := newService(true) + + info, err := svc.CreateIngress(adminCtx(), createReq("udp://1.2.3.4:1234")) + require.NoError(t, err) + require.Equal(t, "udp://1.2.3.4:1234", info.Url) + require.Equal(t, 1, launcher.LaunchPullIngressCallCount()) + }) + + t.Run("other schemes unaffected by the udp option", func(t *testing.T) { + for _, url := range []string{"http://example.com/live", "https://example.com/live", "srt://1.2.3.4:1234"} { + svc, _ := newService(false) + + info, err := svc.CreateIngress(adminCtx(), createReq(url)) + require.NoError(t, err, url) + require.Equal(t, url, info.Url) + } + + svc, _ := newService(true) + _, err := svc.CreateIngress(adminCtx(), createReq("rtsp://1.2.3.4/live")) + require.Error(t, err) + require.Contains(t, err.Error(), "invalid url scheme rtsp") + }) +} diff --git a/pkg/service/servicefakes/fake_ingress_launcher.go b/pkg/service/servicefakes/fake_ingress_launcher.go new file mode 100644 index 000000000..06ed69e6b --- /dev/null +++ b/pkg/service/servicefakes/fake_ingress_launcher.go @@ -0,0 +1,118 @@ +// Code generated by counterfeiter. DO NOT EDIT. +package servicefakes + +import ( + "context" + "sync" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/livekit" +) + +type FakeIngressLauncher struct { + LaunchPullIngressStub func(context.Context, *livekit.IngressInfo) (*livekit.IngressInfo, error) + launchPullIngressMutex sync.RWMutex + launchPullIngressArgsForCall []struct { + arg1 context.Context + arg2 *livekit.IngressInfo + } + launchPullIngressReturns struct { + result1 *livekit.IngressInfo + result2 error + } + launchPullIngressReturnsOnCall map[int]struct { + result1 *livekit.IngressInfo + result2 error + } + invocations map[string][][]interface{} + invocationsMutex sync.RWMutex +} + +func (fake *FakeIngressLauncher) LaunchPullIngress(arg1 context.Context, arg2 *livekit.IngressInfo) (*livekit.IngressInfo, error) { + fake.launchPullIngressMutex.Lock() + ret, specificReturn := fake.launchPullIngressReturnsOnCall[len(fake.launchPullIngressArgsForCall)] + fake.launchPullIngressArgsForCall = append(fake.launchPullIngressArgsForCall, struct { + arg1 context.Context + arg2 *livekit.IngressInfo + }{arg1, arg2}) + stub := fake.LaunchPullIngressStub + fakeReturns := fake.launchPullIngressReturns + fake.recordInvocation("LaunchPullIngress", []interface{}{arg1, arg2}) + fake.launchPullIngressMutex.Unlock() + if stub != nil { + return stub(arg1, arg2) + } + if specificReturn { + return ret.result1, ret.result2 + } + return fakeReturns.result1, fakeReturns.result2 +} + +func (fake *FakeIngressLauncher) LaunchPullIngressCallCount() int { + fake.launchPullIngressMutex.RLock() + defer fake.launchPullIngressMutex.RUnlock() + return len(fake.launchPullIngressArgsForCall) +} + +func (fake *FakeIngressLauncher) LaunchPullIngressCalls(stub func(context.Context, *livekit.IngressInfo) (*livekit.IngressInfo, error)) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = stub +} + +func (fake *FakeIngressLauncher) LaunchPullIngressArgsForCall(i int) (context.Context, *livekit.IngressInfo) { + fake.launchPullIngressMutex.RLock() + defer fake.launchPullIngressMutex.RUnlock() + argsForCall := fake.launchPullIngressArgsForCall[i] + return argsForCall.arg1, argsForCall.arg2 +} + +func (fake *FakeIngressLauncher) LaunchPullIngressReturns(result1 *livekit.IngressInfo, result2 error) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = nil + fake.launchPullIngressReturns = struct { + result1 *livekit.IngressInfo + result2 error + }{result1, result2} +} + +func (fake *FakeIngressLauncher) LaunchPullIngressReturnsOnCall(i int, result1 *livekit.IngressInfo, result2 error) { + fake.launchPullIngressMutex.Lock() + defer fake.launchPullIngressMutex.Unlock() + fake.LaunchPullIngressStub = nil + if fake.launchPullIngressReturnsOnCall == nil { + fake.launchPullIngressReturnsOnCall = make(map[int]struct { + result1 *livekit.IngressInfo + result2 error + }) + } + fake.launchPullIngressReturnsOnCall[i] = struct { + result1 *livekit.IngressInfo + result2 error + }{result1, result2} +} + +func (fake *FakeIngressLauncher) Invocations() map[string][][]interface{} { + fake.invocationsMutex.RLock() + defer fake.invocationsMutex.RUnlock() + copiedInvocations := map[string][][]interface{}{} + for key, value := range fake.invocations { + copiedInvocations[key] = value + } + return copiedInvocations +} + +func (fake *FakeIngressLauncher) recordInvocation(key string, args []interface{}) { + fake.invocationsMutex.Lock() + defer fake.invocationsMutex.Unlock() + if fake.invocations == nil { + fake.invocations = map[string][][]interface{}{} + } + if fake.invocations[key] == nil { + fake.invocations[key] = [][]interface{}{} + } + fake.invocations[key] = append(fake.invocations[key], args) +} + +var _ service.IngressLauncher = new(FakeIngressLauncher) From 4645bca189814016384845c7b0bfc73d462a1c94 Mon Sep 17 00:00:00 2001 From: Jacob Gelman <3182119+ladvoc@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:02:04 -0700 Subject: [PATCH 010/130] Move data track packet serialization to protocol (#4829) * Pin protocol to data track serialization branch * Use datatrack package from protocol repo * Update protocol sha --- go.mod | 2 +- go.sum | 4 +- pkg/rtc/datadowntrack.go | 2 +- pkg/rtc/datatrack.go | 2 +- .../datatrack/extension_participant_sid.go | 59 ---- .../extension_participant_sid_test.go | 46 --- pkg/rtc/datatrack/packet.go | 310 ------------------ pkg/rtc/datatrack/packet_test.go | 294 ----------------- pkg/rtc/datatrack/testutils.go | 74 ----- pkg/rtc/datatrack_stats.go | 2 +- pkg/rtc/participant_data_track.go | 2 +- pkg/rtc/room.go | 2 +- pkg/rtc/types/interfaces.go | 2 +- pkg/rtc/types/typesfakes/fake_data_track.go | 2 +- .../typesfakes/fake_data_track_sender.go | 2 +- .../typesfakes/fake_local_participant.go | 2 +- .../fake_local_participant_listener.go | 2 +- pkg/rtc/types/typesfakes/fake_participant.go | 2 +- .../typesfakes/fake_participant_listener.go | 2 +- pkg/rtc/updatatrackmanager.go | 2 +- test/client/client.go | 2 +- test/client/datatrack_remote.go | 2 +- test/client/datatrack_writer.go | 5 +- 23 files changed, 21 insertions(+), 803 deletions(-) delete mode 100644 pkg/rtc/datatrack/extension_participant_sid.go delete mode 100644 pkg/rtc/datatrack/extension_participant_sid_test.go delete mode 100644 pkg/rtc/datatrack/packet.go delete mode 100644 pkg/rtc/datatrack/packet_test.go delete mode 100644 pkg/rtc/datatrack/testutils.go diff --git a/go.mod b/go.mod index 9c2d915b9..04ce5dd4a 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b + github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0 github.com/livekit/psrpc v0.7.5 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 78af2b233..515944102 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b h1:OOqCnFt5AZRVdSjYzaOzCR9KiB9IeeTPMGls+C1Ti2g= -github.com/livekit/protocol v1.50.5-0.20260829123501-a469dd43727b/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= +github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0 h1:HdcExqttH0EkDNyrVf7dy4McuLjuUdztWTF0a7I0ans= +github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= diff --git a/pkg/rtc/datadowntrack.go b/pkg/rtc/datadowntrack.go index 45f5e19a8..a1b0fdc35 100644 --- a/pkg/rtc/datadowntrack.go +++ b/pkg/rtc/datadowntrack.go @@ -19,8 +19,8 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) diff --git a/pkg/rtc/datatrack.go b/pkg/rtc/datatrack.go index 2f3629b47..1d93c296d 100644 --- a/pkg/rtc/datatrack.go +++ b/pkg/rtc/datatrack.go @@ -21,9 +21,9 @@ import ( "github.com/frostbyte73/core" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" sfuutils "github.com/livekit/livekit-server/pkg/sfu/utils" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" diff --git a/pkg/rtc/datatrack/extension_participant_sid.go b/pkg/rtc/datatrack/extension_participant_sid.go deleted file mode 100644 index 35ada18f1..000000000 --- a/pkg/rtc/datatrack/extension_participant_sid.go +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "errors" - - "github.com/livekit/protocol/livekit" -) - -type ExtensionParticipantSid struct { - participantID livekit.ParticipantID -} - -func NewExtensionParticipantSid(participantID livekit.ParticipantID) (*ExtensionParticipantSid, error) { - if len(participantID) >= 256 { - return nil, errors.New("participantID too long") - } - - return &ExtensionParticipantSid{participantID}, nil -} - -func (e *ExtensionParticipantSid) ParticipantID() livekit.ParticipantID { - return e.participantID -} - -func (e *ExtensionParticipantSid) Marshal() (Extension, error) { - data := make([]byte, len(e.participantID)) - copy(data, e.participantID) - return Extension{ - id: uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID), - data: data, - }, nil -} - -func (e *ExtensionParticipantSid) Unmarshal(ext Extension) error { - if ext.id != uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID) { - return errors.New("invalid extension ID") - } - - if len(ext.data) == 0 { - return errors.New("empty extension data") - } - - e.participantID = livekit.ParticipantID(ext.data) - return nil -} diff --git a/pkg/rtc/datatrack/extension_participant_sid_test.go b/pkg/rtc/datatrack/extension_participant_sid_test.go deleted file mode 100644 index 7128e8bdc..000000000 --- a/pkg/rtc/datatrack/extension_participant_sid_test.go +++ /dev/null @@ -1,46 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "testing" - - "github.com/livekit/protocol/livekit" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestExtensionParticipantSid(t *testing.T) { - longTestParticipantID := livekit.ParticipantID(make([]byte, 256)) - _, err := NewExtensionParticipantSid(longTestParticipantID) - require.Error(t, err) - - testParticipantID := livekit.ParticipantID("test") - extParticipantSid, err := NewExtensionParticipantSid(testParticipantID) - require.NoError(t, err) - - expectedExt := Extension{ - id: uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID), - data: []byte{'t', 'e', 's', 't'}, - } - ext, err := extParticipantSid.Marshal() - require.NoError(t, err) - require.Equal(t, expectedExt, ext) - - var unmarshaled ExtensionParticipantSid - err = unmarshaled.Unmarshal(ext) - require.NoError(t, err) - assert.Equal(t, testParticipantID, unmarshaled.ParticipantID()) -} diff --git a/pkg/rtc/datatrack/packet.go b/pkg/rtc/datatrack/packet.go deleted file mode 100644 index 42fe1eef2..000000000 --- a/pkg/rtc/datatrack/packet.go +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "encoding/binary" - "errors" - "fmt" -) - -var ( - errHeaderSizeInsufficient = errors.New("data track packet header size insufficient") - errBufferSizeInsufficient = errors.New("data track packet buffer size insufficient") - errExtensionSizeInsufficient = errors.New("data track packet extension size insufficient") - errExtensionNotFound = errors.New("data track packet extension not found") - errExtensionSizeTooBig = errors.New("extension size is too big") -) - -const ( - headerLength = 12 - - versionShift = 5 - versionMask = (1 << 3) - 1 - - startOfFrameShift = 4 - startOfFrameMask = (1 << 1) - 1 - - finalOfFrameShift = 3 - finalOfFrameMask = (1 << 1) - 1 - - extensionsShift = 2 - extensionsMask = (1 << 1) - 1 - - handleOffset = 2 - handleLength = 2 - - seqNumOffset = 4 - seqNumLength = 2 - - frameNumOffset = 6 - frameNumLength = 2 - - timestampOffset = 8 - timestampLength = 4 - - extensionsSizeOffset = headerLength - extensionsSizeLength = 2 - - extensionIDLength = 1 - extensionSizeLength = 1 -) - -type Extension struct { - id uint8 - data []byte -} - -type Header struct { - Version uint8 - IsStartOfFrame bool - IsFinalOfFrame bool - HasExtensions bool - Handle uint16 - SequenceNumber uint16 - FrameNumber uint16 - Timestamp uint32 - ExtensionsSize uint16 - Extensions []Extension -} - -/* - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* 0 1 2 3 - ┆* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* |V |S|F|X| reserved | handle | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | sequence number | frame number | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | timestamp | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |* Extensions Size if X=1 | Extensions... | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - Each extension - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* 0 1 2 3 - ┆* 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - ┆* | Extension ID | Extension size| Extension payload | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - - End of all extensions - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |* padded to 4 byte boundary if aggregate of `Extensions Size` | - |* field and all extensions do not end on a 4 byte boundary | - ┆* +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ -*/ - -func (h *Header) Unmarshal(buf []byte) (int, error) { - if len(buf) < headerLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), headerLength) - } - - hdrSize := headerLength - h.Version = buf[0] >> versionShift & versionMask - h.IsStartOfFrame = (buf[0] >> startOfFrameShift & startOfFrameMask) > 0 - h.IsFinalOfFrame = (buf[0] >> finalOfFrameShift & finalOfFrameMask) > 0 - h.HasExtensions = (buf[0] >> extensionsShift & extensionsMask) > 0 - - h.Handle = binary.BigEndian.Uint16(buf[handleOffset : handleOffset+handleLength]) - h.SequenceNumber = binary.BigEndian.Uint16(buf[seqNumOffset : seqNumOffset+seqNumLength]) - h.FrameNumber = binary.BigEndian.Uint16(buf[frameNumOffset : frameNumOffset+frameNumLength]) - h.Timestamp = binary.BigEndian.Uint32(buf[timestampOffset : timestampOffset+timestampLength]) - - if h.HasExtensions { - if len(buf) < extensionsSizeOffset+extensionsSizeLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), extensionsSizeOffset+extensionsSizeLength) - } - extensionsSize := (int(binary.BigEndian.Uint16(buf[extensionsSizeOffset:extensionsSizeOffset+extensionsSizeLength]))+1)*4 - extensionsSizeLength - hdrSize += extensionsSizeLength - - extensionHeaderSize := extensionIDLength + extensionSizeLength - remainingSize := extensionsSize - idx := extensionsSizeOffset + extensionsSizeLength - for remainingSize != 0 { - // read extension header - if len(buf[idx:]) < extensionIDLength || remainingSize < extensionIDLength { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), extensionIDLength) - } - id := buf[idx] - if id == 0 { - // end of extensions, padding has started - if len(buf[idx:]) < remainingSize { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), remainingSize) - } - hdrSize += remainingSize - break - } - - if len(buf[idx+1:]) < extensionSizeLength || remainingSize < extensionSizeLength { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), extensionSizeLength) - } - size := int(buf[idx+1]) - - remainingSize -= extensionHeaderSize - idx += extensionHeaderSize - hdrSize += extensionHeaderSize - - // read extension data - if len(buf[idx:]) < size || remainingSize < size { - return 0, fmt.Errorf("%w: %d/%d < %d", errExtensionSizeInsufficient, remainingSize, len(buf[idx:]), size) - } - h.Extensions = append(h.Extensions, Extension{id: id, data: buf[idx : idx+size]}) - - remainingSize -= size - idx += size - hdrSize += size - } - h.ExtensionsSize = uint16(extensionsSize - remainingSize) - } - - return hdrSize, nil -} - -func (h *Header) MarshalSize() int { - extensionsSize := 0 - if h.HasExtensions { - extensionsSize += extensionsSizeLength - for _, ext := range h.Extensions { - extensionsSize += len(ext.data) + extensionIDLength + extensionSizeLength - } - } - - return headerLength + (extensionsSize+3)/4*4 -} - -func (h *Header) MarshalTo(buf []byte) (int, error) { - if len(buf) < headerLength { - return 0, fmt.Errorf("%w: %d < %d", errHeaderSizeInsufficient, len(buf), headerLength) - } - - hdrSize := headerLength - buf[0] = h.Version << versionShift - if h.IsStartOfFrame { - buf[0] |= (1 << startOfFrameShift) - } - if h.IsFinalOfFrame { - buf[0] |= (1 << finalOfFrameShift) - } - if h.HasExtensions { - buf[0] |= (1 << extensionsShift) - } - - binary.BigEndian.PutUint16(buf[handleOffset:handleOffset+handleLength], h.Handle) - binary.BigEndian.PutUint16(buf[seqNumOffset:seqNumOffset+seqNumLength], h.SequenceNumber) - binary.BigEndian.PutUint16(buf[frameNumOffset:frameNumOffset+frameNumLength], h.FrameNumber) - binary.BigEndian.PutUint32(buf[timestampOffset:timestampOffset+timestampLength], h.Timestamp) - - if h.HasExtensions { - extensionsSize := (extensionsSizeLength + h.ExtensionsSize + 3) / 4 * 4 - binary.BigEndian.PutUint16(buf[extensionsSizeOffset:extensionsSizeOffset+extensionsSizeLength], (extensionsSize/4)-1) - hdrSize += extensionsSizeLength - - addedSize := 0 - idx := extensionsSizeOffset + extensionsSizeLength - for _, ext := range h.Extensions { - buf[idx] = ext.id - if len(ext.data) > 255 { - return 0, fmt.Errorf("%w: %d > 255", errExtensionSizeTooBig, len(ext.data)) - } - buf[idx+extensionIDLength] = byte(len(ext.data)) - copy(buf[idx+extensionIDLength+extensionSizeLength:], ext.data) - - extSize := len(ext.data) + extensionIDLength + extensionSizeLength - idx += extSize - hdrSize += extSize - addedSize += extSize - } - - paddingSize := extensionsSize - extensionsSizeLength - uint16(addedSize) - for i := range paddingSize { - buf[idx+int(i)] = 0 - } - idx += int(paddingSize) - hdrSize += int(paddingSize) - } - - return hdrSize, nil -} - -func (h *Header) AddExtension(ext Extension) { - for i, existingExt := range h.Extensions { - if existingExt.id == ext.id { - h.ExtensionsSize -= uint16(len(existingExt.data) + extensionIDLength + extensionSizeLength) - h.Extensions[i].data = ext.data - h.ExtensionsSize += uint16(len(h.Extensions[i].data) + extensionIDLength + extensionSizeLength) - return - } - } - - h.Extensions = append(h.Extensions, ext) - h.ExtensionsSize += uint16(len(ext.data) + extensionIDLength + extensionSizeLength) - h.HasExtensions = true -} - -func (h *Header) GetExtension(id uint8) (Extension, error) { - for _, ext := range h.Extensions { - if ext.id == id { - return ext, nil - } - } - return Extension{}, fmt.Errorf("%w, id: %d", errExtensionNotFound, id) -} - -// ---------------------------------------------------- - -type Packet struct { - Header - Payload []byte -} - -func (p *Packet) Unmarshal(buf []byte) error { - hdrSize, err := p.Header.Unmarshal(buf) - if err != nil { - return err - } - if hdrSize > len(buf) { - return fmt.Errorf("%w: %d < %d", errBufferSizeInsufficient, len(buf), hdrSize) - } - - p.Payload = buf[hdrSize:] - return nil -} - -func (p *Packet) Marshal() ([]byte, error) { - buf := make([]byte, p.Header.MarshalSize()+len(p.Payload)) - if err := p.MarshalTo(buf); err != nil { - return nil, err - } - - return buf, nil -} - -func (p *Packet) MarshalTo(buf []byte) error { - size := p.Header.MarshalSize() + len(p.Payload) - if len(buf) < size { - return fmt.Errorf("%w: %d < %d", errBufferSizeInsufficient, len(buf), size) - } - - hdrSize, err := p.Header.MarshalTo(buf) - if err != nil { - return err - } - - copy(buf[hdrSize:], p.Payload) - return nil -} diff --git a/pkg/rtc/datatrack/packet_test.go b/pkg/rtc/datatrack/packet_test.go deleted file mode 100644 index 8f1bc672b..000000000 --- a/pkg/rtc/datatrack/packet_test.go +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "testing" - - "github.com/livekit/protocol/livekit" - "github.com/stretchr/testify/require" -) - -func TestPacket(t *testing.T) { - t.Run("without extension", func(t *testing.T) { - payload := make([]byte, 6) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: true, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x18, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0xff, 0xfe, 0xfd, 0xfc, - 0xfb, 0xfa, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - }) - - t.Run("with extension", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x04, 0x01, 0x10, - 0x74, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x72, - 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, - 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("test_participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("with extension padding", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("replace extension", func(t *testing.T) { - payload := make([]byte, 4) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: true, - IsFinalOfFrame: false, - Handle: 3333, - SequenceNumber: 6666, - FrameNumber: 9999, - Timestamp: 0xdeadbeef, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - require.NoError(t, err) - - expectedRawPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - // replace existing extension ID and ensure that marshalled packet is updated - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err = packet.Marshal() - require.NoError(t, err) - - expectedRawPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x04, 0x01, 0x10, - 0x74, 0x65, 0x73, 0x74, 0x5f, 0x70, 0x61, 0x72, - 0x74, 0x69, 0x63, 0x69, 0x70, 0x61, 0x6e, 0x74, - 0xff, 0xfe, 0xfd, 0xfc, - } - require.Equal(t, expectedRawPacket, rawPacket) - - var unmarshaled Packet - err = unmarshaled.Unmarshal(rawPacket) - require.NoError(t, err) - require.Equal(t, packet, &unmarshaled) - - ext, err := unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.NoError(t, err) - - var extParticipantSid ExtensionParticipantSid - require.NoError(t, extParticipantSid.Unmarshal(ext)) - require.Equal(t, livekit.ParticipantID("test_participant"), extParticipantSid.ParticipantID()) - }) - - t.Run("bad packet", func(t *testing.T) { - var unmarshaled Packet - // extensions size too small - badPacket := []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x02, 0x01, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - - // get an invalid extension id - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x02, 0x0b, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.NoError(t, err) - _, err = unmarshaled.GetExtension(uint8(livekit.DataTrackExtensionID_DTEI_PARTICIPANT_SID)) - require.Error(t, err) - - // extension payload size bigger than payload - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x0d, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - - // extension payload size smaller than payload - badPacket = []byte{ - 0x14, 0x00, 0x0d, 0x05, 0x1a, 0x0a, 0x27, 0x0f, - 0xde, 0xad, 0xbe, 0xef, 0x00, 0x03, 0x01, 0x07, - 0x70, 0x61, 0x72, 0x74, 0x69, 0x63, 0x69, 0x70, - 0x61, 0x6e, 0x74, 0x00, 0xff, 0xfe, 0xfd, 0xfc, - } - err = unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("oversized extension padding does not panic", func(t *testing.T) { - var unmarshaled Packet - // HasExtensions set, extensionsSize describes more bytes than present, - // terminated by a 0x00 padding id -> hdrSize would exceed len(buf) - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("extensions size wraparound does not panic", func(t *testing.T) { - var unmarshaled Packet - // 0xFFFF extensions-size field wraps (raw+1)*4 uint16 arithmetic to a huge - // remainingSize; the 0x00 padding id must not push hdrSize past len(buf) - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) - - t.Run("truncated extensions size field does not panic", func(t *testing.T) { - var unmarshaled Packet - // HasExtensions set but buffer too short to hold the extensionsSize field - badPacket := []byte{ - 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, - } - err := unmarshaled.Unmarshal(badPacket) - require.Error(t, err) - }) -} diff --git a/pkg/rtc/datatrack/testutils.go b/pkg/rtc/datatrack/testutils.go deleted file mode 100644 index e7df0bbd9..000000000 --- a/pkg/rtc/datatrack/testutils.go +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright 2023 LiveKit, Inc. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package datatrack - -import ( - "math/rand" - "time" -) - -func GenerateRawDataPackets(handle uint16, seqNum uint16, frameNum uint16, numFrames int, frameSize int, frameDuration time.Duration) [][]byte { - if seqNum == 0 { - seqNum = uint16(rand.Intn(256) + 1) - } - if frameNum == 0 { - frameNum = uint16(rand.Intn(256) + 1) - } - timestamp := uint32(rand.Intn(1024)) - - packetsPerFrame := (frameSize + 255) / 256 // using 256 bytes of payload per packet - if packetsPerFrame == 0 { - return nil - } - numPackets := packetsPerFrame * numFrames - rawPackets := make([][]byte, 0, numPackets) - for range numFrames { - remainingSize := frameSize - for packetIdx := range packetsPerFrame { - payloadSize := min(remainingSize, 256) - payload := make([]byte, payloadSize) - for i := range len(payload) { - payload[i] = byte(255 - i) - } - packet := &Packet{ - Header: Header{ - Version: 0, - IsStartOfFrame: packetIdx == 0, - IsFinalOfFrame: packetIdx == packetsPerFrame-1, - Handle: handle, - SequenceNumber: seqNum, - FrameNumber: frameNum, - Timestamp: timestamp, - }, - Payload: payload, - } - if extParticipantSid, err := NewExtensionParticipantSid("test_participant"); err == nil { - if ext, err := extParticipantSid.Marshal(); err == nil { - packet.AddExtension(ext) - } - } - rawPacket, err := packet.Marshal() - if err == nil { - rawPackets = append(rawPackets, rawPacket) - } - seqNum++ - remainingSize -= payloadSize - } - frameNum++ - timestamp += uint32(90000 * frameDuration.Seconds()) - } - - return rawPackets -} diff --git a/pkg/rtc/datatrack_stats.go b/pkg/rtc/datatrack_stats.go index 5bde6f57f..37ae15640 100644 --- a/pkg/rtc/datatrack_stats.go +++ b/pkg/rtc/datatrack_stats.go @@ -18,7 +18,7 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils/mono" ) diff --git a/pkg/rtc/participant_data_track.go b/pkg/rtc/participant_data_track.go index eff47b82b..23a34134f 100644 --- a/pkg/rtc/participant_data_track.go +++ b/pkg/rtc/participant_data_track.go @@ -15,8 +15,8 @@ package rtc import ( - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 92bc9218f..76224e731 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -40,7 +40,6 @@ import ( "github.com/livekit/livekit-server/pkg/agent" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" @@ -48,6 +47,7 @@ import ( "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" sutils "github.com/livekit/livekit-server/pkg/utils" + "github.com/livekit/protocol/datatrack" ) const ( diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 0393177a9..bfd06e322 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -30,11 +30,11 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/pacer" "github.com/livekit/livekit-server/pkg/telemetry" + "github.com/livekit/protocol/datatrack" "google.golang.org/protobuf/proto" ) diff --git a/pkg/rtc/types/typesfakes/fake_data_track.go b/pkg/rtc/types/typesfakes/fake_data_track.go index 07a49f9b1..ddd512d92 100644 --- a/pkg/rtc/types/typesfakes/fake_data_track.go +++ b/pkg/rtc/types/typesfakes/fake_data_track.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_data_track_sender.go b/pkg/rtc/types/typesfakes/fake_data_track_sender.go index a9a69486a..f45c28e82 100644 --- a/pkg/rtc/types/typesfakes/fake_data_track_sender.go +++ b/pkg/rtc/types/typesfakes/fake_data_track_sender.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index e0900c54f..cd94f6f26 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -6,13 +6,13 @@ import ( "time" "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/pacer" "github.com/livekit/livekit-server/pkg/telemetry" "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/observability/roomobs" diff --git a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go index 4c0a3a07a..7f6912626 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant_listener.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/types/typesfakes/fake_participant.go b/pkg/rtc/types/typesfakes/fake_participant.go index 652c8bf14..df9f2255d 100644 --- a/pkg/rtc/types/typesfakes/fake_participant.go +++ b/pkg/rtc/types/typesfakes/fake_participant.go @@ -5,8 +5,8 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" diff --git a/pkg/rtc/types/typesfakes/fake_participant_listener.go b/pkg/rtc/types/typesfakes/fake_participant_listener.go index a05a3e0f5..ac1b05bea 100644 --- a/pkg/rtc/types/typesfakes/fake_participant_listener.go +++ b/pkg/rtc/types/typesfakes/fake_participant_listener.go @@ -4,8 +4,8 @@ package typesfakes import ( "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" ) diff --git a/pkg/rtc/updatatrackmanager.go b/pkg/rtc/updatatrackmanager.go index c3af8dc50..dbf6c5a32 100644 --- a/pkg/rtc/updatatrackmanager.go +++ b/pkg/rtc/updatatrackmanager.go @@ -19,8 +19,8 @@ import ( "slices" "sync" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) diff --git a/test/client/client.go b/test/client/client.go index bd8c76f20..7a74c4841 100644 --- a/test/client/client.go +++ b/test/client/client.go @@ -46,10 +46,10 @@ import ( "github.com/livekit/protocol/signalling" "github.com/livekit/livekit-server/pkg/rtc" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/protocol/datatrack" ) type SignalRequestHandler func(msg *livekit.SignalRequest) error diff --git a/test/client/datatrack_remote.go b/test/client/datatrack_remote.go index 2d2f66365..97097620a 100644 --- a/test/client/datatrack_remote.go +++ b/test/client/datatrack_remote.go @@ -2,7 +2,7 @@ package client import ( "github.com/frostbyte73/core" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" + "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "go.uber.org/atomic" diff --git a/test/client/datatrack_writer.go b/test/client/datatrack_writer.go index 82c6a188b..aaadde9f6 100644 --- a/test/client/datatrack_writer.go +++ b/test/client/datatrack_writer.go @@ -19,8 +19,9 @@ import ( "math/rand" "time" - "github.com/livekit/livekit-server/pkg/rtc/datatrack" "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/datatrack" + "github.com/livekit/protocol/datatrack/datatracktest" "github.com/livekit/protocol/logger" ) @@ -59,7 +60,7 @@ func (d *dataTrackWriter) writeFrames() { return default: - packets := datatrack.GenerateRawDataPackets(d.handle, seqNum, frameNum, 1, rand.Intn(2048)+1, 100*time.Millisecond) + packets := datatracktest.GenerateRawDataPackets(d.handle, seqNum, frameNum, 1, rand.Intn(2048)+1, 100*time.Millisecond) for _, packet := range packets { if err := d.transport.SendDataTrackMessage(packet); err != nil { logger.Errorw("could not send data track packet", err) From ee45c3f0b1a83bf4352dbccb6607ebf70b2a5de6 Mon Sep 17 00:00:00 2001 From: Shayne Parlo Date: Tue, 1 Sep 2026 16:05:24 -0400 Subject: [PATCH 011/130] Update README to include snippet about Agents (#4808) * Add Agents snippet to README. Added important information about LiveKit Agents SDK for building Voice AI, including a code example and links to relevant documentation. * Add tags for snippet --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index c49da91db..f3e995de8 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,39 @@ LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://githu [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/livekit/livekit/buildtest.yaml?branch=master)](https://github.com/livekit/livekit/actions/workflows/buildtest.yaml) [![License](https://img.shields.io/github/license/livekit/livekit)](https://github.com/livekit/livekit/blob/master/LICENSE) + +> [!IMPORTANT] +> If you're building Voice AI, [LiveKit Agents](https://github.com/livekit/agents) is the SDK for code-first realtime voice agents. STT, LLM, TTS, turn detection, [expressive speech](https://docs.livekit.io/agents/models/tts/expressive/), [keyterm accuracy](https://docs.livekit.io/agents/models/stt/keyterms/), tool usage, and telephony all come bundled in the framework. It's available in both [Python](https://github.com/livekit/agents) and [Node.js](https://github.com/livekit/agents-js). +> +>```python +># agent.py +>from livekit import agents +>from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference +> +>server = AgentServer() +> +> +>@server.rtc_session(agent_name="my-agent") +>async def my_agent(ctx: agents.JobContext): +> session = AgentSession( +> stt=inference.STT(model="deepgram/nova-3", language="multi"), +> llm=inference.LLM(model="google/gemma-4-31b-it"), +> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"), +> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()), +> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]), +> expressive=True, +> ) +> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant.")) +> await session.generate_reply(instructions="Greet the user and offer your assistance.") +> +> +>if __name__ == "__main__": +> agents.cli.run_app(server) +>``` +> +>Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/). + + ## Features - Scalable, distributed WebRTC SFU (Selective Forwarding Unit) From 61ec725c5240e6b12aac1d945413995606e58038 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Wed, 2 Sep 2026 15:07:43 +0800 Subject: [PATCH 012/130] Support VP9/AV1 simulcast (#4830) Comes from #4718. Remove #2 of original PR as the simulcast SR looks good to me. --- pkg/sfu/buffer/dependencydescriptorparser.go | 40 +++- .../buffer/dependencydescriptorparser_test.go | 188 ++++++++++++++++++ pkg/sfu/receiver_base.go | 2 +- 3 files changed, 221 insertions(+), 9 deletions(-) create mode 100644 pkg/sfu/buffer/dependencydescriptorparser_test.go diff --git a/pkg/sfu/buffer/dependencydescriptorparser.go b/pkg/sfu/buffer/dependencydescriptorparser.go index 618200aab..09a0d3b52 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser.go +++ b/pkg/sfu/buffer/dependencydescriptorparser.go @@ -60,12 +60,18 @@ type DependencyDescriptorParser struct { onMaxLayerChanged func(int32, int32) decodeTargets []DependencyDescriptorDecodeTarget - seqWrapAround *utils.WrapAround[uint16, uint64] - frameWrapAround *utils.WrapAround[uint16, uint64] - structureExtFrameNum uint64 - activeDecodeTargetsExtSeq uint64 - activeDecodeTargetsMask uint32 - frameChecker *FrameIntegrityChecker + seqWrapAround *utils.WrapAround[uint16, uint64] + frameWrapAround *utils.WrapAround[uint16, uint64] + structureExtFrameNum uint64 + // drop threshold for frames belonging to a previous dependency structure. + // advanced only when the structure id actually changes: structureExtFrameNum + // advances on every structure-bearing key frame, so with frequent key frames + // repeating the same structure (e. g. screen content), valid late/retransmitted + // frames would be dropped as "earlier than current structure". + structureChangeExtFrameNum uint64 + activeDecodeTargetsExtSeq uint64 + activeDecodeTargetsMask uint32 + frameChecker *FrameIntegrityChecker ddNotFoundCount atomic.Uint32 @@ -153,12 +159,12 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr unwrapped := r.frameWrapAround.UpdateWithOrderKnown(ddVal.FrameNumber, restart) extFN := unwrapped.ExtendedVal - if extFN < r.structureExtFrameNum { + if extFN < r.structureChangeExtFrameNum { r.logger.Debugw( "drop frame which is earlier than current structure", "fn", ddVal.FrameNumber, "extFN", extFN, - "structureExtFrameNum", r.structureExtFrameNum, + "structureChangeExtFrameNum", r.structureChangeExtFrameNum, "unwrappedFN", unwrapped, "frameWrapAround", r.frameWrapAround, ) @@ -188,7 +194,24 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr return nil, videoLayer, ErrDDStructureAttachedToNonFirstPacket } + if extFN < r.structureExtFrameNum { + // out-of-order key frame repeating the current structure: accepting it + // would regress structureExtFrameNum (ExtKeyFrameNum) and replay a stale + // structure update, confusing the downtrack's dependency descriptor + // selector. + r.logger.Debugw( + "drop out-of-order key frame", + "extFN", extFN, + "structureExtFrameNum", r.structureExtFrameNum, + ) + ReleaseExtDependencyDescriptor(extDD) + return nil, videoLayer, ErrFrameEarlierThanKeyFrame + } + if r.structure == nil || ddVal.AttachedStructure.StructureId != r.structure.StructureId { + // structure actually changed (or first structure): advance the drop threshold + // so that only frames preceding this structure are dropped. + r.structureChangeExtFrameNum = extFN r.logger.Debugw( "structure updated", "structureID", ddVal.AttachedStructure.StructureId, @@ -252,6 +275,7 @@ func (r *DependencyDescriptorParser) restart() { r.frameChecker = NewFrameIntegrityChecker(integrityCheckFrame, integrityCheckPkt) r.structure = nil r.structureExtFrameNum = 0 + r.structureChangeExtFrameNum = 0 r.activeDecodeTargetsExtSeq = 0 r.activeDecodeTargetsMask = 0 r.decodeTargets = r.decodeTargets[:0] diff --git a/pkg/sfu/buffer/dependencydescriptorparser_test.go b/pkg/sfu/buffer/dependencydescriptorparser_test.go new file mode 100644 index 000000000..652b0b637 --- /dev/null +++ b/pkg/sfu/buffer/dependencydescriptorparser_test.go @@ -0,0 +1,188 @@ +// 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 buffer + +import ( + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" +) + +const ddTestExtID = uint8(1) + +// single spatial layer, single temporal layer, one decode target, one chain +func newL1T1Structure(structureID int) *dd.FrameDependencyStructure { + return &dd.FrameDependencyStructure{ + StructureId: structureID, + NumDecodeTargets: 1, + NumChains: 1, + DecodeTargetProtectedByChain: []int{0}, + Templates: []*dd.FrameDependencyTemplate{ + { // key frame + DecodeTargetIndications: []dd.DecodeTargetIndication{dd.DecodeTargetSwitch}, + ChainDiffs: []int{0}, + }, + { // delta frame + DecodeTargetIndications: []dd.DecodeTargetIndication{dd.DecodeTargetRequired}, + FrameDiffs: []int{1}, + ChainDiffs: []int{1}, + }, + }, + } +} + +type ddTestFeeder struct { + t *testing.T + parser *DependencyDescriptorParser + // structure the writer marshals against, i. e. what the publisher last sent + structure *dd.FrameDependencyStructure + seq uint16 +} + +func newDDTestFeeder(t *testing.T) *ddTestFeeder { + return &ddTestFeeder{ + t: t, + parser: NewDependencyDescriptorParser(ddTestExtID, logger.GetLogger(), func(int32, int32) {}, false), + } +} + +// keyFrame sends a key frame carrying `structure`. Passing the same structure id as +// the previous key frame models a publisher that repeats an unchanged structure, +// which is what screen content with frequent key frames does. +func (f *ddTestFeeder) keyFrame(frameNumber uint16, structure *dd.FrameDependencyStructure) (*ExtDependencyDescriptor, error) { + f.structure = structure + return f.feed(frameNumber, structure.Templates[0], structure) +} + +func (f *ddTestFeeder) deltaFrame(frameNumber uint16) (*ExtDependencyDescriptor, error) { + return f.feed(frameNumber, f.structure.Templates[1], nil) +} + +func (f *ddTestFeeder) feed( + frameNumber uint16, + template *dd.FrameDependencyTemplate, + attachedStructure *dd.FrameDependencyStructure, +) (*ExtDependencyDescriptor, error) { + f.t.Helper() + + ddVal := &dd.DependencyDescriptor{ + FirstPacketInFrame: true, + LastPacketInFrame: true, + FrameNumber: frameNumber, + FrameDependencies: template, + AttachedStructure: attachedStructure, + } + buf, err := (&dd.DependencyDescriptorExtension{Descriptor: ddVal, Structure: f.structure}).Marshal() + require.NoError(f.t, err) + + f.seq++ + pkt := &rtp.Packet{Header: rtp.Header{SequenceNumber: f.seq}} + require.NoError(f.t, pkt.SetExtension(ddTestExtID, buf)) + + extDD, _, err := f.parser.Parse(pkt) + return extDD, err +} + +// A key frame that repeats the current structure must not start dropping frames that +// precede it. Upstream advanced the drop threshold on every structure bearing key +// frame, so with frequent key frames (screen content) a late or retransmitted frame +// arriving after one was discarded as "earlier than current structure". +func TestDependencyDescriptorParserLateFrameAfterRepeatedStructure(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + _, err := f.keyFrame(0, structure) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.deltaFrame(3) + require.NoError(t, err) + + // key frame repeating the same structure id + _, err = f.keyFrame(4, structure) + require.NoError(t, err) + + // frame 2 finally shows up (reordered or retransmitted). The structure has not + // changed, so it is still decodable and must be forwarded. + extDD, err := f.deltaFrame(2) + require.NoError(t, err) + require.NotNil(t, extDD) + require.EqualValues(t, 2, extDD.ExtFrameNum) +} + +// A key frame carrying a *different* structure does invalidate everything before it: +// earlier frames reference templates that no longer exist. +func TestDependencyDescriptorParserLateFrameAfterStructureChange(t *testing.T) { + f := newDDTestFeeder(t) + + _, err := f.keyFrame(0, newL1T1Structure(0)) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.deltaFrame(3) + require.NoError(t, err) + + _, err = f.keyFrame(4, newL1T1Structure(1)) + require.NoError(t, err) + + _, err = f.deltaFrame(2) + require.ErrorIs(t, err, ErrFrameEarlierThanKeyFrame) +} + +// An out-of-order key frame must still be dropped: accepting it would regress +// structureExtFrameNum (ExtKeyFrameNum) and replay a stale structure update. +func TestDependencyDescriptorParserOutOfOrderKeyFrame(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + _, err := f.keyFrame(0, structure) + require.NoError(t, err) + _, err = f.deltaFrame(1) + require.NoError(t, err) + _, err = f.keyFrame(4, structure) + require.NoError(t, err) + + _, err = f.keyFrame(2, structure) + require.ErrorIs(t, err, ErrFrameEarlierThanKeyFrame) +} + +// ExtKeyFrameNum keeps tracking every structure bearing key frame, unchanged by the +// split of the drop threshold into its own field. +func TestDependencyDescriptorParserExtKeyFrameNum(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ExtKeyFrameNum) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ExtKeyFrameNum) + + // repeated structure still advances ExtKeyFrameNum + extDD, err = f.keyFrame(4, structure) + require.NoError(t, err) + require.EqualValues(t, 4, extDD.ExtKeyFrameNum) + + extDD, err = f.deltaFrame(5) + require.NoError(t, err) + require.EqualValues(t, 4, extDD.ExtKeyFrameNum) +} diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index ecc08d04b..80874a893 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -948,7 +948,7 @@ func (r *ReceiverBase) forwardRTP( } spatialLayer := layer - if extPkt.Spatial >= 0 { + if extPkt.Spatial >= 0 && !sfuutils.IsSimulcastMode(r.videoLayerMode) { // svc packet, take spatial layer info from packet spatialLayer = extPkt.Spatial } From 6939fe3c86e5be04ec446e4e4f802f2998e3f9bf Mon Sep 17 00:00:00 2001 From: Abhishek Date: Wed, 2 Sep 2026 13:15:03 +0530 Subject: [PATCH 013/130] Fix pacer activity tracking without RTP header extensions (#4831) --- pkg/sfu/pacer/base.go | 5 +--- pkg/sfu/pacer/base_test.go | 51 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 pkg/sfu/pacer/base_test.go diff --git a/pkg/sfu/pacer/base.go b/pkg/sfu/pacer/base.go index 96e3be250..94d64d222 100644 --- a/pkg/sfu/pacer/base.go +++ b/pkg/sfu/pacer/base.go @@ -85,6 +85,7 @@ func (b *Base) SendPacket(p *Packet) (int, error) { return 0, err } + b.lastPacketSentAt.Store(mono.UnixNano()) return written, nil } @@ -103,8 +104,6 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { if err = p.Header.SetExtension(p.AbsSendTimeExtID, absSendTimeBytes); err != nil { return err } - - b.lastPacketSentAt.Store(sendingAt.UnixNano()) } packetSize := p.HeaderSize + len(p.Payload) @@ -127,8 +126,6 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { if err = p.Header.SetExtension(p.TransportWideExtID, twccExtBytes); err != nil { return err } - - b.lastPacketSentAt.Store(sendingAt.UnixNano()) } b.ProbeObserver.RecordPacket(packetSize, p.IsRTX, p.ProbeClusterId, p.IsProbe) diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go new file mode 100644 index 000000000..e046a98e2 --- /dev/null +++ b/pkg/sfu/pacer/base_test.go @@ -0,0 +1,51 @@ +// 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 pacer + +import ( + "testing" + "time" + + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/mono" + "github.com/pion/rtp" + "github.com/stretchr/testify/require" +) + +type testTrackLocalWriter struct{} + +func (testTrackLocalWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) { + return header.MarshalSize() + len(payload), nil +} + +func (testTrackLocalWriter) Write(b []byte) (int, error) { + return len(b), nil +} + +func TestBaseSendPacketTracksActivityWithoutHeaderExtensions(t *testing.T) { + b := NewBase(logger.GetLogger(), nil) + b.lastPacketSentAt.Store(mono.UnixNano() - int64(time.Second)) + + p := &Packet{ + Header: &rtp.Header{Version: 2}, + HeaderSize: 12, + Payload: []byte{1, 2, 3, 4}, + WriteStream: testTrackLocalWriter{}, + } + + _, err := b.SendPacket(p) + require.NoError(t, err) + require.Less(t, b.TimeSinceLastSentPacket(), 100*time.Millisecond) +} From ceb68a0dbf33b385a0d9ae39692eaa9e029e34d6 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 2 Sep 2026 13:47:46 +0530 Subject: [PATCH 014/130] Revert "Fix pacer activity tracking without RTP header extensions (#4831)" (#4833) This reverts commit 6939fe3c86e5be04ec446e4e4f802f2998e3f9bf. --- pkg/sfu/pacer/base.go | 5 +++- pkg/sfu/pacer/base_test.go | 51 -------------------------------------- 2 files changed, 4 insertions(+), 52 deletions(-) delete mode 100644 pkg/sfu/pacer/base_test.go diff --git a/pkg/sfu/pacer/base.go b/pkg/sfu/pacer/base.go index 94d64d222..96e3be250 100644 --- a/pkg/sfu/pacer/base.go +++ b/pkg/sfu/pacer/base.go @@ -85,7 +85,6 @@ func (b *Base) SendPacket(p *Packet) (int, error) { return 0, err } - b.lastPacketSentAt.Store(mono.UnixNano()) return written, nil } @@ -104,6 +103,8 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { if err = p.Header.SetExtension(p.AbsSendTimeExtID, absSendTimeBytes); err != nil { return err } + + b.lastPacketSentAt.Store(sendingAt.UnixNano()) } packetSize := p.HeaderSize + len(p.Payload) @@ -126,6 +127,8 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { if err = p.Header.SetExtension(p.TransportWideExtID, twccExtBytes); err != nil { return err } + + b.lastPacketSentAt.Store(sendingAt.UnixNano()) } b.ProbeObserver.RecordPacket(packetSize, p.IsRTX, p.ProbeClusterId, p.IsProbe) diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go deleted file mode 100644 index e046a98e2..000000000 --- a/pkg/sfu/pacer/base_test.go +++ /dev/null @@ -1,51 +0,0 @@ -// 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 pacer - -import ( - "testing" - "time" - - "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils/mono" - "github.com/pion/rtp" - "github.com/stretchr/testify/require" -) - -type testTrackLocalWriter struct{} - -func (testTrackLocalWriter) WriteRTP(header *rtp.Header, payload []byte) (int, error) { - return header.MarshalSize() + len(payload), nil -} - -func (testTrackLocalWriter) Write(b []byte) (int, error) { - return len(b), nil -} - -func TestBaseSendPacketTracksActivityWithoutHeaderExtensions(t *testing.T) { - b := NewBase(logger.GetLogger(), nil) - b.lastPacketSentAt.Store(mono.UnixNano() - int64(time.Second)) - - p := &Packet{ - Header: &rtp.Header{Version: 2}, - HeaderSize: 12, - Payload: []byte{1, 2, 3, 4}, - WriteStream: testTrackLocalWriter{}, - } - - _, err := b.SendPacket(p) - require.NoError(t, err) - require.Less(t, b.TimeSinceLastSentPacket(), 100*time.Millisecond) -} From 49e93ba0f8c65fc673e1c288fbf2dab0ff93b0ec Mon Sep 17 00:00:00 2001 From: lukasIO Date: Wed, 2 Sep 2026 13:35:29 +0200 Subject: [PATCH 015/130] add test-server support for compressed join requests (#4834) --- cmd/test-server/signal.go | 45 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/cmd/test-server/signal.go b/cmd/test-server/signal.go index 376359ab4..22d52651b 100644 --- a/cmd/test-server/signal.go +++ b/cmd/test-server/signal.go @@ -15,7 +15,11 @@ package main import ( + "bytes" + "compress/gzip" + "encoding/base64" "encoding/json" + "io" "net/http" "strconv" "strings" @@ -216,7 +220,7 @@ func (h *mockHandler) handleSignal(w http.ResponseWriter, r *http.Request) { return } - reconnect := r.URL.Query().Get("reconnect") == "1" + reconnect := r.URL.Query().Get("reconnect") == "1" || joinRequestSaysReconnect(r.URL.Query().Get("join_request")) conn, err := signalUpgrader.Upgrade(w, r, nil) if err != nil { @@ -416,6 +420,45 @@ func reconnectResponse(regionIndex int) *livekit.SignalResponse { } } +// joinRequestSaysReconnect unpacks the v1 `join_request` param and reports its +// Reconnect flag. Any decode error is ignored (treated as non-reconnect); this +// is a test mock, not a validator. +func joinRequestSaysReconnect(param string) bool { + if param == "" { + return false + } + wrappedBytes, err := base64.URLEncoding.DecodeString(param) + if err != nil { + return false + } + wrapped := &livekit.WrappedJoinRequest{} + if err := proto.Unmarshal(wrappedBytes, wrapped); err != nil { + return false + } + var joinBytes []byte + switch wrapped.Compression { + case livekit.WrappedJoinRequest_NONE: + joinBytes = wrapped.JoinRequest + case livekit.WrappedJoinRequest_GZIP: + gz, err := gzip.NewReader(bytes.NewReader(wrapped.JoinRequest)) + if err != nil { + return false + } + defer func() { _ = gz.Close() }() + joinBytes, err = io.ReadAll(gz) + if err != nil { + return false + } + default: + return false + } + join := &livekit.JoinRequest{} + if err := proto.Unmarshal(joinBytes, join); err != nil { + return false + } + return join.Reconnect +} + func leaveResponse(action livekit.LeaveRequest_Action) *livekit.SignalResponse { return &livekit.SignalResponse{ Message: &livekit.SignalResponse_Leave{ From e77173df955609194dbc3d13529feed612f63741 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Thu, 3 Sep 2026 11:42:59 +0530 Subject: [PATCH 016/130] protocol update for SDP unmarshal harderning (#4836) --- go.mod | 22 +++++++++++----------- go.sum | 44 ++++++++++++++++++++++---------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/go.mod b/go.mod index 04ce5dd4a..488bf8d0c 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0 + github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e github.com/livekit/psrpc v0.7.5 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 @@ -60,6 +60,7 @@ require ( ) require ( + cel.dev/cel-go v0.32.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cilium/ebpf v0.22.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect @@ -101,7 +102,7 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 // indirect - buf.build/go/protovalidate v1.3.0 // indirect + buf.build/go/protovalidate v1.4.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect cel.dev/expr v0.25.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect @@ -113,14 +114,13 @@ 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.4 // indirect - github.com/google/cel-go v0.31.0 // 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.19.2 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/lithammer/shortuuid/v4 v4.3.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -134,25 +134,25 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/logging v0.2.4 - github.com/pion/mdns/v2 v2.1.0 // indirect + github.com/pion/mdns/v2 v2.2.0 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/srtp/v3 v3.0.13 // indirect github.com/pion/stun/v3 v3.1.7 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.70.1 // indirect - github.com/prometheus/procfs v0.21.1 // indirect + github.com/prometheus/client_model v0.6.3 // indirect + github.com/prometheus/common v0.71.0 // indirect + github.com/prometheus/procfs v0.22.0 // indirect github.com/urfave/cli/v3 v3.10.1 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.55.0 // indirect + golang.org/x/crypto v0.56.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect google.golang.org/grpc v1.83.2 // indirect ) diff --git a/go.sum b/go.sum index 515944102..458914a7b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,11 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 h1:Slv0uGxx219srASyiaI5C9cDlyG8kNDcXpTSYcuAeE4= buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= -buf.build/go/protovalidate v1.3.0 h1:8ITcnZGkAHx6TyhZvro+iET/AyqU8gEWQJK2WsT62ms= -buf.build/go/protovalidate v1.3.0/go.mod h1:82s5g+rFRj1CZPiLv6OTA31jBu2fpq7mLXHwa9mZfEs= +buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= +buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0= +cel.dev/cel-go v0.32.0 h1:irvpFKr5EuGPyxeME03ERh0rii1TX+BDAnB9eL3IvNk= +cel.dev/cel-go v0.32.0/go.mod h1:DnVip7tpJSsgZymwfT+m1tnEVy3ivAjSMXPx12YrMkU= cel.dev/expr v0.25.3 h1:A2jO8jwOugrrovveCWfj0KEZOfqiLgAcwjpHPhzIGw0= cel.dev/expr v0.25.3/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -83,8 +85,6 @@ 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.31.0 h1:H0bhpFTqOvmHrBGrWKp7ZlhBm5Hh8PYUEXnwxT1LL7A= -github.com/google/cel-go v0.31.0/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,8 +139,8 @@ 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.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= 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= @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0 h1:HdcExqttH0EkDNyrVf7dy4McuLjuUdztWTF0a7I0ans= -github.com/livekit/protocol v1.51.1-0.20260901194019-9660e8efd0b0/go.mod h1:x7m1nX86XfvS0YoqXnVe0jixkCmAZglR4mcbRHURSro= +github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e h1:QAfYvElm7Jr4ZHs1NiX4OTKpVnRO2d9hzSfCPzGMyMI= +github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e/go.mod h1:qt6LOKs6pzIwJF+P/dSUKHJE1SPowzFWDK6byqktdfU= github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= @@ -243,8 +243,8 @@ github.com/pion/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSD github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -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/mdns/v2 v2.2.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA= +github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y= 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.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= @@ -272,12 +272,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= -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.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= -github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= -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/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= +github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= +github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= +github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= +github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= 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.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= @@ -351,8 +351,8 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= +golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= @@ -421,10 +421,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-20260825221802-da73d73af1c5 h1:izFU9hz7aeLI/Mi1J0991ae+xcwRLr7hTqWnB/9aIIU= -google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5/go.mod h1:3LhxRw4YYkf+ylAfgaY9JlVLFKhokkCV8duhLLe7+t0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 h1:1VUiZAXyC+zmiFYi+WLtBzr68Cj8wOofHjjrA/kkizc= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= +google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= From 407fe3fe26f7aad72f1307f01020d9a7671de6d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Fuente=20P=C3=A9rez?= Date: Fri, 4 Sep 2026 20:19:33 +0200 Subject: [PATCH 017/130] rtc: send connection quality to participants that subscribe after their first update (#4838) * rtc: send connection quality to participants that subscribe after their first update Added tracking for participants' connection quality updates to ensure all subscribed participants receive necessary updates, even if they were added after the last quality announcement * rtc: record sent connection quality only after a successful send A failed SendConnectionQualityUpdate must not mark the participant as informed, otherwise the update is never retried while qualities stay stable. Also simplify the untold-subscription check. --- pkg/rtc/room.go | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 76224e731..0585fe48a 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1681,6 +1681,8 @@ func (r *Room) connectionQualityWorker() { defer ticker.Stop() prevConnectionInfos := make(map[livekit.ParticipantID]*livekit.ConnectionQualityInfo) + // per participant, the participants whose quality it was told about in its last update + toldQualityOf := make(map[livekit.ParticipantID]map[livekit.ParticipantID]struct{}) // send updates to only users that are subscribed to each other for !r.IsClosed() { <-ticker.C @@ -1718,6 +1720,32 @@ func (r *Room) connectionQualityWorker() { } } + // also send if a participant is subscribed to someone whose quality it has never been told about: + // its subscriptions may have been established after the tick that announced it as a new entrant, + // and without a later quality change it would never receive their quality + for pID := range toldQualityOf { + if _, nowOk := nowConnectionInfos[pID]; !nowOk { + // participant is not ACTIVE any more + delete(toldQualityOf, pID) + } + } + if !sendUpdate { + sendUpdate = slices.ContainsFunc(participants, func(op types.LocalParticipant) bool { + if !op.ProtocolVersion().SupportsConnectionQuality() || op.State() != livekit.ParticipantInfo_ACTIVE { + return false + } + for _, sid := range op.GetSubscribedParticipants() { + if _, nowOk := nowConnectionInfos[sid]; !nowOk { + continue + } + if _, told := toldQualityOf[op.ID()][sid]; !told { + return true + } + } + return false + }) + } + if !sendUpdate { prevConnectionInfos = nowConnectionInfos continue @@ -1746,10 +1774,20 @@ func (r *Room) connectionQualityWorker() { // no change continue } + if err := op.SendConnectionQualityUpdate(update); err != nil { r.logger.Warnw("could not send connection quality update", err, "participant", op.Identity()) + continue } + + // remember which participants' quality this participant has been sent, + // only after a successful send so that a failed one is retried on next tick + told := make(map[livekit.ParticipantID]struct{}, len(update.Updates)) + for _, info := range update.Updates { + told[livekit.ParticipantID(info.ParticipantSid)] = struct{}{} + } + toldQualityOf[op.ID()] = told } prevConnectionInfos = nowConnectionInfos From a8ce3985ca7553185de4b43ba1f468defb6c13e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Fuente=20P=C3=A9rez?= Date: Fri, 4 Sep 2026 21:32:48 +0200 Subject: [PATCH 018/130] Revert PR #4838 in favor of a better solution (#4840) --- pkg/rtc/participant.go | 3 +++ pkg/rtc/room.go | 38 ----------------------------- pkg/rtc/subscriptionmanager.go | 2 +- pkg/rtc/subscriptionmanager_test.go | 1 - 4 files changed, 4 insertions(+), 40 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 17b07dac3..01b14479d 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -2204,6 +2204,9 @@ func (p *ParticipantImpl) setupSubscriptionManager() { SubscriptionLimitAudio: p.params.SubscriptionLimitAudio, UseOneShotSignallingMode: p.params.UseOneShotSignallingMode, }) + p.SubscriptionManager.OnSubscribeStatusChanged(func(publisherID livekit.ParticipantID, subscribed bool) { + p.listener().OnSubscribeStatusChanged(p, publisherID, subscribed) + }) } func (p *ParticipantImpl) MetricsCollectorTimeToCollectMetrics() { diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 0585fe48a..76224e731 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1681,8 +1681,6 @@ func (r *Room) connectionQualityWorker() { defer ticker.Stop() prevConnectionInfos := make(map[livekit.ParticipantID]*livekit.ConnectionQualityInfo) - // per participant, the participants whose quality it was told about in its last update - toldQualityOf := make(map[livekit.ParticipantID]map[livekit.ParticipantID]struct{}) // send updates to only users that are subscribed to each other for !r.IsClosed() { <-ticker.C @@ -1720,32 +1718,6 @@ func (r *Room) connectionQualityWorker() { } } - // also send if a participant is subscribed to someone whose quality it has never been told about: - // its subscriptions may have been established after the tick that announced it as a new entrant, - // and without a later quality change it would never receive their quality - for pID := range toldQualityOf { - if _, nowOk := nowConnectionInfos[pID]; !nowOk { - // participant is not ACTIVE any more - delete(toldQualityOf, pID) - } - } - if !sendUpdate { - sendUpdate = slices.ContainsFunc(participants, func(op types.LocalParticipant) bool { - if !op.ProtocolVersion().SupportsConnectionQuality() || op.State() != livekit.ParticipantInfo_ACTIVE { - return false - } - for _, sid := range op.GetSubscribedParticipants() { - if _, nowOk := nowConnectionInfos[sid]; !nowOk { - continue - } - if _, told := toldQualityOf[op.ID()][sid]; !told { - return true - } - } - return false - }) - } - if !sendUpdate { prevConnectionInfos = nowConnectionInfos continue @@ -1774,20 +1746,10 @@ func (r *Room) connectionQualityWorker() { // no change continue } - if err := op.SendConnectionQualityUpdate(update); err != nil { r.logger.Warnw("could not send connection quality update", err, "participant", op.Identity()) - continue } - - // remember which participants' quality this participant has been sent, - // only after a successful send so that a failed one is retried on next tick - told := make(map[livekit.ParticipantID]struct{}, len(update.Updates)) - for _, info := range update.Updates { - told[livekit.ParticipantID(info.ParticipantSid)] = struct{}{} - } - toldQualityOf[op.ID()] = told } prevConnectionInfos = nowConnectionInfos diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index 3b5ddd849..773989e51 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -1222,7 +1222,7 @@ func (m *SubscriptionManager) unmarkSubscribedTo(publisherID livekit.Participant } m.lock.Unlock() if changedCB != nil && lastSubscription { - go changedCB(publisherID, false) + changedCB(publisherID, false) } } diff --git a/pkg/rtc/subscriptionmanager_test.go b/pkg/rtc/subscriptionmanager_test.go index 28b1a4864..9dd32f68c 100644 --- a/pkg/rtc/subscriptionmanager_test.go +++ b/pkg/rtc/subscriptionmanager_test.go @@ -113,7 +113,6 @@ func TestSubscribe(t *testing.T) { require.Eventually(t, func() bool { return numParticipantSubscribed.Load() == 2 }, subSettleTimeout, subCheckInterval, "participant subscribe status was not updated twice") - // the unsubscribed callback is delivered on a goroutine of its own require.Eventually(t, func() bool { return numParticipantUnsubscribed.Load() == 1 }, subSettleTimeout, subCheckInterval, "participant unsubscribe status was not updated") From 443549ae9077e43a73cd3f24898c742549b388d1 Mon Sep 17 00:00:00 2001 From: Viacheslav Sergeev Date: Sat, 5 Sep 2026 15:50:17 +0200 Subject: [PATCH 019/130] Fix/reconcile data track subscription deadlock (#4843) * fixed deadlock in reconcileDataTrackSubscription * test * review changes --- pkg/rtc/subscriptionmanager.go | 12 ++++++++---- pkg/rtc/subscriptionmanager_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index 773989e51..b65b8b063 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -599,7 +599,7 @@ func (m *SubscriptionManager) reconcileDataTrackSubscriptions() { var needsToReconcile []*dataTrackSubscription m.lock.RLock() for _, sub := range m.dataTrackSubscriptions { - if sub.needsSubscribe() || sub.needsUnsubscribe() { + if sub.needsSubscribe() || sub.needsUnsubscribe() || sub.needsCleanup() { needsToReconcile = append(needsToReconcile, sub) } } @@ -630,7 +630,7 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri if s.durationSinceStart() > notFoundTimeout { s.logger.Infow("unsubscribing from data track after notFoundTimeout", "error", err) s.setDesired(false) - m.queueReconcile(s.trackID) + m.queueReconcileDataTrack(s.trackID) } default: // all other errors @@ -668,12 +668,16 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri } m.lock.Lock() - if s.needsCleanup() { + cleanedUp := s.needsCleanup() + if cleanedUp { s.logger.Debugw("cleanup removing data track subscription") delete(m.dataTrackSubscriptions, s.trackID) - m.notifyDataTrackSubscriberHandles() } m.lock.Unlock() + + if cleanedUp { + m.notifyDataTrackSubscriberHandles() + } } // trigger an immediate reconciliation, when trackID is empty, will reconcile all subscriptions diff --git a/pkg/rtc/subscriptionmanager_test.go b/pkg/rtc/subscriptionmanager_test.go index 9dd32f68c..15ecc1096 100644 --- a/pkg/rtc/subscriptionmanager_test.go +++ b/pkg/rtc/subscriptionmanager_test.go @@ -526,6 +526,33 @@ func TestSubscribeDataTrack(t *testing.T) { }, subSettleTimeout, subCheckInterval, "should be resubscribed") require.Equal(t, 2, resolver.dataTrack.AddSubscriberCallCount()) }) + + t.Run("unsubscribe before data track resolves", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + // no track available, subscribe attempts fail with ErrTrackNotFound + resolver := newTestDataTrackResolver(true, false, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + sm.SubscribeToDataTrack("track") + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.NotNil(t, s) + + // let the worker attempt (and fail) the subscribe + require.Eventually(t, func() bool { + return s.getNumAttempts() > 0 + }, subSettleTimeout, subCheckInterval, "no subscribe attempt was made") + + sm.UnsubscribeFromDataTrack("track") + require.Eventually(t, func() bool { + sm.lock.RLock() + _, ok := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + return !ok + }, subSettleTimeout, subCheckInterval, "data track subscription was not cleaned up") + }) } type testSubscriptionParams struct { From e18fbcc0fa40233db212a9fb2f1dc8a0ebd8813a Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Sat, 5 Sep 2026 06:58:59 -0700 Subject: [PATCH 020/130] wire psrpc bus compression into the message bus constructor (#4844) getMessageBus built the bus from the redis client alone, so there was no way to reach the gzip settings psrpc v0.7.6 added at the bus boundary. Take rpc.PSRPCConfig, which the wire graph already provides, and pass its bus options to both the redis and the local bus. Compression is off by default. A peer on an older psrpc cannot decode a compressed payload and drops it silently, so egress, ingress, SIP and agent workers all have to be upgraded before quality is raised. --- config-sample.yaml | 9 +++++++ go.mod | 4 +-- go.sum | 8 +++--- pkg/config/config_test.go | 21 +++++++++++++++ pkg/service/wire.go | 7 ++--- pkg/service/wire_gen.go | 55 ++++++++++++++++++++------------------- 6 files changed, 68 insertions(+), 36 deletions(-) diff --git a/config-sample.yaml b/config-sample.yaml index f07299972..459b693f6 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -268,6 +268,15 @@ keys: # backoff: 500ms # # number of messages to buffer before dropping # buffer_size: 1000 +# # optional gzip compression of bus payloads +# compression: +# # gzip level 1-9; 0 disables. every node on the bus must support +# # compression before enabling it +# quality: 0 +# # payload bytes below which compression is skipped +# threshold: 1024 +# # cap on an inbound payload after decompression, 0 for unlimited +# max_decompressed_size: 0 # customize audio level sensitivity # audio: diff --git a/go.mod b/go.mod index 488bf8d0c..4f13448d4 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 - github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e - github.com/livekit/psrpc v0.7.5 + github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f + github.com/livekit/psrpc v0.7.6 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 diff --git a/go.sum b/go.sum index 458914a7b..083396f13 100644 --- a/go.sum +++ b/go.sum @@ -164,10 +164,10 @@ 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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e h1:QAfYvElm7Jr4ZHs1NiX4OTKpVnRO2d9hzSfCPzGMyMI= -github.com/livekit/protocol v1.51.1-0.20260903060125-0cf5ba018b8e/go.mod h1:qt6LOKs6pzIwJF+P/dSUKHJE1SPowzFWDK6byqktdfU= -github.com/livekit/psrpc v0.7.5 h1:WxfJIQ41X1b+48A1uzc8Gy9FhYEMxBJNCpCYqPdO/Ds= -github.com/livekit/psrpc v0.7.5/go.mod h1:rAI+m2+/cb4x9RXhLRtUx5ZwdfjjXOl4zi46IjEetaw= +github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc= +github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs= +github.com/livekit/psrpc v0.7.6 h1:YG07lUMTtf+eaYI2goT9zcVZ0kGJNWN1K6ETNFtv1HQ= +github.com/livekit/psrpc v0.7.6/go.mod h1:DMw15RO7x5XmcgfwzWJYk2In605kx+wu1QRVbPfzf8M= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 3e9cfbf48..0b6439945 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -61,6 +61,27 @@ func TestConfig_SignalMessageSizeLimitOverride(t *testing.T) { require.Equal(t, int64(0), conf.Limit.AgentSignalMessageSizeLimit) } +func TestConfig_PSRPCCompressionDefaults(t *testing.T) { + conf, err := NewConfig("", true, nil, nil) + require.NoError(t, err) + require.Equal(t, 0, conf.PSRPC.Compression.Quality) + require.Equal(t, 1024, conf.PSRPC.Compression.Threshold) + require.Equal(t, 0, conf.PSRPC.Compression.MaxDecompressedSize) +} + +func TestConfig_PSRPCCompressionOverride(t *testing.T) { + const content = `psrpc: + compression: + quality: 6 + max_decompressed_size: 4096` + conf, err := NewConfig(content, true, nil, nil) + require.NoError(t, err) + require.Equal(t, 6, conf.PSRPC.Compression.Quality) + require.Equal(t, 4096, conf.PSRPC.Compression.MaxDecompressedSize) + require.Equal(t, 1024, conf.PSRPC.Compression.Threshold) + require.Equal(t, 3, conf.PSRPC.MaxAttempts) +} + func TestConfig_UnknownKeys(t *testing.T) { const content = `unknown: 10 room: diff --git a/pkg/service/wire.go b/pkg/service/wire.go index a2d60fd32..9d1842b4b 100644 --- a/pkg/service/wire.go +++ b/pkg/service/wire.go @@ -193,11 +193,12 @@ func createStore(rc redis.UniversalClient) ObjectStore { return NewLocalStore() } -func getMessageBus(rc redis.UniversalClient) psrpc.MessageBus { +func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.MessageBus { + opts := psrpcConf.BusOptions() if rc == nil { - return psrpc.NewLocalMessageBus() + return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc) + return psrpc.NewRedisMessageBus(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 0da441132..f7c5a6267 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -39,14 +39,14 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live return nil, err } nodeID := getNodeID(currentNode) - messageBus := getMessageBus(universalClient) + psrpcConfig := getPSRPCConfig(conf) + v := getMessageBus(universalClient, psrpcConfig) signalRelayConfig := getSignalRelayConfig(conf) - signalClient, err := routing.NewSignalClient(nodeID, messageBus, signalRelayConfig) + signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig) if err != nil { return nil, err } - psrpcConfig := getPSRPCConfig(conf) - clientParams := getPSRPCClientParams(psrpcConfig, messageBus) + clientParams := getPSRPCClientParams(psrpcConfig, v) roomConfig := getRoomConfig(conf) roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig) if err != nil { @@ -80,57 +80,57 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live } analyticsService := telemetry.NewAnalyticsService(conf, currentNode) telemetryService := createTelemetryService(queuedNotifier, analyticsService) - ioInfoService, err := NewIOInfoService(messageBus, egressStore, ingressStore, sipStore, telemetryService) + ioInfoService, err := NewIOInfoService(v, egressStore, ingressStore, sipStore, telemetryService) if err != nil { return nil, err } rtcEgressLauncher := NewEgressLauncher(egressClient, ioInfoService, objectStore) topicFormatter := rpc.NewTopicFormatter() - v, err := rpc.NewTypedRoomClient(clientParams) + v2, err := rpc.NewTypedRoomClient(clientParams) if err != nil { return nil, err } - v2, err := rpc.NewTypedParticipantClient(clientParams) + v3, 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, v2, v3) if err != nil { return nil, err } - v3, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) + v4, err := rpc.NewTypedAgentDispatchInternalClient(clientParams) if err != nil { return nil, err } - agentDispatchService := NewAgentDispatchService(limitConfig, v3, topicFormatter, roomAllocator, router) + agentDispatchService := NewAgentDispatchService(limitConfig, v4, topicFormatter, roomAllocator, router) egressService := NewEgressService(egressClient, rtcEgressLauncher, ioInfoService, roomService) ingressConfig := getIngressConfig(conf) ingressClient, err := rpc.NewIngressClient(clientParams) if err != nil { return nil, err } - ingressService := NewIngressService(ingressConfig, nodeID, messageBus, ingressClient, ingressStore, ioInfoService, telemetryService) + ingressService := NewIngressService(ingressConfig, nodeID, v, ingressClient, ingressStore, ioInfoService, telemetryService) sipConfig := getSIPConfig(conf) sipClient, err := newSIPClient(clientParams) if err != nil { return nil, err } - sipService := NewSIPService(sipConfig, nodeID, messageBus, sipClient, sipStore, roomService, telemetryService) + sipService := NewSIPService(sipConfig, nodeID, v, sipClient, sipStore, roomService, telemetryService) rtcService := NewRTCService(conf, roomAllocator, router, telemetryService) - v4, err := rpc.NewTypedWHIPParticipantClient(clientParams) + v5, 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, v5) if err != nil { return nil, err } - agentService, err := NewAgentService(conf, currentNode, messageBus, keyProvider) + agentService, err := NewAgentService(conf, currentNode, v, keyProvider) if err != nil { return nil, err } agentConfig := getAgentConfig(conf) - client, err := agent.NewAgentClient(messageBus, agentConfig) + client, err := agent.NewAgentClient(v, agentConfig) if err != nil { return nil, err } @@ -138,16 +138,16 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live timedVersionGenerator := utils.NewDefaultTimedVersionGenerator() turnAuthHandler := NewTURNAuthHandler(keyProvider) forwardStats := createForwardStats(conf) - roomManager, err := NewLocalRoomManager(conf, objectStore, currentNode, router, roomAllocator, telemetryService, client, agentStore, rtcEgressLauncher, timedVersionGenerator, turnAuthHandler, messageBus, forwardStats) + roomManager, err := NewLocalRoomManager(conf, objectStore, currentNode, router, roomAllocator, telemetryService, client, agentStore, rtcEgressLauncher, timedVersionGenerator, turnAuthHandler, v, forwardStats) if err != nil { return nil, err } - signalServer, err := NewDefaultSignalServer(currentNode, messageBus, signalRelayConfig, router, roomManager) + signalServer, err := NewDefaultSignalServer(currentNode, v, signalRelayConfig, router, roomManager) if err != nil { return nil, err } - v5 := getTURNAuthHandlerFunc(turnAuthHandler) - server, err := newInProcessTurnServer(conf, v5) + v6 := getTURNAuthHandlerFunc(turnAuthHandler) + server, err := newInProcessTurnServer(conf, v6) if err != nil { return nil, err } @@ -164,14 +164,14 @@ func InitializeRouter(conf *config.Config, currentNode routing.LocalNode) (routi return nil, err } nodeID := getNodeID(currentNode) - messageBus := getMessageBus(universalClient) + psrpcConfig := getPSRPCConfig(conf) + v := getMessageBus(universalClient, psrpcConfig) signalRelayConfig := getSignalRelayConfig(conf) - signalClient, err := routing.NewSignalClient(nodeID, messageBus, signalRelayConfig) + signalClient, err := routing.NewSignalClient(nodeID, v, signalRelayConfig) if err != nil { return nil, err } - psrpcConfig := getPSRPCConfig(conf) - clientParams := getPSRPCClientParams(psrpcConfig, messageBus) + clientParams := getPSRPCClientParams(psrpcConfig, v) roomConfig := getRoomConfig(conf) roomManagerClient, err := routing.NewRoomManagerClient(clientParams, roomConfig) if err != nil { @@ -254,11 +254,12 @@ func createStore(rc redis.UniversalClient) ObjectStore { return NewLocalStore() } -func getMessageBus(rc redis.UniversalClient) psrpc.MessageBus { +func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.MessageBus { + opts := psrpcConf.BusOptions() if rc == nil { - return psrpc.NewLocalMessageBus() + return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc) + return psrpc.NewRedisMessageBus(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { From a3a760bb48b518674480c4c02c777b0203271ed9 Mon Sep 17 00:00:00 2001 From: XiaoShao Date: Tue, 8 Sep 2026 11:24:29 +0800 Subject: [PATCH 021/130] telemetry: drop unused getCPUStats, unblocking darwin builds without cgo (#4841) getCPUStats has no callers: node CPU load comes from hwstats.CPUStats in GetNodeStats, and nothing in the tree reads getCPUStats. Only getLoadAvg is still used. Remove the function from both the windows and non-windows variants along with the state it kept. getLoadAvg is untouched, and go-osstat stays a direct dependency through it. Co-authored-by: XiaoShao <26596822+xiaoshao9704@users.noreply.github.com> --- pkg/telemetry/prometheus/node_nonwindows.go | 33 +-------------------- pkg/telemetry/prometheus/node_windows.go | 4 --- 2 files changed, 1 insertion(+), 36 deletions(-) diff --git a/pkg/telemetry/prometheus/node_nonwindows.go b/pkg/telemetry/prometheus/node_nonwindows.go index b765ce271..9661e08fa 100644 --- a/pkg/telemetry/prometheus/node_nonwindows.go +++ b/pkg/telemetry/prometheus/node_nonwindows.go @@ -18,39 +18,8 @@ package prometheus -import ( - "runtime" - "sync" - - "github.com/mackerelio/go-osstat/cpu" - "github.com/mackerelio/go-osstat/loadavg" -) - -var ( - cpuStatsLock sync.RWMutex - lastCPUTotal, lastCPUIdle uint64 -) +import "github.com/mackerelio/go-osstat/loadavg" func getLoadAvg() (*loadavg.Stats, error) { return loadavg.Get() } - -func getCPUStats() (cpuLoad float32, numCPUs uint32, err error) { - cpuInfo, err := cpu.Get() - if err != nil { - return - } - - cpuStatsLock.Lock() - if lastCPUTotal > 0 && lastCPUTotal < cpuInfo.Total { - cpuLoad = 1 - float32(cpuInfo.Idle-lastCPUIdle)/float32(cpuInfo.Total-lastCPUTotal) - } - - lastCPUTotal = cpuInfo.Total - lastCPUIdle = cpuInfo.Idle - cpuStatsLock.Unlock() - - numCPUs = uint32(runtime.NumCPU()) - - return -} diff --git a/pkg/telemetry/prometheus/node_windows.go b/pkg/telemetry/prometheus/node_windows.go index eb0ba48a9..507b80437 100644 --- a/pkg/telemetry/prometheus/node_windows.go +++ b/pkg/telemetry/prometheus/node_windows.go @@ -23,7 +23,3 @@ import "github.com/mackerelio/go-osstat/loadavg" func getLoadAvg() (*loadavg.Stats, error) { return &loadavg.Stats{}, nil } - -func getCPUStats() (cpuLoad float32, numCPUs uint32, err error) { - return 1, 1, nil -} From 4e118aa043d8f4570cb5c98841aa41f87c3814ad Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:57:23 -0700 Subject: [PATCH 022/130] Update golang Docker tag to v1.26.7 (#4846) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- cmd/test-server/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c71b191d3..1629557ee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # This image is also the single source of truth for the Go toolchain: CI reads the # version out of this line (see .github/scripts/go-version.sh) so tests and images # always run the same runtime. -FROM golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder +FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder ARG TARGETPLATFORM ARG TARGETARCH diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile index e0dc826db..f0ff258cc 100644 --- a/cmd/test-server/Dockerfile +++ b/cmd/test-server/Dockerfile @@ -17,7 +17,7 @@ # # Pinned by digest so the build is reproducible even if the tag is republished. # The tag is kept alongside it for readability; Renovate updates both together. -FROM golang:1.26.6-alpine3.24@sha256:af8d6740070b8906d12eae1c3e3ea0957fb63f492051ea05e354c38ef9fe88df AS builder +FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder ARG TARGETARCH From 5121860448763817c72ab211adf693e99c3c2c60 Mon Sep 17 00:00:00 2001 From: Shayne Parlo Date: Tue, 8 Sep 2026 15:48:01 -0400 Subject: [PATCH 023/130] README rewrite (#4837) * Update README with more focus on Agents * small edit --- README.md | 254 +++++++++++++++--------------------------------------- 1 file changed, 69 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index f3e995de8..7d74cd3bf 100644 --- a/README.md +++ b/README.md @@ -8,10 +8,12 @@ -# LiveKit: Real-time video, audio and data for developers +# LiveKit: Realtime infrastructure for voice, video, and AI agents -[LiveKit](https://livekit.io) is an open source project that provides scalable, multi-user conferencing based on WebRTC. -It's designed to provide everything you need to build real-time video audio data capabilities in your applications. +[LiveKit](https://livekit.com) is an open source platform for building voice, video, and physical AI agents. +This repository is the LiveKit server: a scalable, distributed WebRTC SFU that moves realtime audio, video, and +data between people, devices, and AI models. The SDKs, agents frameworks, and companion services are linked in +the table at the bottom of this page. LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://github.com/pion/webrtc) implementation. @@ -27,211 +29,82 @@ LiveKit's server is written in Go, using the awesome [Pion WebRTC](https://githu > [!IMPORTANT] > If you're building Voice AI, [LiveKit Agents](https://github.com/livekit/agents) is the SDK for code-first realtime voice agents. STT, LLM, TTS, turn detection, [expressive speech](https://docs.livekit.io/agents/models/tts/expressive/), [keyterm accuracy](https://docs.livekit.io/agents/models/stt/keyterms/), tool usage, and telephony all come bundled in the framework. It's available in both [Python](https://github.com/livekit/agents) and [Node.js](https://github.com/livekit/agents-js). > ->```python -># agent.py ->from livekit import agents ->from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference +> ```python +> # agent.py +> from livekit import agents +> from livekit.agents import Agent, AgentServer, AgentSession, STTContextOptions, TurnHandlingOptions, inference > ->server = AgentServer() +> server = AgentServer() > > ->@server.rtc_session(agent_name="my-agent") ->async def my_agent(ctx: agents.JobContext): -> session = AgentSession( -> stt=inference.STT(model="deepgram/nova-3", language="multi"), -> llm=inference.LLM(model="google/gemma-4-31b-it"), -> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"), -> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()), -> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]), -> expressive=True, -> ) -> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant.")) -> await session.generate_reply(instructions="Greet the user and offer your assistance.") +> @server.rtc_session(agent_name="my-agent") +> async def my_agent(ctx: agents.JobContext): +> session = AgentSession( +> stt=inference.STT(model="deepgram/nova-3", language="multi"), +> llm=inference.LLM(model="google/gemma-4-31b-it"), +> tts=inference.TTS(model="inworld/inworld-tts-2", voice="Ashley"), +> turn_handling=TurnHandlingOptions(turn_detection=inference.TurnDetector()), +> stt_context_options=STTContextOptions(keyterms=["LiveKit", "Acme Corp"]), +> expressive=True, +> ) +> await session.start(room=ctx.room, agent=Agent(instructions="You are a helpful voice AI assistant.")) +> await session.generate_reply(instructions="Greet the user and offer your assistance.") > > ->if __name__ == "__main__": -> agents.cli.run_app(server) ->``` +> if __name__ == "__main__": +> agents.cli.run_app(server) +> ``` > ->Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/). +> Models come from [LiveKit Inference](https://docs.livekit.io/agents/models/) with no per-provider API keys, and LiveKit Cloud handles [deployment](https://docs.livekit.io/deploy/agents/) and [observability](https://docs.livekit.io/deploy/observability/). Visit the docs for more info at [docs.livekit.io/agents](https://docs.livekit.io/agents/). +## Used in production by + +LiveKit carries billions of calls a year for companies including Salesforce, Nvidia, Oracle, SAP, +Deutsche Telekom, Spotify, Tinder, Coursera, Headspace, Skydio, Retell, Decagon, Cresta, and HeyGen. Read how +[Assort Health](https://livekit.com/customers/assort-health), [Playback](https://livekit.com/customers/playback), and +[Polymath Robotics](https://livekit.com/customers/polymath) use it, or see [more customers](https://livekit.com/customers). + ## Features - Scalable, distributed WebRTC SFU (Selective Forwarding Unit) -- Modern, full-featured client SDKs +- People, devices, and AI agents join the same room as participants, with + [agent dispatch](https://docs.livekit.io/agents/server/agent-dispatch/) to route agents in automatically or on demand +- Modern, full-featured SDKs for web, mobile, desktop, embedded, and server - Built for production, supports JWT authentication - Robust networking and connectivity, UDP/TCP/TURN - Easy to deploy: single binary, Docker or Kubernetes - Advanced features including: - - [speaker detection](https://docs.livekit.io/home/client/tracks/subscribe/#speaker-detection) - - [simulcast](https://docs.livekit.io/home/client/tracks/publish/#video-simulcast) - - [end-to-end optimizations](https://blog.livekit.io/livekit-one-dot-zero/) - - [selective subscription](https://docs.livekit.io/home/client/tracks/subscribe/#selective-subscription) - - [moderation APIs](https://docs.livekit.io/home/server/managing-participants/) - - end-to-end encryption + - [speaker detection](https://docs.livekit.io/transport/media/subscribe/) + - [simulcast](https://docs.livekit.io/transport/media/publish/) + - [selective subscription](https://docs.livekit.io/transport/media/subscribe/) + - [moderation APIs](https://docs.livekit.io/intro/basics/rooms-participants-tracks/participants/) + - [end-to-end encryption](https://docs.livekit.io/transport/media/encryption/) - SVC codecs (VP9, AV1) - - [webhooks](https://docs.livekit.io/home/server/webhooks/) - - [distributed and multi-region](https://docs.livekit.io/home/self-hosting/distributed/) + - [data tracks](https://docs.livekit.io/transport/data/data-tracks/) for low-latency telemetry and teleoperation + - [telephony](https://docs.livekit.io/telephony/) over SIP + - [webhooks](https://docs.livekit.io/intro/basics/rooms-participants-tracks/webhooks-events/) + - [distributed and multi-region](https://docs.livekit.io/transport/self-hosting/distributed/) ## Documentation & Guides https://docs.livekit.io +Working with a coding agent? Give it the [LiveKit Docs MCP server](https://docs.livekit.io/mcp/), or start with the +[coding agents guide](https://docs.livekit.io/intro/coding-agents/). + ## Live Demos +- [Talk to a voice agent](https://livekit.com) built with LiveKit Agents - [LiveKit Meet](https://meet.livekit.io) ([source](https://github.com/livekit-examples/meet)) - [Spatial Audio](https://spatial-audio-demo.livekit.io/) ([source](https://github.com/livekit-examples/spatial-audio)) - Livestreaming from OBS Studio ([source](https://github.com/livekit-examples/livestream)) -- [AI voice assistant using ChatGPT](https://livekit.io/kitt) ([source](https://github.com/livekit-examples/kitt)) - -## Ecosystem - -- [Agents](https://github.com/livekit/agents): build real-time multimodal AI applications with programmable backend participants -- [Egress](https://github.com/livekit/egress): record or multi-stream rooms and export individual tracks -- [Ingress](https://github.com/livekit/ingress): ingest streams from external sources like RTMP, WHIP, HLS, or OBS Studio - -## SDKs & Tools - -### Client SDKs - -Client SDKs enable your frontend to include interactive, multi-user experiences. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LanguageRepo - Declarative UI - Links
JavaScript (TypeScript) - client-sdk-js - - React - - docs - | - JS example - | - React example -
Swift (iOS / MacOS) - client-sdk-swift - Swift UI - docs - | - example -
Kotlin (Android) - client-sdk-android - Compose - docs - | - example - | - Compose example -
Flutter (all platforms) - client-sdk-flutter - native - docs - | - example -
Unity WebGL - client-sdk-unity-web - - docs -
React Native (beta) - client-sdk-react-native - native
Rust - client-sdk-rust -
- -### Server SDKs - -Server SDKs enable your backend to generate [access tokens](https://docs.livekit.io/home/get-started/authentication/), -call [server APIs](https://docs.livekit.io/reference/server/server-apis/), and -receive [webhooks](https://docs.livekit.io/home/server/webhooks/). In addition, the Go SDK includes client capabilities, -enabling you to build automations that behave like end-users. - -| Language | Repo | Docs | -| :---------------------- | :-------------------------------------------------------------------------------------- | :---------------------------------------------------------- | -| Go | [server-sdk-go](https://github.com/livekit/server-sdk-go) | [docs](https://pkg.go.dev/github.com/livekit/server-sdk-go) | -| JavaScript (TypeScript) | [server-sdk-js](https://github.com/livekit/server-sdk-js) | [docs](https://docs.livekit.io/server-sdk-js/) | -| Ruby | [server-sdk-ruby](https://github.com/livekit/server-sdk-ruby) | | -| Java (Kotlin) | [server-sdk-kotlin](https://github.com/livekit/server-sdk-kotlin) | | -| Python (community) | [python-sdks](https://github.com/livekit/python-sdks) | | -| PHP (community) | [agence104/livekit-server-sdk-php](https://github.com/agence104/livekit-server-sdk-php) | | - -### Tools - -- [CLI](https://github.com/livekit/livekit-cli) - command line interface & load tester -- [Docker image](https://hub.docker.com/r/livekit/livekit-server) -- [Helm charts](https://github.com/livekit/livekit-helm) ## Install > [!TIP] > We recommend installing [LiveKit CLI](https://github.com/livekit/livekit-cli) along with the server. It lets you access -> server APIs, create tokens, and generate test traffic. +> server APIs, create tokens, generate test traffic, and scaffold and deploy agents. The following will install LiveKit's media server: @@ -262,11 +135,11 @@ API Key: devkey API Secret: secret ``` -To customize your setup for production, refer to our [deployment docs](https://docs.livekit.io/deploy/) +To customize your setup for production, refer to our [deployment docs](https://docs.livekit.io/transport/self-hosting/deployment/) ### Creating access token -A user connecting to a LiveKit room requires an [access token](https://docs.livekit.io/home/get-started/authentication/#creating-a-token). Access +A user connecting to a LiveKit room requires an [access token](https://docs.livekit.io/frontends/build/authentication/). Access tokens (JWT) encode the user's identity and the room permissions they've been granted. You can generate a token with our CLI: @@ -280,7 +153,7 @@ lk token create \ ### Test with example app Head over to our [example app](https://example.livekit.io) and enter a generated token to connect to your LiveKit -server. This app is built with our [React SDK](https://github.com/livekit/livekit-react). +server. Once connected, your video and audio are now being published to your new LiveKit instance! @@ -299,24 +172,34 @@ This command publishes a looped demo video to a room. Due to how the video clip there's a slight delay before the browser has sufficient data to begin rendering frames. This is an artifact of the simulation. +### Adding an agent + +Agents join rooms as participants, the same way a browser or a phone does. Follow the +[Voice AI quickstart](https://docs.livekit.io/agents/start/voice-ai/) to build one. An agent connects to a self-hosted +server the same way it connects to LiveKit Cloud; when running without Cloud, use +[model plugins](https://docs.livekit.io/agents/models/#plugins) in place of LiveKit Inference. + ## Deployment ### Use LiveKit Cloud -LiveKit Cloud is the fastest and most reliable way to run LiveKit. Every project gets free monthly bandwidth and -transcoding credits. +LiveKit Cloud is the fastest and most reliable way to run LiveKit. It runs in 19+ regions with 99.99% uptime and adds +agent hosting, model inference, telephony, and observability on top of the server. The Build plan is free, with no +credit card required. Sign up for [LiveKit Cloud](https://cloud.livekit.io/). ### Self-host -Read our [deployment docs](https://docs.livekit.io/transport/self-hosting/) for more information. +Read our [deployment docs](https://docs.livekit.io/transport/self-hosting/) for more information. Official +[Docker images](https://hub.docker.com/r/livekit/livekit-server) and [Helm charts](https://github.com/livekit/livekit-helm) +are available. ## Building from source Pre-requisites: -- Go 1.23+ is installed +- Go 1.26+ is installed - GOPATH/bin is in your PATH Then run @@ -331,7 +214,8 @@ mage ## Contributing We welcome your contributions toward improving LiveKit! Please join us -[on Slack](http://livekit.io/join-slack) to discuss your ideas and/or PRs. +[on Slack](http://livekit.io/join-slack) or in the [Developer Community](https://community.livekit.io) to discuss your +ideas and/or PRs. ## License From adf44a2eb6f35c1b0df81a2a54428329c1a61dec Mon Sep 17 00:00:00 2001 From: Sapan Diwakar Date: Wed, 9 Sep 2026 13:46:44 +0530 Subject: [PATCH 024/130] turn: accept PROXY protocol on the TCP listener (#4852) * turn: accept PROXY protocol on the TCP listener Behind a TLS-terminating or reverse proxy that dials from its own address, the embedded TURN server reports the proxy's address to the client as XOR-MAPPED-ADDRESS. Firefox rejects a loopback or wildcard mapped address and abandons the allocation, so relay-only clients never get a relay candidate (#4851). Add turn.proxy_protocol. When set, the TCP listener requires a PROXY protocol v1/v2 header on every connection and uses the client address it carries; connections without the header are rejected. The header is read before TLS, so it works with both the built-in TLS listener and external_tls. * turn: only trust PROXY headers from configured proxies A PROXY header from any peer that can reach the port would let a direct client claim an arbitrary source address. Add turn.proxy_protocol_trusted_cidrs, defaulting to loopback, and close connections from any other address before reading the header. --- config-sample.yaml | 13 ++++ go.mod | 1 + go.sum | 2 + pkg/config/config.go | 14 ++++ pkg/service/turn.go | 85 ++++++++++++++++++------ pkg/service/turn_test.go | 136 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 230 insertions(+), 21 deletions(-) diff --git a/config-sample.yaml b/config-sample.yaml index 459b693f6..5aca3bea7 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -317,6 +317,19 @@ keys: # # set external_tls to true if using a L4 load balancer to terminate TLS. when enabled, # # LiveKit expects unencrypted traffic on tls_port, and still advertise tls_port as a TURN/TLS candidate. # external_tls: true +# # set proxy_protocol to true if the proxy or load balancer in front of tls_port does not preserve +# # the client address (it terminates TLS, or dials LiveKit from its own IP) and can send a +# # PROXY protocol v1/v2 header instead. Without it TURN reports the proxy's address to the +# # client as XOR-MAPPED-ADDRESS, which Firefox rejects when it is loopback or wildcard. +# # every connection on tls_port must then carry the header; connections without it are rejected. +# # prefer PROXY protocol v2 on the proxy side: the v1 text header must arrive in a single read. +# proxy_protocol: false +# # proxies whose PROXY header is trusted. connections from any other address are closed, so a +# # client that reaches tls_port directly cannot claim an arbitrary source address. +# # defaults to loopback, for a proxy running on the same host. +# proxy_protocol_trusted_cidrs: +# - 127.0.0.0/8 +# - ::1/128 # # needs to match tls cert domain # domain: turn.myhost.com # # optional (set only if not using external TLS termination) diff --git a/go.mod b/go.mod index 4f13448d4..3683d54f6 100644 --- a/go.mod +++ b/go.mod @@ -40,6 +40,7 @@ require ( github.com/pion/transport/v4 v4.1.0 github.com/pion/turn/v5 v5.0.13 github.com/pion/webrtc/v4 v4.2.18 + github.com/pires/go-proxyproto v0.15.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.24.1 github.com/redis/go-redis/v9 v9.22.0 diff --git a/go.sum b/go.sum index 083396f13..a1abf139d 100644 --- a/go.sum +++ b/go.sum @@ -265,6 +265,8 @@ github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97B github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4= github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= 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= diff --git a/pkg/config/config.go b/pkg/config/config.go index b46756adb..3ca89107e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -279,6 +279,19 @@ type TURNConfig struct { RelayPortRangeEnd uint16 `yaml:"relay_range_end,omitempty"` ExternalTLS bool `yaml:"external_tls,omitempty"` BindAddresses []string `yaml:"bind_addresses,omitempty"` + // ProxyProtocol makes the TURN TCP listener require a PROXY protocol (v1 or v2) + // header on every connection and use the client address it carries. Needed when + // a proxy in front of TURN dials from its own address (a TLS-terminating L4 + // proxy, a TCP reverse proxy): TURN echoes the address it sees back to the + // client as XOR-MAPPED-ADDRESS, and browsers such as Firefox reject a loopback + // or wildcard address there and abandon the allocation. Connections without + // the header are rejected. + ProxyProtocol bool `yaml:"proxy_protocol,omitempty"` + // ProxyProtocolTrustedCIDRs lists the proxies whose PROXY header is believed. + // Connections from any other address are closed, so a client that reaches + // tls_port directly cannot claim an arbitrary source address. Defaults to + // loopback only, for a proxy on the same host. + ProxyProtocolTrustedCIDRs []string `yaml:"proxy_protocol_trusted_cidrs,omitempty"` // PerUserRelayAllocationLimit caps the number of concurrent relay allocations // a single participant credential may hold, keyed by the participant ID. This // stops one authenticated participant from consuming the shared relay-port @@ -585,6 +598,7 @@ var DefaultConfig = Config{ TURN: TURNConfig{ Enabled: false, BindAddresses: []string{"0.0.0.0"}, + ProxyProtocolTrustedCIDRs: []string{"127.0.0.0/8", "::1/128"}, TTLSeconds: DefaultTURNTTLSeconds, PerUserRelayAllocationLimit: DefaultTURNPerUserRelayAllocationLimit, }, diff --git a/pkg/service/turn.go b/pkg/service/turn.go index 0b3834c91..6e8da2e0e 100644 --- a/pkg/service/turn.go +++ b/pkg/service/turn.go @@ -26,6 +26,7 @@ import ( "github.com/jxskiss/base62" "github.com/pion/stun/v3" "github.com/pion/turn/v5" + "github.com/pires/go-proxyproto" "github.com/pkg/errors" "github.com/livekit/protocol/auth" @@ -162,26 +163,9 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone } if turnConf.TLSPort > 0 { - var listener net.Listener - var listenerErr error - - if turnConf.ExternalTLS { - listener, listenerErr = net.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort))) - } else { - cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile) - if err != nil { - return nil, errors.Wrap(err, "TURN tls cert required") - } - - listener, listenerErr = tls.Listen("tcp", net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort)), - &tls.Config{ - MinVersion: tls.VersionTLS12, - Certificates: []tls.Certificate{cert}, - }) - } - - if listenerErr != nil { - return nil, errors.Wrap(listenerErr, "could not listen on TURN TCP port") + listener, err := newTURNTCPListener(turnConf, net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort))) + if err != nil { + return nil, err } if standalone { listener = telemetry.NewListener(listener) @@ -194,7 +178,7 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone } serverConfig.ListenerConfigs = append(serverConfig.ListenerConfigs, listenerConfig) - logValues = append(logValues, "turn.portTLS", turnConf.TLSPort, "turn.externalTLS", turnConf.ExternalTLS) + logValues = append(logValues, "turn.portTLS", turnConf.TLSPort, "turn.externalTLS", turnConf.ExternalTLS, "turn.proxyProtocol", turnConf.ProxyProtocol) } if turnConf.UDPPort > 0 { @@ -221,6 +205,65 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone return turn.NewServer(serverConfig) } +// newTURNTCPListener returns the TCP listener for TURN/TLS. The PROXY protocol +// header, when enabled, is read before TLS so the client address is known to +// the TLS layer and to TURN regardless of who terminates TLS. +func newTURNTCPListener(turnConf config.TURNConfig, address string) (net.Listener, error) { + var tlsConfig *tls.Config + if !turnConf.ExternalTLS { + cert, err := tls.LoadX509KeyPair(turnConf.CertFile, turnConf.KeyFile) + if err != nil { + return nil, errors.Wrap(err, "TURN tls cert required") + } + tlsConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + Certificates: []tls.Certificate{cert}, + } + } + + var proxyPolicy proxyproto.ConnPolicyFunc + if turnConf.ProxyProtocol { + trusted, err := parsePeerCIDRs("turn.proxy_protocol_trusted_cidrs", turnConf.ProxyProtocolTrustedCIDRs) + if err != nil { + return nil, err + } + if len(trusted) == 0 { + return nil, errors.New("turn.proxy_protocol requires at least one entry in turn.proxy_protocol_trusted_cidrs") + } + proxyPolicy = proxyProtocolPolicy(trusted) + } + + listener, err := net.Listen("tcp", address) + if err != nil { + return nil, errors.Wrap(err, "could not listen on TURN TCP port") + } + if proxyPolicy != nil { + listener = &proxyproto.Listener{Listener: listener, ConnPolicy: proxyPolicy} + } + if tlsConfig != nil { + listener = tls.NewListener(listener, tlsConfig) + } + return listener, nil +} + +// proxyProtocolPolicy requires the PROXY header from trusted proxies and closes +// every other connection, so the header cannot be forged by a direct client. +func proxyProtocolPolicy(trusted []*net.IPNet) proxyproto.ConnPolicyFunc { + return func(opts proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) { + tcpAddr, ok := opts.Upstream.(*net.TCPAddr) + if !ok { + return proxyproto.REJECT, fmt.Errorf("%w: unexpected address %v", proxyproto.ErrInvalidUpstream, opts.Upstream) + } + for _, ipnet := range trusted { + if ipnet.Contains(tcpAddr.IP) { + return proxyproto.REQUIRE, nil + } + } + // wrapping ErrInvalidUpstream closes this connection and keeps the listener accepting + return proxyproto.REJECT, fmt.Errorf("%w: %s is not a trusted proxy", proxyproto.ErrInvalidUpstream, tcpAddr.IP) + } +} + func getTURNAuthHandlerFunc(handler *TURNAuthHandler) turn.AuthHandler { return handler.HandleAuth } diff --git a/pkg/service/turn_test.go b/pkg/service/turn_test.go index ab6b2d9ad..c310df2b4 100644 --- a/pkg/service/turn_test.go +++ b/pkg/service/turn_test.go @@ -278,3 +278,139 @@ func TestTURNAuthHandler_CreateUsername_TTLClamped(t *testing.T) { _, negativeExpiry := h.CreateUsername(turnTestAPIKey, pID, -1<<40) require.InDelta(t, time.Now().Unix()+int64(config.DefaultTURNTTLSeconds), negativeExpiry, 2) } + +func proxyProtocolTURNConfig(trustedCIDRs ...string) config.TURNConfig { + return config.TURNConfig{ExternalTLS: true, ProxyProtocol: true, ProxyProtocolTrustedCIDRs: trustedCIDRs} +} + +func TestNewTURNTCPListener_ProxyProtocol(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("127.0.0.0/8"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan net.Addr, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- nil + return + } + defer conn.Close() + // the PROXY header is consumed lazily, on the first read + buf := make([]byte, 1) + _, _ = conn.Read(buf) + accepted <- conn.RemoteAddr() + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("PROXY TCP4 203.0.113.9 127.0.0.1 40123 443\r\nx")) + require.NoError(t, err) + + select { + case addr := <-accepted: + require.NotNil(t, addr) + require.Equal(t, "203.0.113.9:40123", addr.String()) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the accepted connection") + } +} + +func TestNewTURNTCPListener_ProxyProtocolRejectsBareConnection(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("127.0.0.0/8"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + result := make(chan error, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + result <- err + return + } + defer conn.Close() + buf := make([]byte, 1) + _, err = conn.Read(buf) + result <- err + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, err = conn.Write([]byte("not a proxy header\r\n")) + require.NoError(t, err) + + select { + case err := <-result: + require.Error(t, err) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the rejected connection") + } +} + +func TestNewTURNTCPListener_WithoutProxyProtocol(t *testing.T) { + listener, err := newTURNTCPListener(config.TURNConfig{ExternalTLS: true}, "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan net.Addr, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + accepted <- nil + return + } + defer conn.Close() + accepted <- conn.RemoteAddr() + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + + select { + case addr := <-accepted: + require.NotNil(t, addr) + require.Equal(t, conn.LocalAddr().String(), addr.String()) + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for the accepted connection") + } +} + +func TestNewTURNTCPListener_ProxyProtocolClosesUntrustedProxy(t *testing.T) { + listener, err := newTURNTCPListener(proxyProtocolTURNConfig("203.0.113.0/24"), "127.0.0.1:0") + require.NoError(t, err) + defer listener.Close() + + accepted := make(chan struct{}, 1) + go func() { + if conn, err := listener.Accept(); err == nil { + conn.Close() + accepted <- struct{}{} + } + }() + + conn, err := net.Dial("tcp", listener.Addr().String()) + require.NoError(t, err) + defer conn.Close() + _, _ = conn.Write([]byte("PROXY TCP4 203.0.113.9 127.0.0.1 40123 443\r\nx")) + + require.NoError(t, conn.SetReadDeadline(time.Now().Add(5*time.Second))) + _, err = conn.Read(make([]byte, 1)) + require.Error(t, err, "the listener should have closed the connection") + + select { + case <-accepted: + t.Fatal("connection from an untrusted proxy must not be accepted") + case <-time.After(200 * time.Millisecond): + } +} + +func TestNewTURNTCPListener_ProxyProtocolRequiresTrustedCIDRs(t *testing.T) { + _, err := newTURNTCPListener(proxyProtocolTURNConfig(), "127.0.0.1:0") + require.Error(t, err) + + _, err = newTURNTCPListener(proxyProtocolTURNConfig("not-a-cidr"), "127.0.0.1:0") + require.Error(t, err) +} From d2405a72b16f8579074c4f4ae9128881dd6bf760 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 11 Sep 2026 14:34:23 +0530 Subject: [PATCH 025/130] telemetry: add visibility into stats worker reference underflow (#4856) * telemetry: add visibility into stats worker reference underflow Log the paths that can leave a stats worker with no references, so the `-1` never-closed cases seen in production can be traced to their origin. Co-Authored-By: Claude Fable 5.1 * telemetry: guard StatsWorker.MarshalLogObject against a nil receiver The new worker-created log passes the existing worker, which is a typed nil when there is none. Mirror ReferenceGuard.MarshalLogObject. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/telemetry/statsworker.go | 4 ++++ pkg/telemetry/statsworker_test.go | 6 +++++ pkg/telemetry/telemetryservice.go | 37 ++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 21da5240a..a6746dfb5 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -328,6 +328,10 @@ func (s *StatsWorker) collectStats( } func (s *StatsWorker) MarshalLogObject(e zapcore.ObjectEncoder) error { + if s == nil { + return nil + } + s.lock.RLock() defer s.lock.RUnlock() diff --git a/pkg/telemetry/statsworker_test.go b/pkg/telemetry/statsworker_test.go index f89992c94..1963e44e2 100644 --- a/pkg/telemetry/statsworker_test.go +++ b/pkg/telemetry/statsworker_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" ) func TestStatsWorker(t *testing.T) { @@ -60,4 +61,9 @@ func TestStatsWorker(t *testing.T) { require.Equal(t, 0, survivor.refCount.count) }) }) + + t.Run("logging a nil worker does not panic", func(t *testing.T) { + var w *StatsWorker + require.NoError(t, w.MarshalLogObject(zapcore.NewMapObjectEncoder())) + }) } diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index c8e31efd0..bc838a07e 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -286,6 +286,16 @@ func (t *telemetryService) getOrCreateWorker( t.workersMu.Lock() defer t.workersMu.Unlock() + if roomID == "" { + logger.Warnw( + "telemetry stats worker keyed under an empty room id", nil, + "room", roomName, + "participant", participantIdentity, + "participantID", participantID, + "guard", guard, + ) + } + roomWorkers := t.workers[roomID] worker, ok := roomWorkers[participantID] if ok && !worker.Closed(guard) { @@ -297,6 +307,21 @@ func (t *telemetryService) getOrCreateWorker( existingIsConnected = worker.IsConnected() } + // a guard references at most once, so a nil or already activated guard leaves the + // new worker with no references and its owner's release drives it negative + if guard == nil || guard.activated { + logger.Infow( + "telemetry stats worker created without a reference", + "room", roomName, + "roomID", roomID, + "participant", participantIdentity, + "participantID", participantID, + "guard", guard, + "replacedClosed", ok, + "existing", worker, + ) + } + worker = newStatsWorker( ctx, t, @@ -366,7 +391,17 @@ func (t *telemetryService) reKeyRoom(prevRoomID livekit.RoomID, roomID livekit.R // only one worker can be keyed at (room, participant) and the one already // filed there wins, close the superseded one so that it drains and is // reaped instead of lingering in the flush list unreachable - if worker.ForceClose(survivor) { + forceClosed := worker.ForceClose(survivor) + logger.Infow( + "telemetry force closing superseded stats worker", + "prevRoomID", prevRoomID, + "roomID", roomID, + "participantID", participantID, + "forceClosed", forceClosed, + "superseded", worker, + "survivor", survivor, + ) + if forceClosed { prometheus.SubParticipant() } continue From c6f3ceba106bfad2f0e52b97b56f352c51cf094b Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Fri, 11 Sep 2026 05:41:48 -0700 Subject: [PATCH 026/130] update psrpc for subscription close goroutine leak fix (#4859) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 3683d54f6..b2bdab9dd 100644 --- a/go.mod +++ b/go.mod @@ -22,7 +22,7 @@ require ( github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f - github.com/livekit/psrpc v0.7.6 + github.com/livekit/psrpc v0.7.7 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 diff --git a/go.sum b/go.sum index a1abf139d..49d89faf0 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,8 @@ github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:Bcli github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc= github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs= -github.com/livekit/psrpc v0.7.6 h1:YG07lUMTtf+eaYI2goT9zcVZ0kGJNWN1K6ETNFtv1HQ= -github.com/livekit/psrpc v0.7.6/go.mod h1:DMw15RO7x5XmcgfwzWJYk2In605kx+wu1QRVbPfzf8M= +github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4= +github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= From 7c82d3e103142508b6fd6eb1254858c904143515 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 11 Sep 2026 21:23:13 +0530 Subject: [PATCH 027/130] telemetry: do not recreate a stats worker for a released guard (#4860) * telemetry: do not recreate a stats worker for a released guard A ParticipantActive overtaken by the participant's close arrives with a guard ParticipantLeft already released and replaced the closed worker with one nothing could release. Co-Authored-By: Claude Fable 5.1 * telemetry: handle a released guard independently of map presence A released guard reaching getOrCreateWorker after the closed worker was reaped still created a zero-reference worker. Return nil as found instead, and make SetConnected nil-safe for ParticipantActive. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/telemetry/statsworker.go | 4 ++++ pkg/telemetry/statsworker_test.go | 33 +++++++++++++++++++++++++++++++ pkg/telemetry/telemetryservice.go | 8 ++++++++ 3 files changed, 45 insertions(+) diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index a6746dfb5..03ec21a04 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -205,6 +205,10 @@ func (s *StatsWorker) sealStatsLocked() statsBatch { } func (s *StatsWorker) SetConnected() { + if s == nil { + return + } + s.lock.Lock() s.isConnected = true s.lock.Unlock() diff --git a/pkg/telemetry/statsworker_test.go b/pkg/telemetry/statsworker_test.go index 1963e44e2..82997f469 100644 --- a/pkg/telemetry/statsworker_test.go +++ b/pkg/telemetry/statsworker_test.go @@ -1,10 +1,13 @@ package telemetry import ( + "context" "testing" "github.com/stretchr/testify/require" "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit" ) func TestStatsWorker(t *testing.T) { @@ -67,3 +70,33 @@ func TestStatsWorker(t *testing.T) { require.NoError(t, w.MarshalLogObject(zapcore.NewMapObjectEncoder())) }) } + +func TestGetOrCreateWorkerReleasedGuard(t *testing.T) { + // ParticipantActive overtaken by the participant's close arrives with a guard that + // ParticipantLeft already released. It must not replace the closed worker with one + // nothing can release. + ts := &telemetryService{workers: make(map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker)} + roomID, pID := livekit.RoomID("room"), livekit.ParticipantID("participant") + + var g ReferenceGuard + w, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.False(t, found) + require.True(t, w.Close(&g)) + + t.Run("closed worker still in the map", func(t *testing.T) { + late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.True(t, found) + require.Same(t, w, late) + require.Same(t, w, ts.workers[roomID][pID]) + }) + + t.Run("closed worker already reaped", func(t *testing.T) { + delete(ts.workers[roomID], pID) + + late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) + require.True(t, found) + require.Nil(t, late) + require.Empty(t, ts.workers[roomID]) + late.SetConnected() + }) +} diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index bc838a07e..f316f6e4f 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -302,6 +302,14 @@ func (t *telemetryService) getOrCreateWorker( return worker, true } + // only ParticipantLeft releases a guard, so a released guard is a call landing after + // the participant left, e.g. ParticipantActive overtaken by the close. Do not create + // a worker nothing can ever release. The closed worker, if not yet reaped, is returned + // as found, otherwise nil is + if guard != nil && guard.released { + return worker, true + } + existingIsConnected := false if ok { existingIsConnected = worker.IsConnected() From a487105a64202b6e5c80fcfaa366b644d7e09f16 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 12 Sep 2026 15:33:24 +0530 Subject: [PATCH 028/130] Get participant by authed identity in WHIP participant service. (#4861) Addresses - https://github.com/livekit/livekit/security/advisories/GHSA-gfh2-98qv-ggmw --- pkg/service/roommanager_service.go | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/pkg/service/roommanager_service.go b/pkg/service/roommanager_service.go index d8ea05433..d6ead74ad 100644 --- a/pkg/service/roommanager_service.go +++ b/pkg/service/roommanager_service.go @@ -11,6 +11,7 @@ import ( "google.golang.org/protobuf/types/known/emptypb" "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/rtc" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu/rtpstats" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" @@ -281,13 +282,25 @@ type whipParticipantService struct { *RoomManager } +func getWHIPParticipant( + room *rtc.Room, + identity livekit.ParticipantIdentity, + participantID livekit.ParticipantID, +) types.LocalParticipant { + lp := room.GetParticipant(identity) + if lp == nil || lp.ID() != participantID { + return nil + } + return lp +} + func (r whipParticipantService) ICETrickle(ctx context.Context, req *rpc.WHIPParticipantICETrickleRequest) (*emptypb.Empty, error) { room := r.RoomManager.GetRoom(ctx, livekit.RoomName(req.Room)) if room == nil { return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp == nil { return nil, ErrParticipantNotFound } @@ -318,7 +331,7 @@ func (r whipParticipantService) ICERestart(ctx context.Context, req *rpc.WHIPPar return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp == nil { return nil, ErrParticipantNotFound } @@ -346,7 +359,7 @@ func (r whipParticipantService) DeleteSession(ctx context.Context, req *rpc.WHIP return nil, ErrRoomNotFound } - lp := room.GetParticipantByID(livekit.ParticipantID(req.ParticipantId)) + lp := getWHIPParticipant(room, livekit.ParticipantIdentity(req.GetParticipantIdentity()), livekit.ParticipantID(req.GetParticipantId())) if lp != nil { room.RemoveParticipant( lp.Identity(), From 1d3ff5621c4577151d6657318018d5a732eea0e7 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 12 Sep 2026 20:00:53 +0530 Subject: [PATCH 029/130] rtc: add GetSubscribedDataTracks to LocalParticipant (#4862) SubscriptionManager tracks data-track subscriptions but exposed no accessor for the active ones, so callers could see subscribed and published media tracks and published data tracks, but not subscribed data tracks. Add GetSubscribedDataTracks(), returning the bound data down-tracks, mirroring GetSubscribedTracks() for media, so a participant's full track set can be accounted for. Regenerated the LocalParticipant fake. Co-authored-by: Claude Opus 4.8 --- pkg/rtc/subscriptionmanager.go | 13 ++++ pkg/rtc/types/interfaces.go | 1 + .../typesfakes/fake_local_participant.go | 63 +++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index b65b8b063..38d939f22 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -307,6 +307,19 @@ func (m *SubscriptionManager) GetSubscribedTracks() []types.SubscribedTrack { return tracks } +func (m *SubscriptionManager) GetSubscribedDataTracks() []types.DataDownTrack { + m.lock.RLock() + defer m.lock.RUnlock() + + tracks := make([]types.DataDownTrack, 0, len(m.dataTrackSubscriptions)) + for _, s := range m.dataTrackSubscriptions { + if dt := s.getDataDownTrack(); dt != nil { + tracks = append(tracks, dt) + } + } + return tracks +} + func (m *SubscriptionManager) IsTrackNameSubscribed(publisherIdentity livekit.ParticipantIdentity, trackName string) bool { m.lock.RLock() defer m.lock.RUnlock() diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index bfd06e322..fa8e260b8 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -548,6 +548,7 @@ type LocalParticipant interface { UnsubscribeFromTrack(trackID livekit.TrackID) UpdateSubscribedTrackSettings(trackID livekit.TrackID, settings *livekit.UpdateTrackSettings) GetSubscribedTracks() []SubscribedTrack + GetSubscribedDataTracks() []DataDownTrack IsTrackNameSubscribed(publisherIdentity livekit.ParticipantIdentity, trackName string) bool SubscribeToDataTrack(trackID livekit.TrackID) UnsubscribeFromDataTrack(trackID livekit.TrackID) diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index cd94f6f26..b6f349e70 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -557,6 +557,16 @@ type FakeLocalParticipant struct { getResponseSinkReturnsOnCall map[int]struct { result1 routing.MessageSink } + GetSubscribedDataTracksStub func() []types.DataDownTrack + getSubscribedDataTracksMutex sync.RWMutex + getSubscribedDataTracksArgsForCall []struct { + } + getSubscribedDataTracksReturns struct { + result1 []types.DataDownTrack + } + getSubscribedDataTracksReturnsOnCall map[int]struct { + result1 []types.DataDownTrack + } GetSubscribedParticipantsStub func() []livekit.ParticipantID getSubscribedParticipantsMutex sync.RWMutex getSubscribedParticipantsArgsForCall []struct { @@ -4413,6 +4423,59 @@ func (fake *FakeLocalParticipant) GetResponseSinkReturnsOnCall(i int, result1 ro }{result1} } +func (fake *FakeLocalParticipant) GetSubscribedDataTracks() []types.DataDownTrack { + fake.getSubscribedDataTracksMutex.Lock() + ret, specificReturn := fake.getSubscribedDataTracksReturnsOnCall[len(fake.getSubscribedDataTracksArgsForCall)] + fake.getSubscribedDataTracksArgsForCall = append(fake.getSubscribedDataTracksArgsForCall, struct { + }{}) + stub := fake.GetSubscribedDataTracksStub + fakeReturns := fake.getSubscribedDataTracksReturns + fake.recordInvocation("GetSubscribedDataTracks", []interface{}{}) + fake.getSubscribedDataTracksMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksCallCount() int { + fake.getSubscribedDataTracksMutex.RLock() + defer fake.getSubscribedDataTracksMutex.RUnlock() + return len(fake.getSubscribedDataTracksArgsForCall) +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksCalls(stub func() []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = stub +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksReturns(result1 []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = nil + fake.getSubscribedDataTracksReturns = struct { + result1 []types.DataDownTrack + }{result1} +} + +func (fake *FakeLocalParticipant) GetSubscribedDataTracksReturnsOnCall(i int, result1 []types.DataDownTrack) { + fake.getSubscribedDataTracksMutex.Lock() + defer fake.getSubscribedDataTracksMutex.Unlock() + fake.GetSubscribedDataTracksStub = nil + if fake.getSubscribedDataTracksReturnsOnCall == nil { + fake.getSubscribedDataTracksReturnsOnCall = make(map[int]struct { + result1 []types.DataDownTrack + }) + } + fake.getSubscribedDataTracksReturnsOnCall[i] = struct { + result1 []types.DataDownTrack + }{result1} +} + func (fake *FakeLocalParticipant) GetSubscribedParticipants() []livekit.ParticipantID { fake.getSubscribedParticipantsMutex.Lock() ret, specificReturn := fake.getSubscribedParticipantsReturnsOnCall[len(fake.getSubscribedParticipantsArgsForCall)] From 8d11efdfcd4220092b6ac7b8a21af28526da5a6b Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 14 Sep 2026 10:20:40 +0530 Subject: [PATCH 030/130] Release v1.13.7. (#4866) Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ version/version.go | 2 +- 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 864055f60..aa86675f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.7] - 2026-09-14 + +### Added + +- Participant kind agent details (#4809) +- report a reason on room-ended telemetry (#4815) +- telemetry: support roomID change for a participant (#4816) +- log telemetry guard details (#4817) +- ingress: add opt-in support for udp:// URL pull ingress (#4810) +- Support VP9/AV1 simulcast (#4830) +- add test-server support for compressed join requests (#4834) +- wire psrpc bus compression into the message bus constructor (#4844) +- turn: accept PROXY protocol on the TCP listener (#4852) +- telemetry: add visibility into stats worker reference underflow (#4856) +- rtc: add GetSubscribedDataTracks to LocalParticipant (#4862) + +### Changed + +- Skip the docker-backed service tests when there is no docker (#4799) +- Wait for the callbacks these tests assert on. (#4803) +- utils: make Median generic, overflow-safe, and add tests (#4553) +- warp log (#4811) +- Remove duplication within cpuload and sysload (#4818) +- Update github.com/livekit/protocol digest to a469dd4 (#4820) +- update ice fork for warp (#4824) +- Move data track packet serialization to protocol (#4829) +- Update README to include snippet about Agents (#4808) +- protocol update for SDP unmarshal hardening (#4836) +- Update golang Docker tag to v1.26.7 (#4846) +- README rewrite (#4837) + +### Fixed + +- Set up track info properly for dummy receiver. (#4807) +- Do not escape the ICE server URI in the WHIP Link header (#4795) +- fix: hold signal messages until the ReconnectResponse goes out (#4827) +- Fix/reconcile data track subscription deadlock (#4843) +- telemetry: drop unused getCPUStats, unblocking darwin builds without cgo (#4841) +- update psrpc for subscription close goroutine leak fix (#4859) +- telemetry: do not recreate a stats worker for a released guard (#4860) +- Get participant by authed identity in WHIP participant service. (#4861) + ## [1.13.6] - 2026-08-26 ### Added diff --git a/version/version.go b/version/version.go index 29d306b8c..0486d6293 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.6" +const Version = "1.13.7" From 776185e4a5b74210f048327f57880311ce3033e0 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 22:38:49 -0700 Subject: [PATCH 031/130] Update module github.com/gotesttools/gotestfmt/v2 to v2.5.0 (#4865) 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 b2bdab9dd..e131ed8c0 100644 --- a/go.mod +++ b/go.mod @@ -74,7 +74,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/gotesttools/gotestfmt/v2 v2.4.1 // indirect + github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect diff --git a/go.sum b/go.sum index 49d89faf0..08d64c4d9 100644 --- a/go.sum +++ b/go.sum @@ -105,8 +105,8 @@ github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4= github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/gotesttools/gotestfmt/v2 v2.4.1 h1:Ml+KPqPocp/KckpizL+tgsy/dlddI4/z2w6lgS7YIFE= -github.com/gotesttools/gotestfmt/v2 v2.4.1/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= +github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4= +github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= From 08c5433449c106943231037d0b892895f6e92d3b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:04:12 -0700 Subject: [PATCH 032/130] Update module github.com/urfave/cli/v3 to v3.11.0 (#4867) 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 e131ed8c0..cc7cdadf6 100644 --- a/go.mod +++ b/go.mod @@ -143,7 +143,7 @@ require ( github.com/prometheus/client_model v0.6.3 // indirect github.com/prometheus/common v0.71.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect - github.com/urfave/cli/v3 v3.10.1 + github.com/urfave/cli/v3 v3.11.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 08d64c4d9..42e1d8aca 100644 --- a/go.sum +++ b/go.sum @@ -307,8 +307,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.10.1 h1:7Kx9H50hrHbRbyxgO1KP6/BcbiGRz0uYh5YyQ30JEEY= -github.com/urfave/cli/v3 v3.10.1/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= +github.com/urfave/cli/v3 v3.11.0 h1:P/euJp99kb9p0tlVY+iYTLYYTAQlfl0hR2gUO1Img1Q= +github.com/urfave/cli/v3 v3.11.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 0392ca90b40503438c0022cfc895955e05d65457 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 15 Sep 2026 21:34:10 +0530 Subject: [PATCH 033/130] sfu: return VP8 munged header by value to avoid per-packet allocation (#4871) CodecMunger.UpdateAndGet returned a freshly allocated slice for every forwarded packet on every down track. Return the header in a fixed array with a length instead and marshal into it with MarshalTo. TranslationParams carries the array; WriteRTP slices it locally. UpdateAndGet now allocates zero times per call (was one). Escape analysis confirms TranslationParams stays on the stack. Co-authored-by: Claude Fable 5.1 --- pkg/sfu/codecmunger/codecmunger.go | 7 ++++++- pkg/sfu/codecmunger/null.go | 4 ++-- pkg/sfu/codecmunger/vp8.go | 21 ++++++++++---------- pkg/sfu/codecmunger/vp8_test.go | 32 +++++++++++++++--------------- pkg/sfu/downtrack.go | 9 +++++---- pkg/sfu/forwarder.go | 11 ++++++++-- pkg/sfu/forwarder_test.go | 22 +++++++++++++++----- 7 files changed, 66 insertions(+), 40 deletions(-) diff --git a/pkg/sfu/codecmunger/codecmunger.go b/pkg/sfu/codecmunger/codecmunger.go index 413af9688..47566a96e 100644 --- a/pkg/sfu/codecmunger/codecmunger.go +++ b/pkg/sfu/codecmunger/codecmunger.go @@ -26,6 +26,9 @@ var ( ErrFilteredVP8TemporalLayer = errors.New("filtered VP8 temporal layer") ) +// MaxHeaderSize is the largest codec header the mungers produce (VP8: 6 bytes). +const MaxHeaderSize = 8 + type CodecMunger interface { GetState() any SeedState(state any) @@ -33,7 +36,9 @@ type CodecMunger interface { SetLast(extPkt *buffer.ExtPacket) UpdateOffsets(extPkt *buffer.ExtPacket) - UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, []byte, error) + // UpdateAndGet returns the incoming codec header size and the munged + // header by value, so the per-packet path does not allocate. + UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, [MaxHeaderSize]byte, int, error) UpdateAndGetPadding(newPicture bool) ([]byte, error) } diff --git a/pkg/sfu/codecmunger/null.go b/pkg/sfu/codecmunger/null.go index 616786a69..41f5cb405 100644 --- a/pkg/sfu/codecmunger/null.go +++ b/pkg/sfu/codecmunger/null.go @@ -45,8 +45,8 @@ func (n *Null) SetLast(_extPkt *buffer.ExtPacket) { func (n *Null) UpdateOffsets(_extPkt *buffer.ExtPacket) { } -func (n *Null) UpdateAndGet(_extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, []byte, error) { - return 0, nil, nil +func (n *Null) UpdateAndGet(_extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporal int32) (int, [MaxHeaderSize]byte, int, error) { + return 0, [MaxHeaderSize]byte{}, 0, nil } func (n *Null) UpdateAndGetPadding(newPicture bool) ([]byte, error) { diff --git a/pkg/sfu/codecmunger/vp8.go b/pkg/sfu/codecmunger/vp8.go index f6f45e1e6..4ffc7e65a 100644 --- a/pkg/sfu/codecmunger/vp8.go +++ b/pkg/sfu/codecmunger/vp8.go @@ -153,10 +153,11 @@ func (v *VP8) UpdateOffsets(extPkt *buffer.ExtPacket) { v.exemptedPictureIds = orderedmap.NewOrderedMap[int32, bool]() } -func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporalLayer int32) (int, []byte, error) { +func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { + var hdr [MaxHeaderSize]byte vp8, ok := extPkt.Payload.(codec.VP8) if !ok { - return 0, nil, ErrNotVP8 + return 0, hdr, 0, ErrNotVP8 } extPictureId := v.pictureIdWrapHandler.Unwrap(vp8.PictureID, vp8.M) @@ -165,7 +166,7 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap if snOutOfOrder { pictureIdOffset, ok := v.missingPictureIds.Get(extPictureId) if !ok { - return 0, nil, ErrOutOfOrderVP8PictureIdCacheMiss + return 0, hdr, 0, ErrOutOfOrderVP8PictureIdCacheMiss } // the out-of-order picture id cannot be deleted from the cache @@ -190,11 +191,11 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap IsKeyFrame: vp8.IsKeyFrame, HeaderSize: vp8.HeaderSize + codec.VPxPictureIdSizeDiff(mungedPictureId > 127, vp8.M), } - vp8HeaderBytes, err := vp8Packet.Marshal() + n, err := vp8Packet.MarshalTo(hdr[:]) if err != nil { - return 0, nil, err + return 0, hdr, 0, err } - return vp8.HeaderSize, vp8HeaderBytes, nil + return vp8.HeaderSize, hdr, n, nil } prevMaxPictureId := v.pictureIdWrapHandler.MaxPictureId() @@ -262,7 +263,7 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap v.pictureIdOffset += 1 } - return 0, nil, ErrFilteredVP8TemporalLayer + return 0, hdr, 0, ErrFilteredVP8TemporalLayer } } } @@ -297,11 +298,11 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap IsKeyFrame: vp8.IsKeyFrame, HeaderSize: vp8.HeaderSize + codec.VPxPictureIdSizeDiff(mungedPictureId > 127, vp8.M), } - vp8HeaderBytes, err := vp8Packet.Marshal() + n, err := vp8Packet.MarshalTo(hdr[:]) if err != nil { - return 0, nil, err + return 0, hdr, 0, err } - return vp8.HeaderSize, vp8HeaderBytes, nil + return vp8.HeaderSize, hdr, n, nil } func (v *VP8) UpdateAndGetPadding(newPicture bool) ([]byte, error) { diff --git a/pkg/sfu/codecmunger/vp8_test.go b/pkg/sfu/codecmunger/vp8_test.go index 2c5714bed..7c6d1a36f 100644 --- a/pkg/sfu/codecmunger/vp8_test.go +++ b/pkg/sfu/codecmunger/vp8_test.go @@ -195,11 +195,11 @@ func TestOutOfOrderPictureId(t *testing.T) { vp8.PictureID = 13466 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err := v.UpdateAndGet(extPkt, true, false, 2) + nIn, hdr, nOut, err := v.UpdateAndGet(extPkt, true, false, 2) require.Error(t, err) require.ErrorIs(t, err, ErrOutOfOrderVP8PictureIdCacheMiss) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) // create a hole in picture id vp8.PictureID = 13469 @@ -222,10 +222,10 @@ func TestOutOfOrderPictureId(t *testing.T) { } marshalledVP8, err := expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, false, true, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, false, true, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) // all three, the last, the current and the in-between should have been added to missing picture id cache value, ok := v.PictureIdOffset(13467) @@ -261,10 +261,10 @@ func TestOutOfOrderPictureId(t *testing.T) { } marshalledVP8, err = expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, true, false, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, true, false, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) } func TestTemporalLayerFiltering(t *testing.T) { @@ -294,11 +294,11 @@ func TestTemporalLayerFiltering(t *testing.T) { v.SetLast(extPkt) // translate - nIn, buf, err := v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err := v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ := v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -308,11 +308,11 @@ func TestTemporalLayerFiltering(t *testing.T) { params.SequenceNumber = 23334 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err = v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -322,11 +322,11 @@ func TestTemporalLayerFiltering(t *testing.T) { params.SequenceNumber = 23337 extPkt, _ = testutils.GetTestExtPacketVP8(params, vp8) - nIn, buf, err = v.UpdateAndGet(extPkt, false, false, 0) + nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) require.Equal(t, 0, nIn) - require.Nil(t, buf) + require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) require.True(t, dropped) require.EqualValues(t, 1, v.pictureIdOffset) @@ -376,10 +376,10 @@ func TestGapInSequenceNumberSamePicture(t *testing.T) { } marshalledVP8, err := expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err := v.UpdateAndGet(extPkt, false, false, 2) + nIn, hdr, nOut, err := v.UpdateAndGet(extPkt, false, false, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) // telling there is a gap in sequence number will add pictures to missing picture cache expectedVP8 = &codec.VP8{ @@ -399,10 +399,10 @@ func TestGapInSequenceNumberSamePicture(t *testing.T) { } marshalledVP8, err = expectedVP8.Marshal() require.NoError(t, err) - nIn, buf, err = v.UpdateAndGet(extPkt, false, true, 2) + nIn, hdr, nOut, err = v.UpdateAndGet(extPkt, false, true, 2) require.NoError(t, err) require.Equal(t, 6, nIn) - require.Equal(t, marshalledVP8, buf) + require.Equal(t, marshalledVP8, hdr[:nOut]) value, ok := v.PictureIdOffset(13467) require.True(t, ok) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 1b993158c..e48d717d7 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1044,10 +1044,11 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { return 0 } + codecBytes := tp.codecHeader() poolEntity := PacketFactory.Get().(*[]byte) payload := *poolEntity - copy(payload, tp.codecBytes) - n := copy(payload[len(tp.codecBytes):], extPkt.Packet.Payload[tp.incomingHeaderSize:]) + copy(payload, codecBytes) + n := copy(payload[len(codecBytes):], extPkt.Packet.Payload[tp.incomingHeaderSize:]) if n != len(extPkt.Packet.Payload[tp.incomingHeaderSize:]) { d.params.Logger.Errorw( "payload overflow", errPayloadOverflow, @@ -1057,7 +1058,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { PacketFactory.Put(poolEntity) return 0 } - payload = payload[:len(tp.codecBytes)+n] + payload = payload[:len(codecBytes)+n] trailerStripped := 0 if d.params.StripPacketTrailer { @@ -1129,7 +1130,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { tp.rtp.extTimestamp, hdr.Marker, int8(layer), - payload[:len(tp.codecBytes)], + payload[:len(codecBytes)], tp.incomingHeaderSize, tp.ddBytes, actBytes, diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 6d9069151..5dfc69038 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -201,12 +201,18 @@ type TranslationParams struct { rtp TranslationParamsRTP ddBytes []byte incomingHeaderSize int - codecBytes []byte + codecBytes [codecmunger.MaxHeaderSize]byte + codecBytesLen int marker bool // end of the svc spatial layer frame isEndOfLayerFrame bool } +// codecHeader returns the munged codec header to prepend to the payload. +func (tp *TranslationParams) codecHeader() []byte { + return tp.codecBytes[:tp.codecBytesLen] +} + // ------------------------------------------------------------------- type refInfo struct { @@ -2223,7 +2229,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *TranslationParams) error { // codec specific forwarding check and any needed packet munging tl := f.vls.SelectTemporal(extPkt) - inputSize, codecBytes, err := f.codecMunger.UpdateAndGet( + inputSize, codecBytes, codecBytesLen, err := f.codecMunger.UpdateAndGet( extPkt, tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder, tp.rtp.snOrdering == SequenceNumberOrderingGap, @@ -2243,6 +2249,7 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati } tp.incomingHeaderSize = inputSize tp.codecBytes = codecBytes + tp.codecBytesLen = codecBytesLen return nil } diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index b9d8170f8..defec62db 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -27,6 +27,7 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/codecmunger" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/testutils" ) @@ -1598,7 +1599,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), marker: true, } actualTP, err = f.GetTranslationParams(extPkt, 0) @@ -1677,7 +1679,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1731,7 +1734,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1819,7 +1823,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdef, }, incomingHeaderSize: 6, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 0) require.NoError(t, err) @@ -1918,7 +1923,8 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { extTimestamp: 0xabcdf0, }, incomingHeaderSize: 5, - codecBytes: marshalledVP8, + codecBytes: codecHeaderOf(marshalledVP8), + codecBytesLen: len(marshalledVP8), } actualTP, err = f.GetTranslationParams(extPkt, 1) require.NoError(t, err) @@ -2249,3 +2255,9 @@ func TestForwarderIsEndOfLayerFrame(t *testing.T) { DependencyDescriptor: &buffer.ExtDependencyDescriptor{}, })) } + +func codecHeaderOf(b []byte) [codecmunger.MaxHeaderSize]byte { + var hdr [codecmunger.MaxHeaderSize]byte + copy(hdr[:], b) + return hdr +} From 61a31d31ff80ba29c8b8e7530840b87736f49ab9 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 15 Sep 2026 21:48:24 +0530 Subject: [PATCH 034/130] sfu: patch pacer header extensions without per-packet allocation (#4872) SendPacket allocated four times per down track write: a 3 byte slice for abs-send-time, a 2 byte slice for transport-cc, and two appends to a nil Extensions slice because the pooled header was reset to a zero value. Give pacer.Packet fixed scratch arrays for the two extensions and marshal into them. The Packet is pooled and owned by one send until SendPacket returns, so nothing is shared across down tracks. Keep the pooled header's Extensions capacity across reuse, both when returning it to the pool and when a down track initializes it. Adds a test that asserts zero allocations per SendPacket. Co-authored-by: Claude Fable 5.1 --- pkg/sfu/downtrack.go | 16 ++++---- pkg/sfu/pacer/base.go | 14 ++++--- pkg/sfu/pacer/base_test.go | 84 ++++++++++++++++++++++++++++++++++++++ pkg/sfu/pacer/pacer.go | 5 +++ pkg/sfu/sfu.go | 7 ++++ 5 files changed, 112 insertions(+), 14 deletions(-) create mode 100644 pkg/sfu/pacer/base_test.go diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index e48d717d7..d83c020f4 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1069,7 +1069,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { // translate RTP header hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: extPkt.Packet.Version, Padding: false, Marker: tp.marker, @@ -1077,7 +1077,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { SequenceNumber: uint16(tp.rtp.extSequenceNumber), Timestamp: uint32(tp.rtp.extTimestamp), SSRC: d.ssrc, - } + }) // add extensions if d.dependencyDescriptorExtID != 0 && tp.ddBytes != nil { @@ -1281,7 +1281,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa payloads := make([]byte, RTPPaddingMaxPayloadSize*len(snts)) for i := range snts { hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: 2, Padding: true, PaddingSize: byte(RTPPaddingMaxPayloadSize), @@ -1290,7 +1290,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa SequenceNumber: uint16(snts[i].extSequenceNumber), Timestamp: uint32(snts[i].extTimestamp), SSRC: d.ssrc, - } + }) d.addDummyExtensions(hdr) payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] @@ -2194,7 +2194,7 @@ func (d *DownTrack) retransmitPacket(epm *extPacketMeta, sourcePkt []byte, isPro return 0, errPayloadOverflow } hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: pkt.Header.Version, Padding: false, Marker: epm.marker, @@ -2202,7 +2202,7 @@ func (d *DownTrack) retransmitPacket(epm *extPacketMeta, sourcePkt []byte, isPro SequenceNumber: epm.targetSeqNo, Timestamp: epm.timestamp, SSRC: d.ssrc, - } + }) rtxOffset := 0 var rtxExtSequenceNumber uint64 if rtxPT := d.payloadTypeRTX.Load(); rtxPT != 0 && d.ssrcRTX != 0 { @@ -2434,7 +2434,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { for i := range num { rtxExtSequenceNumber := d.rtxSequenceNumber.Inc() hdr := RTPHeaderFactory.Get().(*rtp.Header) - *hdr = rtp.Header{ + initPooledRTPHeader(hdr, rtp.Header{ Version: 2, Padding: true, PaddingSize: byte(RTPPaddingMaxPayloadSize), @@ -2443,7 +2443,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { SequenceNumber: uint16(rtxExtSequenceNumber), Timestamp: 0, SSRC: d.ssrcRTX, - } + }) d.addDummyExtensions(hdr) payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] diff --git a/pkg/sfu/pacer/base.go b/pkg/sfu/pacer/base.go index 96e3be250..b5a35e9e8 100644 --- a/pkg/sfu/pacer/base.go +++ b/pkg/sfu/pacer/base.go @@ -58,7 +58,11 @@ func (b *Base) TimeSinceLastSentPacket() time.Duration { func (b *Base) SendPacket(p *Packet) (int, error) { defer func() { if p.HeaderPool != nil && p.Header != nil { + // keep Extensions capacity so the next user does not allocate on SetExtension + exts := p.Header.Extensions + clear(exts) *p.Header = rtp.Header{} + p.Header.Extensions = exts[:0] p.HeaderPool.Put(p.Header) } @@ -95,12 +99,11 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { absSendTimeExt := rtp.AbsSendTimeExtension{ Timestamp: uint64(mediatransportutil.ToNtpTime(sendingAt) >> 14), } - absSendTimeBytes, err := absSendTimeExt.Marshal() - if err != nil { + if _, err := absSendTimeExt.MarshalTo(p.absSendTimeBuf[:]); err != nil { return err } - if err = p.Header.SetExtension(p.AbsSendTimeExtID, absSendTimeBytes); err != nil { + if err := p.Header.SetExtension(p.AbsSendTimeExtID, p.absSendTimeBuf[:]); err != nil { return err } @@ -119,12 +122,11 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { twccExt := rtp.TransportCCExtension{ TransportSequence: twccSN, } - twccExtBytes, err := twccExt.Marshal() - if err != nil { + if _, err := twccExt.MarshalTo(p.twccBuf[:]); err != nil { return err } - if err = p.Header.SetExtension(p.TransportWideExtID, twccExtBytes); err != nil { + if err := p.Header.SetExtension(p.TransportWideExtID, p.twccBuf[:]); err != nil { return err } diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go new file mode 100644 index 000000000..3b68fa168 --- /dev/null +++ b/pkg/sfu/pacer/base_test.go @@ -0,0 +1,84 @@ +// 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 pacer + +import ( + "sync" + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/sfu/bwe" +) + +// bwe.NullBWE is meant to be embedded and lacks Type +type nullBWE struct{ *bwe.NullBWE } + +func (nullBWE) Type() bwe.BWEType { return bwe.BWETypeNone } + +// records extension sizes without allocating so AllocsPerRun measures only SendPacket +type extCheckWriter struct { + writes int + absSendTimeLen int + transportWideLen int +} + +func (w *extCheckWriter) WriteRTP(header *rtp.Header, _ []byte) (int, error) { + w.writes++ + w.absSendTimeLen += len(header.GetExtension(1)) + w.transportWideLen += len(header.GetExtension(2)) + return 0, nil +} + +func (w *extCheckWriter) Write(_ []byte) (int, error) { return 0, nil } + +// SendPacket patches abs-send-time and transport-cc into a pooled header, +// this must not allocate per packet +func TestSendPacketHeaderExtensionsNoAlloc(t *testing.T) { + b := NewBase(logger.GetLogger(), nullBWE{&bwe.NullBWE{}}) + headerPool := &sync.Pool{New: func() any { return &rtp.Header{} }} + w := &extCheckWriter{} + payload := make([]byte, 100) + + send := func() { + hdr := headerPool.Get().(*rtp.Header) + exts := hdr.Extensions[:0] + *hdr = rtp.Header{Version: 2, SequenceNumber: 1, Timestamp: 2, SSRC: 3} + hdr.Extensions = exts + + p := PacketFactory.Get().(*Packet) + *p = Packet{ + Header: hdr, + HeaderPool: headerPool, + HeaderSize: hdr.MarshalSize(), + Payload: payload, + AbsSendTimeExtID: 1, + TransportWideExtID: 2, + WriteStream: w, + } + _, err := b.SendPacket(p) + require.NoError(t, err) + } + + send() // warm the pools; AllocsPerRun also runs once before measuring + allocs := testing.AllocsPerRun(1000, send) + require.Equal(t, 1002, w.writes) + require.Equal(t, 3*w.writes, w.absSendTimeLen) + require.Equal(t, 2*w.writes, w.transportWideLen) + require.Equal(t, 0.0, allocs, "allocations per SendPacket") +} diff --git a/pkg/sfu/pacer/pacer.go b/pkg/sfu/pacer/pacer.go index f23212519..199a8bf17 100644 --- a/pkg/sfu/pacer/pacer.go +++ b/pkg/sfu/pacer/pacer.go @@ -54,6 +54,11 @@ type Packet struct { WriteStream webrtc.TrackLocalWriter Pool *sync.Pool PoolEntity *[]byte + + // per-packet scratch for header extensions patched at send time, + // the Packet is owned by one send until SendPacket returns + absSendTimeBuf [3]byte + twccBuf [2]byte } type Pacer interface { diff --git a/pkg/sfu/sfu.go b/pkg/sfu/sfu.go index c8760d069..1cd41d13f 100644 --- a/pkg/sfu/sfu.go +++ b/pkg/sfu/sfu.go @@ -34,3 +34,10 @@ var ( }, } ) + +// initPooledRTPHeader sets hdr to h while keeping the pooled header's +// Extensions capacity, so SetExtension does not allocate per packet. +func initPooledRTPHeader(hdr *rtp.Header, h rtp.Header) { + h.Extensions = hdr.Extensions[:0] + *hdr = h +} From 482fbe2cf7d030e4b79d2995f0e94ff6a9670100 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 16 Sep 2026 02:59:09 +0530 Subject: [PATCH 035/130] sfu: skip pacer allocation assertion under the race detector (#4874) sync.Pool drops a quarter of returned items when built with -race, so the pooled send path averages about one allocation per packet in CI and the zero-allocation assertion flips between passing and failing. Keep the extension checks and skip only the allocation count under race. Co-authored-by: Claude Fable 5.1 --- pkg/sfu/pacer/base_test.go | 5 ++++- pkg/sfu/utils/norace.go | 6 ++++++ pkg/sfu/utils/race.go | 8 ++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) create mode 100644 pkg/sfu/utils/norace.go create mode 100644 pkg/sfu/utils/race.go diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go index 3b68fa168..3842203a6 100644 --- a/pkg/sfu/pacer/base_test.go +++ b/pkg/sfu/pacer/base_test.go @@ -24,6 +24,7 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/livekit-server/pkg/sfu/bwe" + "github.com/livekit/livekit-server/pkg/sfu/utils" ) // bwe.NullBWE is meant to be embedded and lacks Type @@ -80,5 +81,7 @@ func TestSendPacketHeaderExtensionsNoAlloc(t *testing.T) { require.Equal(t, 1002, w.writes) require.Equal(t, 3*w.writes, w.absSendTimeLen) require.Equal(t, 2*w.writes, w.transportWideLen) - require.Equal(t, 0.0, allocs, "allocations per SendPacket") + if !utils.RaceEnabled { + require.Equal(t, 0.0, allocs, "allocations per SendPacket") + } } diff --git a/pkg/sfu/utils/norace.go b/pkg/sfu/utils/norace.go new file mode 100644 index 000000000..ba683f0f7 --- /dev/null +++ b/pkg/sfu/utils/norace.go @@ -0,0 +1,6 @@ +//go:build !race + +package utils + +// RaceEnabled reports whether the binary was built with the race detector. +const RaceEnabled = false diff --git a/pkg/sfu/utils/race.go b/pkg/sfu/utils/race.go new file mode 100644 index 000000000..72223acef --- /dev/null +++ b/pkg/sfu/utils/race.go @@ -0,0 +1,8 @@ +//go:build race + +package utils + +// RaceEnabled reports whether the binary was built with the race detector. +// sync.Pool drops a quarter of returned items under it, so allocation counts +// on pooled paths are not meaningful. +const RaceEnabled = true From 4ea0facf856aa19ea601a896fc3db941420fe151 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 16 Sep 2026 03:09:40 +0530 Subject: [PATCH 036/130] Update pion/sdp to get lesser SDP retention (#4873) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index cc7cdadf6..1e0088fe5 100644 --- a/go.mod +++ b/go.mod @@ -36,7 +36,7 @@ require ( github.com/pion/rtcp v1.2.17 github.com/pion/rtp v1.10.5 github.com/pion/sctp v1.11.1 - github.com/pion/sdp/v3 v3.0.19 + github.com/pion/sdp/v3 v3.0.20 github.com/pion/transport/v4 v4.1.0 github.com/pion/turn/v5 v5.0.13 github.com/pion/webrtc/v4 v4.2.18 diff --git a/go.sum b/go.sum index 42e1d8aca..df7274bd3 100644 --- a/go.sum +++ b/go.sum @@ -253,8 +253,8 @@ github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= github.com/pion/sctp v1.11.1/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/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ= +github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q= github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q= github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= From 48700da3f3f2c3b514f673308bed861ec09fd729 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 16 Sep 2026 11:53:54 +0530 Subject: [PATCH 037/130] sfu: broadcast RTP to down tracks without per-packet allocations (#4875) * sfu: broadcast RTP to down tracks without a per-packet closure Every forwarded packet allocated a closure capturing the packet and layer, and an atomic write counter that escaped with it, even for tracks with a single subscriber. Add BroadcastRTP, which carries the packet and layer in the worker state and sums the writes per worker. The serial path no longer allocates; the parallel path allocates the shared state and the worker funcval only. Co-Authored-By: Claude Fable 5.1 * sfu: reuse the forwarded packet copy in the RED receivers Both RED receivers copied the ExtPacket (and the opus one the rtp.Packet) per forwarded packet, and the copies moved to the heap because the broadcast takes their address. Keep them on the receiver like the existing redPayloadBuf: ForwardRTP runs on one goroutine and down tracks do not keep the packet past WriteRTP. Co-Authored-By: Claude Fable 5.1 * sfu: skip BroadcastRTP allocation assertion under the race detector Co-Authored-By: Claude Fable 5.1 * sfu: group BroadcastRTP and its interfaces after the spreader methods Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/sfu/receiver_base.go | 11 ++- pkg/sfu/redprimaryreceiver.go | 21 +++--- pkg/sfu/redreceiver.go | 24 +++---- pkg/sfu/utils/downtrackspreader.go | 95 +++++++++++++++++++++---- pkg/sfu/utils/downtrackspreader_test.go | 84 ++++++++++++++++++++++ 5 files changed, 192 insertions(+), 43 deletions(-) create mode 100644 pkg/sfu/utils/downtrackspreader_test.go diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index 80874a893..617d7fc64 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -962,12 +962,9 @@ func (r *ReceiverBase) forwardRTP( continue } - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(extPkt, spatialLayer)) - }) + writeCount := sfuutils.BroadcastRTP(r.downTrackSpreader, extPkt, spatialLayer) if rt := r.loadREDTransformer(); rt != nil { - writeCount.Add(rt.ForwardRTP(extPkt, spatialLayer)) + writeCount += rt.ForwardRTP(extPkt, spatialLayer) } // track delay/jitter @@ -977,13 +974,13 @@ func (r *ReceiverBase) forwardRTP( // 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 writeCount > 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", "latency", time.Duration(latency), "queuingLatency", time.Duration(dequeuedAt-extPkt.Arrival), - "writeCount", writeCount.Load(), + "writeCount", writeCount, "isOutOfOrder", extPkt.IsOutOfOrder, "layer", layer, ) diff --git a/pkg/sfu/redprimaryreceiver.go b/pkg/sfu/redprimaryreceiver.go index 17b639abb..a34ef8586 100644 --- a/pkg/sfu/redprimaryreceiver.go +++ b/pkg/sfu/redprimaryreceiver.go @@ -50,6 +50,10 @@ type RedPrimaryReceiver struct { // bitset for upstream packet receive history [lastSeq-8, lastSeq-1], bit 1 represents packet received pktHistory byte + + // forwarded packet, reused since ForwardRTP runs on one goroutine and + // down tracks do not keep the packet past WriteRTP + sendExtPkt buffer.ExtPacket } func NewRedPrimaryReceiver(receiver TrackReceiver, dsp utils.DownTrackSpreaderParams) REDTransformer { @@ -73,11 +77,7 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 if pkt.Packet.PayloadType != r.redPT { // forward non-red packet directly - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(pkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pkt, spatialLayer) } pkts, err := r.getSendPktsFromRed(pkt.Packet) @@ -86,9 +86,10 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 return 0 } - var writeCount atomic.Int32 + var writeCount int32 for i, sendPkt := range pkts { - pPkt := *pkt + pPkt := &r.sendExtPkt + *pPkt = *pkt if i != len(pkts)-1 { // patch extended sequence number and time stamp for all but the last packet, // last packet is the primary payload @@ -129,11 +130,9 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 // not modify the ExtPacket.RawPacket here for performance since it is not used by the DownTrack, // otherwise it should be set to the correct value (marshal the primary rtp packet) - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(&pPkt, spatialLayer)) - }) + writeCount += utils.BroadcastRTP(r.downTrackSpreader, pPkt, spatialLayer) } - return writeCount.Load() + return writeCount } func (r *RedPrimaryReceiver) ForwardRTCPSenderReport( diff --git a/pkg/sfu/redreceiver.go b/pkg/sfu/redreceiver.go index 959a54e96..4977878bf 100644 --- a/pkg/sfu/redreceiver.go +++ b/pkg/sfu/redreceiver.go @@ -51,6 +51,10 @@ type RedReceiver struct { closed atomic.Bool pktBuff [maxRedCount]*rtp.Packet redPayloadBuf [mtuSize]byte + // forwarded packet, reused like redPayloadBuf since ForwardRTP runs on one goroutine + // and down tracks do not keep the packet past WriteRTP + redExtPkt buffer.ExtPacket + redRtpPkt rtp.Packet } func NewRedReceiver(receiver TrackReceiver, dsp utils.DownTrackSpreaderParams) REDTransformer { @@ -73,11 +77,7 @@ func (r *RedReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int32) int3 // fallback to primary codec if payload size exceeds redundant block length if len(pkt.Packet.Payload) >= maxRedPayload { - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(pkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pkt, spatialLayer) } redLen, err := r.encodeRedForPrimary(pkt.Packet, r.redPayloadBuf[:]) @@ -86,19 +86,17 @@ func (r *RedReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int32) int3 return 0 } - pPkt := *pkt - redRtpPacket := *pkt.Packet + redRtpPacket := &r.redRtpPkt + *redRtpPacket = *pkt.Packet redRtpPacket.PayloadType = opusRedPT redRtpPacket.Payload = r.redPayloadBuf[:redLen] - pPkt.Packet = &redRtpPacket + pPkt := &r.redExtPkt + *pPkt = *pkt + pPkt.Packet = redRtpPacket // not modify the ExtPacket.RawPacket here for performance since it is not used by the DownTrack, // otherwise it should be set to the correct value (marshal the primary rtp packet) - var writeCount atomic.Int32 - r.downTrackSpreader.Broadcast(func(dt TrackSender) { - writeCount.Add(dt.WriteRTP(&pPkt, spatialLayer)) - }) - return writeCount.Load() + return utils.BroadcastRTP(r.downTrackSpreader, pPkt, spatialLayer) } func (r *RedReceiver) ForwardRTCPSenderReport( diff --git a/pkg/sfu/utils/downtrackspreader.go b/pkg/sfu/utils/downtrackspreader.go index e06f6c945..29ba07f2d 100644 --- a/pkg/sfu/utils/downtrackspreader.go +++ b/pkg/sfu/utils/downtrackspreader.go @@ -15,16 +15,18 @@ package utils import ( + "runtime" "sync" + "sync/atomic" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" ) -type sender interface { - SubscriberID() livekit.ParticipantID -} +// 100µs is enough to amortize the overhead and provide sufficient load balancing. +// WriteRTP takes about 50µs on average, so we write to 2 down tracks per loop. +const broadcastStep = 2 type DownTrackSpreaderParams struct { Threshold int @@ -90,23 +92,26 @@ func (d *DownTrackSpreader[T]) HasDownTrack(subscriberID livekit.ParticipantID) return ok } -func (d *DownTrackSpreader[T]) Broadcast(writer func(T)) { +// snapshot returns the current down tracks and the parallelization threshold +func (d *DownTrackSpreader[T]) snapshot() ([]T, int) { d.downTrackMu.RLock() downTracks := d.downTracksShadow - threshold := uint64(d.params.Threshold) + threshold := d.params.Threshold d.downTrackMu.RUnlock() - if len(downTracks) == 0 { - return - } if threshold == 0 { threshold = 1000000 } + return downTracks, threshold +} - // 100µs is enough to amortize the overhead and provide sufficient load balancing. - // WriteRTP takes about 50µs on average, so we write to 2 down tracks per loop. - step := uint64(2) - utils.ParallelExec(downTracks, threshold, step, writer) +func (d *DownTrackSpreader[T]) Broadcast(writer func(T)) { + downTracks, threshold := d.snapshot() + if len(downTracks) == 0 { + return + } + + utils.ParallelExec(downTracks, uint64(threshold), broadcastStep, writer) } func (d *DownTrackSpreader[T]) DownTrackCount() int { @@ -127,3 +132,69 @@ func (d *DownTrackSpreader[T]) SetThreshold(threshold int) { d.params.Threshold = threshold d.downTrackMu.Unlock() } + +// ------------------------------------------------ + +type sender interface { + SubscriberID() livekit.ParticipantID +} + +type rtpWriter[P any] interface { + WriteRTP(pkt P, layer int32) int32 +} + +// rtpBroadcast is the shared state of one parallel BroadcastRTP, it carries the +// packet and layer so that no closure has to be allocated per packet +type rtpBroadcast[T rtpWriter[P], P any] struct { + downTracks []T + pkt P + layer int32 + next atomic.Uint64 + written atomic.Int32 + wg sync.WaitGroup +} + +func (b *rtpBroadcast[T, P]) run() { + defer b.wg.Done() + + var written int32 + end := uint64(len(b.downTracks)) + for { + n := b.next.Add(broadcastStep) + if n >= end+broadcastStep { + break + } + for i := n - broadcastStep; i < n && i < end; i++ { + written += b.downTracks[i].WriteRTP(b.pkt, b.layer) + } + } + b.written.Add(written) +} + +// BroadcastRTP writes pkt to every down track and returns how many accepted it. +// Below the threshold it runs on the caller without allocating; above it, the +// only allocations are the shared state and the worker funcval. +func BroadcastRTP[T interface { + sender + rtpWriter[P] +}, P any](d *DownTrackSpreader[T], pkt P, layer int32) int32 { + downTracks, threshold := d.snapshot() + if len(downTracks) < threshold { + var written int32 + for _, dt := range downTracks { + written += dt.WriteRTP(pkt, layer) + } + return written + } + + numWorkers := min(runtime.NumCPU(), len(downTracks)) + b := &rtpBroadcast[T, P]{downTracks: downTracks, pkt: pkt, layer: layer} + b.wg.Add(numWorkers) + worker := b.run + for i := 0; i < numWorkers; i++ { + go worker() + } + b.wg.Wait() + + return b.written.Load() +} diff --git a/pkg/sfu/utils/downtrackspreader_test.go b/pkg/sfu/utils/downtrackspreader_test.go new file mode 100644 index 000000000..1de2d96ce --- /dev/null +++ b/pkg/sfu/utils/downtrackspreader_test.go @@ -0,0 +1,84 @@ +// 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 utils + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +type testPacket struct{ seq int } + +type testSender struct { + id livekit.ParticipantID + writes atomic.Int32 + accept int32 +} + +func (s *testSender) SubscriberID() livekit.ParticipantID { return s.id } + +func (s *testSender) WriteRTP(pkt *testPacket, layer int32) int32 { + s.writes.Add(1) + return s.accept +} + +func newTestSpreader(numDownTracks, threshold int) (*DownTrackSpreader[*testSender], []*testSender) { + d := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{Threshold: threshold}) + senders := make([]*testSender, numDownTracks) + for i := range senders { + senders[i] = &testSender{id: livekit.ParticipantID(fmt.Sprintf("p%d", i)), accept: 1} + d.Store(senders[i]) + } + return d, senders +} + +func TestBroadcastRTP(t *testing.T) { + for _, tc := range []struct { + name string + numDownTracks int + threshold int + maxAllocs float64 + }{ + {"serial", 5, 20, 0}, + {"parallel", 50, 20, 2}, // shared state and worker funcval + } { + t.Run(tc.name, func(t *testing.T) { + d, senders := newTestSpreader(tc.numDownTracks, tc.threshold) + senders[0].accept = 0 // one down track that drops + pkt := &testPacket{} + + written := BroadcastRTP(d, pkt, 2) + require.EqualValues(t, tc.numDownTracks-1, written) + for _, s := range senders { + require.EqualValues(t, 1, s.writes.Load()) + } + + if !RaceEnabled { + allocs := testing.AllocsPerRun(1000, func() { BroadcastRTP(d, pkt, 2) }) + require.LessOrEqual(t, allocs, tc.maxAllocs) + } + }) + } +} + +func TestBroadcastRTPEmpty(t *testing.T) { + d := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{Threshold: 20}) + require.EqualValues(t, 0, BroadcastRTP(d, &testPacket{}, 0)) +} From 7874ce7d5118ce8ab63d0231ff63f260ef0b37c8 Mon Sep 17 00:00:00 2001 From: Benjamin Pracht Date: Wed, 16 Sep 2026 20:45:20 -0700 Subject: [PATCH 038/130] Track action versions and stop quarantining our own actions (#4877) helpers:pinGitHubActionDigestsToSemver extends the preset we already used, so actions stay pinned by digest; it adds an extractVersion/versioning pair that makes Renovate follow the full vX.Y.Z tag behind the digest instead of the mutable major, so the comment a reviewer reads carries the exact version and updates arrive typed as patch/minor with a changelog range. minimumReleaseAge exists to let a third-party release sit before we adopt it. Our own actions have nothing to wait out, and the quarantine actively hurts any referenced by branch: Renovate ages a branch ref against its head commit, so slack-notifier-action only moves once that repo goes two weeks without a push. This mirrors the exemption github.com/livekit/** already has under gomod. Co-authored-by: Claude Opus 5 --- renovate.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/renovate.json b/renovate.json index f2106f3b1..efd16dc9f 100644 --- a/renovate.json +++ b/renovate.json @@ -2,7 +2,7 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - "helpers:pinGitHubActionDigests" + "helpers:pinGitHubActionDigestsToSemver" ], "minimumReleaseAge": "2 weeks", "commitBody": "Generated by renovateBot", @@ -19,6 +19,14 @@ "matchManagers": ["github-actions"], "groupName": "github workflows" }, + { + "description": "First-party actions, no need to quarantine: we own them, and one referenced by branch would otherwise sit pending until its default branch goes two weeks without a commit", + "matchManagers": ["github-actions"], + "matchPackageNames": [ + "livekit/**" + ], + "minimumReleaseAge": null + }, { "matchManagers": ["dockerfile"], "groupName": "docker deps", From 60df5df38b6e0d1b96afd6f1c7aca0a813627a5b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:38:11 -0700 Subject: [PATCH 039/130] Update github.com/livekit/protocol digest to a879e94 (#4864) 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 1e0088fe5..e04b16e94 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f + github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713 github.com/livekit/psrpc v0.7.7 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index df7274bd3..f4646deb8 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f h1:+48IWNrsoTgbB0JGv+xJl4umx6e/vh1BX1Tcokuacwc= -github.com/livekit/protocol v1.51.1-0.20260905133529-a4f4b5c0c23f/go.mod h1:zxowkRnQlJ2VMn6ZyinXMDi985wcKXuWNeXmEERqFAs= +github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713 h1:dGJJP2/ix4fCL4wK6eaNWApURJNetzp8w8QoNb/MdMg= +github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713/go.mod h1:vlreOPeiOr2pSCVCHDr2xIbSGf+2++y4QFaTgZ6rjp4= github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4= github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= From f42efc3de16857c2925bb0bff15885d3f45d7c7b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:46:56 -0700 Subject: [PATCH 040/130] Update module golang.org/x/mod to v0.41.0 (#4868) 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 e04b16e94..043dcb64a 100644 --- a/go.mod +++ b/go.mod @@ -54,7 +54,7 @@ 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.40.0 + golang.org/x/mod v0.41.0 golang.org/x/sync v0.22.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index f4646deb8..9b5b9aeae 100644 --- a/go.sum +++ b/go.sum @@ -357,8 +357,8 @@ golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= 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= From 9ac58dc7b40821673fb49802493ecfa846c33825 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:11:45 -0700 Subject: [PATCH 041/130] Update github workflows (#4446) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/buildtest.yaml | 10 +++++----- .github/workflows/docker.yaml | 10 +++++----- .github/workflows/release.yaml | 4 ++-- .github/workflows/slack-notifier.yaml | 2 +- .github/workflows/test-server-docker.yaml | 8 ++++---- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/buildtest.yaml b/.github/workflows/buildtest.yaml index b11478cd9..408602152 100644 --- a/.github/workflows/buildtest.yaml +++ b/.github/workflows/buildtest.yaml @@ -28,8 +28,8 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 - - uses: shogo82148/actions-setup-redis@2f3253b148c73d7a0682eae73e862b777a4fa74e # v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: shogo82148/actions-setup-redis@88a1fb308aae95aa485bf3da667c9d6e61be4c53 # v1.58.0 with: redis-version: "6.x" auto-start: true @@ -65,9 +65,9 @@ jobs: run: go run github.com/magefile/mage build - name: Lint - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9.3.0 with: - version: v2.11.4 + version: v2.13.2 - name: Test run: | @@ -76,7 +76,7 @@ jobs: # Upload the original go test log as an artifact for later review. - name: Upload test log - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: name: test-log diff --git a/.github/workflows/docker.yaml b/.github/workflows/docker.yaml index 72a510135..43f95922f 100644 --- a/.github/workflows/docker.yaml +++ b/.github/workflows/docker.yaml @@ -29,10 +29,10 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Docker meta id: meta - uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: # list of Docker images to use as base name for tags images: | @@ -67,18 +67,18 @@ jobs: run: go run github.com/magefile/mage generate - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push id: docker_build - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index c19e14113..ab3adf275 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Fetch all tags run: git fetch --force --tags @@ -47,7 +47,7 @@ jobs: go-version: ${{ steps.go-version.outputs.version }} - name: Run GoReleaser - uses: goreleaser/goreleaser-action@ec59f474b9834571250b370d4735c50f8e2d1e29 # v7 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: distribution: goreleaser version: '~> v2' diff --git a/.github/workflows/slack-notifier.yaml b/.github/workflows/slack-notifier.yaml index 7770345bf..e550807d4 100644 --- a/.github/workflows/slack-notifier.yaml +++ b/.github/workflows/slack-notifier.yaml @@ -19,7 +19,7 @@ jobs: notify-devs: runs-on: ubuntu-latest steps: - - uses: livekit/slack-notifier-action@main + - uses: livekit/slack-notifier-action@34d9e973391fe5e4afacba015c4d144eb912e7ea # main with: config_json: ${{ secrets.SLACK_NOTIFY_CONFIG_JSON }} slack_token: ${{ secrets.SLACK_PR_NOTIFIER_TOKEN }} diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml index 184f0de04..bb7a4b5ca 100644 --- a/.github/workflows/test-server-docker.yaml +++ b/.github/workflows/test-server-docker.yaml @@ -30,19 +30,19 @@ jobs: docker: runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - name: Login to DockerHub - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: cmd/test-server/Dockerfile From d2ec76a0bf4f0680da4f817695f6c8451fbf9125 Mon Sep 17 00:00:00 2001 From: Benjamin Pracht Date: Wed, 16 Sep 2026 22:35:30 -0700 Subject: [PATCH 042/130] test: fix two data races in the test suites (#4878) * test: stop registering the global DefaultConfig with the logger InitLoggerFromConfig hands the pointer to zaputil.ComponentLeveler as its level resolver, and protocol a879e94 gave logger.Config a ResolveComponentLevel that takes c.lock. The two test packages passed &config.DefaultConfig.Logging, so every first-time component level resolution locked a mutex inside the global DefaultConfig while config.NewConfig marshalled that same global from another goroutine: Read at 0x39baa08 yaml.Marshal -> pkg/config/config.go:633 (NewConfig) Write at 0x39baa08 ComponentLeveler.resolve -> zaputil/leveler.go:117 That tripped the race detector in pkg/rtc TestPreferMediaCodecForPublisher and broke CI on master. Register a test-local LoggingConfig instead, so the logger never touches the global that NewConfig reads. DefaultConfig.Logging only ever sets PionLevel, so behavior is unchanged. A plain struct copy is not an option here: logger.Config holds a mutex and vet's copylocks would reject it. Co-Authored-By: Claude Opus 5 (1M context) * test: make RTCClient callbacks safe to set while the client runs The scenario tests assign c2.OnDataReceived after waitUntilConnected, so the assignment raced the data channel goroutine already reading the field in handleDataMessage: Read test/client.(*RTCClient).handleDataMessage client.go:1146 Write test.scenarioDataPublish.func1 scenarios.go:160 This is what failed TestMultinodeDataPublishing on master. Replace the three exported callback fields with atomic pointers behind SetOnConnected/SetOnDataReceived/SetOnDataUnlabeledReceived. OnConnected and OnDataUnlabeledReceived have no writers today, but they are read from the same background goroutines and would race the moment one appeared, so all three move together rather than leaving a split API on one struct. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- pkg/rtc/room_test.go | 6 +++++- test/client/client.go | 41 ++++++++++++++++++++++++++----------- test/integration_helpers.go | 6 +++++- test/scenarios.go | 8 ++++---- test/singlenode_test.go | 12 +++++------ 5 files changed, 49 insertions(+), 24 deletions(-) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 4f76d0705..8ccd9574f 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -51,7 +51,11 @@ const ( ) func init() { - config.InitLoggerFromConfig(&config.DefaultConfig.Logging) + // The logger retains this config and locks it on every component level + // resolution, so it must not be DefaultConfig: NewConfig marshals that + // global from the goroutines these tests spin up. + loggingConf := config.LoggingConfig{PionLevel: config.DefaultConfig.Logging.PionLevel} + config.InitLoggerFromConfig(&loggingConf) roomUpdateInterval = defaultDelay } diff --git a/test/client/client.go b/test/client/client.go index 7a74c4841..14fd108a2 100644 --- a/test/client/client.go +++ b/test/client/client.go @@ -94,10 +94,12 @@ type RTCClient struct { // remote tracks waiting to be processed pendingRemoteTracks []*webrtc.TrackRemote - pendingTrackWriters []TrackWriter - OnConnected func() - OnDataReceived func(data []byte, sid string) - OnDataUnlabeledReceived func(data []byte) + pendingTrackWriters []TrackWriter + // callbacks are set by tests while the client is already running, and read + // from the signal and data channel goroutines + onConnected atomic.Pointer[func()] + onDataReceived atomic.Pointer[func(data []byte, sid string)] + onDataUnlabeledReceived atomic.Pointer[func(data []byte)] refreshToken string // map of livekit.ParticipantID and last packet @@ -346,8 +348,8 @@ func (c *RTCClient) createTransport(rtcconf webrtc.Configuration) error { } c.pendingDataTrackWriters = nil - if c.OnConnected != nil { - go c.OnConnected() + if f := c.onConnected.Load(); f != nil { + go (*f)() } }) publisherHandler.OnOfferCalls(c.onOffer) @@ -440,8 +442,8 @@ func (c *RTCClient) createTransport(rtcconf webrtc.Configuration) error { } c.pendingDataTrackWriters = nil - if c.OnConnected != nil { - go c.OnConnected() + if f := c.onConnected.Load(); f != nil { + go (*f)() } }) subscriberHandler.OnFullyEstablishedCalls(func() { @@ -472,6 +474,21 @@ func (c *RTCClient) ID() livekit.ParticipantID { return c.id } +// SetOnConnected is safe to call after the client is running. +func (c *RTCClient) SetOnConnected(f func()) { + c.onConnected.Store(&f) +} + +// SetOnDataReceived is safe to call after the client is running. +func (c *RTCClient) SetOnDataReceived(f func(data []byte, sid string)) { + c.onDataReceived.Store(&f) +} + +// SetOnDataUnlabeledReceived is safe to call after the client is running. +func (c *RTCClient) SetOnDataUnlabeledReceived(f func(data []byte)) { + c.onDataUnlabeledReceived.Store(&f) +} + // create an offer for the server func (c *RTCClient) Run() error { c.conn.SetCloseHandler(func(code int, text string) error { @@ -1143,15 +1160,15 @@ func (c *RTCClient) handleDataMessage(kind livekit.DataPacket_Kind, data []byte) } dp.Kind = kind if val, ok := dp.Value.(*livekit.DataPacket_User); ok { - if c.OnDataReceived != nil { - c.OnDataReceived(val.User.Payload, val.User.ParticipantSid) + if f := c.onDataReceived.Load(); f != nil { + (*f)(val.User.Payload, val.User.ParticipantSid) } } } func (c *RTCClient) handleDataMessageUnlabeled(data []byte) { - if c.OnDataUnlabeledReceived != nil { - c.OnDataUnlabeledReceived(data) + if f := c.onDataUnlabeledReceived.Load(); f != nil { + (*f)(data) } } diff --git a/test/integration_helpers.go b/test/integration_helpers.go index ec3469620..508d6ee55 100644 --- a/test/integration_helpers.go +++ b/test/integration_helpers.go @@ -63,7 +63,11 @@ const ( var roomClient livekit.RoomService func init() { - config.InitLoggerFromConfig(&config.DefaultConfig.Logging) + // The logger retains this config and locks it on every component level + // resolution, so it must not be DefaultConfig: NewConfig marshals that + // global from the goroutines these tests spin up. + loggingConf := config.LoggingConfig{PionLevel: config.DefaultConfig.Logging.PionLevel} + config.InitLoggerFromConfig(&loggingConf) prometheus.Init("test", livekit.NodeType_SERVER) } diff --git a/test/scenarios.go b/test/scenarios.go index aee71feb1..809c735b7 100644 --- a/test/scenarios.go +++ b/test/scenarios.go @@ -157,11 +157,11 @@ func scenarioDataPublish(t *testing.T) { payload := "test bytes" received := atomic.NewBool(false) - c2.OnDataReceived = func(data []byte, sid string) { + c2.SetOnDataReceived(func(data []byte, sid string) { if string(data) == payload && livekit.ParticipantID(sid) == c1.ID() { received.Store(true) } - } + }) require.NoError(t, c1.PublishData([]byte(payload), livekit.DataPacket_RELIABLE)) @@ -187,11 +187,11 @@ func scenarioDataUnlabeledPublish(t *testing.T) { payload := "test unlabeled bytes" received := atomic.NewBool(false) - c2.OnDataReceived = func(data []byte, _sid string) { + c2.SetOnDataReceived(func(data []byte, _sid string) { if string(data) == payload { received.Store(true) } - } + }) require.NoError(t, c1.PublishDataUnlabeled([]byte(payload))) diff --git a/test/singlenode_test.go b/test/singlenode_test.go index 08f67fd00..14964d12d 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -1100,30 +1100,30 @@ func TestDataPublishSlowSubscriber(t *testing.T) { // no data should be dropped for fast subscriber var fastDataIndex atomic.Uint64 - fastSub.OnDataReceived = func(data []byte, sid string) { + fastSub.SetOnDataReceived(func(data []byte, sid string) { idx := binary.BigEndian.Uint64(data[len(data)-8:]) require.Equal(t, fastDataIndex.Load()+1, idx) fastDataIndex.Store(idx) - } + }) // no data should be dropped for slow subscriber that is above threshold var slowNoDropDataIndex atomic.Uint64 var drainSlowSubNotDrop atomic.Bool slowNoDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold * 2) - slowSubNotDrop.OnDataReceived = func(data []byte, sid string) { + slowSubNotDrop.SetOnDataReceived(func(data []byte, sid string) { idx := binary.BigEndian.Uint64(data[len(data)-8:]) require.Equal(t, slowNoDropDataIndex.Load()+1, idx) slowNoDropDataIndex.Store(idx) if !drainSlowSubNotDrop.Load() { slowNoDropReader.Read(data, sid) } - } + }) // data should be dropped for slow subscriber that is below threshold var slowDropDataIndex atomic.Uint64 dropped := make(chan struct{}) slowDropReader := testclient.NewDataChannelReader(dataChannelSlowThreshold / 2) - slowSubDrop.OnDataReceived = func(data []byte, sid string) { + slowSubDrop.SetOnDataReceived(func(data []byte, sid string) { select { case <-dropped: return @@ -1135,7 +1135,7 @@ func TestDataPublishSlowSubscriber(t *testing.T) { } slowDropDataIndex.Store(idx) slowDropReader.Read(data, sid) - } + }) // publisher sends data as fast as possible, it will block by the slowest subscriber above the slow threshold var ( From 9feb4c71c5b011650e17b35066c1ae5603625296 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Sep 2026 17:32:14 -0700 Subject: [PATCH 043/130] Bump go.opentelemetry.io/otel/sdk (#4879) Bumps the go_modules group with 1 update in the / directory: [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go). Updates `go.opentelemetry.io/otel/sdk` from 1.44.0 to 1.46.0 - [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases) - [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md) - [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.44.0...v1.46.0) --- updated-dependencies: - dependency-name: go.opentelemetry.io/otel/sdk dependency-version: 1.46.0 dependency-type: indirect dependency-group: go_modules ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 32 ++++++++++++++++---------------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index 043dcb64a..98c7a332d 100644 --- a/go.mod +++ b/go.mod @@ -70,12 +70,12 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/fatih/color v1.19.0 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/goccy/go-json v0.10.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/gotesttools/gotestfmt/v2 v2.5.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect @@ -87,14 +87,14 @@ require ( github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect go.opentelemetry.io/otel/metric v1.46.0 // indirect - go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.46.0 // indirect go.opentelemetry.io/otel/trace v1.46.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect diff --git a/go.sum b/go.sum index 9b5b9aeae..a70375306 100644 --- a/go.sum +++ b/go.sum @@ -60,8 +60,8 @@ github.com/elliotchance/orderedmap/v3 v3.1.1 h1:eV7lfZ5fVL8d36b8Wogqi/eqm7R/kZcf github.com/elliotchance/orderedmap/v3 v3.1.1/go.mod h1:G+Hc2RwaZvJMcS4JpGCOyViCnGeKf0bTYCGTO4uhjSo= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/florianl/go-tc v0.4.8 h1:hgmakUX1Nm0Ba1I0ZkbUl9CH6HbRwqSiwipnpmYp3Es= github.com/florianl/go-tc v0.4.8/go.mod h1:B8GeOEnmrbOnxZtaCvsYJcgIzzmM8c/AIhtfCZsDj3Q= github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= @@ -107,8 +107,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gotesttools/gotestfmt/v2 v2.5.0 h1:fSU3MnR+E+fvuXdw1l8xbufKhDxY3Tfjsjx/I1WerB4= github.com/gotesttools/gotestfmt/v2 v2.5.0/go.mod h1:oQJg2KZ2aGoqEbMC2PDaAeBYm0tOkocgixK9FzsCdp4= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -319,24 +319,24 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= -go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= -go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= -go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= -go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= +go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= +go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= +go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= From 2fe855f332c7f45327a341cabc1be7b36e2dab76 Mon Sep 17 00:00:00 2001 From: shak31599 Date: Sat, 19 Sep 2026 11:24:32 +0530 Subject: [PATCH 044/130] Do not add down tracks after receiver close (#4857) * Do not add down tracks after receiver close * Fold close race tests into existing test files * Retrigger CI --- pkg/sfu/receiver_base.go | 6 +- pkg/sfu/receiver_test.go | 76 +++++++++++++++++++++++++ pkg/sfu/redprimaryreceiver.go | 6 +- pkg/sfu/redreceiver.go | 6 +- pkg/sfu/utils/downtrackspreader.go | 37 +++++++++--- pkg/sfu/utils/downtrackspreader_test.go | 26 +++++++++ 6 files changed, 144 insertions(+), 13 deletions(-) diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index 617d7fc64..678c124c3 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -197,7 +197,7 @@ func (r *ReceiverBase) Close(reason string, clearBuffers bool) { } r.streamTrackerManager.Close() - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) if rt := r.loadREDTransformer(); rt != nil { rt.Close() @@ -491,7 +491,9 @@ func (r *ReceiverBase) AddDownTrack(track TrackSender) error { track.UpTrackMaxPublishedLayerChange(r.streamTrackerManager.GetMaxPublishedLayer()) track.UpTrackMaxTemporalLayerSeenChange(r.streamTrackerManager.GetMaxTemporalLayerSeen()) - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.params.Logger.Debugw("downtrack added", "subscriberID", track.SubscriberID()) return nil } diff --git a/pkg/sfu/receiver_test.go b/pkg/sfu/receiver_test.go index 3cfde0768..27e53ae78 100644 --- a/pkg/sfu/receiver_test.go +++ b/pkg/sfu/receiver_test.go @@ -23,8 +23,13 @@ import ( "testing" "github.com/gammazero/workerpool" + "github.com/pion/webrtc/v4" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "go.uber.org/atomic" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" ) func TestWebRTCReceiver_OnCloseHandler(t *testing.T) { @@ -52,6 +57,77 @@ func TestWebRTCReceiver_OnCloseHandler(t *testing.T) { } } +type receiverCloseTestSender struct { + TrackSender + subscriberID livekit.ParticipantID + upTrackMaxPublishedLayerChange func(int32) +} + +func (s *receiverCloseTestSender) SubscriberID() livekit.ParticipantID { + return s.subscriberID +} + +func (s *receiverCloseTestSender) UpTrackMaxPublishedLayerChange(layer int32) { + if s.upTrackMaxPublishedLayerChange != nil { + s.upTrackMaxPublishedLayerChange(layer) + } +} + +func (s *receiverCloseTestSender) UpTrackMaxTemporalLayerSeenChange(int32) {} + +func TestReceiverBaseAddDownTrackReturnsClosedWhenCloseWinsRace(t *testing.T) { + receiver := NewReceiverBase( + ReceiverBaseParams{ + TrackID: "track", + StreamID: "stream", + Kind: webrtc.RTPCodecTypeAudio, + Codec: webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{ + MimeType: webrtc.MimeTypeOpus, + ClockRate: 48000, + Channels: 2, + }, + PayloadType: 111, + }, + Logger: logger.GetLogger(), + }, + &livekit.TrackInfo{ + Sid: "track", + Type: livekit.TrackType_AUDIO, + Source: livekit.TrackSource_MICROPHONE, + }, + ReceiverCodecStateNormal, + ) + + enteredAdd := make(chan struct{}) + resumeAdd := make(chan struct{}) + track := &receiverCloseTestSender{ + subscriberID: "subscriber", + upTrackMaxPublishedLayerChange: func(int32) { + close(enteredAdd) + <-resumeAdd + }, + } + + errCh := make(chan error, 1) + go func() { + errCh <- receiver.AddDownTrack(track) + }() + + // AddDownTrack has passed its initial IsClosed check and is paused + // before reaching the final TryStore. + <-enteredAdd + + // Close terminally drains/closes the spreader before AddDownTrack resumes. + receiver.Close("test", false) + close(resumeAdd) + + err := <-errCh + require.ErrorIs(t, err, ErrReceiverClosed) + require.True(t, receiver.IsClosed()) + require.Empty(t, receiver.GetDownTracks()) +} + func BenchmarkWriteRTP(b *testing.B) { cases := []int{1, 2, 5, 10, 100, 250, 500} workers := runtime.NumCPU() diff --git a/pkg/sfu/redprimaryreceiver.go b/pkg/sfu/redprimaryreceiver.go index a34ef8586..fdef27c81 100644 --- a/pkg/sfu/redprimaryreceiver.go +++ b/pkg/sfu/redprimaryreceiver.go @@ -154,7 +154,9 @@ func (r *RedPrimaryReceiver) AddDownTrack(track TrackSender) error { r.logger.Infow("subscriberID already exists, replacing downtrack", "subscriberID", track.SubscriberID()) } - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.logger.Debugw("red primary receiver downtrack added", "subscriberID", track.SubscriberID()) return nil } @@ -194,7 +196,7 @@ func (r *RedPrimaryReceiver) CanClose() bool { func (r *RedPrimaryReceiver) Close() { r.closed.Store(true) - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) } func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, error) { diff --git a/pkg/sfu/redreceiver.go b/pkg/sfu/redreceiver.go index 4977878bf..ad0c54b8a 100644 --- a/pkg/sfu/redreceiver.go +++ b/pkg/sfu/redreceiver.go @@ -118,7 +118,9 @@ func (r *RedReceiver) AddDownTrack(track TrackSender) error { r.logger.Infow("subscriberID already exists, replacing downtrack", "subscriberID", track.SubscriberID()) } - r.downTrackSpreader.Store(track) + if !r.downTrackSpreader.TryStore(track) { + return ErrReceiverClosed + } r.logger.Debugw("red receiver downtrack added", "subscriberID", track.SubscriberID()) return nil } @@ -158,7 +160,7 @@ func (r *RedReceiver) IsClosed() bool { func (r *RedReceiver) Close() { r.closed.Store(true) - closeTrackSenders(r.downTrackSpreader.ResetAndGetDownTracks()) + closeTrackSenders(r.downTrackSpreader.CloseAndGetDownTracks()) } func (r *RedReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, error) { diff --git a/pkg/sfu/utils/downtrackspreader.go b/pkg/sfu/utils/downtrackspreader.go index 29ba07f2d..12e309cb5 100644 --- a/pkg/sfu/utils/downtrackspreader.go +++ b/pkg/sfu/utils/downtrackspreader.go @@ -39,6 +39,7 @@ type DownTrackSpreader[T sender] struct { downTrackMu sync.RWMutex downTracks map[livekit.ParticipantID]T downTracksShadow []T + closed bool } func NewDownTrackSpreader[T sender](params DownTrackSpreaderParams) *DownTrackSpreader[T] { @@ -60,20 +61,35 @@ func (d *DownTrackSpreader[T]) ResetAndGetDownTracks() []T { d.downTrackMu.Lock() defer d.downTrackMu.Unlock() - downTracks := d.downTracksShadow - - d.downTracks = make(map[livekit.ParticipantID]T) - d.downTracksShadow = nil - - return downTracks + return d.resetAndGetDownTracksLocked() } -func (d *DownTrackSpreader[T]) Store(sender T) { +// CloseAndGetDownTracks terminally closes the spreader and drains all senders. +// Once it returns, TryStore will reject every subsequent sender. +func (d *DownTrackSpreader[T]) CloseAndGetDownTracks() []T { d.downTrackMu.Lock() defer d.downTrackMu.Unlock() + d.closed = true + return d.resetAndGetDownTracksLocked() +} + +func (d *DownTrackSpreader[T]) Store(sender T) { + _ = d.TryStore(sender) +} + +// TryStore stores sender unless the spreader has been terminally closed. +func (d *DownTrackSpreader[T]) TryStore(sender T) bool { + d.downTrackMu.Lock() + defer d.downTrackMu.Unlock() + + if d.closed { + return false + } + d.downTracks[sender.SubscriberID()] = sender d.shadowDownTracks() + return true } func (d *DownTrackSpreader[T]) Free(subscriberID livekit.ParticipantID) { @@ -120,6 +136,13 @@ func (d *DownTrackSpreader[T]) DownTrackCount() int { return len(d.downTracksShadow) } +func (d *DownTrackSpreader[T]) resetAndGetDownTracksLocked() []T { + downTracks := d.downTracksShadow + d.downTracks = make(map[livekit.ParticipantID]T) + d.downTracksShadow = nil + return downTracks +} + func (d *DownTrackSpreader[T]) shadowDownTracks() { d.downTracksShadow = make([]T, 0, len(d.downTracks)) for _, dt := range d.downTracks { diff --git a/pkg/sfu/utils/downtrackspreader_test.go b/pkg/sfu/utils/downtrackspreader_test.go index 1de2d96ce..11557183e 100644 --- a/pkg/sfu/utils/downtrackspreader_test.go +++ b/pkg/sfu/utils/downtrackspreader_test.go @@ -82,3 +82,29 @@ func TestBroadcastRTPEmpty(t *testing.T) { d := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{Threshold: 20}) require.EqualValues(t, 0, BroadcastRTP(d, &testPacket{}, 0)) } + +func TestDownTrackSpreaderResetRemainsReusable(t *testing.T) { + spreader := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{}) + first := &testSender{id: "first"} + second := &testSender{id: "second"} + + spreader.Store(first) + require.Equal(t, []*testSender{first}, spreader.ResetAndGetDownTracks()) + require.Zero(t, spreader.DownTrackCount()) + + spreader.Store(second) + require.Equal(t, []*testSender{second}, spreader.GetDownTracks()) +} + +func TestDownTrackSpreaderRejectsStoreAfterClose(t *testing.T) { + spreader := NewDownTrackSpreader[*testSender](DownTrackSpreaderParams{}) + first := &testSender{id: "first"} + late := &testSender{id: "late"} + + spreader.Store(first) + require.Equal(t, []*testSender{first}, spreader.CloseAndGetDownTracks()) + require.Zero(t, spreader.DownTrackCount()) + + require.False(t, spreader.TryStore(late)) + require.Zero(t, spreader.DownTrackCount()) +} From d5c691836fe40a182c8e705946e9a2e643e26134 Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Sun, 20 Sep 2026 16:45:40 -0700 Subject: [PATCH 045/130] deps: update psrpc to v0.8.0 (#4882) * deps: update psrpc to v0.8.0 psrpc v0.8.0 splits the brokers into leaf packages so importing psrpc no longer pulls in nats.go and go-redis, and drops the root constructors: - return psrpc.NewRedisMessageBus(rc, opts...) + return redisbus.New(rc, opts...) getMessageBus keeps its NewLocalMessageBus branch -- that shim stays at the root because localbus costs no dependency. wire_gen.go carries a verbatim copy of the provider, so it gets the identical edit rather than a wire rerun, which would churn unrelated output. redisbus.New takes ...bus.BusOption and psrpc.BusOption is an alias for it, so PSRPCConfig.BusOptions() spreads in unchanged. The nats-io requirements drop out of go.mod; this module only ever used the Redis bus, and nats came in indirectly through psrpc's root package. * Reorder import statements in ioservice_sip_test.go --- go.mod | 7 ++----- go.sum | 8 ++++---- pkg/service/ioservice_sip_test.go | 11 ++++++----- pkg/service/wire.go | 3 ++- pkg/service/wire_gen.go | 3 ++- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 98c7a332d..5223127a3 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 - github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713 - github.com/livekit/psrpc v0.7.7 + github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319 + github.com/livekit/psrpc v0.8.0 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 @@ -129,9 +129,6 @@ require ( github.com/mdlayher/socket v0.6.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/nats-io/nats.go v1.53.1 // indirect - github.com/nats-io/nkeys v0.4.16 // indirect - github.com/nats-io/nuid v1.0.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/logging v0.2.4 diff --git a/go.sum b/go.sum index a70375306..df63e63fd 100644 --- a/go.sum +++ b/go.sum @@ -164,10 +164,10 @@ 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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713 h1:dGJJP2/ix4fCL4wK6eaNWApURJNetzp8w8QoNb/MdMg= -github.com/livekit/protocol v1.51.1-0.20260914053631-a879e945e713/go.mod h1:vlreOPeiOr2pSCVCHDr2xIbSGf+2++y4QFaTgZ6rjp4= -github.com/livekit/psrpc v0.7.7 h1:eZ/jYlayQ3Y+C/3+NPtwTJBXZo/aN4vJf2MmBK6AfT4= -github.com/livekit/psrpc v0.7.7/go.mod h1:Twno03W8gpTNRpmc2cQwSoxYEo/nJxf+65iMq+9IuHo= +github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319 h1:mEfaX/HY18LBI9Oy1U57O3qqowkpRFNqk8wybUb+fkM= +github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319/go.mod h1:Ke1q64xQzwZ1S1ZH66SudXHsaaQQAFoX+TL1iLpu7XU= +github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= +github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= diff --git a/pkg/service/ioservice_sip_test.go b/pkg/service/ioservice_sip_test.go index 2a8b75288..3c34ccbc8 100644 --- a/pkg/service/ioservice_sip_test.go +++ b/pkg/service/ioservice_sip_test.go @@ -16,19 +16,20 @@ package service_test import ( "context" - "github.com/dennwc/iters" - "github.com/livekit/livekit-server/pkg/service" - "github.com/livekit/psrpc" "slices" "testing" - "github.com/livekit/protocol/livekit" + "github.com/dennwc/iters" "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/livekit" + "github.com/livekit/psrpc/pkg/bus/redisbus" ) func ioStoreDocker(t testing.TB) (*service.IOInfoService, *service.RedisStore) { r := redisClientDocker(t) - bus := psrpc.NewRedisMessageBus(r) + bus := redisbus.New(r) rs := service.NewRedisStore(r) io, err := service.NewIOInfoService(bus, rs, rs, rs, nil) require.NoError(t, err) diff --git a/pkg/service/wire.go b/pkg/service/wire.go index 9d1842b4b..f8f19759d 100644 --- a/pkg/service/wire.go +++ b/pkg/service/wire.go @@ -35,6 +35,7 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/psrpc" + "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" "github.com/livekit/livekit-server/pkg/agent" @@ -198,7 +199,7 @@ func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.Me if rc == nil { return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc, opts...) + return redisbus.New(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index f7c5a6267..3308626a3 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -21,6 +21,7 @@ import ( "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/psrpc" + "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" "github.com/pion/turn/v5" "github.com/pkg/errors" @@ -259,7 +260,7 @@ func getMessageBus(rc redis.UniversalClient, psrpcConf rpc.PSRPCConfig) psrpc.Me if rc == nil { return psrpc.NewLocalMessageBus(opts...) } - return psrpc.NewRedisMessageBus(rc, opts...) + return redisbus.New(rc, opts...) } func getEgressStore(s ObjectStore) EgressStore { From 885c869ffafe64e5291dd07b4952620b1bf59787 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 21 Sep 2026 13:14:36 +0800 Subject: [PATCH 046/130] Add sdk lable to session start time and participant join (#4885) --- pkg/rtc/participant.go | 8 ++++---- pkg/service/roommanager.go | 16 +++++++-------- pkg/telemetry/events.go | 2 +- pkg/telemetry/prometheus/packets.go | 32 ++++++++++++++--------------- pkg/telemetry/prometheus/rooms.go | 6 +++--- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 01b14479d..b0a663f27 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1480,9 +1480,9 @@ func (p *ParticipantImpl) recordRTCState(closeReason types.ParticipantCloseReaso } if p.IsConnectionCanceled(closeReason) { - prometheus.IncrementParticipantRtcCanceled(1, p.params.EnableWarp) + prometheus.IncrementParticipantRtcCanceled(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } else { - prometheus.IncrementParticipantRtcFailure(1, p.params.EnableWarp) + prometheus.IncrementParticipantRtcFailure(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } } @@ -2718,8 +2718,8 @@ func (p *ParticipantImpl) onPrimaryTransportInitialConnected() { } if !p.sessionStartRecorded.Swap(true) { - prometheus.RecordSessionStartTime(int(p.ProtocolVersion()), p.params.EnableWarp, time.Since(p.params.SessionStartTime)) - prometheus.IncrementParticipantRtcSuccess(1, p.params.EnableWarp) + prometheus.RecordSessionStartTime(int(p.ProtocolVersion()), p.params.EnableWarp, p.GetClientInfo().GetSdk(), time.Since(p.params.SessionStartTime)) + prometheus.IncrementParticipantRtcSuccess(1, p.params.EnableWarp, p.GetClientInfo().GetSdk()) } p.updateState(livekit.ParticipantInfo_ACTIVE) } diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index e25fae883..5aace9845 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -306,7 +306,7 @@ func (r *RoomManager) StartSession( room, err := r.getOrCreateRoom(ctx, createRoom) if err != nil { if pi.Identity != "" { - prometheus.IncrementParticipantRtcCanceled(1, false) + prometheus.IncrementParticipantRtcCanceled(1, false, pi.Client.GetSdk()) } return err } @@ -359,7 +359,7 @@ func (r *RoomManager) StartSession( Leave: leave, }, }) - prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled()) + prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled(), participant.GetClientInfo().GetSdk()) return errors.New("could not restart closed participant") } @@ -383,7 +383,7 @@ func (r *RoomManager) StartSession( pi.ReconnectReason, ); err != nil { participant.GetLogger().Warnw("could not resume participant", err) - prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled()) + prometheus.IncrementParticipantRtcCanceled(1, participant.IsWarpEnabled(), participant.GetClientInfo().GetSdk()) return err } r.telemetry.ParticipantResumed(ctx, room.ToProto(), participant.ToProto(), r.currentNode.NodeID(), pi.ReconnectReason) @@ -419,7 +419,7 @@ func (r *RoomManager) StartSession( Leave: leave, }, }) - prometheus.IncrementParticipantRtcCanceled(1, false) + prometheus.IncrementParticipantRtcCanceled(1, false, pi.Client.GetSdk()) return errors.New("could not restart participant") } @@ -545,7 +545,7 @@ func (r *RoomManager) StartSession( EnableWarp: enableWarp, }) if err != nil { - prometheus.IncrementParticipantRtcCanceled(1, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } iceConfig := r.setIceConfig(room.Name(), participant) @@ -561,7 +561,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } @@ -573,7 +573,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } @@ -584,7 +584,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, enableWarp) + prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) return err } } diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index 657ea69e8..a82bdd161 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -94,7 +94,7 @@ func (t *telemetryService) ParticipantJoined( guard, ) if !found { - prometheus.IncrementParticipantRtcConnected(1) + prometheus.IncrementParticipantRtcConnected(1, clientInfo.GetSdk()) prometheus.AddParticipant() } diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index 37e67e895..5b8e45659 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -151,7 +151,7 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { Subsystem: "participant_join", Name: "total", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, - }, []string{"state", "warp"}) + }, []string{"state", "warp", "sdk"}) promConnections = prometheus.NewGaugeVec(prometheus.GaugeOpts{ Namespace: livekitNamespace, Subsystem: "connection", @@ -299,72 +299,72 @@ func RecordRTT(country string, direction Direction, trackSource livekit.TrackSou func IncrementParticipantJoin(join uint32) { if join > 0 { participantSignalConnected.Add(uint64(join)) - promParticipantJoin.WithLabelValues("signal_connected", "").Add(float64(join)) + promParticipantJoin.WithLabelValues("signal_connected", "", "").Add(float64(join)) } } func IncrementParticipantJoinFail(fail uint32) { if fail > 0 { - promParticipantJoin.WithLabelValues("signal_failed", "").Add(float64(fail)) + promParticipantJoin.WithLabelValues("signal_failed", "", "").Add(float64(fail)) } } func IncrementParticipantJoinValidationFail(validationFail uint32) { if validationFail > 0 { - promParticipantJoin.WithLabelValues("signal_validation_failed", "").Add(float64(validationFail)) + promParticipantJoin.WithLabelValues("signal_validation_failed", "", "").Add(float64(validationFail)) } } func IncrementParticipantJoinUpgradeFail(upgradeFail uint32) { if upgradeFail > 0 { - promParticipantJoin.WithLabelValues("signal_upgrade_failed", "").Add(float64(upgradeFail)) + promParticipantJoin.WithLabelValues("signal_upgrade_failed", "", "").Add(float64(upgradeFail)) } } func IncrementParticipantJoinWriteInitialResponseFail(writeInitialResponseFail uint32) { if writeInitialResponseFail > 0 { - promParticipantJoin.WithLabelValues("signal_write_initial_response_failed", "").Add(float64(writeInitialResponseFail)) + 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)) + promParticipantJoin.WithLabelValues("rtc_init", "", "").Add(float64(init)) } } -func IncrementParticipantRtcConnected(connected uint32) { +func IncrementParticipantRtcConnected(connected uint32, sdk livekit.ClientInfo_SDK) { if connected > 0 { participantRTCConnected.Add(uint64(connected)) - promParticipantJoin.WithLabelValues("rtc_connected", "").Add(float64(connected)) + promParticipantJoin.WithLabelValues("rtc_connected", "", sdk.String()).Add(float64(connected)) } } func IncrementParticipantRtcActive(active uint32, warp bool) { if active > 0 { participantRTCActive.Add(uint64(active)) - promParticipantJoin.WithLabelValues("rtc_active", strconv.FormatBool(warp)).Add(float64(active)) + promParticipantJoin.WithLabelValues("rtc_active", strconv.FormatBool(warp), "").Add(float64(active)) } } -func IncrementParticipantRtcCanceled(canceled uint64, warp bool) { +func IncrementParticipantRtcCanceled(canceled uint64, warp bool, sdk livekit.ClientInfo_SDK) { if canceled > 0 { participantRTCCanceled.Add(canceled) - promParticipantJoin.WithLabelValues("rtc_canceled", strconv.FormatBool(warp)).Add(float64(canceled)) + promParticipantJoin.WithLabelValues("rtc_canceled", strconv.FormatBool(warp), sdk.String()).Add(float64(canceled)) } } // todo: check if need to record warp to rtcSucc/Failure -func IncrementParticipantRtcSuccess(success uint64, warp bool) { +func IncrementParticipantRtcSuccess(success uint64, warp bool, sdk livekit.ClientInfo_SDK) { if success > 0 { - promParticipantJoin.WithLabelValues("rtc_success", strconv.FormatBool(warp)).Add(float64(success)) + promParticipantJoin.WithLabelValues("rtc_success", strconv.FormatBool(warp), sdk.String()).Add(float64(success)) } } -func IncrementParticipantRtcFailure(failure uint64, warp bool) { +func IncrementParticipantRtcFailure(failure uint64, warp bool, sdk livekit.ClientInfo_SDK) { if failure > 0 { - promParticipantJoin.WithLabelValues("rtc_failure", strconv.FormatBool(warp)).Add(float64(failure)) + promParticipantJoin.WithLabelValues("rtc_failure", strconv.FormatBool(warp), sdk.String()).Add(float64(failure)) } } diff --git a/pkg/telemetry/prometheus/rooms.go b/pkg/telemetry/prometheus/rooms.go index 9a7a9c35b..96380f655 100644 --- a/pkg/telemetry/prometheus/rooms.go +++ b/pkg/telemetry/prometheus/rooms.go @@ -113,7 +113,7 @@ func initRoomStats(nodeID string, nodeType livekit.NodeType) { Name: "start_time_ms", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, Buckets: prometheus.ExponentialBucketsRange(100, 10000, 15), - }, []string{"protocol_version", "warp"}) + }, []string{"protocol_version", "warp", "sdk"}) promSessionDuration = prometheus.NewHistogramVec(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "session", @@ -291,8 +291,8 @@ func RecordSessionJoinLatency(protocolVersion int, d time.Duration) { promSessionJoinLatency.WithLabelValues(strconv.Itoa(protocolVersion)).Observe(float64(d.Milliseconds())) } -func RecordSessionStartTime(protocolVersion int, warp bool, d time.Duration) { - promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion), strconv.FormatBool(warp)).Observe(float64(d.Milliseconds())) +func RecordSessionStartTime(protocolVersion int, warp bool, sdk livekit.ClientInfo_SDK, d time.Duration) { + promSessionStartTime.WithLabelValues(strconv.Itoa(protocolVersion), strconv.FormatBool(warp), sdk.String()).Observe(float64(d.Milliseconds())) } func RecordSessionDuration(protocolVersion int, d time.Duration) { From d72a7cae3cc8c3a80e1659239496a106115462c2 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 12:56:27 -0700 Subject: [PATCH 047/130] Update module golang.org/x/sync to v0.23.0 (#4884) 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 5223127a3..b526624c5 100644 --- a/go.mod +++ b/go.mod @@ -55,7 +55,7 @@ require ( go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/mod v0.41.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index df63e63fd..dbb8a18a7 100644 --- a/go.sum +++ b/go.sum @@ -377,8 +377,8 @@ golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= 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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= 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= From 094ac494e0406275bb8fd779dfa5dc30fe513871 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 21 Sep 2026 13:20:43 -0700 Subject: [PATCH 048/130] Update module github.com/moby/moby/client to v0.6.0 (#4883) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index b526624c5..5fedc0728 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 - github.com/moby/moby/client v0.5.1 + github.com/moby/moby/client v0.6.0 github.com/olekukonko/tablewriter v1.1.4 github.com/ory/dockertest/v4 v4.0.0 github.com/pion/datachannel v1.6.2 @@ -79,7 +79,7 @@ require ( github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect - github.com/moby/moby/api v1.55.0 // indirect + github.com/moby/moby/api v1.56.0 // indirect github.com/nyaruka/phonenumbers v1.8.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect diff --git a/go.sum b/go.sum index dbb8a18a7..b850b5c96 100644 --- a/go.sum +++ b/go.sum @@ -205,10 +205,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.1 h1:tYNaJno4c0HXz12y5BiqEDy0rVTYkWzI26lGvnTMiJw= -github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nats-io/nats.go v1.53.1 h1:Otsq3uLc/kLdjmkNHkXH0jBqwUquwdKFoe3fq6/3/Xo= From 502346df00383eaa69af96708a7d6a24727e2dfe Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 21 Sep 2026 15:40:21 -0700 Subject: [PATCH 049/130] Propagate SIP_TRUNK_FAILURE (#4890) --- pkg/rtc/signalling/signalhandler.go | 2 ++ pkg/rtc/types/interfaces.go | 8 +++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/signalling/signalhandler.go b/pkg/rtc/signalling/signalhandler.go index 740e65899..fff79c8e6 100644 --- a/pkg/rtc/signalling/signalhandler.go +++ b/pkg/rtc/signalling/signalhandler.go @@ -94,6 +94,8 @@ func (s *signalhandler) HandleMessage(msg proto.Message) error { reason = types.ParticipantCloseReasonUserRejected case livekit.DisconnectReason_AGENT_ERROR: reason = types.ParticipantCloseReasonAgentError + case livekit.DisconnectReason_SIP_TRUNK_FAILURE: + reason = types.ParticipantCloseReasonSIPTrunkFailure } s.params.Logger.Debugw("client leaving room", "reason", reason) s.params.Participant.HandleLeaveRequest(reason) diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index fa8e260b8..87b89ffe1 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -120,6 +120,7 @@ const ( ParticipantCloseReasonUserRejected ParticipantCloseReasonMoveFailed ParticipantCloseReasonAgentError + ParticipantCloseReasonSIPTrunkFailure ) func (p ParticipantCloseReason) String() string { @@ -182,6 +183,8 @@ func (p ParticipantCloseReason) String() string { return "MOVE_FAILED" case ParticipantCloseReasonAgentError: return "AGENT_ERROR" + case ParticipantCloseReasonSIPTrunkFailure: + return "SIP_TRUNK_FAILURE" default: return fmt.Sprintf("%d", int(p)) } @@ -221,6 +224,8 @@ func (p ParticipantCloseReason) ToDisconnectReason() livekit.DisconnectReason { return livekit.DisconnectReason_USER_REJECTED case ParticipantCloseReasonAgentError: return livekit.DisconnectReason_AGENT_ERROR + case ParticipantCloseReasonSIPTrunkFailure: + return livekit.DisconnectReason_SIP_TRUNK_FAILURE default: // the other types will map to unknown reason return livekit.DisconnectReason_UNKNOWN_REASON @@ -240,7 +245,8 @@ func IsIntentionalDisconnect(reason livekit.DisconnectReason) bool { livekit.DisconnectReason_MIGRATION, livekit.DisconnectReason_PARTICIPANT_REMOVED, livekit.DisconnectReason_ROOM_DELETED, - livekit.DisconnectReason_ROOM_CLOSED: + livekit.DisconnectReason_ROOM_CLOSED, + livekit.DisconnectReason_SIP_TRUNK_FAILURE: return true } return false From f07dd913e9fddac70ff610d3b230bbd4290a2507 Mon Sep 17 00:00:00 2001 From: elizabeth-muirhead-simplisafe Date: Tue, 22 Sep 2026 01:22:51 -0400 Subject: [PATCH 050/130] sfu: add opt-in verbatim abs-capture-time forwarding (#4812) * sfu: add opt-in verbatim abs-capture-time forwarding Downtrack ACT forwarding gates on an RTCP sender report and unconditionally rewrites the capture timestamp into SFU clock domain, which suits cross-stream A/V sync but breaks consumers that need the original publisher capture time (e.g. end-to-end latency measurement). Add ForwardAbsCaptureTimeVerbatim (RTC config: forward_abs_capture_time_verbatim), off by default, which skips both the gate and the rewrite. * fix: use DisableSenderReportPassThrough setting to also gate abs-capture-time forwarding --- pkg/sfu/downtrack.go | 42 ++++++++++++++++++------------- pkg/sfu/rtpstats/rtpstats_base.go | 6 +---- 2 files changed, 25 insertions(+), 23 deletions(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index d83c020f4..68aaa6908 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1098,24 +1098,30 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { } var actBytes []byte if extPkt.AbsCaptureTimeExt != nil && d.absCaptureTimeExtID != 0 { - // normalize capture time to SFU clock. - // NOTE: even if there is estimated offset populated, just re-map the - // absolute capture time stamp as it should be the same RTCP sender report - // clock domain of publisher. SFU is normalising sender reports of publisher - // to SFU clock before sending to subscribers. So, capture time should be - // normalized to the same clock. Clear out any offset. - _, _, _, refSenderReport := d.forwarder.GetSenderReportParams() - if refSenderReport != nil { - actExtCopy := *extPkt.AbsCaptureTimeExt - if err = actExtCopy.Rewrite( - rtpstats.RTCPSenderReportPropagationDelay( - refSenderReport, - !d.params.DisableSenderReportPassThrough, - ), - ); err == nil { - actBytes, err = actExtCopy.Marshal() - if err == nil { - hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + if !d.params.DisableSenderReportPassThrough { + // pass through the original publisher capture time verbatim, consistent + // with sender reports also being passed through unchanged. + actBytes, err = extPkt.AbsCaptureTimeExt.Marshal() + if err == nil { + hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + } + } else { + // normalize capture time to SFU clock. + // NOTE: even if there is estimated offset populated, just re-map the + // absolute capture time stamp as it should be the same RTCP sender report + // clock domain of publisher. SFU is normalising sender reports of publisher + // to SFU clock before sending to subscribers. So, capture time should be + // normalized to the same clock. Clear out any offset. + _, _, _, refSenderReport := d.forwarder.GetSenderReportParams() + if refSenderReport != nil { + actExtCopy := *extPkt.AbsCaptureTimeExt + if err = actExtCopy.Rewrite( + rtpstats.RTCPSenderReportPropagationDelay(refSenderReport), + ); err == nil { + actBytes, err = actExtCopy.Marshal() + if err == nil { + hdr.SetExtension(uint8(d.absCaptureTimeExtID), actBytes) + } } } } diff --git a/pkg/sfu/rtpstats/rtpstats_base.go b/pkg/sfu/rtpstats/rtpstats_base.go index b14a92b8b..b5f2f6fe7 100644 --- a/pkg/sfu/rtpstats/rtpstats_base.go +++ b/pkg/sfu/rtpstats/rtpstats_base.go @@ -193,11 +193,7 @@ func (w WrappedRTCPSenderReportStateLogger) MarshalLogObject(e zapcore.ObjectEnc // ------------------------------------------------------------------ -func RTCPSenderReportPropagationDelay(rsrs *livekit.RTCPSenderReportState, passThrough bool) time.Duration { - if passThrough { - return 0 - } - +func RTCPSenderReportPropagationDelay(rsrs *livekit.RTCPSenderReportState) time.Duration { return time.Unix(0, rsrs.AtAdjusted).Sub(mediatransportutil.NtpTime(rsrs.NtpTimestamp).Time()) } From 1e539510c06a2351c96d4d961eee12d98aaa786c Mon Sep 17 00:00:00 2001 From: Leo Camus Date: Tue, 22 Sep 2026 07:34:53 +0200 Subject: [PATCH 051/130] ccutils: use millisecond resolution for probe interval backoff (#4891) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ccutils: use millisecond resolution for probe interval backoff ProbeSignal grew probeInterval via Seconds(), which truncates the float to the nearest nanosecond before scaling by time.Second: time.Duration(probeInterval.Seconds() * BackoffFactor) * time.Second With BackoffFactor=1.5 and the default BaseInterval of 3 s, the first congestion signal produced 4 s instead of 4.5 s, and the error compounded on every subsequent signal (4 s → 6 s → 9 s → 13 s ... instead of 4.5 s → 6.75 s → 10.125 s ...). The interval converges to MaxInterval later than intended, so the server probes more frequently during sustained congestion than the configuration requests. probeDuration in the same function already uses the correct idiom: time.Duration(float64(probeDuration.Milliseconds()) * Factor) * time.Millisecond Apply the same pattern to probeInterval so both fields are computed consistently with millisecond precision. Found by a defect-hunting pipeline I build and run ([Dev-next-gen](https://github.com/Dev-next-gen)), using Claude Code with Anthropic's Claude Opus 5. * ccutils: remove probe interval backoff test Requested by reviewer. --- pkg/sfu/ccutils/probe_regulator.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sfu/ccutils/probe_regulator.go b/pkg/sfu/ccutils/probe_regulator.go index ed0940bd9..5804404b1 100644 --- a/pkg/sfu/ccutils/probe_regulator.go +++ b/pkg/sfu/ccutils/probe_regulator.go @@ -80,7 +80,7 @@ func (p *ProbeRegulator) ProbeDuration() time.Duration { func (p *ProbeRegulator) ProbeSignal(probeSignal ProbeSignal, baseTime time.Time) { if probeSignal == ProbeSignalCongesting { // wait longer till next probe - p.probeInterval = time.Duration(p.probeInterval.Seconds()*p.params.Config.BackoffFactor) * time.Second + p.probeInterval = time.Duration(float64(p.probeInterval.Milliseconds())*p.params.Config.BackoffFactor) * time.Millisecond if p.probeInterval > p.params.Config.MaxInterval { p.probeInterval = p.params.Config.MaxInterval } From abf98e5a28bdb916234677b9e008183467ef7b7c Mon Sep 17 00:00:00 2001 From: Sergey Katerinenko <82874937+SKaterinenko@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:37:26 +0500 Subject: [PATCH 052/130] Do not request media sections for transceivers that cannot send (#4892) * Do not request media sections for transceivers that cannot send getNumUnmatchedTransceivers counted every local transceiver without a mid. A transceiver whose track was removed before its section was negotiated (direction recvonly, no sender) or that was stopped (inactive) can never be matched to the recvonly section the remote adds in response to a MediaSectionsRequirement, so it stayed "outstanding" forever: every answer asked the remote for a section for it again, the remote appended another batch of transceivers each round, and its offer grew by a fixed amount per negotiation until it exceeded limit.signal_message_size_limit and the signalling connection was closed (full reconnect, then the cycle restarts). Count only transceivers that have something to send. A released transceiver that AddTrack reuses later becomes sendrecv and is counted again. Co-Authored-By: Claude Opus 5 * Address review: blank lines between test functions, assert transceiver reuse Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5 --- pkg/rtc/transport.go | 6 + pkg/rtc/transport_media_sections_test.go | 236 +++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 pkg/rtc/transport_media_sections_test.go diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 2f9f7d440..c1f13def5 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -1233,6 +1233,12 @@ func (t *PCTransport) getNumUnmatchedTransceivers() (uint32, uint32) { continue } + switch tr.Direction() { + case webrtc.RTPTransceiverDirectionSendonly, webrtc.RTPTransceiverDirectionSendrecv: + default: + continue + } + switch tr.Kind() { case webrtc.RTPCodecTypeAudio: numAudios++ diff --git a/pkg/rtc/transport_media_sections_test.go b/pkg/rtc/transport_media_sections_test.go new file mode 100644 index 000000000..899896427 --- /dev/null +++ b/pkg/rtc/transport_media_sections_test.go @@ -0,0 +1,236 @@ +// 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" + "testing" + "time" + + "github.com/pion/webrtc/v4" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/protocol/codecs/mime" + "github.com/livekit/protocol/livekit" +) + +type mediaSectionsHarness struct { + t *testing.T + server *PCTransport + handler *transportfakes.FakeHandler + client *webrtc.PeerConnection + + mu sync.Mutex + answer *webrtc.SessionDescription + answers int + requirements int + requestedAudios uint32 + requestedVideos uint32 + pendingAudios uint32 + pendingVideos uint32 + offerID uint32 +} + +func newMediaSectionsHarness(t *testing.T) *mediaSectionsHarness { + codecs := []*livekit.Codec{ + {Mime: mime.MimeTypeOpus.String()}, + {Mime: mime.MimeTypeVP8.String()}, + } + + h := &mediaSectionsHarness{t: t, handler: &transportfakes.FakeHandler{}} + + server, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{}, + EnabledPublishCodecs: codecs, + EnabledSubscribeCodecs: codecs, + IsSendSide: true, + Handler: h.handler, + }) + require.NoError(t, err) + h.server = server + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + h.client = client + + h.handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error { + h.mu.Lock() + defer h.mu.Unlock() + h.answer = &sd + h.answers++ + return nil + }) + h.handler.OnUnmatchedMediaCalls(func(numAudios uint32, numVideos uint32) error { + h.mu.Lock() + defer h.mu.Unlock() + h.requirements++ + h.requestedAudios += numAudios + h.requestedVideos += numVideos + h.pendingAudios += numAudios + h.pendingVideos += numVideos + return nil + }) + + t.Cleanup(func() { + server.Close() + client.Close() + }) + return h +} + +func (h *mediaSectionsHarness) negotiate(options *webrtc.OfferOptions) { + h.mu.Lock() + for i := uint32(0); i < h.pendingAudios; i++ { + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + require.NoError(h.t, err) + } + for i := uint32(0); i < h.pendingVideos; i++ { + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionRecvonly, + }) + require.NoError(h.t, err) + } + h.pendingAudios, h.pendingVideos = 0, 0 + answersBefore := h.answers + h.offerID++ + offerID := h.offerID + h.mu.Unlock() + + offer, err := h.client.CreateOffer(options) + require.NoError(h.t, err) + require.NoError(h.t, h.client.SetLocalDescription(offer)) + require.NoError(h.t, h.server.HandleRemoteDescription(*h.client.LocalDescription(), offerID)) + + require.Eventually(h.t, func() bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.answers > answersBefore + }, 5*time.Second, 10*time.Millisecond, "server did not produce answer") + + h.mu.Lock() + answer := *h.answer + h.mu.Unlock() + require.NoError(h.t, h.client.SetRemoteDescription(answer)) +} + +func (h *mediaSectionsHarness) addVideoTrack(id string) (*webrtc.RTPSender, *webrtc.RTPTransceiver) { + track, err := webrtc.NewTrackLocalStaticRTP( + webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String(), ClockRate: 90000}, + id, id, + ) + require.NoError(h.t, err) + + sender, transceiver, err := h.server.AddTrack( + track, + types.AddTrackParams{}, + []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}}, + RTCPFeedbackConfig{}, + ) + require.NoError(h.t, err) + return sender, transceiver +} + +func (h *mediaSectionsHarness) requested() (int, uint32, uint32) { + h.mu.Lock() + defer h.mu.Unlock() + return h.requirements, h.requestedAudios, h.requestedVideos +} + +func (h *mediaSectionsHarness) clientVideoTransceivers() int { + n := 0 + for _, tr := range h.client.GetTransceivers() { + if tr.Kind() == webrtc.RTPCodecTypeVideo { + n++ + } + } + return n +} + +func TestMediaSectionsRequirementNotRepeatedForUnmatchableTransceiver(t *testing.T) { + h := newMediaSectionsHarness(t) + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + sender, transceiver := h.addVideoTrack("v1") + require.Empty(t, transceiver.Mid()) + require.NoError(t, h.server.RemoveTrack(sender)) + + const rounds = 5 + for i := 0; i < rounds; i++ { + h.negotiate(nil) + } + h.negotiate(nil) + + _, _, requestedVideos := h.requested() + require.LessOrEqual(t, requestedVideos, uint32(1), + "unmatchable transceiver re-requested on every answer: %d video sections requested over %d rounds", requestedVideos, rounds) + require.LessOrEqual(t, h.clientVideoTransceivers(), 1) +} + +func TestMediaSectionsRequirementAfterTransceiverReuse(t *testing.T) { + h := newMediaSectionsHarness(t) + + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + + sender, released := h.addVideoTrack("v1") + require.NoError(t, h.server.RemoveTrack(sender)) + h.negotiate(nil) + h.negotiate(nil) + require.Empty(t, released.Mid()) + + _, transceiver := h.addVideoTrack("v2") + require.Same(t, released, transceiver, "released transceiver should be reused") + require.Empty(t, transceiver.Mid()) + h.negotiate(nil) + h.server.Negotiate(true) + h.negotiate(nil) + h.negotiate(nil) + + require.NotEmpty(t, transceiver.Mid(), "subscribed track never got a media section") +} + +func TestMediaSectionsRequirementReissuedWhenLost(t *testing.T) { + h := newMediaSectionsHarness(t) + + _, err := h.client.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio, webrtc.RTPTransceiverInit{ + Direction: webrtc.RTPTransceiverDirectionSendonly, + }) + require.NoError(t, err) + h.negotiate(nil) + + _, transceiver := h.addVideoTrack("v1") + h.negotiate(nil) + h.mu.Lock() + h.pendingVideos = 0 + h.mu.Unlock() + + h.negotiate(&webrtc.OfferOptions{ICERestart: true}) + h.negotiate(nil) + h.negotiate(nil) + + require.NotEmpty(t, transceiver.Mid(), "lost requirement was never re-issued") +} From be944ee9642eb75a35fa3b2d127b277ad9b7bb74 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 22 Sep 2026 22:45:14 +0530 Subject: [PATCH 053/130] sfu: don't hold bindLock across the blank-frame flush in CloseWithFlush (#4895) * sfu: don't hold bindLock across the blank-frame flush in CloseWithFlush CloseWithFlush held bindLock across the up-to-flushTimeout (1s) wait for the closing blank-frame flush. bindLock is a control-plane lock, also taken by Bind/SetConnected/ReceiverRestart, so every close serialized those behind its flush wait. This was the single largest accumulator in mutex contention profiling under track churn, aggregated across all closing downtracks. The flush runs in its own goroutine (writeBlankFrameRTP) and is cancelled via blankFramesGeneration, so the lock guards nothing during the wait; release it and re-acquire for the unbind/delete teardown. Guard the control-plane mutators on isClosed so none can act on a downtrack through the newly opened window. Bind in particular was reachable: its bindState != unbound check does not catch a closed track, since close leaves the state unbound. Co-Authored-By: Claude Opus 4.8 * sfu: drop redundant isClosed guard in Bind Bind already handles a closed downtrack after codec matching (returns the codec with no error, preserving pion's non-error contract and never rebuilding state). The early guard returned errDownTrackClosed, which handleRemoteAnswerReceived treats as a negotiation failure, so a close/negotiation race could abort the remote answer. Remove it and the error type. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- pkg/sfu/downtrack.go | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 68aaa6908..1ff4ac7aa 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1440,15 +1440,24 @@ func (d *DownTrack) CloseWithFlush(flush bool, isEnding bool) { if flush { doneFlushing := d.writeBlankFrameRTP(RTPBlankFramesCloseSeconds, d.blankFramesGeneration.Inc()) + // The flush runs in its own goroutine (writeBlankFrameRTP) and is + // cancelled via blankFramesGeneration, so bindLock guards nothing + // during the wait. Release it: bindLock is a control-plane lock + // (Bind/SetConnected/ReceiverRestart), and holding it across the + // up-to-flushTimeout wait serializes all of those behind every + // close. isClosed is already set, so no other close can enter. + d.bindLock.Unlock() + // wait a limited time to flush timer := time.NewTimer(flushTimeout) - defer timer.Stop() - select { case <-doneFlushing: case <-timer.C: d.blankFramesGeneration.Inc() // in case flush is still running } + timer.Stop() + + d.bindLock.Lock() } d.params.Logger.Debugw("closing sender", "kind", d.kind) @@ -1755,6 +1764,10 @@ func (d *DownTrack) ReceiverRestart(rcvr TrackReceiver) { } d.bindLock.Lock() + if d.isClosed.Load() { + d.bindLock.Unlock() + return + } codec := d.codec.Load().(webrtc.RTPCodecCapability) d.bindLock.Unlock() @@ -2167,6 +2180,10 @@ func (d *DownTrack) handleRTCPRTX(bytes []byte) { func (d *DownTrack) SetConnected() { d.bindLock.Lock() + if d.isClosed.Load() { + d.bindLock.Unlock() + return + } if !d.connected.Swap(true) { d.onBindAndConnectedChange() } From 9af0f542b92f540c80eda8cbe7df0cef03b5598a Mon Sep 17 00:00:00 2001 From: David Chen Date: Tue, 22 Sep 2026 15:07:32 -0700 Subject: [PATCH 054/130] Handle FEC packet recovery failure Increment FEC packets discarded counter and remove packet. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- pkg/sfu/flexfec/decoder.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/flexfec/decoder.go b/pkg/sfu/flexfec/decoder.go index 6523d4867..11427f2f9 100644 --- a/pkg/sfu/flexfec/decoder.go +++ b/pkg/sfu/flexfec/decoder.go @@ -273,7 +273,8 @@ func (d *Decoder) attemptRecovery() []*rtp.Packet { recovered, err := d.recoverPacket(fecPkt, recoveredPackets) if err != nil { d.logger.Debugw("flexfec: failed to recover packet", "error", err) - i++ + d.stats.FECPacketsDiscarded++ + d.removeFECPacketAt(i) continue } From 6b2e3ece698e84d8dde915bd85d298bb0e245c27 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Wed, 23 Sep 2026 13:54:40 +0800 Subject: [PATCH 055/130] Increase receiver loadbalance threshold when batch io enabled (#4899) * Increase receiver loadbalance threshold when batch io enabled With batch IO the send syscall moves to the BatchConn flush goroutine, so WriteRTP only translates the packet and enqueues it. Increash the threshold to reduce * lint --- pkg/rtc/mediatrack.go | 15 ++++++++++++++- pkg/rtc/participant.go | 2 ++ pkg/service/roommanager.go | 3 +++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/mediatrack.go b/pkg/rtc/mediatrack.go index 3c167e0f2..be1436fce 100644 --- a/pkg/rtc/mediatrack.go +++ b/pkg/rtc/mediatrack.go @@ -95,6 +95,7 @@ type MediaTrackParams struct { EnableRTPStreamRestartDetection bool UpdateTrackInfoByVideoSizeChange bool ForceBackupCodecPolicySimulcast bool + MediaBatchIOEnabled bool OnSubscribedMaxQualityChange func( trackID livekit.TrackID, trackInfo *livekit.TrackInfo, @@ -272,6 +273,18 @@ func (t *MediaTrack) ToProto() *livekit.TrackInfo { return t.MediaTrackReceiver.TrackInfoClone() } +func (t *MediaTrack) getReceiverLBThreshold() int { + // With batch IO the send syscall moves to the BatchConn flush goroutine, so WriteRTP only + // translates the packet and enqueues it: ~3.3us per downtrack, against ~26us when the send runs + // inline. The last subscriber of 300 subscribers at a serial forwarder is enqueued ~1ms + // after the first. + if t.params.MediaBatchIOEnabled { + return 300 + } + + return 20 +} + // AddReceiver adds a new RTP receiver to the track, returns true when receiver represents a new codec // and if a receiver was added successfully func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRemote, mid string) (bool, bool) { @@ -401,7 +414,7 @@ func (t *MediaTrack) AddReceiver(receiver *webrtc.RTPReceiver, track sfu.TrackRe t.params.VideoConfig.StreamTrackerManager, sfu.WithPliThrottleConfig(t.params.PLIThrottleConfig), sfu.WithAudioConfig(t.params.AudioConfig), - sfu.WithLoadBalanceThreshold(20), + sfu.WithLoadBalanceThreshold(t.getReceiverLBThreshold()), sfu.WithForwardStats(t.params.ForwardStats), sfu.WithEnableRTPStreamRestartDetection(t.params.EnableRTPStreamRestartDetection), ) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index b0a663f27..a63529fb5 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -241,6 +241,7 @@ type ParticipantParams struct { MigrationWaitDuration time.Duration ExcludeIPv6LocalCandidates bool EnableWarp bool + MediaBatchIOEnabled bool } type ParticipantImpl struct { @@ -3573,6 +3574,7 @@ func (p *ParticipantImpl) addMediaTrack(signalCid string, ti *livekit.TrackInfo) EnableRTPStreamRestartDetection: p.params.EnableRTPStreamRestartDetection, UpdateTrackInfoByVideoSizeChange: p.params.UseOneShotSignallingMode, ForceBackupCodecPolicySimulcast: p.params.ForceBackupCodecPolicySimulcast, + MediaBatchIOEnabled: p.params.MediaBatchIOEnabled, OnSubscribedMaxQualityChange: p.onSubscribedMaxQualityChange, OnSubscribedAudioCodecChange: p.onSubscribedAudioCodecChange, }, ti) diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index 5aace9845..b667344f3 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -543,6 +543,9 @@ func (r *RoomManager) StartSession( EnableParticipantDataBlob: r.config.EnableParticipantDataBlob, EnableRTPStreamRestartDetection: r.config.RTC.EnableRTPStreamRestartDetection, EnableWarp: enableWarp, + MediaBatchIOEnabled: r.config.RTC.BatchIO.BatchSize > 0 && + (r.config.RTC.ICEPortRangeStart == 0 || r.config.RTC.ICEPortRangeEnd == 0) && + r.config.RTC.UDPPort.Valid(), }) if err != nil { prometheus.IncrementParticipantRtcCanceled(1, enableWarp, pi.Client.GetSdk()) From f52b0e2bd5b68e05872b5034f8b0a981605bc679 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Fri, 25 Sep 2026 05:01:30 +0530 Subject: [PATCH 056/130] rtc: parse each remote offer once per negotiation (#4904) --- go.mod | 2 +- go.sum | 4 +- pkg/rtc/participant.go | 22 ++---- pkg/rtc/participant_sdp.go | 18 +++-- pkg/rtc/transport.go | 96 ++++++++++++++++-------- pkg/rtc/transport_test.go | 144 ++++++++++++++++++++++++++++++++++++ pkg/rtc/transportmanager.go | 9 +-- 7 files changed, 235 insertions(+), 60 deletions(-) diff --git a/go.mod b/go.mod index 5fedc0728..ed6da6119 100644 --- a/go.mod +++ b/go.mod @@ -161,7 +161,7 @@ tool ( golang.org/x/tools/cmd/goimports ) -replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 +replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2 replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1 diff --git a/go.sum b/go.sum index b850b5c96..510325b72 100644 --- a/go.sum +++ b/go.sum @@ -168,8 +168,8 @@ github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319 h1:mEfaX/HY18L github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319/go.mod h1:Ke1q64xQzwZ1S1ZH66SudXHsaaQQAFoX+TL1iLpu7XU= github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1 h1:fH+v4W+NFp9FfPzON6FaUFNmazGcctaAhb2P+Ksf+1s= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.1/go.mod h1:rbKGHo2OpNUImWTvRIV776/3xjjq/t47H3IZiTtwluc= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2 h1:EKmLnuIs/2wRJmfQKv8JbZIGjHt7PQZYf94TAGyZGSI= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2/go.mod h1:Ps5ynXt7Heejsbg8fclYIDCBsUC0nCHNoWRUwFqyA2k= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index a63529fb5..2bcff98a4 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -1215,20 +1215,19 @@ func (p *ParticipantImpl) HandleOffer(sd *livekit.SessionDescription) error { lgr.Debugw("received offer") - parsedOffer, err := offer.Unmarshal() - if err != nil { - lgr.Warnw("could not parse offer", err) - return err - } - if p.params.UseOneShotSignallingMode { + parsedOffer, err := offer.Unmarshal() + if err != nil { + lgr.Warnw("could not parse offer", err) + return err + } if err := p.synthesizeAddTrackRequests(parsedOffer); err != nil { lgr.Warnw("could not synthesize add track requests", err) return err } } - err = p.TransportManager.HandleOffer(offer, offerId, p.MigrateState() == types.MigrateStateInit) + err := p.TransportManager.HandleOffer(offer, offerId, p.MigrateState() == types.MigrateStateInit) if err != nil { lgr.Warnw("could not handle offer", err, "mungedOffer", offer) return err @@ -1243,13 +1242,8 @@ func (p *ParticipantImpl) HandleOffer(sd *livekit.SessionDescription) error { } func (p *ParticipantImpl) onPublisherSetRemoteDescription() { - offer := p.TransportManager.LastPublisherOfferPending() - if offer == nil { - return - } - parsedOffer, err := offer.Unmarshal() - if err != nil { - p.pubLogger.Warnw("could not parse offer", err) + parsedOffer := p.TransportManager.LastPublisherOfferParsed() + if parsedOffer == nil { return } diff --git a/pkg/rtc/participant_sdp.go b/pkg/rtc/participant_sdp.go index 79cb595cd..ca8ae095a 100644 --- a/pkg/rtc/participant_sdp.go +++ b/pkg/rtc/participant_sdp.go @@ -319,9 +319,8 @@ func (p *ParticipantImpl) setCodecPreferencesOpusRedForPublisher( // configure publisher answer for audio track's dtx and stereo settings func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescription) webrtc.SessionDescription { - offer := p.TransportManager.LastPublisherOffer() - parsedOffer, err := offer.Unmarshal() - if err != nil { + parsedOffer := p.TransportManager.LastPublisherOfferParsed() + if parsedOffer == nil { return answer } @@ -330,6 +329,9 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript return answer } + // payload type lookup scans the whole answer, resolve it once + opusPT, opusPTErr := parsedAnswer.GetPayloadTypeForCodec(sdp.Codec{Name: mime.MimeTypeCodecOpus.String()}) + changed := false for _, m := range parsedAnswer.MediaDescriptions { switch m.MediaName.Media { case "audio": @@ -371,9 +373,8 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript continue } - opusPT, err := parsedAnswer.GetPayloadTypeForCodec(sdp.Codec{Name: mime.MimeTypeCodecOpus.String()}) - if err != nil { - p.pubLogger.Infow("failed to get opus payload type", "error", err, "trackID", ti.Sid) + if opusPTErr != nil { + p.pubLogger.Infow("failed to get opus payload type", "error", opusPTErr, "trackID", ti.Sid) continue } @@ -390,6 +391,7 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript attr.Value = strings.ReplaceAll(attr.Value, ";stereo=1", "") } m.Attributes[i] = attr + changed = true } } @@ -398,6 +400,10 @@ func (p *ParticipantImpl) configurePublisherAnswer(answer webrtc.SessionDescript } } + if !changed { + return answer + } + bytes, err := parsedAnswer.Marshal() if err != nil { p.pubLogger.Infow("failed to marshal answer", "error", err) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index c1f13def5..c00f629dd 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -300,6 +300,9 @@ type PCTransport struct { signalStateCheckTimer *time.Timer currentOfferIceCredential string // ice user:pwd, for publish side ice restart checking pendingRestartIceOffer *webrtc.SessionDescription + // parsed forms kept so that a remote description is parsed once per negotiation + pendingRestartIceOfferParsed *sdp.SessionDescription + remoteOfferParsed *sdp.SessionDescription } type TransportParams struct { @@ -1646,6 +1649,7 @@ func (t *PCTransport) clearConnTimer() { } func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remoteId uint32) error { + parsed, parseErr := sd.Unmarshal() if t.params.UseOneShotSignallingMode { if sd.Type == webrtc.SDPTypeOffer { remoteOfferId := t.remoteOfferId.Load() @@ -1666,8 +1670,7 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo } // add remote candidates to ICE connection details - parsed, err := sd.Unmarshal() - if err == nil { + if parseErr == nil { addRemoteICECandidates := func(attrs []sdp.Attribute) { for _, a := range attrs { if a.IsICECandidate() { @@ -1686,11 +1689,11 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo } } - err = t.pc.SetRemoteDescription(sd) - if err != nil { + if err := t.pc.SetRemoteDescription(sd); err != nil { t.params.Logger.Errorw("could not set remote description on synchronous mode peer connection", err) return err } + t.setRemoteOfferParsed(sd.Type, parsed) rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger) if len(rtxRepairs) > 0 { @@ -1706,6 +1709,8 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo signal: signalRemoteDescriptionReceived, data: remoteDescriptionData{ sessionDescription: &sd, + parsed: parsed, + parseErr: parseErr, remoteId: remoteId, }, }) @@ -1907,6 +1912,7 @@ func (t *PCTransport) HandleICERestartSDPFragment(sdpFragment string) (string, e t.params.Logger.Warnw("could not set remote description", err) return "", err } + t.setRemoteOfferParsed(sd.Type, parsedRemote) // clear out connection details on ICE restart and re-populate t.connectionDetails.Clear() @@ -2289,6 +2295,23 @@ func (t *PCTransport) SetPreviousSdp(localDescription, remoteDescription *webrtc t.lock.Unlock() } +func (t *PCTransport) setRemoteOfferParsed(sdType webrtc.SDPType, parsed *sdp.SessionDescription) { + if sdType != webrtc.SDPTypeOffer { + return + } + t.lock.Lock() + t.remoteOfferParsed = parsed + t.lock.Unlock() +} + +// RemoteOfferParsed returns the parsed form of the last remote offer set on the peer connection, +// candidates filtered the same way pion saw them +func (t *PCTransport) RemoteOfferParsed() *sdp.SessionDescription { + t.lock.RLock() + defer t.lock.RUnlock() + return t.remoteOfferParsed +} + func (t *PCTransport) parseTrackMid(sd webrtc.SessionDescription, senders map[string]*webrtc.RTPSender) error { parsed, err := sd.Unmarshal() if err != nil { @@ -2369,22 +2392,17 @@ func (t *PCTransport) handleICEGatheringCompleteAnswerer() error { } offer := *t.pendingRestartIceOffer + parsed := t.pendingRestartIceOfferParsed t.pendingRestartIceOffer = nil + t.pendingRestartIceOfferParsed = 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 { + if err := t.setRemoteDescription(offer, parsed); err != nil { return err } t.params.Handler.OnSetRemoteDescriptionOffer() - t.processSendersPendingConfig(offerAudioPT) + t.processSendersPendingConfig(offerAudioPayloadTypes(parsed)) return t.createAndSendAnswer() } @@ -2498,10 +2516,23 @@ func (t *PCTransport) isCandidateFilterActive(preferTCP bool) bool { } func (t *PCTransport) filterCandidates(sd webrtc.SessionDescription, preferTCP, isLocal bool) webrtc.SessionDescription { - parsed, err := sd.Unmarshal() - if err != nil { - t.params.Logger.Warnw("could not unmarshal SDP to filter candidates", err) - return sd + return t.filterCandidatesParsed(sd, nil, preferTCP, isLocal) +} + +// filterCandidatesParsed filters using an already parsed description when available, +// the parsed description is modified in place to match the returned SDP +func (t *PCTransport) filterCandidatesParsed( + sd webrtc.SessionDescription, + parsed *sdp.SessionDescription, + preferTCP, isLocal bool, +) webrtc.SessionDescription { + if parsed == nil { + var err error + parsed, err = sd.Unmarshal() + if err != nil { + t.params.Logger.Warnw("could not unmarshal SDP to filter candidates", err) + return sd + } } filterAttributes := func(attrs []sdp.Attribute) []sdp.Attribute { @@ -2727,15 +2758,20 @@ func (t *PCTransport) handleSendOffer(_ event) error { type remoteDescriptionData struct { sessionDescription *webrtc.SessionDescription + parsed *sdp.SessionDescription + parseErr error // set when parsed is nil remoteId uint32 } func (t *PCTransport) handleRemoteDescriptionReceived(e event) error { rdd := e.data.(remoteDescriptionData) + if rdd.parsed == nil { + return rdd.parseErr + } if rdd.sessionDescription.Type == webrtc.SDPTypeOffer { - return t.handleRemoteOfferReceived(rdd.sessionDescription, rdd.remoteId) + return t.handleRemoteOfferReceived(rdd.sessionDescription, rdd.parsed, rdd.remoteId) } else { - return t.handleRemoteAnswerReceived(rdd.sessionDescription, rdd.remoteId) + return t.handleRemoteAnswerReceived(rdd.sessionDescription, rdd.parsed, rdd.remoteId) } } @@ -2751,13 +2787,13 @@ func (t *PCTransport) isRemoteOfferRestartICE(parsed *sdp.SessionDescription) (s return credential, restartICE, nil } -func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error { +func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription, parsed *sdp.SessionDescription) error { // filter before setting remote description so that pion does not see filtered remote candidates preferTCP := t.preferTCP.Load() if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (unfiltered)", "type", sd.Type, "sdp", sd.SDP) } - sd = t.filterCandidates(sd, preferTCP, false) + sd = t.filterCandidatesParsed(sd, parsed, preferTCP, false) if t.isCandidateFilterActive(preferTCP) { t.params.Logger.Debugw("remote description (filtered)", "type", sd.Type, "sdp", sd.SDP) } @@ -2774,7 +2810,9 @@ func (t *PCTransport) setRemoteDescription(sd webrtc.SessionDescription) error { } prometheus.RecordServiceOperationError(sdpType, "remote_description") return errors.Wrap(err, "setting remote description failed") - } else if sd.Type == webrtc.SDPTypeAnswer { + } + t.setRemoteOfferParsed(sd.Type, parsed) + if sd.Type == webrtc.SDPTypeAnswer { t.lock.Lock() if !t.canReuseTransceiver { t.canReuseTransceiver = true @@ -2871,7 +2909,7 @@ func (t *PCTransport) createAndSendAnswer() error { return t.localDescriptionSent() } -func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, offerId uint32) error { +func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, parsed *sdp.SessionDescription, offerId uint32) error { t.params.Logger.Debugw("processing offer", "offerId", offerId) remoteOfferId := t.remoteOfferId.Load() if remoteOfferId != 0 && remoteOfferId != t.localAnswerId.Load() { @@ -2884,11 +2922,6 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o } t.remoteOfferId.Store(offerId) - parsed, err := sd.Unmarshal() - if err != nil { - return err - } - t.lock.Lock() if !t.firstOfferReceived { t.firstOfferReceived = true @@ -2915,6 +2948,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o if offerRestartICE && t.pc.ICEGatheringState() == webrtc.ICEGatheringStateGathering { t.params.Logger.Debugw("remote offer restart ice while ice gathering") t.pendingRestartIceOffer = sd + t.pendingRestartIceOfferParsed = parsed return nil } @@ -2928,7 +2962,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o isStartOfConnectionSequence := t.pc.RemoteDescription() == nil - if err := t.setRemoteDescription(*sd); err != nil { + if err := t.setRemoteDescription(*sd, parsed); err != nil { return err } @@ -2954,7 +2988,7 @@ func (t *PCTransport) handleRemoteOfferReceived(sd *webrtc.SessionDescription, o return t.createAndSendAnswer() } -func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, answerId uint32) error { +func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, parsed *sdp.SessionDescription, answerId uint32) error { t.params.Logger.Debugw("processing answer", "answerId", answerId) if answerId != 0 && answerId != t.localOfferId.Load() { t.params.Logger.Warnw( @@ -2967,7 +3001,7 @@ func (t *PCTransport) handleRemoteAnswerReceived(sd *webrtc.SessionDescription, t.clearSignalStateCheckTimer() - if err := t.setRemoteDescription(*sd); err != nil { + if err := t.setRemoteDescription(*sd, parsed); err != nil { // Pion will call RTPSender.Send method for each new added Downtrack, and return error if the DownTrack.Bind // returns error. In case of Downtrack.Bind returns ErrUnsupportedCodec, the signal state will be stable as negotiation is already completed // before startRTPSenders, and the peerconnection state can be recovered by next negotiation which will be triggered diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index 3c0ea425e..d0eb5a697 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -28,9 +28,11 @@ import ( "github.com/livekit/livekit-server/pkg/rtc/transport" "github.com/livekit/livekit-server/pkg/rtc/transport/transportfakes" + "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/testutils" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" + lksdp "github.com/livekit/protocol/sdp" ) func TestMissingAnswerDuringICERestart(t *testing.T) { @@ -803,3 +805,145 @@ func TestSinglePCAnswerStripsSubscribeOnlyCodecsFromRecvSide(t *testing.T) { "answer must not advertise H.264 in recv-side m-section: %s", a.Value) } } + +// the parsed remote offer kept by the transport must follow every accepted offer +// and survive an offer that does not parse +func TestRemoteOfferParsedTracksOffers(t *testing.T) { + codecs := []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}} + handler := &transportfakes.FakeHandler{} + tr, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{ + // offers from a real client carry RTX pairs that the transport registers here + BufferFactory: buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory(), + }, + EnabledPublishCodecs: codecs, + IsOfferer: false, + Handler: handler, + }) + require.NoError(t, err) + defer tr.Close() + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + handler.OnAnswerCalls(func(sd webrtc.SessionDescription, _ uint32, _ map[string]string) error { + return client.SetRemoteDescription(sd) + }) + + mids := func(parsed *sdp.SessionDescription) []string { + var out []string + for _, m := range parsed.MediaDescriptions { + mid, _ := m.Attribute(sdp.AttrKeyMID) + out = append(out, mid) + } + return out + } + + var offerId uint32 + sendOffer := func(numSections int) { + _, err := client.AddTransceiverFromKind( + webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + require.NoError(t, err) + offer, err := client.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, client.SetLocalDescription(offer)) + + offerId++ + setCalls := handler.OnSetRemoteDescriptionOfferCallCount() + require.NoError(t, tr.HandleRemoteDescription(offer, offerId)) + require.Eventually(t, func() bool { + return handler.OnSetRemoteDescriptionOfferCallCount() > setCalls + }, 5*time.Second, 10*time.Millisecond) + + parsed := tr.RemoteOfferParsed() + require.NotNil(t, parsed) + require.Len(t, parsed.MediaDescriptions, numSections) + var want []string + for _, tc := range client.GetTransceivers() { + want = append(want, tc.Mid()) + } + require.Equal(t, want, mids(parsed)) + + // wait for the answer so that the client can offer again + require.Eventually(t, func() bool { + return client.SignalingState() == webrtc.SignalingStateStable + }, 5*time.Second, 10*time.Millisecond) + } + + sendOffer(1) + sendOffer(2) + + // an offer that does not parse fails the negotiation and leaves the last parse alone + before := tr.RemoteOfferParsed() + failures := handler.OnNegotiationFailedCallCount() + require.NoError(t, tr.HandleRemoteDescription(webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: "not an sdp"}, offerId+1)) + require.Eventually(t, func() bool { + return handler.OnNegotiationFailedCallCount() > failures + }, 5*time.Second, 10*time.Millisecond) + require.Same(t, before, tr.RemoteOfferParsed()) +} + +// in one-shot signalling mode an ICE restart arrives as an SDP fragment that is patched into +// the remote offer and set on pion directly, the stored parse must follow it +func TestRemoteOfferParsedFollowsICERestartFragment(t *testing.T) { + codecs := []*livekit.Codec{{Mime: mime.MimeTypeVP8.String()}} + handler := &transportfakes.FakeHandler{} + tr, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{ + BufferFactory: buffer.NewFactoryOfBufferFactory(500, 200).CreateBufferFactory(), + }, + EnabledPublishCodecs: codecs, + IsOfferer: false, + UseOneShotSignallingMode: true, + Handler: handler, + }) + require.NoError(t, err) + defer tr.Close() + + var clientME webrtc.MediaEngine + require.NoError(t, registerCodecs(&clientME, codecs, RTCPFeedbackConfig{}, false)) + client, err := webrtc.NewAPI(webrtc.WithMediaEngine(&clientME)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + _, err = client.AddTransceiverFromKind( + webrtc.RTPCodecTypeVideo, + webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}, + ) + require.NoError(t, err) + offer, err := client.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, client.SetLocalDescription(offer)) + + require.NoError(t, tr.HandleRemoteDescription(offer, 1)) + before := tr.RemoteOfferParsed() + require.NotNil(t, before) + ufrag, _, err := lksdp.ExtractICECredential(before) + require.NoError(t, err) + require.NotEqual(t, "restartUfrag", ufrag) + + _, _, err = tr.GetAnswer() + require.NoError(t, err) + + mid := client.GetTransceivers()[0].Mid() + fragment := "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + + "a=mid:" + mid + "\r\n" + + "a=ice-ufrag:restartUfrag\r\n" + + "a=ice-pwd:restartPwd0123456789abcdef\r\n" + + "a=candidate:1 1 udp 2122260223 192.0.2.1 61764 typ host generation 0\r\n" + answer, err := tr.HandleICERestartSDPFragment(fragment) + require.NoError(t, err) + require.NotEmpty(t, answer) + + after := tr.RemoteOfferParsed() + require.NotSame(t, before, after) + ufrag, pwd, err := lksdp.ExtractICECredential(after) + require.NoError(t, err) + require.Equal(t, "restartUfrag", ufrag) + require.Equal(t, "restartPwd0123456789abcdef", pwd) +} diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 108651f21..2fb5c22e7 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -523,12 +523,9 @@ func (t *TransportManager) GetUnmatchMediaForOffer(parsedOffer *sdp.SessionDescr return } -func (t *TransportManager) LastPublisherOffer() *webrtc.SessionDescription { - return t.publisher.CurrentRemoteDescription() -} - -func (t *TransportManager) LastPublisherOfferPending() *webrtc.SessionDescription { - return t.publisher.PendingRemoteDescription() +// LastPublisherOfferParsed returns the parsed form of the last offer set on the publisher peer connection +func (t *TransportManager) LastPublisherOfferParsed() *sdp.SessionDescription { + return t.publisher.RemoteOfferParsed() } func (t *TransportManager) HandleOffer(offer webrtc.SessionDescription, offerId uint32, shouldPend bool) error { From 1ba2cadccdb4c23b166f671ef909738e01ccb641 Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Fri, 25 Sep 2026 00:58:22 -0700 Subject: [PATCH 057/130] agent: release JobTerminate handler when job ends before registration (#4908) * agent: release JobTerminate handler when job ends before registration JobRequest registered the job's JobTerminate handler after AssignJob returned, so an ended UpdateJobStatus or worker disconnect handled in between deregistered nothing and the handler leaked. AssignJob also sent the assignment before recording the job as running, so an immediate ended update was dropped. Record the job before sending the assignment, and after registering the handler recheck under h.mu that the job is still running and its worker still registered, deregistering otherwise. Fixes #4901 * replace utils.CloneProto with proto.CloneOf * agent: wait on worker readiness and poll for released handlers in test --- pkg/agent/agent_test.go | 110 ++++++++++++++++++ pkg/agent/worker.go | 21 ++-- .../staticconfiguration.go | 3 +- pkg/routing/node.go | 8 +- pkg/routing/nodestats.go | 5 +- pkg/rtc/datatrack.go | 4 +- pkg/rtc/mediatrackreceiver.go | 15 ++- pkg/rtc/participant.go | 36 +++--- pkg/rtc/participant_data_track.go | 18 +-- pkg/rtc/participant_internal_test.go | 2 +- pkg/rtc/participant_sdp.go | 4 +- pkg/rtc/room.go | 8 +- pkg/rtc/room_test.go | 15 ++- pkg/rtc/subscribedtrack.go | 2 +- pkg/rtc/transportmanager.go | 2 +- pkg/service/agent_dispatch_service.go | 5 +- pkg/service/agentservice.go | 17 ++- pkg/service/localstore.go | 11 +- pkg/service/redisstore.go | 6 +- pkg/service/redisstore_test.go | 6 +- pkg/service/roomallocator.go | 8 +- pkg/sfu/forwarder.go | 6 +- pkg/sfu/receiver_base.go | 9 +- pkg/sfu/rtpstats/rtpstats_base.go | 5 +- pkg/sfu/rtpstats/rtpstats_receiver.go | 5 +- pkg/sfu/streamtrackermanager.go | 6 +- pkg/telemetry/statsworker.go | 4 +- pkg/utils/protocol.go | 5 +- 28 files changed, 237 insertions(+), 109 deletions(-) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 94be44aa7..0a546d7da 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -2,6 +2,7 @@ package agent_test import ( "context" + "errors" "fmt" "sync" "testing" @@ -256,3 +257,112 @@ func TestDrainConnectionsDoesNotDeadlock(t *testing.T) { }) } } + +func TestJobTerminateHandlerReleased(t *testing.T) { + const jobCount = 50 + + requestJobs := func(t *testing.T, client rpc.AgentInternalClient, agentName func(i int) string) []string { + jobIDs := make([]string, jobCount) + errs := make([]error, jobCount) + var wg sync.WaitGroup + for i := range jobCount { + job := &livekit.Job{ + Id: guid.New(guid.AgentJobPrefix), + DispatchId: guid.New(guid.AgentDispatchPrefix), + Type: livekit.JobType_JT_ROOM, + Room: &livekit.Room{}, + AgentName: agentName(i), + } + jobIDs[i] = job.Id + wg.Add(1) + go func() { + defer wg.Done() + _, errs[i] = client.JobRequest(context.Background(), job.AgentName, agent.RoomAgentTopic, job) + }() + } + wg.Wait() + for _, err := range errs { + require.NoError(t, err) + } + return jobIDs + } + + // the server reads the ping only after registering the worker's job request topic. + waitRegistered := func(t *testing.T, w *testutils.AgentWorker, agentName string) { + pongs := w.WorkerPongs.Observe() + defer pongs.Stop() + w.Register(agentName, livekit.JobType_JT_ROOM) + w.SendPing(&livekit.WorkerPing{Timestamp: time.Now().UnixMilli()}) + select { + case <-pongs.Events(): + case <-time.After(5 * time.Second): + require.Fail(t, "registration timeout") + } + } + + // any response means a JobTerminate handler is still registered. + countAnswered := func(client rpc.AgentInternalClient, jobIDs []string) int32 { + var answered atomic.Int32 + var wg sync.WaitGroup + for _, id := range jobIDs { + wg.Add(1) + go func() { + defer wg.Done() + _, err := client.JobTerminate(context.Background(), id, &rpc.JobTerminateRequest{JobId: id}, psrpc.WithRequestTimeout(200*time.Millisecond)) + if !errors.Is(err, psrpc.ErrRequestTimedOut) && !errors.Is(err, psrpc.ErrNoResponse) { + answered.Inc() + } + }() + } + wg.Wait() + return answered.Load() + } + + // handlers are released as the server processes the worker's messages; a leaked one never is. + requireHandlersReleased := func(t *testing.T, client rpc.AgentInternalClient, jobIDs []string) { + var answered int32 + require.Eventually(t, func() bool { + answered = countAnswered(client, jobIDs) + return answered == 0 + }, 10*time.Second, 100*time.Millisecond, "jobs with leaked JobTerminate handlers: %d", answered) + } + + t.Run("job fails on assignment", func(t *testing.T) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + var worker *testutils.AgentWorker + worker = server.SimulateAgentWorker(testutils.WithJobAssignmentHandler(func(j *livekit.Job) testutils.JobLoad { + worker.SendUpdateJob(&livekit.UpdateJobStatus{JobId: j.Id, Status: livekit.JobStatus_JS_FAILED}) + return testutils.NewStableJobLoad(0) + })) + waitRegistered(t, worker, "fail_agent") + + jobIDs := requestJobs(t, client, func(int) string { return "fail_agent" }) + + requireHandlersReleased(t, client, jobIDs) + }) + + t.Run("worker disconnects on assignment", func(t *testing.T) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + agentName := func(i int) string { return fmt.Sprintf("disconnect_agent_%d", i) } + for i := range jobCount { + var worker *testutils.AgentWorker + worker = server.SimulateAgentWorker(testutils.WithJobAssignmentHandler(func(j *livekit.Job) testutils.JobLoad { + worker.Close() + return nil + })) + waitRegistered(t, worker, agentName(i)) + } + + jobIDs := requestJobs(t, client, agentName) + + requireHandlersReleased(t, client, jobIDs) + }) +} diff --git a/pkg/agent/worker.go b/pkg/agent/worker.go index d8d14b94a..75f80f00f 100644 --- a/pkg/agent/worker.go +++ b/pkg/agent/worker.go @@ -29,7 +29,6 @@ import ( "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" ) @@ -333,12 +332,12 @@ func (w *Worker) GetJobState(jobID livekit.JobID) (*livekit.JobState, error) { if !ok { return nil, ErrJobNotFound } - return utils.CloneProto(j.State), nil + return proto.CloneOf(j.State), nil } func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook AssignmentHook) (*livekit.JobState, error) { availCh := make(chan *livekit.AvailabilityResponse, 1) - job = utils.CloneProto(job) + job = proto.CloneOf(job) jobID := livekit.JobID(job.Id) w.mu.Lock() @@ -420,16 +419,20 @@ func (w *Worker) AssignJob(ctx context.Context, job *livekit.Job, hook Assignmen if hook != nil { send = hook(send) } + state := proto.CloneOf(job.State) + + // must be in runningJobs before the assignment is sent; stored as a copy because send reads job without w.mu. + w.mu.Lock() + w.runningJobs[jobID] = proto.CloneOf(job) + w.mu.Unlock() + if err := send(&livekit.JobAssignment{Job: job, Token: token}); err != nil { + w.mu.Lock() + delete(w.runningJobs, jobID) + w.mu.Unlock() return nil, err } - state := utils.CloneProto(job.State) - - w.mu.Lock() - w.runningJobs[jobID] = job - w.mu.Unlock() - // TODO sweep jobs that are never started. We can't do this until all SDKs actually update the the JOB state return state, nil diff --git a/pkg/clientconfiguration/staticconfiguration.go b/pkg/clientconfiguration/staticconfiguration.go index d8145c260..198b81dc9 100644 --- a/pkg/clientconfiguration/staticconfiguration.go +++ b/pkg/clientconfiguration/staticconfiguration.go @@ -20,7 +20,6 @@ import ( "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - protoutils "github.com/livekit/protocol/utils" ) type ConfigurationItem struct { @@ -59,7 +58,7 @@ func (s *StaticClientConfigurationManager) GetConfiguration(clientInfo *livekit. var conf *livekit.ClientConfiguration for k, v := range matchedConf { if k == 0 { - conf = protoutils.CloneProto(matchedConf[0]) + conf = proto.CloneOf(matchedConf[0]) } else { // TODO : there is a problem use protobuf merge, we don't have flag to indicate 'no value', // don't override default behavior or other configuration's field. So a bool value = false or diff --git a/pkg/routing/node.go b/pkg/routing/node.go index df47226e9..dceddb92b 100644 --- a/pkg/routing/node.go +++ b/pkg/routing/node.go @@ -19,6 +19,8 @@ import ( "sync" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" @@ -76,14 +78,14 @@ func NewLocalNode(conf *config.Config) (*LocalNodeImpl, error) { } func NewLocalNodeFromNodeProto(node *livekit.Node) (*LocalNodeImpl, error) { - return &LocalNodeImpl{node: utils.CloneProto(node)}, nil + return &LocalNodeImpl{node: proto.CloneOf(node)}, nil } func (l *LocalNodeImpl) Clone() *livekit.Node { l.lock.RLock() defer l.lock.RUnlock() - return utils.CloneProto(l.node) + return proto.CloneOf(l.node) } // for testing only @@ -134,7 +136,7 @@ func (l *LocalNodeImpl) SetStats(stats *livekit.NodeStats) { l.lock.Lock() defer l.lock.Unlock() - l.node.Stats = utils.CloneProto(stats) + l.node.Stats = proto.CloneOf(stats) } func (l *LocalNodeImpl) UpdateNodeStats() bool { diff --git a/pkg/routing/nodestats.go b/pkg/routing/nodestats.go index ac16bb0ef..1a49a01af 100644 --- a/pkg/routing/nodestats.go +++ b/pkg/routing/nodestats.go @@ -18,9 +18,10 @@ import ( "sync" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/telemetry/prometheus" @@ -87,5 +88,5 @@ func (n *NodeStats) GetLatestNodeStats() *livekit.NodeStats { defer n.lock.RUnlock() latestPtr := (n.statsHistoryWritePtr - 1 + len(n.statsHistory)) % len(n.statsHistory) - return utils.CloneProto(n.statsHistory[latestPtr]) + return proto.CloneOf(n.statsHistory[latestPtr]) } diff --git a/pkg/rtc/datatrack.go b/pkg/rtc/datatrack.go index 1d93c296d..e5fd98a7c 100644 --- a/pkg/rtc/datatrack.go +++ b/pkg/rtc/datatrack.go @@ -20,13 +20,13 @@ import ( "sync" "github.com/frostbyte73/core" + "google.golang.org/protobuf/proto" "github.com/livekit/livekit-server/pkg/rtc/types" sfuutils "github.com/livekit/livekit-server/pkg/sfu/utils" "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" ) var ( @@ -98,7 +98,7 @@ func (d *DataTrack) PublisherIdentity() livekit.ParticipantIdentity { } func (d *DataTrack) ToProto() *livekit.DataTrackInfo { - return utils.CloneProto(d.dti) + return proto.CloneOf(d.dti) } func (d *DataTrack) PubHandle() uint16 { diff --git a/pkg/rtc/mediatrackreceiver.go b/pkg/rtc/mediatrackreceiver.go index c6130e352..e372207f9 100644 --- a/pkg/rtc/mediatrackreceiver.go +++ b/pkg/rtc/mediatrackreceiver.go @@ -30,7 +30,6 @@ import ( "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/sfu" @@ -154,7 +153,7 @@ func NewMediaTrackReceiver(params MediaTrackReceiverParams, ti *livekit.TrackInf params: params, state: mediaTrackReceiverStateOpen, } - t.trackInfo.Store(utils.CloneProto(ti)) + t.trackInfo.Store(proto.CloneOf(ti)) t.MediaTrackSubscriptions = NewMediaTrackSubscriptions(MediaTrackSubscriptionsParams{ MediaTrack: params.MediaTrack, @@ -812,7 +811,7 @@ func (t *MediaTrackReceiver) UpdateCodecInfo(codecs []*livekit.SimulcastCodec) { if len(c.Layers) != 0 { clonedLayers := make([]*livekit.VideoLayer, 0, len(c.Layers)) for _, l := range c.Layers { - clonedLayers = append(clonedLayers, utils.CloneProto(l)) + clonedLayers = append(clonedLayers, proto.CloneOf(l)) } origin.Layers = clonedLayers @@ -871,7 +870,7 @@ func (t *MediaTrackReceiver) UpdateCodecRids(mimeType mime.MimeType, rids buffer } func (t *MediaTrackReceiver) UpdateTrackInfo(ti *livekit.TrackInfo) { - clonedInfo := utils.CloneProto(ti) + clonedInfo := proto.CloneOf(ti) t.lock.Lock() trackInfo := t.TrackInfo() @@ -924,7 +923,7 @@ func (t *MediaTrackReceiver) UpdateAudioTrack(update *livekit.UpdateLocalAudioTr t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) clonedInfo.AudioFeatures = sutils.DedupeSlice(update.Features) @@ -960,7 +959,7 @@ func (t *MediaTrackReceiver) UpdateVideoTrack(update *livekit.UpdateLocalVideoTr t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) clonedInfo.Width = update.Width clonedInfo.Height = update.Height if proto.Equal(trackInfo, clonedInfo) { @@ -981,7 +980,7 @@ func (t *MediaTrackReceiver) UpdateVideoSize(mimeType mime.MimeType, sizes []cod var changed bool t.lock.Lock() trackInfo := t.TrackInfo() - clonedInfo := utils.CloneProto(trackInfo) + clonedInfo := proto.CloneOf(trackInfo) var maxWidth, maxHeight uint32 for _, size := range sizes { if size.Width > maxWidth { @@ -1028,7 +1027,7 @@ func (t *MediaTrackReceiver) TrackInfo() *livekit.TrackInfo { } func (t *MediaTrackReceiver) TrackInfoClone() *livekit.TrackInfo { - return utils.CloneProto(t.TrackInfo()) + return proto.CloneOf(t.TrackInfo()) } func (t *MediaTrackReceiver) NotifyMaxLayerChange(mimeType mime.MimeType, maxLayer int32) { diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 2bcff98a4..af3f8e2d3 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -667,7 +667,7 @@ func (p *ParticipantImpl) GetClientInfo() *livekit.ClientInfo { func (p *ParticipantImpl) GetClientConfiguration() *livekit.ClientConfiguration { p.lock.RLock() defer p.lock.RUnlock() - return utils.CloneProto(p.params.ClientConf) + return proto.CloneOf(p.params.ClientConf) } func (p *ParticipantImpl) GetBufferFactory() *buffer.Factory { @@ -709,7 +709,7 @@ func (p *ParticipantImpl) UpdateMetadata(update *livekit.UpdateParticipantMetada sendRequestResponse := func() error { if !fromAdmin || (update.RequestId != 0 || err != nil) { requestResponse.Request = &livekit.RequestResponse_UpdateMetadata{ - UpdateMetadata: utils.CloneProto(update), + UpdateMetadata: proto.CloneOf(update), } p.sendRequestResponse(requestResponse) } @@ -985,7 +985,7 @@ func (p *ParticipantImpl) ToProtoWithVersion() (*livekit.ParticipantInfo, utils. } if !found { - pi.Tracks = append(pi.Tracks, utils.CloneProto(pti.trackInfos[0])) + pi.Tracks = append(pi.Tracks, proto.CloneOf(pti.trackInfos[0])) } } @@ -1195,7 +1195,7 @@ func (p *ParticipantImpl) HandleICETrickle(trickleRequest *livekit.TrickleReques Reason: livekit.RequestResponse_UNCLASSIFIED_ERROR, Message: err.Error(), Request: &livekit.RequestResponse_Trickle{ - Trickle: utils.CloneProto(trickleRequest), + Trickle: proto.CloneOf(trickleRequest), }, }) return @@ -1361,7 +1361,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_ALLOWED, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) return @@ -1372,7 +1372,7 @@ func (p *ParticipantImpl) AddTrack(req *livekit.AddTrackRequest) { p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_UNSUPPORTED_TYPE, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) return @@ -2948,7 +2948,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() @@ -2967,7 +2967,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit Reason: livekit.RequestResponse_LIMIT_EXCEEDED, Message: fmt.Sprintf("too many pending tracks, rejecting new track, numPendingTracks: %d", len(p.pendingTracks)), Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() @@ -2989,7 +2989,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit clonedLayers := make([]*livekit.VideoLayer, 0, len(layers)) for _, l := range layers { - clonedLayers = append(clonedLayers, utils.CloneProto(l)) + clonedLayers = append(clonedLayers, proto.CloneOf(l)) } slices.SortFunc(clonedLayers, func(i, j *livekit.VideoLayer) int { return int(i.Quality) - int(j.Quality) @@ -3157,7 +3157,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit Reason: livekit.RequestResponse_LIMIT_EXCEEDED, Message: fmt.Sprintf("too many pending queued tracks, rejecting new track, numPendingTracksQueued: %d", len(p.pendingTracks[req.Cid].trackInfos)), Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) p.pendingTracksLock.Unlock() @@ -3175,7 +3175,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_QUEUED, Request: &livekit.RequestResponse_AddTrack{ - AddTrack: utils.CloneProto(req), + AddTrack: proto.CloneOf(req), }, }) @@ -3185,7 +3185,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit } p.pendingTracksLock.Unlock() - p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true) + p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), proto.CloneOf(ti), true) return nil } @@ -3210,7 +3210,7 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit } p.pendingTracksLock.Unlock() - p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), utils.CloneProto(ti), true) + p.GetTelemetryListener().OnTrackPublishRequested(p.ID(), p.Identity(), proto.CloneOf(ti), true) return ti } @@ -3262,7 +3262,7 @@ func (p *ParticipantImpl) setTrackMuted(mute *livekit.MuteTrackRequest, fromAdmi for _, pti := range p.pendingTracks { for i, ti := range pti.trackInfos { if livekit.TrackID(ti.Sid) == trackID { - ti = utils.CloneProto(ti) + ti = proto.CloneOf(ti) changed = changed || ti.Muted != mute.Muted ti.Muted = mute.Muted pti.trackInfos[i] = ti @@ -3286,7 +3286,7 @@ func (p *ParticipantImpl) setTrackMuted(mute *livekit.MuteTrackRequest, fromAdmi p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_Mute{ - Mute: utils.CloneProto(mute), + Mute: proto.CloneOf(mute), }, }) } @@ -3714,7 +3714,7 @@ func (p *ParticipantImpl) getPendingTrack(clientId string, kind livekit.TrackTyp return signalCid, nil, buffer.VideoLayersRid{}, false, time.Time{} } - return signalCid, utils.CloneProto(pendingInfo.trackInfos[0]), pendingInfo.sdpRids, pendingInfo.migrated, pendingInfo.createdAt + return signalCid, proto.CloneOf(pendingInfo.trackInfos[0]), pendingInfo.sdpRids, pendingInfo.migrated, pendingInfo.createdAt } func (p *ParticipantImpl) getPendingTrackPrimaryBySdpCid(sdpCid string) *pendingTrackInfo { @@ -4231,7 +4231,7 @@ func (p *ParticipantImpl) UpdateAudioTrack(update *livekit.UpdateLocalAudioTrack p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UpdateAudioTrack{ - UpdateAudioTrack: utils.CloneProto(update), + UpdateAudioTrack: proto.CloneOf(update), }, }) return errors.New("could not find track") @@ -4263,7 +4263,7 @@ func (p *ParticipantImpl) UpdateVideoTrack(update *livekit.UpdateLocalVideoTrack p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UpdateVideoTrack{ - UpdateVideoTrack: utils.CloneProto(update), + UpdateVideoTrack: proto.CloneOf(update), }, }) return errors.New("could not find track") diff --git a/pkg/rtc/participant_data_track.go b/pkg/rtc/participant_data_track.go index 23a34134f..f025c6452 100644 --- a/pkg/rtc/participant_data_track.go +++ b/pkg/rtc/participant_data_track.go @@ -15,6 +15,8 @@ package rtc import ( + "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/protocol/datatrack" "github.com/livekit/protocol/livekit" @@ -30,7 +32,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_NOT_ALLOWED, Message: "does not have permission to publish data", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -42,7 +44,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_HANDLE, Message: "handle should be > 0 AND < 65536", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -54,7 +56,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_NAME, Message: "name should not be empty and should not exceed 256 characters", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -67,7 +69,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: livekit.RequestResponse_INVALID_REQUEST, Message: "encoding identifier is empty or exceeds the maximum length", Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -95,7 +97,7 @@ func (p *ParticipantImpl) HandlePublishDataTrackRequest(req *livekit.PublishData Reason: reason, Message: message, Request: &livekit.RequestResponse_PublishDataTrack{ - PublishDataTrack: utils.CloneProto(req), + PublishDataTrack: proto.CloneOf(req), }, }) return @@ -108,8 +110,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()) + dti.FrameEncoding = proto.CloneOf(req.GetFrameEncoding()) + dti.Schema = proto.CloneOf(req.GetSchema()) dt := NewDataTrack( DataTrackParams{ Logger: p.params.Logger.WithValues("trackID", dti.Sid), @@ -143,7 +145,7 @@ func (p *ParticipantImpl) HandleUnpublishDataTrackRequest(req *livekit.Unpublish p.sendRequestResponse(&livekit.RequestResponse{ Reason: livekit.RequestResponse_NOT_FOUND, Request: &livekit.RequestResponse_UnpublishDataTrack{ - UnpublishDataTrack: utils.CloneProto(req), + UnpublishDataTrack: proto.CloneOf(req), }, }) return diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 3a32808ac..da41e7ff0 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -560,7 +560,7 @@ func TestPreferMediaCodecForPublisher(t *testing.T) { for i := 0; i < 2; i++ { // publish preferred track without client using setCodecPreferences() trackCid := fmt.Sprintf("%s-%d", tc.trackBaseCid, i) - req := utils.CloneProto(tc.addTrack) + req := proto.CloneOf(tc.addTrack) req.SimulcastCodecs = []*livekit.SimulcastCodec{ { Codec: tc.preferredCodec, diff --git a/pkg/rtc/participant_sdp.go b/pkg/rtc/participant_sdp.go index ca8ae095a..5896e99df 100644 --- a/pkg/rtc/participant_sdp.go +++ b/pkg/rtc/participant_sdp.go @@ -22,13 +22,13 @@ import ( "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" + "google.golang.org/protobuf/proto" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" lksdp "github.com/livekit/protocol/sdp" - "github.com/livekit/protocol/utils" ) func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([]*sdp.MediaDescription, []*sdp.MediaDescription) { @@ -143,7 +143,7 @@ func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([ } if updated { - p.pendingTracks[signalCid].trackInfos[0] = utils.CloneProto(info) + p.pendingTracks[signalCid].trackInfos[0] = proto.CloneOf(info) p.pubLogger.Debugw( "pending track SDP cid updated", "signalCid", signalCid, diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 76224e731..3ae984632 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -254,7 +254,7 @@ func NewRoom( egressLauncher EgressLauncher, ) *Room { r := &Room{ - protoRoom: utils.CloneProto(room), + protoRoom: proto.CloneOf(room), internal: internal, logger: LoggerWithRoom( logger.GetLogger().WithComponent(sutils.ComponentRoom), @@ -874,7 +874,7 @@ func (r *Room) GetAgentDispatches(dispatchID string) ([]*livekit.AgentDispatch, for _, ad := range r.agentDispatches { if dispatchID == "" || ad.Id == dispatchID { - ret = append(ret, utils.CloneProto(ad.AgentDispatch)) + ret = append(ret, proto.CloneOf(ad.AgentDispatch)) } } @@ -1587,7 +1587,7 @@ func (r *Room) sendSpeakerChanges(speakers []*livekit.SpeakerInfo) { func (r *Room) updateProto() *livekit.Room { r.lock.RLock() - room := utils.CloneProto(r.protoRoom) + room := proto.CloneOf(r.protoRoom) r.lock.RUnlock() room.NumPublishers = 0 @@ -1658,7 +1658,7 @@ func (r *Room) audioUpdateWorker() { // changedSpeakers need to include previous speakers that are no longer speaking for sid, speaker := range lastActiveMap { if nextActiveMap[sid] == nil { - inactiveSpeaker := utils.CloneProto(speaker) + inactiveSpeaker := proto.CloneOf(speaker) inactiveSpeaker.Level = 0 inactiveSpeaker.Active = false changedSpeakers = append(changedSpeakers, inactiveSpeaker) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 8ccd9574f..37055c738 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -24,7 +24,6 @@ import ( "github.com/livekit/protocol/auth/authfakes" "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/webhook" "github.com/livekit/livekit-server/version" @@ -291,7 +290,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "last version is enqueued", pi: subscriber1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, // clone the existing value since it can be modified when setting to disconnected + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, // clone the existing value since it can be modified when setting to disconnected validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) { queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)] require.NotNil(t, queued) @@ -301,7 +300,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "latest version when immediate", pi: subscriber1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, immediate: true, expected: []*ParticipantUpdate{{ParticipantInfo: subscriber1v2}}, validate: func(t *testing.T, rm *Room, _ []*ParticipantUpdate) { @@ -312,7 +311,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "out of order updates are rejected", pi: subscriber1v1, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v2)}, validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) { queued := rm.batchedUpdates[livekit.ParticipantIdentity(identity)] requirePIEquals(t, subscriber1v2, queued.ParticipantInfo) @@ -322,7 +321,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { name: "sid change is broadcasted immediately with synthsized disconnect", pi: publisher2, closeReason: types.ParticipantCloseReasonServiceRequestRemoveParticipant, // just to test if update contain the close reason - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v2), CloseReason: types.ParticipantCloseReasonStale}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v2), CloseReason: types.ParticipantCloseReasonStale}, expected: []*ParticipantUpdate{ { ParticipantInfo: &livekit.ParticipantInfo{ @@ -340,7 +339,7 @@ func TestPushAndDequeueUpdates(t *testing.T) { { name: "when switching to publisher, queue is cleared", pi: publisher1v2, - existing: &ParticipantUpdate{ParticipantInfo: utils.CloneProto(subscriber1v1)}, + existing: &ParticipantUpdate{ParticipantInfo: proto.CloneOf(subscriber1v1)}, expected: []*ParticipantUpdate{{ParticipantInfo: publisher1v2}}, validate: func(t *testing.T, rm *Room, updates []*ParticipantUpdate) { require.Empty(t, rm.batchedUpdates) @@ -651,7 +650,7 @@ func TestDataChannel(t *testing.T) { } setSource(mode, packet, p) - packetExp := utils.CloneProto(packet) + packetExp := proto.CloneOf(packet) if mode != legacySID { packetExp.ParticipantIdentity = string(p.Identity()) packetExp.GetUser().ParticipantIdentity = string(p.Identity()) @@ -699,7 +698,7 @@ func TestDataChannel(t *testing.T) { setSource(mode, packet, p) setDest(mode, packet, p1) - packetExp := utils.CloneProto(packet) + packetExp := proto.CloneOf(packet) if mode != legacySID { packetExp.ParticipantIdentity = string(p.Identity()) packetExp.GetUser().ParticipantIdentity = string(p.Identity()) diff --git a/pkg/rtc/subscribedtrack.go b/pkg/rtc/subscribedtrack.go index 27d18c382..1771767d6 100644 --- a/pkg/rtc/subscribedtrack.go +++ b/pkg/rtc/subscribedtrack.go @@ -323,7 +323,7 @@ func (t *SubscribedTrack) UpdateSubscriberSettings(settings *livekit.UpdateTrack } isImmediate = isImmediate || (!settings.Disabled && settings.Disabled != t.isMutedLocked()) - t.settings = utils.CloneProto(settings) + t.settings = proto.CloneOf(settings) t.logger.Debugw("saving subscriber track settings", "settings", logger.Proto(t.settings)) t.settingsLock.Unlock() diff --git a/pkg/rtc/transportmanager.go b/pkg/rtc/transportmanager.go index 2fb5c22e7..e6d983a43 100644 --- a/pkg/rtc/transportmanager.go +++ b/pkg/rtc/transportmanager.go @@ -675,7 +675,7 @@ func (t *TransportManager) GetICEConfig() *livekit.ICEConfig { if t.iceConfig == nil { return nil } - return utils.CloneProto(t.iceConfig) + return proto.CloneOf(t.iceConfig) } func (t *TransportManager) resetTransportConfigureLocked(reconfigured bool) { diff --git a/pkg/service/agent_dispatch_service.go b/pkg/service/agent_dispatch_service.go index f2dc581f5..bab17b268 100644 --- a/pkg/service/agent_dispatch_service.go +++ b/pkg/service/agent_dispatch_service.go @@ -18,13 +18,14 @@ import ( "context" "fmt" + "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/protocol/agent" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/guid" "github.com/livekit/psrpc" ) @@ -120,7 +121,7 @@ func redactCreateAgentDispatchRequest(req *livekit.CreateAgentDispatchRequest) * return req } - clone := utils.CloneProto(req) + clone := proto.CloneOf(req) // replace with size of metadata to provide visibility on request size if clone.Metadata != "" { diff --git a/pkg/service/agentservice.go b/pkg/service/agentservice.go index b5128cb9e..3e58e832b 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agentservice.go @@ -423,14 +423,19 @@ func (h *AgentHandler) JobRequest(ctx context.Context, job *livekit.Job) (*rpc.J switch state.GetStatus() { case livekit.JobStatus_JS_RUNNING: logger.Infow("assigned job to worker", "apiKey", selected.APIKey()) - h.mu.Lock() - h.jobToWorker[livekit.JobID(job.Id)] = selected - h.mu.Unlock() - - err = h.agentServer.RegisterJobTerminateTopic(job.Id) - if err != nil { + jobID := livekit.JobID(job.Id) + if err := h.agentServer.RegisterJobTerminateTopic(job.Id); err != nil { logger.Errorw("failed to register JobTerminate handler", err) } + + // checked under h.mu after registering so that either this or the job/worker cleanup, whichever runs second, releases the handler. + h.mu.Lock() + if _, err := selected.GetJobState(jobID); err == nil && h.workers[selected.ID] == selected { + h.jobToWorker[jobID] = selected + } else { + h.deregisterJob(jobID) + } + h.mu.Unlock() fallthrough case livekit.JobStatus_JS_SUCCESS: return &rpc.JobRequestResponse{ diff --git a/pkg/service/localstore.go b/pkg/service/localstore.go index 1cf747f94..863a90ae0 100644 --- a/pkg/service/localstore.go +++ b/pkg/service/localstore.go @@ -20,6 +20,7 @@ import ( "time" "github.com/thoas/go-funk" + "google.golang.org/protobuf/proto" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils" @@ -203,7 +204,7 @@ func (s *LocalStore) StoreAgentDispatch(ctx context.Context, dispatch *livekit.A s.lock.Lock() defer s.lock.Unlock() - clone := utils.CloneProto(dispatch) + clone := proto.CloneOf(dispatch) if clone.State != nil { clone.State.Jobs = nil } @@ -242,13 +243,13 @@ func (s *LocalStore) ListAgentDispatches(ctx context.Context, roomName livekit.R var js []*livekit.Job for _, j := range agentJobs { - js = append(js, utils.CloneProto(j)) + js = append(js, proto.CloneOf(j)) } var ds []*livekit.AgentDispatch m := make(map[string]*livekit.AgentDispatch) for _, d := range agentDispatches { - clone := utils.CloneProto(d) + clone := proto.CloneOf(d) m[d.Id] = clone ds = append(ds, clone) } @@ -256,7 +257,7 @@ func (s *LocalStore) ListAgentDispatches(ctx context.Context, roomName livekit.R for _, j := range js { d := m[j.DispatchId] if d != nil { - d.State.Jobs = append(d.State.Jobs, utils.CloneProto(j)) + d.State.Jobs = append(d.State.Jobs, proto.CloneOf(j)) } } @@ -267,7 +268,7 @@ func (s *LocalStore) StoreAgentJob(ctx context.Context, job *livekit.Job) error s.lock.Lock() defer s.lock.Unlock() - clone := utils.CloneProto(job) + clone := proto.CloneOf(job) clone.Room = nil if clone.Participant != nil { clone.Participant = &livekit.ParticipantInfo{ diff --git a/pkg/service/redisstore.go b/pkg/service/redisstore.go index 292ed9524..936f403a1 100644 --- a/pkg/service/redisstore.go +++ b/pkg/service/redisstore.go @@ -548,7 +548,7 @@ func (s *RedisStore) storeIngress(_ context.Context, info *livekit.IngressInfo) } // ignore state - infoCopy := utils.CloneProto(info) + infoCopy := proto.CloneOf(info) infoCopy.State = nil data, err := proto.Marshal(infoCopy) @@ -851,7 +851,7 @@ func (s *RedisStore) DeleteIngress(_ context.Context, info *livekit.IngressInfo) } func (s *RedisStore) StoreAgentDispatch(_ context.Context, dispatch *livekit.AgentDispatch) error { - di := utils.CloneProto(dispatch) + di := proto.CloneOf(dispatch) // Do not store jobs with the dispatch if di.State != nil { @@ -914,7 +914,7 @@ func (s *RedisStore) StoreAgentJob(_ context.Context, job *livekit.Job) error { key := AgentJobPrefix + string(job.Room.Name) - jb := utils.CloneProto(job) + jb := proto.CloneOf(job) // Do not store room with the job jb.Room = nil diff --git a/pkg/service/redisstore_test.go b/pkg/service/redisstore_test.go index e84a6c41f..419694537 100644 --- a/pkg/service/redisstore_test.go +++ b/pkg/service/redisstore_test.go @@ -347,7 +347,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected := utils.CloneProto(ad) + expected := proto.CloneOf(ad) expected.State.Jobs = nil require.True(t, proto.Equal(expected, rd[0])) @@ -358,7 +358,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected = utils.CloneProto(ad) + expected = proto.CloneOf(ad) expected.State.Jobs[0].Room = nil expected.State.Jobs[0].Participant = &livekit.ParticipantInfo{ Identity: "identity", @@ -372,7 +372,7 @@ func TestAgentStore(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(rd)) - expected = utils.CloneProto(ad) + expected = proto.CloneOf(ad) expected.State.Jobs = nil require.True(t, proto.Equal(expected, rd[0])) diff --git a/pkg/service/roomallocator.go b/pkg/service/roomallocator.go index 436a433c0..d843529c9 100644 --- a/pkg/service/roomallocator.go +++ b/pkg/service/roomallocator.go @@ -19,6 +19,8 @@ import ( "errors" "time" + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils" @@ -211,7 +213,7 @@ func (r *StandardRoomAllocator) applyNamedRoomConfiguration(req *livekit.CreateR return req, psrpc.NewErrorf(psrpc.InvalidArgument, "unknown room configuration in create room request") } - clone := utils.CloneProto(req) + clone := proto.CloneOf(req) if clone.EmptyTimeout == 0 { clone.EmptyTimeout = conf.EmptyTimeout @@ -223,12 +225,12 @@ func (r *StandardRoomAllocator) applyNamedRoomConfiguration(req *livekit.CreateR clone.MaxParticipants = conf.MaxParticipants } if clone.Egress == nil { - clone.Egress = utils.CloneProto(conf.Egress) + clone.Egress = proto.CloneOf(conf.Egress) } if clone.Agents == nil { clone.Agents = make([]*livekit.RoomAgentDispatch, 0, len(conf.Agents)) for _, agent := range conf.Agents { - clone.Agents = append(clone.Agents, utils.CloneProto(agent)) + clone.Agents = append(clone.Agents, proto.CloneOf(agent)) } } if clone.MinPlayoutDelay == 0 { diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 5dfc69038..9c87b0653 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -26,12 +26,12 @@ import ( "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/protocol/utils/mono" "github.com/livekit/livekit-server/pkg/sfu/buffer" @@ -491,7 +491,7 @@ func (f *Forwarder) GetState() *livekit.RTPForwarderState { state.SenderReportState = make([]*livekit.RTCPSenderReportState, len(f.refInfos)) for layer, refInfo := range f.refInfos { - state.SenderReportState[layer] = utils.CloneProto(refInfo.senderReport) + state.SenderReportState[layer] = proto.CloneOf(refInfo.senderReport) } return state } @@ -506,7 +506,7 @@ func (f *Forwarder) SeedState(state *livekit.RTPForwarderState) { for layer, rtcpSenderReportState := range state.SenderReportState { f.refInfos[layer] = refInfo{} - if senderReport := utils.CloneProto(rtcpSenderReportState); senderReport != nil && senderReport.NtpTimestamp != 0 { + if senderReport := proto.CloneOf(rtcpSenderReportState); senderReport != nil && senderReport.NtpTimestamp != 0 { f.refInfos[layer].senderReport = senderReport } } diff --git a/pkg/sfu/receiver_base.go b/pkg/sfu/receiver_base.go index 678c124c3..8b3492064 100644 --- a/pkg/sfu/receiver_base.go +++ b/pkg/sfu/receiver_base.go @@ -25,6 +25,7 @@ import ( "github.com/pion/webrtc/v4" "go.uber.org/atomic" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil/pkg/bucket" "github.com/livekit/mediatransportutil/pkg/codec" @@ -164,7 +165,7 @@ func NewReceiverBase(params ReceiverBaseParams, trackInfo *livekit.TrackInfo, co params: params, codecState: codecState, isRED: mime.IsMimeTypeStringRED(params.Codec.MimeType), - trackInfo: utils.CloneProto(trackInfo), + trackInfo: proto.CloneOf(trackInfo), videoLayerMode: buffer.GetVideoLayerModeForMimeType(mime.NormalizeMimeType(params.Codec.MimeType), trackInfo), } @@ -256,7 +257,7 @@ func (r *ReceiverBase) TrackInfo() *livekit.TrackInfo { r.bufferMu.RLock() defer r.bufferMu.RUnlock() - return utils.CloneProto(r.trackInfo) + return proto.CloneOf(r.trackInfo) } func (r *ReceiverBase) UpdateTrackInfo(ti *livekit.TrackInfo) { @@ -282,7 +283,7 @@ func (r *ReceiverBase) UpdateTrackInfo(ti *livekit.TrackInfo) { "shouldResync", shouldResync, ) } - r.trackInfo = utils.CloneProto(ti) + r.trackInfo = proto.CloneOf(ti) paused := r.trackInfo.GetMuted() for _, buff := range r.buffers { @@ -665,7 +666,7 @@ func (r *ReceiverBase) GetOrCreateBuffer(layer int32) buffer.BufferProvider { } r.bufferPromises[layer] = bp - ti := utils.CloneProto(r.trackInfo) + ti := proto.CloneOf(r.trackInfo) r.bufferMu.Unlock() defer close(bp.ready) diff --git a/pkg/sfu/rtpstats/rtpstats_base.go b/pkg/sfu/rtpstats/rtpstats_base.go index b5f2f6fe7..fdd92e89e 100644 --- a/pkg/sfu/rtpstats/rtpstats_base.go +++ b/pkg/sfu/rtpstats/rtpstats_base.go @@ -19,6 +19,7 @@ import ( "time" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/livekit/mediatransportutil" @@ -298,8 +299,8 @@ func (r *rtpStatsBase) seed(from *rtpStatsBase) bool { r.rtt = from.rtt r.maxRtt = from.maxRtt - r.srFirst = utils.CloneProto(from.srFirst) - r.srNewest = utils.CloneProto(from.srNewest) + r.srFirst = proto.CloneOf(from.srFirst) + r.srNewest = proto.CloneOf(from.srNewest) r.nextSnapshotID = from.nextSnapshotID r.snapshots = make([]snapshot, cap(from.snapshots)) diff --git a/pkg/sfu/rtpstats/rtpstats_receiver.go b/pkg/sfu/rtpstats/rtpstats_receiver.go index dfd127397..90f7eb2b2 100644 --- a/pkg/sfu/rtpstats/rtpstats_receiver.go +++ b/pkg/sfu/rtpstats/rtpstats_receiver.go @@ -22,6 +22,7 @@ import ( "github.com/pion/rtcp" "go.uber.org/zap" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" "github.com/livekit/mediatransportutil/pkg/latency" @@ -654,7 +655,7 @@ func (r *RTPStatsReceiver) getExtendedSenderReport(srData *livekit.RTCPSenderRep } } - srDataExt := protoutils.CloneProto(srData) + srDataExt := proto.CloneOf(srData) srDataExt.RtpTimestampExt = uint64(srDataExt.RtpTimestamp) + tsCycles return srDataExt } @@ -816,7 +817,7 @@ func (r *RTPStatsReceiver) GetRtcpSenderReportData() *livekit.RTCPSenderReportSt r.lock.RLock() defer r.lock.RUnlock() - return protoutils.CloneProto(r.srNewest) + return proto.CloneOf(r.srNewest) } func (r *RTPStatsReceiver) LastSenderReportTime() time.Time { diff --git a/pkg/sfu/streamtrackermanager.go b/pkg/sfu/streamtrackermanager.go index 6eb28e5f2..c13ab18ad 100644 --- a/pkg/sfu/streamtrackermanager.go +++ b/pkg/sfu/streamtrackermanager.go @@ -20,11 +20,11 @@ import ( "time" "github.com/frostbyte73/core" + "google.golang.org/protobuf/proto" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - "github.com/livekit/protocol/utils" "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/streamtracker" @@ -144,7 +144,7 @@ func NewStreamTrackerManager( logger: logger, mimeType: mimeType, videoLayerMode: buffer.GetVideoLayerModeForMimeType(mimeType, trackInfo), - trackInfo: utils.CloneProto(trackInfo), + trackInfo: proto.CloneOf(trackInfo), maxPublishedLayer: buffer.InvalidLayerSpatial, maxTemporalLayerSeen: buffer.InvalidLayerTemporal, clockRate: clockRate, @@ -374,7 +374,7 @@ func (s *StreamTrackerManager) setPaused(paused bool) { func (s *StreamTrackerManager) UpdateTrackInfo(ti *livekit.TrackInfo) { s.lock.Lock() - s.trackInfo = utils.CloneProto(ti) + s.trackInfo = proto.CloneOf(ti) s.maxExpectedLayerFromTrackInfoLocked(false) paused := s.trackInfo.GetMuted() diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 03ec21a04..463e6c1b7 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -21,12 +21,12 @@ import ( "unsafe" "go.uber.org/zap/zapcore" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" - protoutils "github.com/livekit/protocol/utils" ) type ReferenceGuard struct { @@ -419,7 +419,7 @@ func coalesce(stats []*livekit.AnalyticsStat) *livekit.AnalyticsStat { for _, videoLayer := range analyticsStream.VideoLayers { coalescedVideoLayer := coalescedVideoLayers[videoLayer.Layer] if coalescedVideoLayer == nil { - coalescedVideoLayer = protoutils.CloneProto(videoLayer) + coalescedVideoLayer = proto.CloneOf(videoLayer) coalescedVideoLayers[videoLayer.Layer] = coalescedVideoLayer } else { coalescedVideoLayer.Packets += videoLayer.Packets diff --git a/pkg/utils/protocol.go b/pkg/utils/protocol.go index 77d324b94..0cfab8aca 100644 --- a/pkg/utils/protocol.go +++ b/pkg/utils/protocol.go @@ -17,15 +17,16 @@ package utils import ( + "google.golang.org/protobuf/proto" + "github.com/livekit/protocol/livekit" - "github.com/livekit/protocol/utils" ) func ClientInfoWithoutAddress(c *livekit.ClientInfo) *livekit.ClientInfo { if c == nil { return nil } - clone := utils.CloneProto(c) + clone := proto.CloneOf(c) clone.Address = "" return clone } From f7ec53ca73dc895c447a7bd110b71c6de32e3b43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Fuente=20P=C3=A9rez?= Date: Fri, 25 Sep 2026 12:53:40 +0200 Subject: [PATCH 058/130] dynacast: re-send subscribed qualities after every publisher answer (#4907) * dynacast: re-send subscribed qualities after every publisher answer The SFU answers publisher offers without the simulcast pause markers (SetIgnoreRidPauseForRecv), so applying the answer re-enables in the browser the layers that dynacast had paused. Dynacast only notifies changes, so those layers stayed on until the subscribed quality of the track changed again. Send the publisher its committed subscribed qualities again 1 s after each answer, once the client has applied it: livekit-client handles the next signal message without waiting for the answer to be applied. Fixes https://github.com/livekit/livekit/issues/4906 * dynacast: resend once per burst of publisher answers, only for paused layers In SPC, every subscription change of a participant goes through a new publisher offer and answer, so answers come in bursts when a room is busy. Each answer scheduled its own resend of the subscribed qualities, sending the same update several times in a row. Thist change keeps one pending resend per participant instead. It fires 2 s after the last answer, and never later than 8 s after the first answer of the burst, so a steady stream of answers cannot postpone it forever. It also skips the resend for tracks whose committed qualities are all HIGH: with every layer enabled, an answer has nothing paused to re-enable. We can save those messages. --- pkg/rtc/dynacast/dynacastmanager_test.go | 47 ++++++++++++++++++++++++ pkg/rtc/dynacast/dynacastmanagervideo.go | 15 ++++++++ pkg/rtc/dynacast/interfaces.go | 2 + pkg/rtc/mediatrack.go | 7 ++++ pkg/rtc/participant.go | 39 +++++++++++++++++++- 5 files changed, 109 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/dynacast/dynacastmanager_test.go b/pkg/rtc/dynacast/dynacastmanager_test.go index 6d48e67ea..43d939546 100644 --- a/pkg/rtc/dynacast/dynacastmanager_test.go +++ b/pkg/rtc/dynacast/dynacastmanager_test.go @@ -498,6 +498,53 @@ func TestCodecRegression(t *testing.T) { }) } +func TestResendCommittedQuality(t *testing.T) { + var lock sync.Mutex + var notified []string + dm := NewDynacastManagerVideo(DynacastManagerVideoParams{ + // keeps the downgrade below pending for the whole test + DynacastPauseDelay: time.Minute, + Listener: &testDynacastManagerListener{ + onSubscribedMaxQualityChange: func(subscribedQualities []*livekit.SubscribedCodec) { + lock.Lock() + notified = append(notified, subscribedCodecsAsString(subscribedQualities)) + lock.Unlock() + }, + }, + }) + defer dm.Close() + notifications := func() []string { + lock.Lock() + defer lock.Unlock() + return slices.Clone(notified) + } + + // nothing committed yet, nothing to resend + dm.ResendCommittedQuality() + time.Sleep(100 * time.Millisecond) + require.Empty(t, notifications()) + + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_LOW) + require.Eventually(t, func() bool { return len(notifications()) == 1 }, 5*time.Second, 10*time.Millisecond) + + // the downgrade waits for the debounce, LOW is still the committed quality + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_OFF) + time.Sleep(100 * time.Millisecond) + require.Len(t, notifications(), 1) + + // the resend notifies LOW again, without committing the pending OFF + dm.ResendCommittedQuality() + require.Eventually(t, func() bool { return len(notifications()) == 2 }, 5*time.Second, 10*time.Millisecond) + require.Equal(t, notifications()[0], notifications()[1]) + + // no layer paused, nothing to resend + dm.NotifySubscriberMaxQuality("s1", mime.MimeTypeVP8, livekit.VideoQuality_HIGH) + require.Eventually(t, func() bool { return len(notifications()) == 3 }, 5*time.Second, 10*time.Millisecond) + dm.ResendCommittedQuality() + time.Sleep(100 * time.Millisecond) + require.Len(t, notifications(), 3) +} + func subscribedCodecsAsString(c1 []*livekit.SubscribedCodec) string { slices.SortFunc(c1, func(a, b *livekit.SubscribedCodec) int { return strings.Compare(a.Codec, b.Codec) diff --git a/pkg/rtc/dynacast/dynacastmanagervideo.go b/pkg/rtc/dynacast/dynacastmanagervideo.go index 8178b4627..86c04e818 100644 --- a/pkg/rtc/dynacast/dynacastmanagervideo.go +++ b/pkg/rtc/dynacast/dynacastmanagervideo.go @@ -104,6 +104,21 @@ func (d *dynacastManagerVideo) ForceQuality(quality livekit.VideoQuality) { d.enqueueSubscribedQualityChange() } +// ResendCommittedQuality notifies the listener again of the last committed subscribed qualities, +// for a publisher that may have lost them. Nothing new is committed: a debounced downgrade stays pending. +// Only needed if some layer is paused, i.e. some committed quality is not HIGH. +func (d *dynacastManagerVideo) ResendCommittedQuality() { + d.lock.Lock() + defer d.lock.Unlock() + + for _, quality := range d.committedMaxSubscribedQuality { + if quality != livekit.VideoQuality_HIGH { + d.enqueueSubscribedQualityChange() + return + } + } +} + func (d *dynacastManagerVideo) NotifySubscriberMaxQuality( subscriberID livekit.ParticipantID, mime mime.MimeType, diff --git a/pkg/rtc/dynacast/interfaces.go b/pkg/rtc/dynacast/interfaces.go index c544ff54a..a64cfc962 100644 --- a/pkg/rtc/dynacast/interfaces.go +++ b/pkg/rtc/dynacast/interfaces.go @@ -53,6 +53,7 @@ type DynacastManager interface { Restart() Close() ForceUpdate() + ResendCommittedQuality() ForceQuality(quality livekit.VideoQuality) ForceEnable(enabled bool) @@ -88,6 +89,7 @@ func (d *dynacastManagerNull) HandleCodecRegression(fromMime, toMime mime.MimeTy func (d *dynacastManagerNull) Restart() {} func (d *dynacastManagerNull) Close() {} func (d *dynacastManagerNull) ForceUpdate() {} +func (d *dynacastManagerNull) ResendCommittedQuality() {} func (d *dynacastManagerNull) ForceQuality(quality livekit.VideoQuality) {} func (d *dynacastManagerNull) ForceEnable(enabled bool) {} func (d *dynacastManagerNull) NotifySubscriberMaxQuality( diff --git a/pkg/rtc/mediatrack.go b/pkg/rtc/mediatrack.go index be1436fce..9793e2103 100644 --- a/pkg/rtc/mediatrack.go +++ b/pkg/rtc/mediatrack.go @@ -644,6 +644,13 @@ func (t *MediaTrack) SetMuted(muted bool) { t.MediaTrackReceiver.SetMuted(muted) } +// ResendSubscribedQuality sends the publisher the subscribed qualities of the track again. +func (t *MediaTrack) ResendSubscribedQuality() { + if t.dynacastManager != nil { + t.dynacastManager.ResendCommittedQuality() + } +} + // OnTrackSubscribed is called when the track is subscribed by a non-hidden subscriber // this allows the publisher to know when they should start sending data func (t *MediaTrack) OnTrackSubscribed() { diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index af3f8e2d3..4491c6047 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -74,6 +74,9 @@ const ( sdBatchSize = 30 rttUpdateInterval = 5 * time.Second + publisherAnswerDynacastResendDelay = 2 * time.Second + publisherAnswerDynacastResendMaxDelay = 8 * time.Second + disconnectCleanupDuration = 5 * time.Second migrationWaitDuration = 3 * time.Second migrationWaitContinuousMsgDuration = 2 * time.Second @@ -277,6 +280,9 @@ type ParticipantImpl struct { // timer that's set when disconnect is detected on primary PC disconnectTimer *time.Timer migrationTimer *time.Timer + // re-send of the subscribed qualities pending after publisher answers + dynacastResendTimer *time.Timer + dynacastResendDeadline time.Time migratedInAt atomic.Pointer[time.Time] @@ -1268,7 +1274,38 @@ func (p *ParticipantImpl) onPublisherAnswer(answer webrtc.SessionDescription, an "midToTrackID", midToTrackID, ) - return p.sendSdpAnswer(answer, answerId, midToTrackID) + if err := p.sendSdpAnswer(answer, answerId, midToTrackID); err != nil { + return err + } + + // The answer does not carry the pause state of the simulcast layers (SetIgnoreRidPauseForRecv), + // so applying it re-enables in the publisher the layers paused by dynacast. Send the subscribed + // qualities again with a delay to ensure the answer has been applied, once for a burst of answers: + // the delay restarts with each answer, up to a maximum from the first one + p.lock.Lock() + if p.dynacastResendTimer == nil { + p.dynacastResendDeadline = time.Now().Add(publisherAnswerDynacastResendMaxDelay) + p.dynacastResendTimer = time.AfterFunc(publisherAnswerDynacastResendDelay, p.resendSubscribedQualities) + } else { + p.dynacastResendTimer.Reset(min(publisherAnswerDynacastResendDelay, time.Until(p.dynacastResendDeadline))) + } + p.lock.Unlock() + return nil +} + +func (p *ParticipantImpl) resendSubscribedQualities() { + p.lock.Lock() + p.dynacastResendTimer = nil + p.lock.Unlock() + + if p.IsClosed() || p.IsDisconnected() { + return + } + for _, track := range p.GetPublishedTracks() { + if mt, ok := track.(*MediaTrack); ok { + mt.ResendSubscribedQuality() + } + } } func (p *ParticipantImpl) GetAnswer() (webrtc.SessionDescription, uint32, error) { From 90f31a764a4e6a4fc074089a1dabd6049dc0d4e9 Mon Sep 17 00:00:00 2001 From: Alex Date: Fri, 25 Sep 2026 11:01:02 -0700 Subject: [PATCH 059/130] Bump protocol (#4909) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index ed6da6119..916d610f3 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319 + github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d github.com/livekit/psrpc v0.8.0 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 510325b72..7f8cc6d08 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319 h1:mEfaX/HY18LBI9Oy1U57O3qqowkpRFNqk8wybUb+fkM= -github.com/livekit/protocol v1.52.1-0.20260920231100-26af364d0319/go.mod h1:Ke1q64xQzwZ1S1ZH66SudXHsaaQQAFoX+TL1iLpu7XU= +github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d h1:zE0V9Qt210TlPMZb0ocT3GVBNqKmiGUFgTb73ECssJE= +github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2 h1:EKmLnuIs/2wRJmfQKv8JbZIGjHt7PQZYf94TAGyZGSI= From 9d8ee5a3190ad6001bb22365e866346059a8c168 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 26 Sep 2026 00:08:42 +0530 Subject: [PATCH 060/130] Report disconnected for older disconnected participants. (#4910) There are reports of disconnected participant update missing - https://github.com/livekit/client-sdk-js/pull/2096. That results in clients retaining disconnected participant(s) in its roster. Two changes 1. Look for participant disconnects a little farther back than last signal time as client app might have lost visibility earlier. 2. Look at all participants within the relaxed window above. The update cache is LRU. So, in theory can stop when an entry is before the time threshold above. But, theoretically it is possible that `updatedAt` time and LRU queuing time can flip ordering although very unlikely. I think we should do one of the following to make it more robust, but both require client side changes 1. Send full participant list in `ReconnectResponse`. Clients can use that to drop participants not in the list. 2. Add a sequence number to all signal responses (maybe signal requests as well) and have client send up the last response it saw in `SyncState` message. Server can then replay all the messages after that. This also requires a message cache on server side. --- pkg/rtc/participant_signal.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 57f03f305..e32a739be 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -243,15 +243,20 @@ func (p *ParticipantImpl) flushQueuedUpdates() { } func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { - lastSignalAt := p.TransportManager.LastSeenSignalAt() + // look back a little more than last signal receive time as WebSocket close + // on client side might have flushed messages and client application may have + // lost connectivity earlier. + lastSignalAt := p.TransportManager.LastSeenSignalAt().Add(-3 * time.Second) var disconnectedParticipants []*livekit.ParticipantInfo p.updateLock.Lock() keys := p.updateCache.Keys() for i := len(keys) - 1; i >= 0; i-- { if info, ok := p.updateCache.Get(keys[i]); ok { if info.updatedAt.Before(lastSignalAt) { - break - } else if info.state == livekit.ParticipantInfo_DISCONNECTED { + continue + } + + if info.state == livekit.ParticipantInfo_DISCONNECTED { disconnectedParticipants = append(disconnectedParticipants, &livekit.ParticipantInfo{ Sid: string(keys[i]), Identity: string(info.identity), From 93f5b3eb615c792b8749b0532ff9855d9fd88418 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sat, 26 Sep 2026 01:33:38 +0530 Subject: [PATCH 061/130] Add a local participant method to get recently disconnected (#4911) participants. While migrating, the old node will have the recently disconnected participants in the migrating out participant's cache and needs to sent out to the participant from the migrating in node via the new WebSocket. This method can be used to exchange that data between the two nodes. Keeping it as `recently` disconnected and leaving the semantics upto how recent for implementation. --- pkg/rtc/participant_internal_test.go | 5 ++ pkg/rtc/participant_signal.go | 25 +++++- pkg/rtc/room.go | 2 +- pkg/rtc/types/interfaces.go | 3 +- .../typesfakes/fake_local_participant.go | 86 +++++++++++++++++-- 5 files changed, 107 insertions(+), 14 deletions(-) diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index da41e7ff0..7492beaf7 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -917,6 +917,7 @@ func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) { require.NoError(t, p.HandleReconnectAndSendResponse( livekit.ReconnectReason_RR_UNKNOWN, &livekit.ReconnectResponse{LastMessageSeq: 21}, + nil, )) require.True(t, p.IsReady()) @@ -942,6 +943,7 @@ func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) { require.NoError(t, p.HandleReconnectAndSendResponse( livekit.ReconnectReason_RR_UNKNOWN, &livekit.ReconnectResponse{}, + nil, )) require.Equal(t, 2, sink.WriteMessageCallCount()) @@ -964,6 +966,7 @@ func TestMigratingInParticipantWaitsForReconnectResponse(t *testing.T) { require.NoError(t, p.HandleReconnectAndSendResponse( livekit.ReconnectReason_RR_UNKNOWN, &livekit.ReconnectResponse{}, + nil, )) require.True(t, p.IsReady()) require.Zero(t, sink.WriteMessageCallCount()) @@ -1004,6 +1007,7 @@ func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) { require.NoError(t, p.HandleReconnectAndSendResponse( livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, &livekit.ReconnectResponse{LastMessageSeq: 7}, + nil, )) require.Equal(t, 2, sink.WriteMessageCallCount()) @@ -1040,6 +1044,7 @@ func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) { require.NoError(t, p.HandleReconnectAndSendResponse( livekit.ReconnectReason_RR_SIGNAL_DISCONNECTED, &livekit.ReconnectResponse{}, + nil, )) require.Zero(t, sink.WriteMessageCallCount()) diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index e32a739be..9202ebb91 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -175,7 +175,11 @@ func (p *ParticipantImpl) SendRoomMovedResponse(roomMovedResponse *livekit.RoomM return p.signaller.WriteMessage(p.signalling.SignalRoomMovedResponse(roomMovedResponse)) } -func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse) error { +func (p *ParticipantImpl) HandleReconnectAndSendResponse( + reconnectReason livekit.ReconnectReason, + reconnectResponse *livekit.ReconnectResponse, + recentlyDisconnectedParticipants []*livekit.ParticipantInfo, // when migrating in, these are the participants disconnected received from migrate out node +) error { p.TransportManager.HandleClientReconnect(reconnectReason) if !p.params.ClientInfo.CanHandleReconnectResponse() { @@ -198,6 +202,11 @@ func (p *ParticipantImpl) HandleReconnectAndSendResponse(reconnectReason livekit } if p.params.ProtocolVersion.SupportsDisconnectedUpdate() { + if len(recentlyDisconnectedParticipants) != 0 { + if err := p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(recentlyDisconnectedParticipants)); err != nil { + return err + } + } return p.sendDisconnectUpdatesForReconnect() } @@ -243,11 +252,19 @@ func (p *ParticipantImpl) flushQueuedUpdates() { } func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { + recentlyDisconnectedParticipants := p.GetRecentlyDisconnectedParticipants() + if len(recentlyDisconnectedParticipants) != 0 { + return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(recentlyDisconnectedParticipants)) + } + return nil +} + +func (p *ParticipantImpl) GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo { // look back a little more than last signal receive time as WebSocket close // on client side might have flushed messages and client application may have // lost connectivity earlier. lastSignalAt := p.TransportManager.LastSeenSignalAt().Add(-3 * time.Second) - var disconnectedParticipants []*livekit.ParticipantInfo + var recentlyDisconnectedParticipants []*livekit.ParticipantInfo p.updateLock.Lock() keys := p.updateCache.Keys() for i := len(keys) - 1; i >= 0; i-- { @@ -257,7 +274,7 @@ func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { } if info.state == livekit.ParticipantInfo_DISCONNECTED { - disconnectedParticipants = append(disconnectedParticipants, &livekit.ParticipantInfo{ + recentlyDisconnectedParticipants = append(recentlyDisconnectedParticipants, &livekit.ParticipantInfo{ Sid: string(keys[i]), Identity: string(info.identity), Version: info.version, @@ -268,7 +285,7 @@ func (p *ParticipantImpl) sendDisconnectUpdatesForReconnect() error { } p.updateLock.Unlock() - return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(disconnectedParticipants)) + return recentlyDisconnectedParticipants } func (p *ParticipantImpl) sendICECandidate(ic *webrtc.ICECandidate, target livekit.SignalTarget) error { diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index 3ae984632..f2891e79a 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -586,7 +586,7 @@ func (r *Room) ResumeParticipant( ClientConfiguration: p.GetClientConfiguration(), ServerInfo: r.serverInfo, LastMessageSeq: p.GetLastReliableSequence(false), - }); err != nil { + }, nil); err != nil { return err } diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index 87b89ffe1..a8e3331df 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -583,10 +583,11 @@ type LocalParticipant interface { SendConnectionQualityUpdate(update *livekit.ConnectionQualityUpdate) error SendSubscriptionPermissionUpdate(publisherID livekit.ParticipantID, trackID livekit.TrackID, allowed bool) error SendRefreshToken(token string) error - HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse) error + HandleReconnectAndSendResponse(reconnectReason livekit.ReconnectReason, reconnectResponse *livekit.ReconnectResponse, recentlyDisconnectedParticipants []*livekit.ParticipantInfo) error IssueFullReconnect(reason ParticipantCloseReason) SendRoomMovedResponse(moved *livekit.RoomMovedResponse) error SendDataTrackSubscriberHandles(handles map[uint32]*livekit.DataTrackSubscriberHandles_PublishedDataTrack) error + GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo AddOnClose(key string, callback func(LocalParticipant)) OnClaimsChanged(callback func(LocalParticipant)) diff --git a/pkg/rtc/types/typesfakes/fake_local_participant.go b/pkg/rtc/types/typesfakes/fake_local_participant.go index b6f349e70..a417a5b31 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -527,6 +527,16 @@ type FakeLocalParticipant struct { result1 string result2 error } + GetRecentlyDisconnectedParticipantsStub func() []*livekit.ParticipantInfo + getRecentlyDisconnectedParticipantsMutex sync.RWMutex + getRecentlyDisconnectedParticipantsArgsForCall []struct { + } + getRecentlyDisconnectedParticipantsReturns struct { + result1 []*livekit.ParticipantInfo + } + getRecentlyDisconnectedParticipantsReturnsOnCall map[int]struct { + result1 []*livekit.ParticipantInfo + } GetReporterStub func() roomobs.ParticipantSessionReporter getReporterMutex sync.RWMutex getReporterArgsForCall []struct { @@ -692,11 +702,12 @@ type FakeLocalParticipant struct { arg1 *sfu.DownTrack arg2 *rtcp.ReceiverReport } - HandleReconnectAndSendResponseStub func(livekit.ReconnectReason, *livekit.ReconnectResponse) error + HandleReconnectAndSendResponseStub func(livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) error handleReconnectAndSendResponseMutex sync.RWMutex handleReconnectAndSendResponseArgsForCall []struct { arg1 livekit.ReconnectReason arg2 *livekit.ReconnectResponse + arg3 []*livekit.ParticipantInfo } handleReconnectAndSendResponseReturns struct { result1 error @@ -4264,6 +4275,59 @@ func (fake *FakeLocalParticipant) GetPublisherICESessionUfragReturnsOnCall(i int }{result1, result2} } +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipants() []*livekit.ParticipantInfo { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + ret, specificReturn := fake.getRecentlyDisconnectedParticipantsReturnsOnCall[len(fake.getRecentlyDisconnectedParticipantsArgsForCall)] + fake.getRecentlyDisconnectedParticipantsArgsForCall = append(fake.getRecentlyDisconnectedParticipantsArgsForCall, struct { + }{}) + stub := fake.GetRecentlyDisconnectedParticipantsStub + fakeReturns := fake.getRecentlyDisconnectedParticipantsReturns + fake.recordInvocation("GetRecentlyDisconnectedParticipants", []interface{}{}) + fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + if stub != nil { + return stub() + } + if specificReturn { + return ret.result1 + } + return fakeReturns.result1 +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsCallCount() int { + fake.getRecentlyDisconnectedParticipantsMutex.RLock() + defer fake.getRecentlyDisconnectedParticipantsMutex.RUnlock() + return len(fake.getRecentlyDisconnectedParticipantsArgsForCall) +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsCalls(stub func() []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = stub +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsReturns(result1 []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = nil + fake.getRecentlyDisconnectedParticipantsReturns = struct { + result1 []*livekit.ParticipantInfo + }{result1} +} + +func (fake *FakeLocalParticipant) GetRecentlyDisconnectedParticipantsReturnsOnCall(i int, result1 []*livekit.ParticipantInfo) { + fake.getRecentlyDisconnectedParticipantsMutex.Lock() + defer fake.getRecentlyDisconnectedParticipantsMutex.Unlock() + fake.GetRecentlyDisconnectedParticipantsStub = nil + if fake.getRecentlyDisconnectedParticipantsReturnsOnCall == nil { + fake.getRecentlyDisconnectedParticipantsReturnsOnCall = make(map[int]struct { + result1 []*livekit.ParticipantInfo + }) + } + fake.getRecentlyDisconnectedParticipantsReturnsOnCall[i] = struct { + result1 []*livekit.ParticipantInfo + }{result1} +} + func (fake *FakeLocalParticipant) GetReporter() roomobs.ParticipantSessionReporter { fake.getReporterMutex.Lock() ret, specificReturn := fake.getReporterReturnsOnCall[len(fake.getReporterArgsForCall)] @@ -5168,19 +5232,25 @@ func (fake *FakeLocalParticipant) HandleReceiverReportArgsForCall(i int) (*sfu.D return argsForCall.arg1, argsForCall.arg2 } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponse(arg1 livekit.ReconnectReason, arg2 *livekit.ReconnectResponse) error { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponse(arg1 livekit.ReconnectReason, arg2 *livekit.ReconnectResponse, arg3 []*livekit.ParticipantInfo) error { + var arg3Copy []*livekit.ParticipantInfo + if arg3 != nil { + arg3Copy = make([]*livekit.ParticipantInfo, len(arg3)) + copy(arg3Copy, arg3) + } fake.handleReconnectAndSendResponseMutex.Lock() ret, specificReturn := fake.handleReconnectAndSendResponseReturnsOnCall[len(fake.handleReconnectAndSendResponseArgsForCall)] fake.handleReconnectAndSendResponseArgsForCall = append(fake.handleReconnectAndSendResponseArgsForCall, struct { arg1 livekit.ReconnectReason arg2 *livekit.ReconnectResponse - }{arg1, arg2}) + arg3 []*livekit.ParticipantInfo + }{arg1, arg2, arg3Copy}) stub := fake.HandleReconnectAndSendResponseStub fakeReturns := fake.handleReconnectAndSendResponseReturns - fake.recordInvocation("HandleReconnectAndSendResponse", []interface{}{arg1, arg2}) + fake.recordInvocation("HandleReconnectAndSendResponse", []interface{}{arg1, arg2, arg3Copy}) fake.handleReconnectAndSendResponseMutex.Unlock() if stub != nil { - return stub(arg1, arg2) + return stub(arg1, arg2, arg3) } if specificReturn { return ret.result1 @@ -5194,17 +5264,17 @@ func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCallCount() int return len(fake.handleReconnectAndSendResponseArgsForCall) } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCalls(stub func(livekit.ReconnectReason, *livekit.ReconnectResponse) error) { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseCalls(stub func(livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) error) { fake.handleReconnectAndSendResponseMutex.Lock() defer fake.handleReconnectAndSendResponseMutex.Unlock() fake.HandleReconnectAndSendResponseStub = stub } -func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseArgsForCall(i int) (livekit.ReconnectReason, *livekit.ReconnectResponse) { +func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseArgsForCall(i int) (livekit.ReconnectReason, *livekit.ReconnectResponse, []*livekit.ParticipantInfo) { fake.handleReconnectAndSendResponseMutex.RLock() defer fake.handleReconnectAndSendResponseMutex.RUnlock() argsForCall := fake.handleReconnectAndSendResponseArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2 + return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } func (fake *FakeLocalParticipant) HandleReconnectAndSendResponseReturns(result1 error) { From 6cfd19bd9b555b854752dceb6d46cb2ce7c84dfd Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 28 Sep 2026 13:41:30 +0530 Subject: [PATCH 062/130] rtc: split EndSession out of MoveToRoom (#4917) * rtc: split EndSession out of MoveToRoom A participant's session in a room can end without the participant closing: today on a move, and next on a room re-key, where a room ended by the sweeper while participants were joining restarts under a new room id with the same participants in it. EndSession is the room-independent half of MoveToRoom: report the published tracks unpublished, report the session end, run the caller's leave under the ending session's telemetry guard, then take a fresh guard and reset the deferred resolvers for the next session. The guard matters: ParticipantLeft releases it and the telemetry service refuses to open a stats worker for a released guard, so without a fresh one the next session's join would silently get no worker. MoveToRoom keeps the subscriber teardown and the onClose fan-out, passed in as the leave. Co-Authored-By: Claude Fable 5.1 * rtc: rename EndSession to LeaveSession, add JoinSession EndSession read as terminal, but the participant survives it and joins another session, so name the pair for what happens to the participant: LeaveSession and JoinSession. JoinSession is the other half that review pointed out was missing from the API: the caller reports the join, then the published tracks are reported published again under the new session, pairing the unpublish LeaveSession reported. Without it the re-publish lived only in the caller, with nothing in OSS proving the two halves balance. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/rtc/participant.go | 86 +++++++++++++------ pkg/rtc/participant_internal_test.go | 47 ++++++++++ pkg/rtc/types/interfaces.go | 2 + .../typesfakes/fake_local_participant.go | 74 ++++++++++++++++ 4 files changed, 183 insertions(+), 26 deletions(-) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index 4491c6047..bf7420d28 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -4344,6 +4344,55 @@ func (p *ParticipantImpl) SupportsMoving() error { return nil } +// LeaveSession takes the participant out of its session in its current room without +// closing the participant, for it to join another session (see JoinSession): its +// published tracks are reported unpublished, the session end is reported, and the +// telemetry guard and deferred resolvers are reset for the next session. `leave` runs in +// between, while the left session's guard is still in place, for the caller to report the +// leave. +func (p *ParticipantImpl) LeaveSession(leave func()) { + for _, track := range p.GetPublishedTracks() { + p.GetTelemetryListener().OnTrackUnpublished( + p.ID(), + p.Identity(), + track.ToProto(), + track.(types.LocalMediaTrack).Published(), + true, + ) + } + + p.params.Reporter.ReportEndTime(time.Now()) + + if leave != nil { + leave() + } + + p.lock.Lock() + p.telemetryGuard = &telemetry.ReferenceGuard{} + p.lock.Unlock() + + p.params.LoggerResolver.Reset() + p.params.ReporterResolver.Reset() +} + +// JoinSession takes the participant, after LeaveSession, into the session its current +// room now serves: `join` runs first, for the caller to report the join, then the +// published tracks are reported published again, in that order as on a fresh join. +func (p *ParticipantImpl) JoinSession(join func()) { + if join != nil { + join() + } + + for _, track := range p.GetPublishedTracks() { + p.GetTelemetryListener().OnTrackPublished( + p.ID(), + p.Identity(), + track.ToProto(), + true, + ) + } +} + func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) { for _, track := range p.GetPublishedTracks() { for _, sub := range track.GetAllSubscribers() { @@ -4353,38 +4402,23 @@ func (p *ParticipantImpl) MoveToRoom(params types.MoveToRoomParams) { // clear the subscriber node max quality/audio codecs as the remote quality notify // from source room would not reach the moving out participant. track.(types.LocalMediaTrack).ClearSubscriberNodes() - - trackInfo := track.ToProto() - p.GetTelemetryListener().OnTrackUnpublished( - p.ID(), - p.Identity(), - trackInfo, - track.(types.LocalMediaTrack).Published(), - true, - ) } - p.params.Reporter.ReportEndTime(time.Now()) - p.SubscriptionManager.ClearAllSubscriptions() + p.LeaveSession(func() { + p.SubscriptionManager.ClearAllSubscriptions() - // fire onClose callback for original room - p.lock.Lock() - onClose := p.onClose - p.onClose = make(map[string]func(types.LocalParticipant)) - p.lock.Unlock() - for _, cb := range onClose { - cb(p) - } + // fire onClose callback for original room + p.lock.Lock() + onClose := p.onClose + p.onClose = make(map[string]func(types.LocalParticipant)) + p.lock.Unlock() + for _, cb := range onClose { + cb(p) + } + }) p.params.Logger.Infow("move participant to new room", "newRoomName", params.RoomName, "newID", params.ParticipantID) - p.lock.Lock() - p.telemetryGuard = &telemetry.ReferenceGuard{} - p.lock.Unlock() - - p.params.LoggerResolver.Reset() - p.params.ReporterResolver.Reset() - p.setListener(params.Listener) p.setTelemetryListener(params.TelemetryListener) p.participantHelper.Store(params.Helper) diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 7492beaf7..c2167eb53 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -32,6 +32,7 @@ import ( "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/logger/zaputil" "github.com/livekit/protocol/observability/roomobs" lksdp "github.com/livekit/protocol/sdp" "github.com/livekit/protocol/signalling" @@ -1052,3 +1053,49 @@ func TestResumedParticipantWaitsForReconnectResponse(t *testing.T) { require.Equal(t, 1, sink.WriteMessageCallCount()) }) } + +func TestLeaveJoinSession(t *testing.T) { + p := newParticipantForTest("leave-join-session") + p.params.LoggerResolver = zaputil.NoOpDeferrer{} + _, p.params.ReporterResolver = roomobs.DeferredParticipantReporter(roomobs.NewNoopProjectReporter()) + tl := p.GetTelemetryListener().(*typesfakes.FakeParticipantTelemetryListener) + + track := &typesfakes.FakeLocalMediaTrack{} + track.IDReturns("TR_test") + track.PublishedReturns(true) + p.UpTrackManager.AddPublishedTrack(track) + + // leaving reports the track unpublished, then runs the leave under the left session's + // guard, and the next session gets a fresh one + prevGuard := p.TelemetryGuard() + require.NotNil(t, prevGuard) + left := false + p.LeaveSession(func() { + left = true + require.Same(t, prevGuard, p.TelemetryGuard()) + require.Equal(t, 1, tl.OnTrackUnpublishedCallCount()) + }) + require.True(t, left) + require.NotSame(t, prevGuard, p.TelemetryGuard()) + require.Equal(t, 1, tl.OnTrackUnpublishedCallCount()) + _, _, _, wasPublished, shouldSend := tl.OnTrackUnpublishedArgsForCall(0) + require.True(t, wasPublished) + require.True(t, shouldSend) + + // joining runs the join first, then reports the track published again + joined := false + p.JoinSession(func() { + joined = true + require.Zero(t, tl.OnTrackPublishedCallCount()) + }) + require.True(t, joined) + require.Equal(t, 1, tl.OnTrackPublishedCallCount()) + _, _, _, shouldSend = tl.OnTrackPublishedArgsForCall(0) + require.True(t, shouldSend) + + // nil callbacks are fine + p.LeaveSession(nil) + p.JoinSession(nil) + require.Equal(t, 2, tl.OnTrackUnpublishedCallCount()) + require.Equal(t, 2, tl.OnTrackPublishedCallCount()) +} diff --git a/pkg/rtc/types/interfaces.go b/pkg/rtc/types/interfaces.go index a8e3331df..1d7734f1d 100644 --- a/pkg/rtc/types/interfaces.go +++ b/pkg/rtc/types/interfaces.go @@ -608,6 +608,8 @@ type LocalParticipant interface { ) IsReconnect() bool IsMigration() bool + LeaveSession(leave func()) + JoinSession(join func()) 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 a417a5b31..6c11e6bf6 100644 --- a/pkg/rtc/types/typesfakes/fake_local_participant.go +++ b/pkg/rtc/types/typesfakes/fake_local_participant.go @@ -1011,6 +1011,11 @@ type FakeLocalParticipant struct { issueFullReconnectArgsForCall []struct { arg1 types.ParticipantCloseReason } + JoinSessionStub func(func()) + joinSessionMutex sync.RWMutex + joinSessionArgsForCall []struct { + arg1 func() + } KindStub func() livekit.ParticipantInfo_Kind kindMutex sync.RWMutex kindArgsForCall []struct { @@ -1031,6 +1036,11 @@ type FakeLocalParticipant struct { kindDetailsReturnsOnCall map[int]struct { result1 []livekit.ParticipantInfo_KindDetail } + LeaveSessionStub func(func()) + leaveSessionMutex sync.RWMutex + leaveSessionArgsForCall []struct { + arg1 func() + } MaybeStartMigrationStub func(bool, func()) bool maybeStartMigrationMutex sync.RWMutex maybeStartMigrationArgsForCall []struct { @@ -6919,6 +6929,38 @@ func (fake *FakeLocalParticipant) IssueFullReconnectArgsForCall(i int) types.Par return argsForCall.arg1 } +func (fake *FakeLocalParticipant) JoinSession(arg1 func()) { + fake.joinSessionMutex.Lock() + fake.joinSessionArgsForCall = append(fake.joinSessionArgsForCall, struct { + arg1 func() + }{arg1}) + stub := fake.JoinSessionStub + fake.recordInvocation("JoinSession", []interface{}{arg1}) + fake.joinSessionMutex.Unlock() + if stub != nil { + fake.JoinSessionStub(arg1) + } +} + +func (fake *FakeLocalParticipant) JoinSessionCallCount() int { + fake.joinSessionMutex.RLock() + defer fake.joinSessionMutex.RUnlock() + return len(fake.joinSessionArgsForCall) +} + +func (fake *FakeLocalParticipant) JoinSessionCalls(stub func(func())) { + fake.joinSessionMutex.Lock() + defer fake.joinSessionMutex.Unlock() + fake.JoinSessionStub = stub +} + +func (fake *FakeLocalParticipant) JoinSessionArgsForCall(i int) func() { + fake.joinSessionMutex.RLock() + defer fake.joinSessionMutex.RUnlock() + argsForCall := fake.joinSessionArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) Kind() livekit.ParticipantInfo_Kind { fake.kindMutex.Lock() ret, specificReturn := fake.kindReturnsOnCall[len(fake.kindArgsForCall)] @@ -7025,6 +7067,38 @@ func (fake *FakeLocalParticipant) KindDetailsReturnsOnCall(i int, result1 []live }{result1} } +func (fake *FakeLocalParticipant) LeaveSession(arg1 func()) { + fake.leaveSessionMutex.Lock() + fake.leaveSessionArgsForCall = append(fake.leaveSessionArgsForCall, struct { + arg1 func() + }{arg1}) + stub := fake.LeaveSessionStub + fake.recordInvocation("LeaveSession", []interface{}{arg1}) + fake.leaveSessionMutex.Unlock() + if stub != nil { + fake.LeaveSessionStub(arg1) + } +} + +func (fake *FakeLocalParticipant) LeaveSessionCallCount() int { + fake.leaveSessionMutex.RLock() + defer fake.leaveSessionMutex.RUnlock() + return len(fake.leaveSessionArgsForCall) +} + +func (fake *FakeLocalParticipant) LeaveSessionCalls(stub func(func())) { + fake.leaveSessionMutex.Lock() + defer fake.leaveSessionMutex.Unlock() + fake.LeaveSessionStub = stub +} + +func (fake *FakeLocalParticipant) LeaveSessionArgsForCall(i int) func() { + fake.leaveSessionMutex.RLock() + defer fake.leaveSessionMutex.RUnlock() + argsForCall := fake.leaveSessionArgsForCall[i] + return argsForCall.arg1 +} + func (fake *FakeLocalParticipant) MaybeStartMigration(arg1 bool, arg2 func()) bool { fake.maybeStartMigrationMutex.Lock() ret, specificReturn := fake.maybeStartMigrationReturnsOnCall[len(fake.maybeStartMigrationArgsForCall)] From 5cd0f37342b38f3d5da7d1c6528269d3b97e0c55 Mon Sep 17 00:00:00 2001 From: Martin Njalale Date: Mon, 28 Sep 2026 11:36:26 +0300 Subject: [PATCH 063/130] rtc: record a room's departure when the participant leaves it (#4913) * rtc: record a room's departure when the participant leaves it RemoveParticipant removed the participant from the room, closed it, and only then recorded leftAt. CloseIfEmpty runs every second, so a tick landing during the close saw an empty room with a stale or zero departure time and closed it at once: with no departure recorded it compares against the room's creation time and empty_timeout, and with an earlier departure it measures the grace period from that older timestamp. Either way departure_timeout is skipped. Record the departure where the participant is removed, under the lock that already covers it. * rtc: drop the removal wait from the departure timeout test The close signal already pins the interleaving, and the room's close state is decided by the CloseIfEmpty call itself. --- pkg/rtc/room.go | 5 +++-- pkg/rtc/room_test.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/pkg/rtc/room.go b/pkg/rtc/room.go index f2891e79a..800d17e40 100644 --- a/pkg/rtc/room.go +++ b/pkg/rtc/room.go @@ -1431,6 +1431,9 @@ func (r *Room) RemoveParticipant( delete(r.hasPublished, identity) delete(r.launchedTrackEgresses, identity) delete(r.agentParticpants, identity) + // recorded while the participant is being removed, not after it is closed: + // the room is already empty to CloseIfEmpty, which runs every second + r.leftAt.Store(time.Now().Unix()) if !p.Hidden() { r.protoRoom.NumParticipants-- } @@ -1491,8 +1494,6 @@ func (r *Room) RemoveParticipant( // close participant as well _ = p.Close(true, reason, false) - r.leftAt.Store(time.Now().Unix()) - if sendUpdates { if r.onParticipantChanged != nil { r.onParticipantChanged(p) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 37055c738..7a565aba5 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -20,6 +20,7 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/atomic" "google.golang.org/protobuf/proto" "github.com/livekit/protocol/auth/authfakes" @@ -402,6 +403,55 @@ func TestRoomClosure(t *testing.T) { require.Equal(t, ErrRoomClosed, rm.Join(p, nil, nil, iceServersForRoom)) }) + // the idle check runs every second and can land while the last participant + // is still closing, when the room is empty but its departure has not been + // recorded yet + closeDuringRemove := func(t *testing.T, priorDeparture bool) bool { + rm := newRoomWithParticipants(t, testRoomOpts{num: 1}) + var isClosed atomic.Bool + rm.OnClose(func(types.RoomCloseReason) { + isClosed.Store(true) + }) + + rm.lock.Lock() + rm.protoRoom.DepartureTimeout = 20 + if priorDeparture { + // someone left earlier in the session, so a departure is recorded + rm.protoRoom.CreationTime = time.Now().Unix() + rm.leftAt.Store(time.Now().Add(-time.Minute).Unix()) + } else { + // older than the empty timeout, as a room in a call for a while is + rm.protoRoom.CreationTime = time.Now().Add(-10 * time.Minute).Unix() + } + rm.lock.Unlock() + + p := rm.GetParticipants()[0].(*typesfakes.FakeLocalParticipant) + closing := make(chan struct{}) + release := make(chan struct{}) + p.CloseStub = func(bool, types.ParticipantCloseReason, bool) error { + close(closing) + <-release + return nil + } + + go rm.RemoveParticipant(p.Identity(), p.ID(), types.ParticipantCloseReasonClientRequestLeave) + + <-closing + require.Empty(t, rm.GetParticipants()) + rm.CloseIfEmpty() + close(release) + + return isClosed.Load() + } + + t.Run("room does not close while the last participant is closing", func(t *testing.T) { + require.False(t, closeDuringRemove(t, false)) + }) + + t.Run("room does not close while the last participant is closing, after an earlier departure", func(t *testing.T) { + require.False(t, closeDuringRemove(t, true)) + }) + t.Run("room does not close before empty timeout", func(t *testing.T) { rm := newRoomWithParticipants(t, testRoomOpts{num: 0}) isClosed := false From 0eb472c8b27a58321177f1476dcb8169e705e511 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 28 Sep 2026 16:39:55 +0800 Subject: [PATCH 064/130] update deps to fix warp issue (#4916) * update deps to fix warp issue * sovle comment --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 916d610f3..10b2b6b18 100644 --- a/go.mod +++ b/go.mod @@ -161,8 +161,8 @@ tool ( golang.org/x/tools/cmd/goimports ) -replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2 +replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4 -replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.1 +replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.3 -replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.2 +replace github.com/pion/ice/v4 => github.com/livekit/ice/v4 v4.4.0-warp.3 diff --git a/go.sum b/go.sum index 7f8cc6d08..e18a12d10 100644 --- a/go.sum +++ b/go.sum @@ -156,10 +156,10 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lithammer/shortuuid/v4 v4.3.0 h1:XRr80OYPOlqxgnCv2/iuElkl/ZtXtPtLKk2AvpXGdWA= github.com/lithammer/shortuuid/v4 v4.3.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y= -github.com/livekit/dtls/v3 v3.1.5-warp.1 h1:5jo2qQreDnUrgw6Al1F+9xVyXHvWQ2IaQkjF53FHEyU= -github.com/livekit/dtls/v3 v3.1.5-warp.1/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= -github.com/livekit/ice/v4 v4.4.0-warp.2 h1:AYfwxksKNs2LGgLIifsrB/K4ixaQDuURxBgrA5dJBo4= -github.com/livekit/ice/v4 v4.4.0-warp.2/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= +github.com/livekit/dtls/v3 v3.1.5-warp.3 h1:oDUvjCb2ky8KXggOv4k4zTQKlAX7YwlGjBAva2zfgmk= +github.com/livekit/dtls/v3 v3.1.5-warp.3/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= +github.com/livekit/ice/v4 v4.4.0-warp.3 h1:iSAtEiLeCi/ZKEKOsRrDlBg/BRcyUKpV/NuAXX+dS3o= +github.com/livekit/ice/v4 v4.4.0-warp.3/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= @@ -168,8 +168,8 @@ github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d h1:zE0V9Qt210T github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2 h1:EKmLnuIs/2wRJmfQKv8JbZIGjHt7PQZYf94TAGyZGSI= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.2/go.mod h1:Ps5ynXt7Heejsbg8fclYIDCBsUC0nCHNoWRUwFqyA2k= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4 h1:9vmK8uymfZs2dFc6Wib+52X0TedlCEqCaBi6Kve1Nr0= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= From dc138b2bf0e53006135699c87a9b8d550468d2dc Mon Sep 17 00:00:00 2001 From: Martin Njalale Date: Mon, 28 Sep 2026 15:15:45 +0300 Subject: [PATCH 065/130] Deregister agent job when the server terminates it (#4893) * Deregister agent job when the server terminates it JobTerminate ended the job on the worker but left its JobTerminate handler and jobToWorker entry registered. Those were only cleaned up when the worker later sent an ended job status, or on worker disconnect for jobs still running. Workers that do not report job status after a termination (agents-js never sends UpdateJobStatus) leaked a psrpc handler for every job terminated by the server, e.g. each time an agent participant left its room. * Wait for worker registration in JobTerminate test The worker's registration response is sent before its job request topic is registered, so wait for the WorkerRegistered event before requesting jobs. Also cover a worker reporting an ended status after the server terminated the job, and clarify the JobTerminate comment. --- pkg/agent/agent_test.go | 85 +++++++++++++++++++++++++++++++++++++ pkg/service/agentservice.go | 9 ++++ 2 files changed, 94 insertions(+) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 0a546d7da..4ec83bc8f 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -190,6 +190,91 @@ func TestAgentLoadBalancing(t *testing.T) { }) } +func TestJobTerminateReleasesJob(t *testing.T) { + const agentName = "test_agent" + + // starts a server with a worker whose job request topic is registered + newWorker := func(t *testing.T) (rpc.AgentInternalClient, *testutils.AgentWorker) { + bus := psrpc.NewLocalMessageBus() + client := must.Get(rpc.NewAgentInternalClient(bus)) + server := testutils.NewTestServer(bus) + t.Cleanup(server.Close) + + registered := must.Get(client.SubscribeWorkerRegistered(context.Background(), agent.DefaultHandlerNamespace)) + defer registered.Close() + + worker := server.SimulateAgentWorker() + worker.Register(agentName, livekit.JobType_JT_ROOM) + select { + case <-registered.Channel(): + case <-time.After(time.Second): + require.Fail(t, "registration timeout") + } + return client, worker + } + + // assigns a job, then terminates it as Room.RemoveParticipant does when + // the agent participant leaves + runJob := func(t *testing.T, client rpc.AgentInternalClient) *livekit.Job { + job := &livekit.Job{ + Id: guid.New(guid.AgentJobPrefix), + DispatchId: guid.New(guid.AgentDispatchPrefix), + Type: livekit.JobType_JT_ROOM, + Room: &livekit.Room{}, + AgentName: agentName, + } + _, err := client.JobRequest(context.Background(), agentName, agent.RoomAgentTopic, job) + require.NoError(t, err) + + res, err := client.JobTerminate(context.Background(), job.Id, &rpc.JobTerminateRequest{ + JobId: job.Id, + Reason: rpc.JobTerminateReason_AGENT_LEFT_ROOM, + }) + require.NoError(t, err) + require.Equal(t, livekit.JobStatus_JS_FAILED, res.State.Status) + return job + } + + // no server should still be handling JobTerminate for the job + requireReleased := func(t *testing.T, client rpc.AgentInternalClient, job *livekit.Job) { + _, err := client.JobTerminate(context.Background(), job.Id, &rpc.JobTerminateRequest{JobId: job.Id}, psrpc.WithRequestTimeout(500*time.Millisecond)) + require.ErrorIs(t, err, psrpc.ErrNoResponse) + } + + t.Run("worker does not report job status", func(t *testing.T) { + // workers are not required to report job status after a termination + // (e.g. agents-js does not currently send UpdateJobStatus), and a + // worker disconnect does not release jobs that are no longer running + client, worker := newWorker(t) + job := runJob(t, client) + require.NoError(t, worker.Close()) + requireReleased(t, client, job) + }) + + t.Run("worker reports ended status after termination", func(t *testing.T) { + client, worker := newWorker(t) + job := runJob(t, client) + worker.SendUpdateJob(&livekit.UpdateJobStatus{ + JobId: job.Id, + Status: livekit.JobStatus_JS_SUCCESS, + }) + + // worker messages are handled in order, so the pong confirms the + // update was handled + pongs := worker.WorkerPongs.Observe() + defer pongs.Stop() + worker.SendPing(&livekit.WorkerPing{}) + select { + case <-pongs.Events(): + case <-time.After(time.Second): + require.Fail(t, "pong timeout") + } + + require.NoError(t, worker.Close()) + requireReleased(t, client, job) + }) +} + func TestConnectionClosedOnDispatchError(t *testing.T) { t.Run("connection closed when unknown message type received", func(t *testing.T) { bus := psrpc.NewLocalMessageBus() diff --git a/pkg/service/agentservice.go b/pkg/service/agentservice.go index 3e58e832b..6780eaf98 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agentservice.go @@ -479,6 +479,15 @@ func (h *AgentHandler) JobTerminate(ctx context.Context, req *rpc.JobTerminateRe } state, err := w.TerminateJob(livekit.JobID(req.JobId), req.Reason) + + // the job is no longer tracked as running, and the worker may never send an + // ended status update for it, so deregister it here. deregistering from + // inside this handler is safe because psrpc force closes the handler + // without waiting for in-flight requests. + h.mu.Lock() + h.deregisterJob(livekit.JobID(req.JobId)) + h.mu.Unlock() + if err != nil { return nil, err } From 9a6c187afdcea4765a6d65dd500ba50e75acce15 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 28 Sep 2026 19:07:12 +0530 Subject: [PATCH 066/130] telemetry: drop the stats worker re-key path (#4918) Reverts #4816. Since livekit/cloud#4747 a room re-key closes every participant's stats worker under the old room id and opens a new one under the new id (LeaveSession/JoinSession, #4917), so RoomIDChanged and reKeyRoom have nothing left to move, and with them go the sealed stats batches, SetRoom, ForceClose and the reference hand-over that only the re-key collision case needed. The worker map goes back to a flat (room, participant) key. The guard fixes that landed after #4816 (#4817, #4856, #4860) are kept. Co-authored-by: Claude Fable 5.1 --- pkg/telemetry/events.go | 12 -- pkg/telemetry/stats_test.go | 127 --------------- pkg/telemetry/statsworker.go | 147 ++---------------- pkg/telemetry/statsworker_test.go | 52 +------ .../telemetryfakes/fake_telemetry_service.go | 41 ----- pkg/telemetry/telemetryservice.go | 110 ++----------- 6 files changed, 34 insertions(+), 455 deletions(-) diff --git a/pkg/telemetry/events.go b/pkg/telemetry/events.go index a82bdd161..f41beda3b 100644 --- a/pkg/telemetry/events.go +++ b/pkg/telemetry/events.go @@ -184,18 +184,6 @@ func (t *telemetryService) ParticipantResumed( }) } -// RoomIDChanged re-keys the room's stats workers. -// -// NOTE: this shares the queue with the stats and participant events it races with, so -// ops raised before the id changed (carrying `prevRoomID`) are applied before the -// re-key and ops raised after it (carrying the new id) are applied after. Callers -// should raise this as soon as the room starts reporting the new id. -func (t *telemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) { - t.enqueue(func() { - t.reKeyRoom(prevRoomID, livekit.RoomID(room.Sid), livekit.RoomName(room.Name)) - }) -} - func (t *telemetryService) ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, diff --git a/pkg/telemetry/stats_test.go b/pkg/telemetry/stats_test.go index f4f9d3dbe..4729d99dd 100644 --- a/pkg/telemetry/stats_test.go +++ b/pkg/telemetry/stats_test.go @@ -626,133 +626,6 @@ func Test_BothDownstreamAndUpstreamStatsAreSentTogether(t *testing.T) { require.Equal(t, livekit.StreamType_DOWNSTREAM, stats[1].Kind) } -func Test_RoomIDChangeReKeysStatsWorkers(t *testing.T) { - fixture := createFixture() - - // prepare - room := &livekit.Room{Sid: "RoomSid", Name: "RoomName"} - partSID := livekit.ParticipantID("part1") - participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} - trackID := livekit.TrackID("trackID") - guard := &telemetry.ReferenceGuard{} - fixture.sut.ParticipantJoined(context.Background(), room, participantInfo, nil, nil, true, guard) - - stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} - fixture.sut.TrackStats(livekit.RoomID(room.Sid), livekit.RoomName(room.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) - - // do - the room restarts and gets a new id - restartedRoom := &livekit.Room{Sid: "RestartedSid", Name: "RoomName"} - fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(room.Sid), restartedRoom) - - // stats reported with the new id reach the same worker - stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} - fixture.sut.TrackStats(livekit.RoomID(restartedRoom.Sid), livekit.RoomName(restartedRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) - - fixture.flush() - - // one worker, one flush, but two stats - each attributed to the session it was collected in - require.Equal(t, 1, fixture.analytics.SendStatsCallCount()) - _, stats := fixture.analytics.SendStatsArgsForCall(0) - require.Equal(t, 2, len(stats)) - - byRoom := map[string]*livekit.AnalyticsStat{} - for _, stat := range stats { - require.Equal(t, string(partSID), stat.ParticipantId) - byRoom[stat.RoomId] = stat - } - require.Len(t, byRoom, 2) - require.Equal(t, uint64(33), byRoom[room.Sid].Streams[0].PrimaryBytes) - require.Equal(t, uint64(44), byRoom[restartedRoom.Sid].Streams[0].PrimaryBytes) - - // the worker moved rather than being duplicated, so closing it out drains everything - fixture.sut.ParticipantLeft(context.Background(), restartedRoom, participantInfo, true, guard) - fixture.flush() - require.Equal(t, 1, fixture.analytics.SendStatsCallCount()) -} - -// a forwarded participant is in more than one room at a time under the same participant -// id, so re-keying one of those rooms must leave the other alone -func Test_RoomIDChangeLeavesForwardedParticipantAlone(t *testing.T) { - fixture := createFixture() - - // prepare - the same participant id in a source room and a forwarding destination room - sourceRoom := &livekit.Room{Sid: "SourceSid", Name: "SourceRoom"} - destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"} - partSID := livekit.ParticipantID("part1") - participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} - trackID := livekit.TrackID("trackID") - fixture.sut.ParticipantJoined(context.Background(), sourceRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) - fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) - - // do - only the destination room restarts - restartedDest := &livekit.Room{Sid: "RestartedDestSid", Name: "DestRoom"} - fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(destRoom.Sid), restartedDest) - - stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} - fixture.sut.TrackStats(livekit.RoomID(sourceRoom.Sid), livekit.RoomName(sourceRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) - stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} - fixture.sut.TrackStats(livekit.RoomID(restartedDest.Sid), livekit.RoomName(restartedDest.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) - - fixture.flush() - - // the source room's worker is untouched, the destination room's worker moved - byRoom := map[string]*livekit.AnalyticsStat{} - for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ { - _, stats := fixture.analytics.SendStatsArgsForCall(i) - for _, stat := range stats { - byRoom[stat.RoomId] = stat - } - } - require.Len(t, byRoom, 2) - require.Equal(t, uint64(33), byRoom[sourceRoom.Sid].Streams[0].PrimaryBytes) - require.Equal(t, sourceRoom.Name, byRoom[sourceRoom.Sid].RoomName) - require.Equal(t, uint64(44), byRoom[restartedDest.Sid].Streams[0].PrimaryBytes) - require.Equal(t, restartedDest.Name, byRoom[restartedDest.Sid].RoomName) -} - -// a re-key should never land on a room that already has workers, but if it does only -// one worker can be keyed at (room, participant) and the superseded one must not be -// left unreachable in the flush list -func Test_RoomIDChangeParticipantCollision(t *testing.T) { - fixture := createFixture() - - // prepare - the same participant id in the room being re-keyed and in its destination - prevRoom := &livekit.Room{Sid: "PrevSid", Name: "PrevRoom"} - destRoom := &livekit.Room{Sid: "DestSid", Name: "DestRoom"} - partSID := livekit.ParticipantID("part1") - participantInfo := &livekit.ParticipantInfo{Sid: string(partSID)} - trackID := livekit.TrackID("trackID") - fixture.sut.ParticipantJoined(context.Background(), prevRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) - fixture.sut.ParticipantJoined(context.Background(), destRoom, participantInfo, nil, nil, true, &telemetry.ReferenceGuard{}) - - stat1 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 33}}} - fixture.sut.TrackStats(livekit.RoomID(prevRoom.Sid), livekit.RoomName(prevRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat1) - - // do - fixture.sut.RoomIDChanged(context.Background(), livekit.RoomID(prevRoom.Sid), destRoom) - - // the superseded worker drains what it collected under the room it was in - fixture.flush() - byRoom := map[string]*livekit.AnalyticsStat{} - for i := 0; i < fixture.analytics.SendStatsCallCount(); i++ { - _, stats := fixture.analytics.SendStatsArgsForCall(i) - for _, stat := range stats { - byRoom[stat.RoomId] = stat - } - } - require.Equal(t, uint64(33), byRoom[prevRoom.Sid].Streams[0].PrimaryBytes) - - // the worker already keyed at the destination wins and keeps receiving stats - stat2 := &livekit.AnalyticsStat{Streams: []*livekit.AnalyticsStream{{PrimaryBytes: 44}}} - fixture.sut.TrackStats(livekit.RoomID(destRoom.Sid), livekit.RoomName(destRoom.Name), telemetry.StatsKeyForData("test", livekit.StreamType_DOWNSTREAM, partSID, trackID), stat2) - - fixture.flush() - _, stats := fixture.analytics.SendStatsArgsForCall(fixture.analytics.SendStatsCallCount() - 1) - require.Equal(t, 1, len(stats)) - require.Equal(t, destRoom.Sid, stats[0].RoomId) - require.Equal(t, uint64(44), stats[0].Streams[0].PrimaryBytes) -} - func (f *telemetryServiceFixture) flush() { time.Sleep(time.Millisecond * 500) f.sut.FlushStats() diff --git a/pkg/telemetry/statsworker.go b/pkg/telemetry/statsworker.go index 463e6c1b7..f83e3a8a1 100644 --- a/pkg/telemetry/statsworker.go +++ b/pkg/telemetry/statsworker.go @@ -64,18 +64,6 @@ func (s *ReferenceCount) Release(guard *ReferenceGuard) bool { return s.count == 0 } -// Take hands over every reference held, leaving none behind. -func (s *ReferenceCount) Take() int { - count := s.count - s.count = 0 - return count -} - -// Absorb takes on references handed over from elsewhere. -func (s *ReferenceCount) Absorb(count int) { - s.count += count -} - func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error { e.AddInt("count", s.count) return nil @@ -83,39 +71,19 @@ func (s ReferenceCount) MarshalLogObject(e zapcore.ObjectEncoder) error { // ---------------------------------------- -// statsBatch is stats collected while the worker was in one room -type statsBatch struct { - roomID livekit.RoomID - roomName livekit.RoomName - incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat - outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat -} - -func (b statsBatch) isEmpty() bool { - return len(b.incomingPerTrack) == 0 && len(b.outgoingPerTrack) == 0 -} - -// ---------------------------------------- - // StatsWorker handles participant stats type StatsWorker struct { next *StatsWorker ctx context.Context t TelemetryService + roomID livekit.RoomID + roomName livekit.RoomName participantID livekit.ParticipantID participantIdentity livekit.ParticipantIdentity + isConnected bool - lock sync.RWMutex - // the room a worker belongs to can change mid-session, so it is mutable state - // guarded by `lock`. it is kept in sync with the key the worker is filed under in - // telemetryService.workers, see telemetryService.reKeyRoom. - roomID livekit.RoomID - roomName livekit.RoomName - // batches sealed off by a room change, they carry the room they were collected - // in and go out on the next flush - sealed []statsBatch - isConnected bool + lock sync.RWMutex outgoingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat incomingPerTrack map[livekit.TrackID][]*livekit.AnalyticsStat refCount ReferenceCount @@ -159,51 +127,6 @@ func (s *StatsWorker) ParticipantID() livekit.ParticipantID { return s.participantID } -func (s *StatsWorker) RoomID() livekit.RoomID { - s.lock.RLock() - defer s.lock.RUnlock() - - return s.roomID -} - -// SetRoom re-points the worker at a room. -// -// Stats collected so far are sealed off rather than re-stamped - a room id changes -// because the previous session ended, so what was collected under it belongs to it. -// Sealing keeps the re-key free of any sending, the sealed stats go out on the next -// flush like every other stat. -func (s *StatsWorker) SetRoom(roomID livekit.RoomID, roomName livekit.RoomName) { - s.lock.Lock() - defer s.lock.Unlock() - - if s.roomID == roomID && s.roomName == roomName { - return - } - - if batch := s.sealStatsLocked(); !batch.isEmpty() { - s.sealed = append(s.sealed, batch) - } - - s.roomID = roomID - s.roomName = roomName -} - -// sealStatsLocked hands over everything collected since the last seal, stamped -// with the room it was collected in -func (s *StatsWorker) sealStatsLocked() statsBatch { - batch := statsBatch{ - roomID: s.roomID, - roomName: s.roomName, - incomingPerTrack: s.incomingPerTrack, - outgoingPerTrack: s.outgoingPerTrack, - } - - s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) - s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) - - return batch -} - func (s *StatsWorker) SetConnected() { if s == nil { return @@ -225,24 +148,19 @@ func (s *StatsWorker) Flush(now time.Time, closeWait time.Duration) bool { ts := timestamppb.New(now) s.lock.Lock() - // anything sealed off by a room change goes out along with the current batch, - // each stamped with the room it was collected in - batches := append(s.sealed, s.sealStatsLocked()) - s.sealed = nil + stats := make([]*livekit.AnalyticsStat, 0, len(s.incomingPerTrack)+len(s.outgoingPerTrack)) + + incomingPerTrack := s.incomingPerTrack + s.incomingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) + + outgoingPerTrack := s.outgoingPerTrack + s.outgoingPerTrack = make(map[livekit.TrackID][]*livekit.AnalyticsStat) closed := !s.closedAt.IsZero() && now.Sub(s.closedAt) > closeWait s.lock.Unlock() - numTracks := 0 - for _, batch := range batches { - numTracks += len(batch.incomingPerTrack) + len(batch.outgoingPerTrack) - } - - stats := make([]*livekit.AnalyticsStat, 0, numTracks) - for _, batch := range batches { - stats = s.collectStats(ts, batch, livekit.StreamType_UPSTREAM, stats) - stats = s.collectStats(ts, batch, livekit.StreamType_DOWNSTREAM, stats) - } + stats = s.collectStats(ts, livekit.StreamType_UPSTREAM, incomingPerTrack, stats) + stats = s.collectStats(ts, livekit.StreamType_DOWNSTREAM, outgoingPerTrack, stats) if len(stats) > 0 { s.t.SendStats(s.ctx, stats) } @@ -265,34 +183,6 @@ func (s *StatsWorker) Close(guard *ReferenceGuard) bool { return ok } -// ForceClose closes the worker irrespective of outstanding references. Used when a -// worker can no longer be reached through the worker map, so that it drains and is -// reaped instead of lingering in the flush list forever. -// -// Its references are handed over to `successor`, the worker that can be reached in its -// place, so that whoever holds one still has a live worker to close. A ReferenceGuard -// records that it activated some worker, not which one, so leaving them behind would -// strand the successor with references it can never see released. -func (s *StatsWorker) ForceClose(successor *StatsWorker) bool { - s.lock.Lock() - if !s.closedAt.IsZero() { - s.lock.Unlock() - return false - } - - s.closedAt = time.Now() - count := s.refCount.Take() - s.lock.Unlock() - - if successor != nil && count != 0 { - successor.lock.Lock() - successor.refCount.Absorb(count) - successor.lock.Unlock() - } - - return true -} - func (s *StatsWorker) Closed(guard *ReferenceGuard) bool { s.lock.Lock() defer s.lock.Unlock() @@ -305,15 +195,10 @@ func (s *StatsWorker) Closed(guard *ReferenceGuard) bool { func (s *StatsWorker) collectStats( ts *timestamppb.Timestamp, - batch statsBatch, streamType livekit.StreamType, + perTrack map[livekit.TrackID][]*livekit.AnalyticsStat, stats []*livekit.AnalyticsStat, ) []*livekit.AnalyticsStat { - perTrack := batch.incomingPerTrack - if streamType == livekit.StreamType_DOWNSTREAM { - perTrack = batch.outgoingPerTrack - } - for trackID, analyticsStats := range perTrack { coalesced := coalesce(analyticsStats) if coalesced == nil { @@ -323,9 +208,9 @@ func (s *StatsWorker) collectStats( coalesced.TimeStamp = ts coalesced.TrackId = string(trackID) coalesced.Kind = streamType - coalesced.RoomId = string(batch.roomID) + coalesced.RoomId = string(s.roomID) coalesced.ParticipantId = string(s.participantID) - coalesced.RoomName = string(batch.roomName) + coalesced.RoomName = string(s.roomName) stats = append(stats, coalesced) } return stats diff --git a/pkg/telemetry/statsworker_test.go b/pkg/telemetry/statsworker_test.go index 82997f469..ce5d02bcd 100644 --- a/pkg/telemetry/statsworker_test.go +++ b/pkg/telemetry/statsworker_test.go @@ -21,50 +21,6 @@ func TestStatsWorker(t *testing.T) { require.True(t, w.Closed(&g1)) }) - // a ReferenceGuard records that it activated some worker, not which one, so a - // superseded worker has to hand its references to the one reachable in its place - t.Run("force close hands references to the successor", func(t *testing.T) { - t.Run("a guard shared by both workers", func(t *testing.T) { - // the second worker never got a reference, the guard was already activated - var g ReferenceGuard - superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g) - survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &g) - require.Equal(t, 1, superseded.refCount.count) - require.Equal(t, 0, survivor.refCount.count) - - require.True(t, superseded.ForceClose(survivor)) - require.Equal(t, 0, superseded.refCount.count) - require.Equal(t, 1, survivor.refCount.count) - - // without the hand over this would leave the survivor at -1 and never closed - require.True(t, survivor.Close(&g)) - require.True(t, survivor.Closed(&g)) - }) - - t.Run("a guard per worker", func(t *testing.T) { - var gSuperseded, gSurvivor ReferenceGuard - superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &gSuperseded) - survivor := newStatsWorker(t.Context(), nil, "", "", "", "", &gSurvivor) - - require.True(t, superseded.ForceClose(survivor)) - require.Equal(t, 2, survivor.refCount.count) - - // the superseded worker's owner departs, it must not close the survivor early - require.False(t, survivor.Close(&gSuperseded)) - require.True(t, survivor.Close(&gSurvivor)) - }) - - t.Run("closing an already closed worker holds on to its references", func(t *testing.T) { - var g ReferenceGuard - superseded := newStatsWorker(t.Context(), nil, "", "", "", "", &g) - survivor := newStatsWorker(t.Context(), nil, "", "", "", "", nil) - - require.True(t, superseded.ForceClose(nil)) - require.False(t, superseded.ForceClose(survivor)) - require.Equal(t, 0, survivor.refCount.count) - }) - }) - t.Run("logging a nil worker does not panic", func(t *testing.T) { var w *StatsWorker require.NoError(t, w.MarshalLogObject(zapcore.NewMapObjectEncoder())) @@ -75,7 +31,7 @@ func TestGetOrCreateWorkerReleasedGuard(t *testing.T) { // ParticipantActive overtaken by the participant's close arrives with a guard that // ParticipantLeft already released. It must not replace the closed worker with one // nothing can release. - ts := &telemetryService{workers: make(map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker)} + ts := &telemetryService{workers: make(map[statsWorkerKey]*StatsWorker)} roomID, pID := livekit.RoomID("room"), livekit.ParticipantID("participant") var g ReferenceGuard @@ -87,16 +43,16 @@ func TestGetOrCreateWorkerReleasedGuard(t *testing.T) { late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) require.True(t, found) require.Same(t, w, late) - require.Same(t, w, ts.workers[roomID][pID]) + require.Same(t, w, ts.workers[statsWorkerKey{roomID, pID}]) }) t.Run("closed worker already reaped", func(t *testing.T) { - delete(ts.workers[roomID], pID) + delete(ts.workers, statsWorkerKey{roomID, pID}) late, found := ts.getOrCreateWorker(context.Background(), roomID, "", pID, "", &g) require.True(t, found) require.Nil(t, late) - require.Empty(t, ts.workers[roomID]) + require.Empty(t, ts.workers) late.SetConnected() }) } diff --git a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go index 12d542127..aed5e7cf3 100644 --- a/pkg/telemetry/telemetryfakes/fake_telemetry_service.go +++ b/pkg/telemetry/telemetryfakes/fake_telemetry_service.go @@ -136,13 +136,6 @@ type FakeTelemetryService struct { arg2 *livekit.Room arg3 livekit.RoomEndReason } - RoomIDChangedStub func(context.Context, livekit.RoomID, *livekit.Room) - roomIDChangedMutex sync.RWMutex - roomIDChangedArgsForCall []struct { - arg1 context.Context - arg2 livekit.RoomID - arg3 *livekit.Room - } RoomProjectReporterStub func(context.Context) roomobs.ProjectReporter roomProjectReporterMutex sync.RWMutex roomProjectReporterArgsForCall []struct { @@ -922,40 +915,6 @@ func (fake *FakeTelemetryService) RoomEndedArgsForCall(i int) (context.Context, return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } -func (fake *FakeTelemetryService) RoomIDChanged(arg1 context.Context, arg2 livekit.RoomID, arg3 *livekit.Room) { - fake.roomIDChangedMutex.Lock() - fake.roomIDChangedArgsForCall = append(fake.roomIDChangedArgsForCall, struct { - arg1 context.Context - arg2 livekit.RoomID - arg3 *livekit.Room - }{arg1, arg2, arg3}) - stub := fake.RoomIDChangedStub - fake.recordInvocation("RoomIDChanged", []interface{}{arg1, arg2, arg3}) - fake.roomIDChangedMutex.Unlock() - if stub != nil { - fake.RoomIDChangedStub(arg1, arg2, arg3) - } -} - -func (fake *FakeTelemetryService) RoomIDChangedCallCount() int { - fake.roomIDChangedMutex.RLock() - defer fake.roomIDChangedMutex.RUnlock() - return len(fake.roomIDChangedArgsForCall) -} - -func (fake *FakeTelemetryService) RoomIDChangedCalls(stub func(context.Context, livekit.RoomID, *livekit.Room)) { - fake.roomIDChangedMutex.Lock() - defer fake.roomIDChangedMutex.Unlock() - fake.RoomIDChangedStub = stub -} - -func (fake *FakeTelemetryService) RoomIDChangedArgsForCall(i int) (context.Context, livekit.RoomID, *livekit.Room) { - fake.roomIDChangedMutex.RLock() - defer fake.roomIDChangedMutex.RUnlock() - argsForCall := fake.roomIDChangedArgsForCall[i] - return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 -} - func (fake *FakeTelemetryService) RoomProjectReporter(arg1 context.Context) roomobs.ProjectReporter { fake.roomProjectReporterMutex.Lock() ret, specificReturn := fake.roomProjectReporterReturnsOnCall[len(fake.roomProjectReporterArgsForCall)] diff --git a/pkg/telemetry/telemetryservice.go b/pkg/telemetry/telemetryservice.go index f316f6e4f..5b7145892 100644 --- a/pkg/telemetry/telemetryservice.go +++ b/pkg/telemetry/telemetryservice.go @@ -19,7 +19,6 @@ import ( "sync" "time" - "github.com/livekit/livekit-server/pkg/telemetry/prometheus" "github.com/livekit/livekit-server/pkg/utils" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" @@ -46,9 +45,6 @@ type TelemetryService interface { ParticipantResumed(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, nodeID livekit.NodeID, reason livekit.ReconnectReason) // ParticipantLeft - the participant leaves the room, only sent if ParticipantActive has been called before ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard) - // RoomIDChanged - the room kept its session, but got a different id (a provisional room id - // replaced by the resolved one), re-keys the stats workers of every participant in the room - RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) // TrackPublishRequested - a publication attempt has been received TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) // TrackPublished - a publication attempt has been successful @@ -119,8 +115,6 @@ func (n NullTelemetryService) ParticipantResumed(ctx context.Context, room *live } func (n NullTelemetryService) ParticipantLeft(ctx context.Context, room *livekit.Room, participant *livekit.ParticipantInfo, shouldSendEvent bool, guard *ReferenceGuard) { } -func (n NullTelemetryService) RoomIDChanged(ctx context.Context, prevRoomID livekit.RoomID, room *livekit.Room) { -} func (n NullTelemetryService) TrackPublishRequested(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) { } func (n NullTelemetryService) TrackPublished(ctx context.Context, room *livekit.Room, participantID livekit.ParticipantID, identity livekit.ParticipantIdentity, track *livekit.TrackInfo, shouldSendEvent bool) { @@ -172,6 +166,11 @@ const ( telemetryStatsUpdateInterval = time.Second * 30 ) +type statsWorkerKey struct { + roomID livekit.RoomID + participantID livekit.ParticipantID +} + type telemetryService struct { AnalyticsService @@ -179,7 +178,7 @@ type telemetryService struct { jobsQueue *utils.OpsQueue workersMu sync.RWMutex - workers map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker + workers map[statsWorkerKey]*StatsWorker workerList *StatsWorker flushMu sync.Mutex @@ -195,7 +194,7 @@ func NewTelemetryService(notifier webhook.QueuedNotifier, analytics AnalyticsSer FlushOnStop: true, Logger: logger.GetLogger(), }), - workers: make(map[livekit.RoomID]map[livekit.ParticipantID]*StatsWorker), + workers: make(map[statsWorkerKey]*StatsWorker), } t.jobsQueue.Start() @@ -244,12 +243,9 @@ func (t *telemetryService) FlushStats() { if reap != nil { t.workersMu.Lock() for reap != nil { - roomID := reap.RoomID() - if roomWorkers := t.workers[roomID]; reap == roomWorkers[reap.participantID] { - delete(roomWorkers, reap.participantID) - if len(roomWorkers) == 0 { - delete(t.workers, roomID) - } + key := statsWorkerKey{reap.roomID, reap.participantID} + if reap == t.workers[key] { + delete(t.workers, key) } reap = reap.next } @@ -271,7 +267,7 @@ func (t *telemetryService) getWorker(roomID livekit.RoomID, participantID liveki t.workersMu.RLock() defer t.workersMu.RUnlock() - worker, ok = t.workers[roomID][participantID] + worker, ok = t.workers[statsWorkerKey{roomID, participantID}] return } @@ -296,8 +292,8 @@ func (t *telemetryService) getOrCreateWorker( ) } - roomWorkers := t.workers[roomID] - worker, ok := roomWorkers[participantID] + key := statsWorkerKey{roomID, participantID} + worker, ok := t.workers[key] if ok && !worker.Closed(guard) { return worker, true } @@ -343,11 +339,7 @@ func (t *telemetryService) getOrCreateWorker( worker.SetConnected() } - if roomWorkers == nil { - roomWorkers = make(map[livekit.ParticipantID]*StatsWorker) - t.workers[roomID] = roomWorkers - } - roomWorkers[participantID] = worker + t.workers[key] = worker worker.next = t.workerList t.workerList = worker @@ -355,80 +347,6 @@ func (t *telemetryService) getOrCreateWorker( return worker, false } -// reKeyRoom files every one of a room's stats workers under `roomID` instead of -// `prevRoomID`. -// -// A room can be restarted while participants are connected and reporting stats, which -// gives it a new id. As every worker of the room moves at once, the move is a single map -// splice - the workers themselves are untouched and keep their place in the flush list. -// Each worker then seals off what it collected under `prevRoomID` so those stats stay -// attributed to the session that ended (see StatsWorker.SetRoom). -func (t *telemetryService) reKeyRoom(prevRoomID livekit.RoomID, roomID livekit.RoomID, roomName livekit.RoomName) { - if prevRoomID == roomID { - return - } - - t.workersMu.Lock() - defer t.workersMu.Unlock() - - roomWorkers := t.workers[prevRoomID] - if len(roomWorkers) == 0 { - delete(t.workers, prevRoomID) - return - } - delete(t.workers, prevRoomID) - - existing := t.workers[roomID] - if existing == nil { - t.workers[roomID] = roomWorkers - } else { - // should not happen as a room id is only ever replaced by a freshly minted one - logger.Warnw( - "telemetry re-keying room into an existing entry", nil, - "prevRoomID", prevRoomID, - "room", roomName, - "roomID", roomID, - "numWorkers", len(roomWorkers), - "numExistingWorkers", len(existing), - ) - } - - for participantID, worker := range roomWorkers { - if existing != nil { - if survivor, ok := existing[participantID]; ok { - // only one worker can be keyed at (room, participant) and the one already - // filed there wins, close the superseded one so that it drains and is - // reaped instead of lingering in the flush list unreachable - forceClosed := worker.ForceClose(survivor) - logger.Infow( - "telemetry force closing superseded stats worker", - "prevRoomID", prevRoomID, - "roomID", roomID, - "participantID", participantID, - "forceClosed", forceClosed, - "superseded", worker, - "survivor", survivor, - ) - if forceClosed { - prometheus.SubParticipant() - } - continue - } - existing[participantID] = worker - } - - worker.SetRoom(roomID, roomName) - } - - logger.Infow( - "telemetry re-keyed room", - "prevRoomID", prevRoomID, - "room", roomName, - "roomID", roomID, - "numWorkers", len(roomWorkers), - ) -} - func (t *telemetryService) LocalRoomState(ctx context.Context, info *livekit.AnalyticsNodeRooms) { t.enqueue(func() { t.SendNodeRoomStates(ctx, info) From 6ed445bad167a981561809de71d9d76a2859bde6 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 29 Sep 2026 01:39:05 +0530 Subject: [PATCH 067/130] sfu: report forwarding latency as p90 instead of the mean (#4920) * sfu: report forwarding latency as p90 instead of the mean The forwarding-latency metric was the mean transit over all forwarded packets. A mean is dominated by a few slow outliers, so a handful of packets stalled on the forward path (e.g. goroutine scheduling latency) inflated the whole node's reported latency even when nearly every packet was forwarded promptly. Report p90 instead: p90 rising means roughly a tenth of forwarded packets are slow, a broad signal of systemic forwarding load rather than a sparse tail. To read a percentile over the report window, the mergeable per-interval summary now keeps a small power-of-two-bucket histogram of transit instead of running moments (sum, sum-of-squares). Drop the jitter (transit std dev) gauge: nothing consumed it, and any spread is derivable from the forward-latency histogram. The protobuf ForwardJitter field is left in place, now unset, to deprecate separately. Co-Authored-By: Claude Opus 4.8 * sfu: clamp forwarding percentile to the observed [min, max] Bucket interpolation assumes a uniform fill, so a single 20ms packet (or uniform traffic) could report a p90 above every observed sample. Clamp the interpolated value to the summary's already-tracked min/max, so a quantile never falls outside the data. Exact for single samples and repeated identical latencies. Co-Authored-By: Claude Opus 4.8 * sfu: fix stale p50 comment in the percentile test The reported metric is p90; the test comment still said p50 replaced the mean. Reword it to reflect that a percentile, not the mean, is reported. Co-Authored-By: Claude Opus 4.8 * sfu: quarter-octave forwarding-latency buckets for threshold resolution Octave buckets are too coarse near the overload thresholds: 300us falls in [256,512), so a p90 clustered at ~265us and one at ~500us interpolate to the same value and would trip (or not) identically. Split each octave into four linear sub-buckets so the two land in different buckets, on the correct side of the threshold. Min/max clamping alone does not fix this once a node has a high tail, since its max no longer bounds the interpolation. Co-Authored-By: Claude Opus 4.8 * sfu: interpolate percentiles within each bucket's observed range Replace the quarter-octave split with plain octave buckets that also carry the observed [min, max] of their samples, and interpolate a percentile within that range instead of the bucket's nominal edges. This is exact when a bucket's samples cluster, so a p90 near an overload threshold that falls mid-bucket lands on the correct side of it regardless of bucket width -- no threshold-aware boundaries needed. It subsumes the min/max clamp, since an estimate can no longer leave the observed samples. Co-Authored-By: Claude Opus 4.8 * sfu: cover the 500us cluster in the threshold test Assert milos's full review example exactly: 265us and 500us clusters that octave-nominal interpolation both read as ~341us now read 265us and 500us. Co-Authored-By: Claude Opus 4.8 * sfu: make forwardSummary.addSample a pointer receiver The summary grew from a small moments struct into a per-bucket histogram (~700 bytes), so the value-receiver addSample copied the whole summary on every drained sample in the flush loop. Mutate in place instead: ~28ns -> ~2ns per sample in the background fold, no behavior change. Co-Authored-By: Claude Opus 4.8 * sfu: eighth-octave buckets for accuracy near thresholds Split each octave into 8 linear sub-buckets (was plain octaves). With the per-bucket min/max interpolation this reports a tight p90 cluster exactly even when it sits mid-octave: 850x265us + 150x410us now reads 410us (above a 400us threshold) instead of 395.5us, and a lognormal p90 lands within ~0.2us of exact. Per-sample add cost is unchanged (~2ns); cost is ~5KB per summary and a larger but per-report merge. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- pkg/sfu/forwardstats.go | 183 ++++++++++++++++++---------- pkg/sfu/forwardstats_test.go | 133 +++++++++++++------- pkg/telemetry/prometheus/node.go | 1 - pkg/telemetry/prometheus/packets.go | 20 +-- 4 files changed, 214 insertions(+), 123 deletions(-) diff --git a/pkg/sfu/forwardstats.go b/pkg/sfu/forwardstats.go index 2ad8497a7..a7dfbb0dc 100644 --- a/pkg/sfu/forwardstats.go +++ b/pkg/sfu/forwardstats.go @@ -1,7 +1,7 @@ package sfu import ( - "math" + "math/bits" "sync" "time" @@ -13,6 +13,12 @@ import ( const ( cHighForwardingLatency = 20 * time.Millisecond cSkewFactor = 10 + + // forwardLatencyPercentile is the transit quantile reported as the node's + // forwarding latency and read by the overload controller. p90 tracks a broad + // slowdown but ignores the sparse tail (a few packets stalled on goroutine + // scheduling), which shedding cannot fix. + forwardLatencyPercentile = 0.90 ) const ( @@ -94,32 +100,86 @@ 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 +// forwardSummary is a mergeable histogram of forwarding transit over an +// interval. Each power-of-two octave [2^e, 2^(e+1)) us is split into +// forwardHistSub linear sub-buckets, and each bucket keeps the observed +// [min, max] of the samples in it. A percentile interpolates within the +// crossing bucket's observed range rather than its nominal edges, so the +// estimate never leaves the samples and is exact when a bucket's samples +// cluster -- which keeps it accurate near an overload threshold that falls +// mid-bucket, without needing threshold-aware boundaries. Two summaries merge +// by adding their buckets, so the percentile covers the whole report window, +// and a few stalled packets (e.g. from goroutine scheduling latency) cannot +// drag it the way a mean does. +const ( + forwardHistSub = 8 // linear sub-buckets per octave + forwardHistOctaves = 27 // up to 2^27 us (~134 s) + forwardHistBuckets = forwardHistOctaves * forwardHistSub // 216 +) + +// bucketStat is one bucket: how many samples fell in it and their observed +// transit range in nanoseconds. +type bucketStat struct { + count 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} +func (b *bucketStat) add(transitNs int64) { + if b.count == 0 { + b.minNs, b.maxNs = transitNs, transitNs + } else { + b.minNs = min(b.minNs, transitNs) + b.maxNs = max(b.maxNs, transitNs) } + b.count++ +} +func (b *bucketStat) mergeIn(o bucketStat) { + if o.count == 0 { + return + } + if b.count == 0 { + *b = o + return + } + b.count += o.count + b.minNs = min(b.minNs, o.minNs) + b.maxNs = max(b.maxNs, o.maxNs) +} + +type forwardSummary struct { + count int64 + minNs int64 + maxNs int64 + buckets [forwardHistBuckets]bucketStat +} + +// forwardBucket returns the bucket index for a transit in nanoseconds: the +// octave floor(log2(us)) times forwardHistSub, plus the linear sub-bucket +// within the octave. +func forwardBucket(transitNs int64) int { + us := transitNs / 1000 + if us <= 0 { + return 0 + } + e := bits.Len64(uint64(us)) - 1 // floor(log2(us)), us >= 1 so e >= 0 + if e >= forwardHistOctaves { + return forwardHistBuckets - 1 + } + octave := int64(1) << e + return e*forwardHistSub + int((us-octave)*forwardHistSub/octave) +} + +func (s *forwardSummary) addSample(transitNs int64) { + if s.count == 0 { + s.minNs, s.maxNs = transitNs, transitNs + } else { + s.minNs = min(s.minNs, transitNs) + s.maxNs = max(s.maxNs, transitNs) + } 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 + s.buckets[forwardBucket(transitNs)].add(transitNs) } func (s forwardSummary) merge(o forwardSummary) forwardSummary { @@ -129,35 +189,36 @@ func (s forwardSummary) merge(o forwardSummary) forwardSummary { 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), + s.count += o.count + s.minNs = min(s.minNs, o.minNs) + s.maxNs = max(s.maxNs, o.maxNs) + for i := range s.buckets { + s.buckets[i].mergeIn(o.buckets[i]) } + return s } -func (s forwardSummary) meanStdDev() (mean, stdDev time.Duration) { +// percentile returns the p-quantile (0..1) of transit, interpolated within the +// observed [min, max] of the bucket the quantile falls in. +func (s forwardSummary) percentile(p float64) time.Duration { if s.count == 0 { - return 0, 0 + return 0 } - - meanUs := float64(s.sumUs) / float64(s.count) - mean = time.Duration(meanUs * float64(time.Microsecond)) - if s.count < 2 { - return mean, 0 + target := p * float64(s.count) + var cum float64 + for i := range s.buckets { + b := s.buckets[i] + c := float64(b.count) + if c == 0 { + continue + } + if cum+c >= target { + lo, hi := float64(b.minNs), float64(b.maxNs) + return time.Duration(lo + (hi-lo)*(target-cum)/c) + } + cum += c } - - // 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 + return time.Duration(s.maxNs) } type ForwardStats struct { @@ -231,12 +292,12 @@ func (s *ForwardStats) run() { // 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. +// latency gauge. func (s *ForwardStats) flush() { var summ forwardSummary s.samples.drain(func(transitNs int64) { prometheus.RecordForwardLatencySample(transitNs) - summ = summ.addSample(transitNs) + summ.addSample(transitNs) }) s.lock.Lock() @@ -274,33 +335,33 @@ func (s *ForwardStats) summarize(window time.Duration) forwardSummary { 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() +// GetStats returns the reported forwarding-latency percentile +// (forwardLatencyPercentile) 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 { + return s.summarize(duration).percentile(forwardLatencyPercentile) } func (s *ForwardStats) report() { w := s.summarize(0) - latency, jitter := w.meanStdDev() + p90 := w.percentile(forwardLatencyPercentile) if dropped := s.samples.takeDropped(); dropped > 0 { logger.Warnw("forward stats sample buffer overflow", nil, "dropped", dropped) } - if w.count > 0 && jitter > latency*cSkewFactor { + // a max far above p90 means a few packets stalled (e.g. goroutine scheduling + // latency) rather than a broad forwarding slowdown; p90 rides through it. + if w.count > 0 && w.maxNs > p90.Nanoseconds()*cSkewFactor { logger.Infow( - "high jitter in forwarding path", + "high spread in forwarding path", "lowest", time.Duration(w.minNs), "highest", time.Duration(w.maxNs), "count", w.count, - "latency", latency, - "jitter", jitter, + "p90", p90, ) } - prometheus.RecordForwardJitter(uint32(jitter.Nanoseconds())) - prometheus.RecordForwardLatency(uint32(latency.Nanoseconds())) + prometheus.RecordForwardLatency(uint32(p90.Nanoseconds())) } diff --git a/pkg/sfu/forwardstats_test.go b/pkg/sfu/forwardstats_test.go index 152404978..fe145659a 100644 --- a/pkg/sfu/forwardstats_test.go +++ b/pkg/sfu/forwardstats_test.go @@ -24,28 +24,39 @@ func initPrometheus(t *testing.T) { // forwardSummary // --------------------------------------------------------------------------- +// bucketSum totals a summary's per-bucket counts, independent of the bucketing +// scheme, so tests can assert samples landed without hard-coding bucket indices. +func bucketSum(s forwardSummary) int64 { + var n int64 + for _, b := range s.buckets { + n += b.count + } + return n +} + 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 + s.addSample(3000) // 3us + s.addSample(1000) // 1us + 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) + require.Equal(t, s.count, bucketSum(s)) // every sample landed in a bucket } func TestForwardSummary_Merge(t *testing.T) { var empty forwardSummary - a := forwardSummary{}.addSample(1000).addSample(2000) - b := forwardSummary{}.addSample(5000).addSample(3000) + var a, b forwardSummary + a.addSample(1000) + a.addSample(2000) + b.addSample(5000) + b.addSample(3000) // merging with empty is identity, in both directions require.Equal(t, a, a.merge(empty)) @@ -53,34 +64,72 @@ func TestForwardSummary_Merge(t *testing.T) { 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) + // merge sums the per-bucket counts + require.Equal(t, bucketSum(a)+bucketSum(b), bucketSum(m)) + require.Equal(t, m.count, bucketSum(m)) } -func TestForwardSummary_MeanStdDev(t *testing.T) { +func TestForwardSummary_Percentile(t *testing.T) { // empty -> zero - mean, stdDev := forwardSummary{}.meanStdDev() - require.Zero(t, mean) - require.Zero(t, stdDev) + require.Zero(t, forwardSummary{}.percentile(0.5)) - // 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) + // half the samples fast (1us), half a slow tail (100us). A percentile is not + // dragged toward the tail the way the mean is -- why the reported metric is + // p90, not the mean. + var s forwardSummary + for i := 0; i < 5; i++ { + s.addSample(1000) // 1us + } + for i := 0; i < 5; i++ { + s.addSample(100_000) // 100us + } + require.Equal(t, int64(10), s.count) - // 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) + // p50 lands in the fast cluster, unmoved by the 100us tail. + require.Equal(t, 1*time.Microsecond, s.percentile(0.5)) + // p90 crosses into the slow cluster. + require.Equal(t, 100*time.Microsecond, s.percentile(0.9)) - // 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)) + // a single sample reports exactly its latency, not the top edge of its bucket + // (nominal-edge interpolation would report the bucket's upper reach instead). + var single forwardSummary + single.addSample(int64(20 * time.Millisecond)) + require.Equal(t, 20*time.Millisecond, single.percentile(0.9)) + + // identical samples share a bucket but must not invent intra-bucket spread. + var u forwardSummary + for i := 0; i < 8; i++ { + u.addSample(int64(2 * time.Millisecond)) + } + require.Equal(t, 2*time.Millisecond, u.percentile(0.5)) + require.Equal(t, 2*time.Millisecond, u.percentile(0.99)) +} + +func TestForwardSummary_ThresholdResolution(t *testing.T) { + // Nodes whose p90 packets cluster near a 300us overload threshold. + // Interpolating within each bucket's observed range reports a tight tail + // exactly, so each cluster lands on the correct side of 300us. + build := func(tailUs int64) forwardSummary { + var s forwardSummary + for i := 0; i < 850; i++ { + s.addSample(50 * int64(time.Microsecond)) + } + for i := 0; i < 150; i++ { + s.addSample(tailUs * int64(time.Microsecond)) + } + return s + } + + require.Less(t, build(265).percentile(0.9), 300*time.Microsecond) // below -> no trip + require.Greater(t, build(310).percentile(0.9), 300*time.Microsecond) // just above -> trips + require.Greater(t, build(500).percentile(0.9), 300*time.Microsecond) // well above -> trips + + // a tight tail is reported exactly, regardless of where it sits in the bucket. + require.Equal(t, 265*time.Microsecond, build(265).percentile(0.9)) + require.Equal(t, 310*time.Microsecond, build(310).percentile(0.9)) + require.Equal(t, 500*time.Microsecond, build(500).percentile(0.9)) } // --------------------------------------------------------------------------- @@ -284,29 +333,25 @@ func TestForwardStats_GetStats(t *testing.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++ { + // fold five 100ms buckets, one sample each, descending 5ms..1ms so trailing + // windows have distinct maxima. + for i := 5; i >= 1; 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)) + // whole window {1..5ms}: p90 clamps to the observed max, 5ms. + require.Equal(t, 5*time.Millisecond, s.GetStats(0)) + require.Equal(t, 5*time.Millisecond, s.GetStats(time.Second)) - // 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 covers only the two most recent buckets {2ms, 1ms}: a lower window + // than the full one, and above the single most-recent bucket. + require.Less(t, s.GetStats(200*time.Millisecond), 3*time.Millisecond) + require.Greater(t, s.GetStats(200*time.Millisecond), s.GetStats(time.Nanosecond)) - // ~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)) + // a sub-interval yields only the most recent bucket, ~1ms. + require.Equal(t, 1*time.Millisecond, s.GetStats(time.Nanosecond)) } func TestForwardStats_Lifecycle(t *testing.T) { diff --git a/pkg/telemetry/prometheus/node.go b/pkg/telemetry/prometheus/node.go index fb540fb30..915b0322a 100644 --- a/pkg/telemetry/prometheus/node.go +++ b/pkg/telemetry/prometheus/node.go @@ -174,7 +174,6 @@ func GetNodeStats(nodeStartedAt int64, prevStats []*livekit.NodeStats, rateInter 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, diff --git a/pkg/telemetry/prometheus/packets.go b/pkg/telemetry/prometheus/packets.go index 5b8e45659..8736d4de6 100644 --- a/pkg/telemetry/prometheus/packets.go +++ b/pkg/telemetry/prometheus/packets.go @@ -51,7 +51,6 @@ var ( participantRTCCanceled atomic.Uint64 participantRTCActive atomic.Uint64 forwardLatency atomic.Uint32 - forwardJitter atomic.Uint32 promPacketLabels = []string{"direction", "transmission", "country"} promPacketTotal *prometheus.CounterVec @@ -70,7 +69,6 @@ var ( promParticipantJoin *prometheus.CounterVec promConnections *prometheus.GaugeVec promForwardLatency prometheus.Gauge - promForwardJitter prometheus.Gauge promForwardLatencyHist prometheus.Histogram ) @@ -164,12 +162,6 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { Name: "latency", ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, }) - promForwardJitter = prometheus.NewGauge(prometheus.GaugeOpts{ - Namespace: livekitNamespace, - Subsystem: "forward", - Name: "jitter", - ConstLabels: prometheus.Labels{"node_id": nodeID, "node_type": nodeType.String()}, - }) promForwardLatencyHist = prometheus.NewHistogram(prometheus.HistogramOpts{ Namespace: livekitNamespace, Subsystem: "forward_latency", @@ -204,7 +196,6 @@ func initPacketStats(nodeID string, nodeType livekit.NodeType) { prometheus.MustRegister(promParticipantJoin) prometheus.MustRegister(promConnections) prometheus.MustRegister(promForwardLatency) - prometheus.MustRegister(promForwardJitter) prometheus.MustRegister(promForwardLatencyHist) } @@ -380,12 +371,7 @@ func RecordForwardLatencySample(forwardLatency int64) { promForwardLatencyHist.Observe(float64(forwardLatency)) } -func RecordForwardLatency(longTermLatencyAvg uint32) { - forwardLatency.Store(longTermLatencyAvg) - promForwardLatency.Set(float64(longTermLatencyAvg)) -} - -func RecordForwardJitter(longTermJitterAvg uint32) { - forwardJitter.Store(longTermJitterAvg) - promForwardJitter.Set(float64(longTermJitterAvg)) +func RecordForwardLatency(p90 uint32) { + forwardLatency.Store(p90) + promForwardLatency.Set(float64(p90)) } From d91b16238a3dab6f0e7343db46707284d80aeb54 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 29 Sep 2026 08:02:36 +0530 Subject: [PATCH 068/130] rtc: accept ICE Completed in ICE-restart connect assertions (#4921) --- pkg/rtc/transport_test.go | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index d0eb5a697..b586d16e6 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -60,8 +60,8 @@ func TestMissingAnswerDuringICERestart(t *testing.T) { handleICEExchange(t, transportA, transportB, handlerA, handlerB) connectTransports(t, transportA, transportB, handlerA, handlerB, false, 1, 1) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState()) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState()) + require.True(t, iceConnected(transportA.pc)) + require.True(t, iceConnected(transportB.pc)) var negotiationState atomic.Value transportA.OnNegotiationStateChanged(func(state transport.NegotiationState) { @@ -82,8 +82,8 @@ func TestMissingAnswerDuringICERestart(t *testing.T) { }, 10*time.Second, time.Millisecond*10, "transportA offer not received") connectTransports(t, transportA, transportB, handlerA, handlerB, true, 1, 1) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportA.pc.ICEConnectionState()) - require.Equal(t, webrtc.ICEConnectionStateConnected, transportB.pc.ICEConnectionState()) + require.True(t, iceConnected(transportA.pc)) + require.True(t, iceConnected(transportB.pc)) transportA.Close() transportB.Close() @@ -270,8 +270,8 @@ func TestFirstOfferMissedDuringICERestart(t *testing.T) { // ensure we are connected require.Eventually(t, func() bool { - return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && - transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && + return iceConnected(transportA.pc) && + iceConnected(transportB.pc) && offerCount.Load() == 2 }, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect") @@ -347,8 +347,8 @@ func TestFirstAnswerMissedDuringICERestart(t *testing.T) { // ensure we are connected require.Eventually(t, func() bool { - return transportA.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && - transportB.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected && + return iceConnected(transportA.pc) && + iceConnected(transportB.pc) && offerCount.Load() == 2 }, testutils.ConnectTimeout, 10*time.Millisecond, "transport did not connect") @@ -518,6 +518,15 @@ func TestFilteringCandidates(t *testing.T) { transport.Close() } +// iceConnected reports whether a peer connection's ICE has established. The +// controlling agent (the offerer) advances Connected -> Completed once its +// connectivity checks finish, so asserting exactly Connected races the poll +// against that transition; both states mean the transport is up. +func iceConnected(pc *webrtc.PeerConnection) bool { + s := pc.ICEConnectionState() + return s == webrtc.ICEConnectionStateConnected || s == webrtc.ICEConnectionStateCompleted +} + func handleICEExchange(t *testing.T, a, b *PCTransport, ah, bh *transportfakes.FakeHandler) { ah.OnICECandidateCalls(func(candidate *webrtc.ICECandidate, target livekit.SignalTarget) error { if candidate == nil { @@ -563,7 +572,7 @@ func connectTransports(t *testing.T, offerer, answerer *PCTransport, offererHand }, 10*time.Second, time.Millisecond*10, fmt.Sprintf("offer count mismatch, expected: %d, actual: %d", expectedOfferCount, offerCount.Load())) require.Eventually(t, func() bool { - return offerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected + return iceConnected(offerer.pc) }, 10*time.Second, time.Millisecond*10, "offerer did not become connected") require.Eventually(t, func() bool { @@ -571,7 +580,7 @@ func connectTransports(t *testing.T, offerer, answerer *PCTransport, offererHand }, 10*time.Second, time.Millisecond*10, fmt.Sprintf("answer count mismatch, expected: %d, actual: %d", expectedAnswerCount, answerCount.Load())) require.Eventually(t, func() bool { - return answerer.pc.ICEConnectionState() == webrtc.ICEConnectionStateConnected + return iceConnected(answerer.pc) }, 10*time.Second, time.Millisecond*10, "answerer did not become connected") transportsConnected := untilTransportsConnected(offererHandler, answererHandler) From 2d9bf12581b5f680b7bf92307c147a18bae590fd Mon Sep 17 00:00:00 2001 From: RaphaelFakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:47:42 +0300 Subject: [PATCH 069/130] rtc: measure data blob keys by their content, not their text form (#4923) --- pkg/config/config.go | 22 ++++++-- pkg/rtc/participant_data_blob_handler.go | 3 +- pkg/rtc/participant_data_blob_handler_test.go | 55 +++++++++++++++++++ 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 3ca89107e..5dc2c91fd 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -399,8 +399,20 @@ 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 +// DataBlobKeyLength returns the length of the identifying content of a data blob key. +// The text form of the key message is not used, as it adds field names and quotes. +func DataBlobKeyLength(key *livekit.DataBlobKey) int { + switch k := key.GetKey().(type) { + case *livekit.DataBlobKey_Generic: + return len(k.Generic) + case *livekit.DataBlobKey_SchemaId: + return len(k.SchemaId.GetName()) + len(k.SchemaId.GetEncoding().GetCustom()) + } + return 0 +} + +func (l LimitConfig) CheckDataBlobKeyLength(key *livekit.DataBlobKey) bool { + return l.MaxDataBlobKeyLength == 0 || DataBlobKeyLength(key) <= l.MaxDataBlobKeyLength } func (l LimitConfig) CheckDataTrackCustomEncodingLength(identifier string) bool { @@ -430,7 +442,7 @@ func (l LimitConfig) CheckDataBlobsSize(dataBlobs []*livekit.DataBlob) bool { total := 0 for _, dataBlob := range dataBlobs { - total += len(dataBlob.GetKey().String()) + len(dataBlob.Contents) + total += DataBlobKeyLength(dataBlob.GetKey()) + len(dataBlob.Contents) } return uint32(total) <= l.MaxDataBlobSize } @@ -442,9 +454,9 @@ func (l LimitConfig) CanAddDataBlob(dataBlobs []*livekit.DataBlob, toAdd *liveki total := 0 for _, dataBlob := range dataBlobs { - total += len(dataBlob.Key.String()) + len(dataBlob.Contents) + total += DataBlobKeyLength(dataBlob.GetKey()) + len(dataBlob.Contents) } - return uint32(total+len(toAdd.GetKey().String())+len(toAdd.Contents)) <= l.MaxDataBlobSize + return uint32(total+DataBlobKeyLength(toAdd.GetKey())+len(toAdd.Contents)) <= l.MaxDataBlobSize } // --------------------------------- diff --git a/pkg/rtc/participant_data_blob_handler.go b/pkg/rtc/participant_data_blob_handler.go index 8c19ee765..b9d131ade 100644 --- a/pkg/rtc/participant_data_blob_handler.go +++ b/pkg/rtc/participant_data_blob_handler.go @@ -15,6 +15,7 @@ package rtc import ( + "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -31,7 +32,7 @@ func (p *ParticipantImpl) HandleStoreDataBlobRequest(req *livekit.StoreDataBlobR return } - if req.Blob == nil || req.Blob.Key == nil || len(req.Blob.Key.String()) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key.String()) { + if req.Blob == nil || req.Blob.Key == nil || config.DataBlobKeyLength(req.Blob.Key) == 0 || !p.params.LimitConfig.CheckDataBlobKeyLength(req.Blob.Key) { p.pubLogger.Warnw("data blob is invalid", nil, "req", logger.Proto(req)) p.sendRequestResponse(&livekit.RequestResponse{ RequestId: req.RequestId, diff --git a/pkg/rtc/participant_data_blob_handler_test.go b/pkg/rtc/participant_data_blob_handler_test.go index 411a6a203..409cc1ddf 100644 --- a/pkg/rtc/participant_data_blob_handler_test.go +++ b/pkg/rtc/participant_data_blob_handler_test.go @@ -124,6 +124,61 @@ func TestHandleStoreDataBlobRequest(t *testing.T) { require.Equal(t, livekit.RequestResponse_INVALID_REQUEST, rr.Reason) }) + t.Run("returns INVALID_REQUEST when generic key 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(""), + Contents: []byte("def"), + }, + }) + + 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("accepts a key exactly at the length limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 5, 0) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + key := genericKey("abcde") + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: key, + Contents: []byte("def"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + _, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse) + require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message) + require.NotNil(t, p.dataBlob.Get(key)) + }) + + t.Run("counts only key and contents bytes against the size limit", func(t *testing.T) { + p := newParticipantWithDataBlob(t, true, 0, 9) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + key := genericKey("abc") + p.HandleStoreDataBlobRequest(&livekit.StoreDataBlobRequest{ + Blob: &livekit.DataBlob{ + Key: key, + Contents: []byte("defghi"), + }, + }) + + require.Equal(t, 1, sink.WriteMessageCallCount()) + msg := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse) + _, ok := msg.Message.(*livekit.SignalResponse_StoreDataBlobResponse) + require.True(t, ok, "expected SignalResponse_StoreDataBlobResponse, got %T", msg.Message) + require.NotNil(t, p.dataBlob.Get(key)) + }) + 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) From ad54223d1b1aa91403fdacd40f985b73b1ae3c0a Mon Sep 17 00:00:00 2001 From: RaphaelFakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:48:25 +0300 Subject: [PATCH 070/130] sfu: fix data stats active window bitrate and duration units (#4922) --- pkg/sfu/buffer/datastats.go | 5 +++-- pkg/sfu/buffer/datastats_test.go | 13 +++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pkg/sfu/buffer/datastats.go b/pkg/sfu/buffer/datastats.go index 795f8a0f2..89255b453 100644 --- a/pkg/sfu/buffer/datastats.go +++ b/pkg/sfu/buffer/datastats.go @@ -68,13 +68,14 @@ func (s *DataStats) ToProtoActive() *livekit.RTPStats { if duration > s.params.WindowDuration.Nanoseconds() { return &livekit.RTPStats{} } + seconds := float64(duration) / 1e9 return &livekit.RTPStats{ StartTime: timestamppb.New(time.Unix(s.windowStart/1e9, s.windowStart%1e9)), EndTime: timestamppb.New(time.Unix(0, now)), - Duration: float64(duration / 1e9), + Duration: seconds, Bytes: uint64(s.windowBytes), - Bitrate: float64(s.windowBytes) * 8 / float64(duration) / 1e9, + Bitrate: float64(s.windowBytes) * 8 / seconds, } } diff --git a/pkg/sfu/buffer/datastats_test.go b/pkg/sfu/buffer/datastats_test.go index 5803fe9d9..bfdc42235 100644 --- a/pkg/sfu/buffer/datastats_test.go +++ b/pkg/sfu/buffer/datastats_test.go @@ -51,3 +51,16 @@ func TestDataStats(t *testing.T) { require.EqualValues(t, 100, r.Bytes) require.NotZero(t, r.Bitrate) } + +func TestDataStatsActiveBitrateAndDuration(t *testing.T) { + stats := NewDataStats(DataStatsParam{WindowDuration: 10 * time.Second}) + + // 1000 bytes over a 1.5 second window is 8000 bits / 1.5 s + stats.windowStart = time.Now().Add(-1500 * time.Millisecond).UnixNano() + stats.windowBytes = 1000 + + r := stats.ToProtoActive() + require.EqualValues(t, 1000, r.Bytes) + require.InDelta(t, 1.5, r.Duration, 0.1) + require.InDelta(t, 8000/1.5, r.Bitrate, 0.1*8000/1.5) +} From aa08becc6a533117ba92a45bef19f333123857e9 Mon Sep 17 00:00:00 2001 From: Nikolay Ivanko Date: Wed, 30 Sep 2026 13:37:19 +0300 Subject: [PATCH 071/130] config: reject unknown send_side_bwe_pacer, warn on unwired fallback (#4804) send_side_bwe_pacer accepted any string and silently ran no-queue. leaky-bucket is a defined PacerBehavior with a full LeakyBucket implementation, but the selection switch in NewPCTransport only handled pass-through and no-queue, so both leaky-bucket and outright typos fell through to the default with no diagnostic. leaky-bucket is deliberately left unwired here. LeakyBucket paces to a bitrate supplied via Pacer.SetBitrate, and no production code calls SetBitrate or SetInterval -- Base no-ops both and the only caller in the tree is the test-only capturingPacer. Wiring it as-is would pace at a static bitrate and ignore the bandwidth estimate, which is worse than the fallback. Instead, make the existing behaviour visible: CongestionControlConfig.Validate rejects unrecognised values at startup, the fallback logs a warning naming the requested pacer, and config-sample.yaml documents send_side_bwe_pacer and use_send_side_bwe for the first time. Refs #4724 Co-authored-by: Claude Opus 5 --- config-sample.yaml | 10 ++++++++++ pkg/config/config.go | 20 ++++++++++++++++++++ pkg/config/config_test.go | 35 +++++++++++++++++++++++++++++++++++ pkg/rtc/transport.go | 15 ++++++++++++++- 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/config-sample.yaml b/config-sample.yaml index 5aca3bea7..059de7beb 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -117,6 +117,16 @@ rtc: # # in the unlikely event of highly congested networks, SFU may choose to pause some tracks # # in order to allow others to stream smoothly. You can disable this behavior here # allow_pause: true + # # use send-side bandwidth estimation instead of relying on receiver reports, default false + # use_send_side_bwe: false + # # how egress is paced when use_send_side_bwe is enabled. one of: + # # no-queue - hand packets to a send worker that drains them without rate + # # limiting, so the sender never blocks on a write (default) + # # pass-through - send each packet inline on the calling goroutine + # # leaky-bucket - accepted, but not currently wired to the bandwidth estimate; it + # # logs a warning and falls back to no-queue. see livekit/livekit#4724 + # # an unrecognized value is rejected at startup. + # send_side_bwe_pacer: no-queue # # allows automatic connection fallback to TCP and TURN/TLS (if configured) when UDP has been unstable, default true # allow_tcp_fallback: true # # signaling RTT (in milliseconds) below which ICE/TCP is attempted on a UDP failure; at or above it, diff --git a/pkg/config/config.go b/pkg/config/config.go index 5dc2c91fd..2f5e5f8fe 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -220,6 +220,22 @@ type CongestionControlConfig struct { SendSideBWE sendsidebwe.SendSideBWEConfig `yaml:"send_side_bwe,omitempty"` } +// Validate reports configuration that would otherwise be ignored at runtime. +func (c CongestionControlConfig) Validate() error { + switch pacer.PacerBehavior(c.SendSideBWEPacer) { + case "", pacer.PacerBehaviorPassThrough, pacer.PacerBehaviorNoQueue, pacer.PacerBehaviorLeakybucket: + return nil + default: + return fmt.Errorf( + "invalid send_side_bwe_pacer %q, must be one of %q, %q, %q", + c.SendSideBWEPacer, + pacer.PacerBehaviorPassThrough, + pacer.PacerBehaviorNoQueue, + pacer.PacerBehaviorLeakybucket, + ) + } +} + type PlayoutDelayConfig struct { Enabled bool `yaml:"enabled,omitempty"` Min int `yaml:"min,omitempty"` @@ -671,6 +687,10 @@ func NewConfig(confString string, strictMode bool, c *cli.Command, baseFlags []c return nil, fmt.Errorf("could not validate RTC config: %v", err) } + if err := conf.RTC.CongestionControl.Validate(); err != nil { + return nil, fmt.Errorf("could not validate congestion control config: %v", err) + } + conf.NormalizeTURNTTLs() // expand env vars in filenames diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 0b6439945..d4117cea5 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -23,6 +23,7 @@ import ( "github.com/urfave/cli/v3" "github.com/livekit/livekit-server/pkg/config/configtest" + "github.com/livekit/livekit-server/pkg/sfu/pacer" ) func TestConfig_UnmarshalKeys(t *testing.T) { @@ -182,6 +183,40 @@ func TestNewConfigNormalizesTURNTTL(t *testing.T) { require.Equal(t, DefaultTURNTTLSeconds, conf.TURN.TTLSeconds) } +func TestNewConfigValidatesSendSideBWEPacer(t *testing.T) { + cases := []struct { + name string + pacer string + wantErr bool + }{ + {name: "unset keeps the default", pacer: "", wantErr: false}, + {name: "pass-through", pacer: string(pacer.PacerBehaviorPassThrough), wantErr: false}, + {name: "no-queue", pacer: string(pacer.PacerBehaviorNoQueue), wantErr: false}, + {name: "leaky-bucket", pacer: string(pacer.PacerBehaviorLeakybucket), wantErr: false}, + {name: "typo is rejected", pacer: "leakybucket", wantErr: true}, + {name: "unknown value is rejected", pacer: "not-a-pacer", wantErr: true}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + content := "rtc:\n congestion_control:\n send_side_bwe_pacer: " + tc.pacer + conf, err := NewConfig(content, true, nil, nil) + if tc.wantErr { + require.Error(t, err) + require.Contains(t, err.Error(), tc.pacer) + return + } + + require.NoError(t, err) + if tc.pacer == "" { + require.Equal(t, string(pacer.PacerBehaviorNoQueue), conf.RTC.CongestionControl.SendSideBWEPacer) + return + } + require.Equal(t, tc.pacer, conf.RTC.CongestionControl.SendSideBWEPacer) + }) + } +} + func writeSecretFile(t *testing.T, content string) string { t.Helper() path := filepath.Join(t.TempDir(), "secret") diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index c00f629dd..4baf2e6e0 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -582,12 +582,25 @@ func NewPCTransport(params TransportParams) (*PCTransport, error) { Config: params.CongestionControlConfig.SendSideBWE, Logger: params.Logger, }) - switch pacer.PacerBehavior(params.CongestionControlConfig.SendSideBWEPacer) { + switch behavior := pacer.PacerBehavior(params.CongestionControlConfig.SendSideBWEPacer); behavior { case pacer.PacerBehaviorPassThrough: t.pacer = pacer.NewPassThrough(params.Logger, t.bwe) case pacer.PacerBehaviorNoQueue: t.pacer = pacer.NewNoQueue(params.Logger, t.bwe) default: + // leaky-bucket lands here: it is a recognized value, but LeakyBucket paces + // to a bitrate set through Pacer.SetBitrate and nothing drives that from + // the bandwidth estimate, so it would pace at a fixed rate and ignore + // congestion. Unknown values are rejected by CongestionControlConfig. + // Validate, so they only reach here from callers building TransportParams + // directly. + if behavior != "" { + params.Logger.Warnw( + "send side BWE pacer unavailable, falling back", nil, + "pacerBehavior", behavior, + "fallback", pacer.PacerBehaviorNoQueue, + ) + } t.pacer = pacer.NewNoQueue(params.Logger, t.bwe) } } else { From 2fab31691bba2dc6317544cc440c37a93f291935 Mon Sep 17 00:00:00 2001 From: Martin Njalale Date: Wed, 30 Sep 2026 14:25:24 +0300 Subject: [PATCH 072/130] service: start room composite egress configured in a room preset (#4926) * service: start room composite egress configured in a room preset StandardRoomAllocator.CreateRoom merged a room preset into a copy of the request and discarded it. RoomInternal has no field for room composite egress, and getOrCreateRoom started it from the caller's original request, so a room composite egress from a preset was never started. RoomAllocator.CreateRoom now also returns the request with the preset applied, and getOrCreateRoom starts the egress from it. * service: return ErrEgressNotConnected instead of calling a nil egress launcher getOrCreateRoom started a room composite egress without checking that an egress launcher exists. RoomService.CreateRoom rejects egress on the request when there is none, but not egress from a room preset or a token's roomConfig. --- pkg/service/interfaces.go | 4 +- pkg/service/roomallocator.go | 12 +- pkg/service/roomallocator_test.go | 34 +++- pkg/service/roommanager.go | 9 +- pkg/service/roommanager_test.go | 181 ++++++++++++++++++ .../servicefakes/fake_room_allocator.go | 43 +++-- 6 files changed, 255 insertions(+), 28 deletions(-) create mode 100644 pkg/service/roommanager_test.go diff --git a/pkg/service/interfaces.go b/pkg/service/interfaces.go index 22a8fddb4..c86b47d86 100644 --- a/pkg/service/interfaces.go +++ b/pkg/service/interfaces.go @@ -81,7 +81,9 @@ type IngressStore interface { type RoomAllocator interface { AutoCreateEnabled(ctx context.Context) bool SelectRoomNode(ctx context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error - CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, bool, error) + // CreateRoom also returns the request with its room preset applied, + // or req unchanged when the implementation does not resolve presets + CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) ValidateCreateRoom(ctx context.Context, roomName livekit.RoomName) error } diff --git a/pkg/service/roomallocator.go b/pkg/service/roomallocator.go index d843529c9..6beb00c09 100644 --- a/pkg/service/roomallocator.go +++ b/pkg/service/roomallocator.go @@ -59,10 +59,10 @@ func (r *StandardRoomAllocator) AutoCreateEnabled(context.Context) bool { // CreateRoom creates a new room from a request and allocates it to a node to handle // it'll also monitor its state, and cleans it up when appropriate -func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, bool, error) { +func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.CreateRoomRequest, isExplicit bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) { token, err := r.roomStore.LockRoom(ctx, livekit.RoomName(req.Name), 5*time.Second) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } defer func() { _ = r.roomStore.UnlockRoom(ctx, livekit.RoomName(req.Name), token) @@ -84,12 +84,12 @@ func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.Cre internal = &livekit.RoomInternal{} applyDefaultRoomConfig(rm, internal, &r.config.Room) } else if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } req, err = r.applyNamedRoomConfiguration(req) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } if req.EmptyTimeout > 0 { @@ -127,10 +127,10 @@ func (r *StandardRoomAllocator) CreateRoom(ctx context.Context, req *livekit.Cre } if err = r.roomStore.StoreRoom(ctx, rm, internal); err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } - return rm, internal, created, nil + return rm, internal, req, created, nil } func (r *StandardRoomAllocator) SelectRoomNode(ctx context.Context, roomName livekit.RoomName, nodeID livekit.NodeID) error { diff --git a/pkg/service/roomallocator_test.go b/pkg/service/roomallocator_test.go index 3d3dd29db..4e9a2ae9a 100644 --- a/pkg/service/roomallocator_test.go +++ b/pkg/service/roomallocator_test.go @@ -39,12 +39,44 @@ func TestCreateRoom(t *testing.T) { ra, conf := newTestRoomAllocator(t, conf, node.Clone()) - room, _, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{Name: "myroom"}, true) + room, _, _, _, err := ra.CreateRoom(context.Background(), &livekit.CreateRoomRequest{Name: "myroom"}, true) require.NoError(t, err) require.Equal(t, conf.Room.EmptyTimeout, room.EmptyTimeout) require.Equal(t, conf.Room.DepartureTimeout, room.DepartureTimeout) require.NotEmpty(t, room.EnabledCodecs) }) + + t.Run("return the request with the room preset applied", func(t *testing.T) { + conf, err := config.NewConfig(` +room: + room_configurations: + record: + egress: + room: + layout: grid +`, true, nil, nil) + require.NoError(t, err) + + node, err := routing.NewLocalNode(conf) + require.NoError(t, err) + + ra, conf := newTestRoomAllocator(t, conf, node.Clone()) + + // without a preset, the request itself is returned + req := &livekit.CreateRoomRequest{Name: "myroom"} + _, _, applied, _, err := ra.CreateRoom(context.Background(), req, true) + require.NoError(t, err) + require.Same(t, req, applied) + + // with a preset, a copy with the preset applied is returned, + // leaving both the request and the preset unchanged + req = &livekit.CreateRoomRequest{Name: "myroom", RoomPreset: "record"} + _, _, applied, _, err = ra.CreateRoom(context.Background(), req, true) + require.NoError(t, err) + require.Nil(t, req.Egress) + require.Equal(t, "grid", applied.GetEgress().GetRoom().GetLayout()) + require.NotSame(t, conf.Room.RoomConfigurations["record"].Egress.Room, applied.Egress.Room) + }) } func SelectRoomNode(t *testing.T) { diff --git a/pkg/service/roommanager.go b/pkg/service/roommanager.go index b667344f3..7b38c82c6 100644 --- a/pkg/service/roommanager.go +++ b/pkg/service/roommanager.go @@ -656,7 +656,9 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C } // create new room, get details first - ri, internal, created, err := r.roomAllocator.CreateRoom(ctx, createRoom, true) + // createRoom is reassigned to the request with the room preset applied, so that a room + // composite egress from the preset, which RoomInternal has no field for, is started + ri, internal, createRoom, created, err := r.roomAllocator.CreateRoom(ctx, createRoom, true) if err != nil { return nil, err } @@ -733,6 +735,11 @@ func (r *RoomManager) getOrCreateRoom(ctx context.Context, createRoom *livekit.C prometheus.RoomStarted() if created && createRoom.GetEgress().GetRoom() != nil { + if r.egressLauncher == nil { + newRoom.Release() + return nil, ErrEgressNotConnected + } + // ensure room name matches createRoom.Egress.Room.RoomName = createRoom.Name _, err = r.egressLauncher.StartEgress(ctx, &rpc.StartEgressRequest{ diff --git a/pkg/service/roommanager_test.go b/pkg/service/roommanager_test.go new file mode 100644 index 000000000..a8208ff2c --- /dev/null +++ b/pkg/service/roommanager_test.go @@ -0,0 +1,181 @@ +// 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 service_test + +import ( + "context" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/livekit/protocol/utils" + "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/routing/routingfakes" + "github.com/livekit/livekit-server/pkg/rtc" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes" +) + +// fakeEgressLauncher records the egress start requests it receives. +type fakeEgressLauncher struct { + mu sync.Mutex + started []*rpc.StartEgressRequest +} + +func (f *fakeEgressLauncher) StartEgress(_ context.Context, req *rpc.StartEgressRequest) (*livekit.EgressInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.started = append(f.started, req) + return &livekit.EgressInfo{}, nil +} + +func (f *fakeEgressLauncher) StopEgress(context.Context, *livekit.StopEgressRequest) (*livekit.EgressInfo, error) { + return &livekit.EgressInfo{}, nil +} + +func (f *fakeEgressLauncher) roomCompositeRoomNames() []string { + f.mu.Lock() + defer f.mu.Unlock() + + var names []string + for _, req := range f.started { + if rc := req.GetRoomComposite(); rc != nil { + names = append(names, rc.RoomName) + } + } + return names +} + +func newTestRoomManager(t *testing.T, conf *config.Config, egressLauncher rtc.EgressLauncher) *service.RoomManager { + // no fixed RTC ports, so that the test does not bind the defaults + conf.RTC.TCPPort = 0 + conf.RTC.UDPPort = rtcconfig.PortRange{} + + node, err := routing.NewLocalNode(conf) + require.NoError(t, err) + router := &routingfakes.FakeRouter{} + router.GetNodeForRoomReturns(node.Clone(), nil) + + store := service.NewLocalStore() + ra, err := service.NewRoomAllocator(conf, router, store) + require.NoError(t, err) + + rm, err := service.NewLocalRoomManager( + conf, + store, + node, + router, + ra, + &telemetryfakes.FakeTelemetryService{}, + nil, + store, + egressLauncher, + utils.NewDefaultTimedVersionGenerator(), + nil, + psrpc.NewLocalMessageBus(), + nil, + ) + require.NoError(t, err) + t.Cleanup(rm.Stop) + return rm +} + +func TestRoomCompositeEgressOnCreateRoom(t *testing.T) { + t.Run("from request", func(t *testing.T) { + conf, err := config.NewConfig("", true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: "room-a", + Egress: &livekit.RoomEgress{ + Room: &livekit.RoomCompositeEgressRequest{ + Layout: "grid", + FileOutputs: []*livekit.EncodedFileOutput{{Filepath: "recordings/{room_name}.mp4"}}, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames()) + }) + + t.Run("from room preset", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + for _, name := range []string{"room-a", "room-b"} { + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: name, + RoomPreset: "record", + }) + require.NoError(t, err) + } + + // each room records itself, and the preset is left as configured + require.Equal(t, []string{"room-a", "room-b"}, launcher.roomCompositeRoomNames()) + require.Empty(t, conf.Room.RoomConfigurations["record"].GetEgress().GetRoom().GetRoomName()) + }) + + t.Run("from room preset when a session starts", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + launcher := &fakeEgressLauncher{} + rm := newTestRoomManager(t, conf, launcher) + + // the join and WHIP paths create the room through StartSession, with the + // token's room preset; without an identity only the room is created + err = rm.StartSession(context.Background(), routing.ParticipantInit{ + CreateRoom: &livekit.CreateRoomRequest{ + Name: "room-a", + RoomPreset: "record", + }, + }, nil, nil, false) + require.NoError(t, err) + require.Equal(t, []string{"room-a"}, launcher.roomCompositeRoomNames()) + }) + + t.Run("from room preset without an egress launcher", func(t *testing.T) { + conf, err := config.NewConfig(roomPresetConfig, true, nil, nil) + require.NoError(t, err) + rm := newTestRoomManager(t, conf, nil) + + _, err = rm.CreateRoom(context.Background(), &livekit.CreateRoomRequest{ + Name: "room-a", + RoomPreset: "record", + }) + require.ErrorIs(t, err, service.ErrEgressNotConnected) + }) +} + +const roomPresetConfig = ` +room: + room_configurations: + record: + egress: + room: + layout: grid + file_outputs: + - filepath: recordings/{room_name}.mp4 +` diff --git a/pkg/service/servicefakes/fake_room_allocator.go b/pkg/service/servicefakes/fake_room_allocator.go index b15ae5648..631ef1faf 100644 --- a/pkg/service/servicefakes/fake_room_allocator.go +++ b/pkg/service/servicefakes/fake_room_allocator.go @@ -21,7 +21,7 @@ type FakeRoomAllocator struct { autoCreateEnabledReturnsOnCall map[int]struct { result1 bool } - CreateRoomStub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, bool, error) + CreateRoomStub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) createRoomMutex sync.RWMutex createRoomArgsForCall []struct { arg1 context.Context @@ -31,14 +31,16 @@ type FakeRoomAllocator struct { createRoomReturns struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error } createRoomReturnsOnCall map[int]struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error } SelectRoomNodeStub func(context.Context, livekit.RoomName, livekit.NodeID) error selectRoomNodeMutex sync.RWMutex @@ -130,7 +132,7 @@ func (fake *FakeRoomAllocator) AutoCreateEnabledReturnsOnCall(i int, result1 boo }{result1} } -func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest, arg3 bool) (*livekit.Room, *livekit.RoomInternal, bool, error) { +func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.CreateRoomRequest, arg3 bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error) { fake.createRoomMutex.Lock() ret, specificReturn := fake.createRoomReturnsOnCall[len(fake.createRoomArgsForCall)] fake.createRoomArgsForCall = append(fake.createRoomArgsForCall, struct { @@ -146,9 +148,9 @@ func (fake *FakeRoomAllocator) CreateRoom(arg1 context.Context, arg2 *livekit.Cr return stub(arg1, arg2, arg3) } if specificReturn { - return ret.result1, ret.result2, ret.result3, ret.result4 + return ret.result1, ret.result2, ret.result3, ret.result4, ret.result5 } - return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4 + return fakeReturns.result1, fakeReturns.result2, fakeReturns.result3, fakeReturns.result4, fakeReturns.result5 } func (fake *FakeRoomAllocator) CreateRoomCallCount() int { @@ -157,7 +159,7 @@ func (fake *FakeRoomAllocator) CreateRoomCallCount() int { return len(fake.createRoomArgsForCall) } -func (fake *FakeRoomAllocator) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, bool, error)) { +func (fake *FakeRoomAllocator) CreateRoomCalls(stub func(context.Context, *livekit.CreateRoomRequest, bool) (*livekit.Room, *livekit.RoomInternal, *livekit.CreateRoomRequest, bool, error)) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = stub @@ -170,19 +172,20 @@ func (fake *FakeRoomAllocator) CreateRoomArgsForCall(i int) (context.Context, *l return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3 } -func (fake *FakeRoomAllocator) CreateRoomReturns(result1 *livekit.Room, result2 *livekit.RoomInternal, result3 bool, result4 error) { +func (fake *FakeRoomAllocator) CreateRoomReturns(result1 *livekit.Room, result2 *livekit.RoomInternal, result3 *livekit.CreateRoomRequest, result4 bool, result5 error) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = nil fake.createRoomReturns = struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error - }{result1, result2, result3, result4} + result3 *livekit.CreateRoomRequest + result4 bool + result5 error + }{result1, result2, result3, result4, result5} } -func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 *livekit.RoomInternal, result3 bool, result4 error) { +func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.Room, result2 *livekit.RoomInternal, result3 *livekit.CreateRoomRequest, result4 bool, result5 error) { fake.createRoomMutex.Lock() defer fake.createRoomMutex.Unlock() fake.CreateRoomStub = nil @@ -190,16 +193,18 @@ func (fake *FakeRoomAllocator) CreateRoomReturnsOnCall(i int, result1 *livekit.R fake.createRoomReturnsOnCall = make(map[int]struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error + result3 *livekit.CreateRoomRequest + result4 bool + result5 error }) } fake.createRoomReturnsOnCall[i] = struct { result1 *livekit.Room result2 *livekit.RoomInternal - result3 bool - result4 error - }{result1, result2, result3, result4} + result3 *livekit.CreateRoomRequest + result4 bool + result5 error + }{result1, result2, result3, result4, result5} } func (fake *FakeRoomAllocator) SelectRoomNode(arg1 context.Context, arg2 livekit.RoomName, arg3 livekit.NodeID) error { From 7ef0cd014e950373acd789557099305716eea196 Mon Sep 17 00:00:00 2001 From: Perfloop Agent Date: Wed, 30 Sep 2026 08:11:49 -0700 Subject: [PATCH 073/130] Reduce descriptor marshal allocations on Go 1.26.4/linux-amd64 (#4681) * Reduce descriptor marshal allocations on Go 1.26.4/linux-amd64 Signed-off-by: Perfloop Agent * Use require in the descriptor marshal benchmark test Signed-off-by: Perfloop Agent * sfu: marshal dependency descriptors into caller-owned scratch * sfu: reuse the selector's descriptor clone * sfu: share one inline header-extension size constant --------- Signed-off-by: Perfloop Agent --- pkg/sfu/downtrack.go | 40 +-- pkg/sfu/forwarder.go | 13 +- pkg/sfu/forwarder_test.go | 11 + pkg/sfu/pacer/base_test.go | 22 ++ pkg/sfu/pacer/pacer.go | 10 + .../dependencydescriptorextension.go | 46 +++- ...dencydescriptorextension_benchmark_test.go | 247 ++++++++++++++++++ .../dependencydescriptorwriter.go | 21 +- .../dependencydescriptor.go | 16 +- .../dependencydescriptor_test.go | 70 +++++ .../videolayerselector/videolayerselector.go | 37 ++- 11 files changed, 490 insertions(+), 43 deletions(-) create mode 100644 pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 1ff4ac7aa..e01c96748 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1079,9 +1079,28 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { SSRC: d.ssrc, }) + pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) + *pacerPacket = pacer.Packet{ + Header: hdr, + HeaderPool: RTPHeaderFactory, + Payload: payload, + ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), + AbsSendTimeExtID: uint8(d.absSendTimeExtID), + TransportWideExtID: uint8(d.transportWideExtID), + WriteStream: d.writeStream, + Pool: PacketFactory, + PoolEntity: poolEntity, + } + // add extensions - if d.dependencyDescriptorExtID != 0 && tp.ddBytes != nil { - hdr.SetExtension(uint8(d.dependencyDescriptorExtID), tp.ddBytes) + // + // the header refers to the descriptor until the pacer marshals it, so hold it in the pacer packet, which one send owns + ddBytes := tp.ddBytesSpill + if inline := tp.ddInline(); ddBytes == nil && len(inline) != 0 { + ddBytes = pacerPacket.HoldExtension(inline) + } + if d.dependencyDescriptorExtID != 0 && len(ddBytes) != 0 { + hdr.SetExtension(uint8(d.dependencyDescriptorExtID), ddBytes) } if d.playoutDelayExtID != 0 && d.playoutDelay != nil { if val := d.playoutDelay.GetDelayExtension(hdr.SequenceNumber); val != nil { @@ -1128,6 +1147,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { } d.addDummyExtensions(hdr) + // the sequencer copies the descriptor, which has to happen before Enqueue frees the scratch if d.sequencer != nil { d.sequencer.push( extPkt.Arrival, @@ -1138,13 +1158,14 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { int8(layer), payload[:len(codecBytes)], tp.incomingHeaderSize, - tp.ddBytes, + ddBytes, actBytes, trailerStripped, ) } headerSize := hdr.MarshalSize() + pacerPacket.HeaderSize = headerSize d.rtpStats.Update( extPkt.Arrival, tp.rtp.extSequenceNumber, @@ -1155,19 +1176,6 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { 0, extPkt.IsOutOfOrder, ) - pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) - *pacerPacket = pacer.Packet{ - Header: hdr, - HeaderPool: RTPHeaderFactory, - HeaderSize: headerSize, - Payload: payload, - ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), - AbsSendTimeExtID: uint8(d.absSendTimeExtID), - TransportWideExtID: uint8(d.transportWideExtID), - WriteStream: d.writeStream, - Pool: PacketFactory, - PoolEntity: poolEntity, - } d.pacer.Enqueue(pacerPacket) if extPkt.IsKeyFrame { diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 9c87b0653..fbdc28092 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -199,7 +199,9 @@ type TranslationParams struct { isResuming bool isSwitching bool rtp TranslationParamsRTP - ddBytes []byte + ddBytes [dd.MaxInlineExtensionSize]byte + ddBytesLen int + ddBytesSpill []byte incomingHeaderSize int codecBytes [codecmunger.MaxHeaderSize]byte codecBytesLen int @@ -213,6 +215,13 @@ func (tp *TranslationParams) codecHeader() []byte { return tp.codecBytes[:tp.codecBytesLen] } +// ddInline returns the dependency descriptor extension held inline. It points +// into tp, so it has to be copied before it is attached to anything that +// outlives the write. +func (tp *TranslationParams) ddInline() []byte { + return tp.ddBytes[:tp.ddBytesLen] +} + // ------------------------------------------------------------------- type refInfo struct { @@ -2190,7 +2199,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in } tp.isResuming = result.IsResuming tp.isSwitching = result.IsSwitching - tp.ddBytes = result.DependencyDescriptorExtension + tp.ddBytes, tp.ddBytesLen, tp.ddBytesSpill = result.DDBytes, result.DDBytesLen, result.DDBytesSpill tp.marker = result.RTPMarker tp.isEndOfLayerFrame = f.isEndOfLayerFrame(extPkt) diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index defec62db..931c79e7e 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -28,6 +28,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/codecmunger" + "github.com/livekit/livekit-server/pkg/sfu/pacer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/testutils" ) @@ -2261,3 +2262,13 @@ func codecHeaderOf(b []byte) [codecmunger.MaxHeaderSize]byte { copy(hdr[:], b) return hdr } + +// WriteRTP holds the dependency descriptor in pacer scratch sized to pion's one +// byte extension profile payload cap, which has to be the inline size +// TranslationParams carries, or descriptors would take the heap copy fallback +func TestPacerScratchFitsInlineDependencyDescriptor(t *testing.T) { + p := &pacer.Packet{} + + require.Len(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize)), dd.MaxInlineExtensionSize) + require.Nil(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize+1))) +} diff --git a/pkg/sfu/pacer/base_test.go b/pkg/sfu/pacer/base_test.go index 3842203a6..82599f049 100644 --- a/pkg/sfu/pacer/base_test.go +++ b/pkg/sfu/pacer/base_test.go @@ -15,6 +15,7 @@ package pacer import ( + "bytes" "sync" "testing" @@ -24,6 +25,7 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/livekit-server/pkg/sfu/bwe" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/utils" ) @@ -85,3 +87,23 @@ func TestSendPacketHeaderExtensionsNoAlloc(t *testing.T) { require.Equal(t, 0.0, allocs, "allocations per SendPacket") } } + +func TestHoldExtension(t *testing.T) { + p := &Packet{} + + ext := []byte{1, 2, 3} + held := p.HoldExtension(ext) + require.Equal(t, ext, held) + require.Same(t, &p.extBuf[0], &held[0], "the extension has to be held in the packet's scratch") + + ext[0] = 9 + require.EqualValues(t, 1, held[0], "the held copy has to be independent of the caller's slice") + + // exactly the one byte extension profile payload cap + exact := bytes.Repeat([]byte{7}, dd.MaxInlineExtensionSize) + held = p.HoldExtension(exact) + require.Equal(t, exact, held) + + // one byte more does not fit + require.Nil(t, p.HoldExtension(bytes.Repeat([]byte{7}, dd.MaxInlineExtensionSize+1))) +} diff --git a/pkg/sfu/pacer/pacer.go b/pkg/sfu/pacer/pacer.go index 199a8bf17..6b89b6d1f 100644 --- a/pkg/sfu/pacer/pacer.go +++ b/pkg/sfu/pacer/pacer.go @@ -19,6 +19,7 @@ import ( "time" "github.com/livekit/livekit-server/pkg/sfu/ccutils" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/pion/rtp" "github.com/pion/webrtc/v4" ) @@ -59,6 +60,15 @@ type Packet struct { // the Packet is owned by one send until SendPacket returns absSendTimeBuf [3]byte twccBuf [2]byte + extBuf [dd.MaxInlineExtensionSize]byte +} + +// HoldExtension copies ext into scratch the header can point at until SendPacket returns, nil if it does not fit +func (p *Packet) HoldExtension(ext []byte) []byte { + if len(ext) > len(p.extBuf) { + return nil + } + return p.extBuf[:copy(p.extBuf[:], ext)] } type Pacer interface { diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go index 293482396..28812d09a 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go @@ -15,6 +15,7 @@ package dependencydescriptor import ( + "errors" "fmt" "math" "strconv" @@ -23,6 +24,10 @@ import ( // DependencyDescriptorExtension is a extension payload format in // https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension +// ErrBufferTooSmall is returned by MarshalTo when the caller's storage cannot +// hold the marshaled extension. +var ErrBufferTooSmall = errors.New("dependency descriptor buffer too small") + func formatBitmask(b *uint32) string { if b == nil { return "-" @@ -42,18 +47,48 @@ func (d *DependencyDescriptorExtension) Marshal() ([]byte, error) { } func (d *DependencyDescriptorExtension) MarshalWithActiveChains(activeChains uint32) ([]byte, error) { - writer, err := NewDependencyDescriptorWriter(nil, d.Structure, activeChains, d.Descriptor) + writer, size, err := d.newWriter(activeChains) if err != nil { return nil, err } - buf := make([]byte, int(math.Ceil(float64(writer.ValueSizeBits())/8))) - writer.ResetBuf(buf) - if err = writer.Write(); err != nil { + + buf := make([]byte, size) + writer = writer.withBuf(buf) + if err := writer.Write(); err != nil { return nil, err } return buf, nil } +// MarshalTo marshals into caller owned storage and returns the number of bytes +// written, or ErrBufferTooSmall, leaving buf untouched, if the extension does not fit. +func (d *DependencyDescriptorExtension) MarshalTo(buf []byte) (int, error) { + writer, size, err := d.newWriter(^uint32(0)) + if err != nil { + return 0, err + } + if len(buf) < size { + // bare sentinel, key frames take this path and formatting an error there would allocate + return 0, ErrBufferTooSmall + } + + writer = writer.withBuf(buf[:size]) + if err := writer.Write(); err != nil { + return 0, err + } + return size, nil +} + +// newWriter returns a writer for the extension, by value so that it stays on the +// caller's stack, along with the number of bytes the marshaled extension needs. +func (d *DependencyDescriptorExtension) newWriter(activeChains uint32) (DependencyDescriptorWriter, int, error) { + writer := newDependencyDescriptorWriter(nil, d.Structure, activeChains, d.Descriptor) + if err := writer.findBestTemplate(); err != nil { + return writer, 0, err + } + return writer, int(math.Ceil(float64(writer.ValueSizeBits()) / 8)), nil +} + func (d *DependencyDescriptorExtension) Unmarshal(buf []byte) (int, error) { reader := NewDependencyDescriptorReader(buf, d.Structure, d.Descriptor) return reader.Parse() @@ -67,6 +102,9 @@ const ( MaxDecodeTargets = 32 MaxTemplates = 64 + // MaxInlineExtensionSize is the inline storage the forwarding path and the pacer reserve for a marshaled extension, pion's one byte profile cap + MaxInlineExtensionSize = 16 + AllChainsAreActive = uint32(0) ExtensionURI = "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension" diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.go new file mode 100644 index 000000000..6e9509c74 --- /dev/null +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_benchmark_test.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. + +package dependencydescriptor + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/utils" +) + +// dependencyDescriptorMarshalFixture is a dependency descriptor captured from +// traffic. The fixture helper decodes its attached structure once, then returns +// a regular per-packet descriptor that references that structure. +const dependencyDescriptorMarshalFixture = "c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d" + +func newDependencyDescriptorMarshalFixture(tb testing.TB) *DependencyDescriptorExtension { + tb.Helper() + + buf, err := hex.DecodeString(dependencyDescriptorMarshalFixture) + require.NoError(tb, err) + + descriptor := DependencyDescriptor{} + parser := DependencyDescriptorExtension{Descriptor: &descriptor} + _, err = parser.Unmarshal(buf) + require.NoError(tb, err) + require.NotNil(tb, descriptor.AttachedStructure, "fixture did not contain a dependency structure") + + structure := descriptor.AttachedStructure + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + + return &DependencyDescriptorExtension{ + Descriptor: &descriptor, + Structure: structure, + } +} + +func checkDependencyDescriptorMarshal(tb testing.TB, buf []byte, structure *FrameDependencyStructure, frameNumber uint16) { + tb.Helper() + + require.NotEmpty(tb, buf, "marshal returned an empty dependency descriptor") + + decoded := DependencyDescriptor{} + parser := DependencyDescriptorExtension{ + Descriptor: &decoded, + Structure: structure, + } + _, err := parser.Unmarshal(buf) + require.NoError(tb, err) + require.Equal(tb, frameNumber, decoded.FrameNumber) +} + +func TestDependencyDescriptorMarshalRoundTrip(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + first, err := extension.Marshal() + require.NoError(t, err) + firstCopy := append([]byte(nil), first...) + checkDependencyDescriptorMarshal(t, first, extension.Structure, extension.Descriptor.FrameNumber) + + extension.Descriptor.FrameNumber++ + second, err := extension.Marshal() + require.NoError(t, err) + checkDependencyDescriptorMarshal(t, second, extension.Structure, extension.Descriptor.FrameNumber) + + require.Equal(t, firstCopy, first, "a later marshal modified a previously returned buffer") + require.NotEqual(t, first, second, "different frame numbers produced identical descriptors") +} + +func TestDependencyDescriptorMarshalTo(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + // every template of the fixture structure, i. e. the per-packet descriptors + // the forwarding path marshals, has to match Marshal byte for byte and fit + // the inline size the forwarding path reserves + for i, template := range extension.Structure.Templates { + extension.Descriptor.FrameDependencies = template + extension.Descriptor.FrameNumber = uint16(i) + + want, err := extension.Marshal() + require.NoError(t, err) + require.LessOrEqual(t, len(want), MaxInlineExtensionSize) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + checkDependencyDescriptorMarshal(t, scratch[:n], extension.Structure, uint16(i)) + } +} + +func TestDependencyDescriptorMarshalToBufferTooSmall(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + + want, err := extension.Marshal() + require.NoError(t, err) + + _, err = extension.MarshalTo(make([]byte, len(want)-1)) + require.ErrorIs(t, err, ErrBufferTooSmall) + + n, err := extension.MarshalTo(make([]byte, len(want))) + require.NoError(t, err) + require.Equal(t, len(want), n) + + // a descriptor carrying the full dependency structure does not fit inline + extension.Descriptor.AttachedStructure = extension.Structure + _, err = extension.MarshalTo(make([]byte, MaxInlineExtensionSize)) + require.ErrorIs(t, err, ErrBufferTooSmall) + + keyFrame, err := extension.Marshal() + require.NoError(t, err) + require.Greater(t, len(keyFrame), MaxInlineExtensionSize) +} + +// TestDependencyDescriptorMarshalToLargerDescriptors covers the 8 to 16 byte +// band, where the active decode targets bitmask and custom frame dependencies +// land and where the sequencer's own inline copy already spills. +func TestDependencyDescriptorMarshalToLargerDescriptors(t *testing.T) { + extension := newDependencyDescriptorMarshalFixture(t) + bitmask := uint32(0x1ff) + + // the widest per-packet descriptor of the fixture, with the bitmask attached + widest := 0 + for _, template := range extension.Structure.Templates { + extension.Descriptor.FrameDependencies = template + extension.Descriptor.ActiveDecodeTargetsBitmask = &bitmask + + want, err := extension.Marshal() + require.NoError(t, err) + widest = max(widest, len(want)) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + } + require.GreaterOrEqual(t, widest, 8) + + // custom frame diffs and chain diffs, i. e. a frame that matches no template + custom := extension.Structure.Templates[0].Clone() + custom.FrameDiffs = []int{1, 300, 4000} + for i := range custom.ChainDiffs { + custom.ChainDiffs[i] = 200 + i + } + extension.Descriptor.FrameDependencies = custom + extension.Descriptor.ActiveDecodeTargetsBitmask = nil + + want, err := extension.Marshal() + require.NoError(t, err) + require.GreaterOrEqual(t, len(want), 8) + require.LessOrEqual(t, len(want), MaxInlineExtensionSize) + + var scratch [MaxInlineExtensionSize]byte + n, err := extension.MarshalTo(scratch[:]) + require.NoError(t, err) + require.Equal(t, want, scratch[:n]) + checkDependencyDescriptorMarshal(t, scratch[:n], extension.Structure, extension.Descriptor.FrameNumber) +} + +func TestDependencyDescriptorMarshalAllocs(t *testing.T) { + if utils.RaceEnabled { + // the race detector perturbs allocation counts, see #4874 + t.Skip("allocation count is not meaningful under the race detector") + } + + extension := newDependencyDescriptorMarshalFixture(t) + + var ( + scratch [MaxInlineExtensionSize]byte + n int + err error + ) + allocs := testing.AllocsPerRun(100, func() { + n, err = extension.MarshalTo(scratch[:]) + }) + require.NoError(t, err) + require.NotZero(t, n) + require.Zero(t, allocs, "a per-packet descriptor has to marshal without allocating") + + // a key frame descriptor carries the full dependency structure, does not fit + // the inline scratch and spills to exactly one allocation, the Marshal slice + extension.Descriptor.AttachedStructure = extension.Structure + + var buf []byte + allocs = testing.AllocsPerRun(100, func() { + if n, err = extension.MarshalTo(scratch[:]); err != nil { + buf, err = extension.Marshal() + } + }) + require.NoError(t, err) + require.Greater(t, len(buf), MaxInlineExtensionSize) + require.Equal(t, float64(1), allocs, "the spill path has to allocate only the Marshal slice") +} + +func BenchmarkDependencyDescriptorMarshal(b *testing.B) { + extension := newDependencyDescriptorMarshalFixture(b) + frameNumber := extension.Descriptor.FrameNumber + + b.ReportAllocs() + var buf []byte + for b.Loop() { + extension.Descriptor.FrameNumber = frameNumber + frameNumber++ + + var err error + buf, err = extension.Marshal() + require.NoError(b, err) + } + + checkDependencyDescriptorMarshal(b, buf, extension.Structure, frameNumber-1) +} + +func BenchmarkDependencyDescriptorMarshalTo(b *testing.B) { + extension := newDependencyDescriptorMarshalFixture(b) + frameNumber := extension.Descriptor.FrameNumber + + b.ReportAllocs() + var ( + scratch [MaxInlineExtensionSize]byte + n int + ) + for b.Loop() { + extension.Descriptor.FrameNumber = frameNumber + frameNumber++ + + var err error + n, err = extension.MarshalTo(scratch[:]) + require.NoError(b, err) + } + + checkDependencyDescriptorMarshal(b, scratch[:n], extension.Structure, frameNumber-1) +} diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go index 3c6ba720d..fac78f036 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorwriter.go @@ -33,23 +33,28 @@ type DependencyDescriptorWriter struct { descriptor *DependencyDescriptor structure *FrameDependencyStructure activeChains uint32 - writer *BitStreamWriter + writer BitStreamWriter bestTemplate TemplateMatch } -func NewDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) (*DependencyDescriptorWriter, error) { - writer := NewBitStreamWriter(buf) - w := &DependencyDescriptorWriter{ +func newDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) DependencyDescriptorWriter { + return DependencyDescriptorWriter{ descriptor: descriptor, structure: structure, activeChains: activeChains, - writer: writer, + writer: BitStreamWriter{buf: buf}, } - return w, w.findBestTemplate() } -func (w *DependencyDescriptorWriter) ResetBuf(buf []byte) { - w.writer = NewBitStreamWriter(buf) +func NewDependencyDescriptorWriter(buf []byte, structure *FrameDependencyStructure, activeChains uint32, descriptor *DependencyDescriptor) (*DependencyDescriptorWriter, error) { + w := newDependencyDescriptorWriter(buf, structure, activeChains, descriptor) + return &w, w.findBestTemplate() +} + +// withBuf returns a copy of the writer that writes into buf, value receiver so a caller owned buffer does not escape +func (w DependencyDescriptorWriter) withBuf(buf []byte) DependencyDescriptorWriter { + w.writer = BitStreamWriter{buf: buf} + return w } func (w *DependencyDescriptorWriter) Write() error { diff --git a/pkg/sfu/videolayerselector/dependencydescriptor.go b/pkg/sfu/videolayerselector/dependencydescriptor.go index 3e2e090a2..2909e1da5 100644 --- a/pkg/sfu/videolayerselector/dependencydescriptor.go +++ b/pkg/sfu/videolayerselector/dependencydescriptor.go @@ -46,6 +46,10 @@ type DependencyDescriptor struct { decodeTargets []*DecodeTarget fnWrapper FrameNumberWrapper + // per packet copy of the descriptor when the frame number or the active + // decode targets are rewritten, owned here so it does not allocate + ddClone dede.DependencyDescriptor + restartGeneration int } @@ -322,8 +326,8 @@ func (d *DependencyDescriptor) Select(extPkt *buffer.ExtPacket, _layer int32) (r unWrapFn := uint16(d.fnWrapper.UpdateAndGet(extFrameNum, ddwdt.StructureUpdated)) var ddClone *dede.DependencyDescriptor if unWrapFn != dd.FrameNumber { - clone := *dd - ddClone = &clone + d.ddClone = *dd + ddClone = &d.ddClone ddClone.FrameNumber = unWrapFn ddExtension.Descriptor = ddClone } @@ -333,8 +337,8 @@ func (d *DependencyDescriptor) Select(extPkt *buffer.ExtPacket, _layer int32) (r if ddClone == nil { // clone and override activebitmask // DD-TODO: if the packet that contains the bitmask is acknowledged by RR, then we don't need it until it changed. - clone := *dd - ddClone = &clone + d.ddClone = *dd + ddClone = &d.ddClone ddExtension.Descriptor = ddClone } ddClone.ActiveDecodeTargetsBitmask = d.activeDecodeTargetsBitmask @@ -355,11 +359,9 @@ func (d *DependencyDescriptor) Select(extPkt *buffer.ExtPacket, _layer int32) (r "stack", string(debug.Stack())) } }() - bytes, err := ddExtension.Marshal() - if err != nil { + if err := result.marshalDependencyDescriptorExtension(ddExtension); err != nil { d.logger.Warnw("error marshalling dependency descriptor extension", err) } else { - result.DependencyDescriptorExtension = bytes ddMarshaled = true } }() diff --git a/pkg/sfu/videolayerselector/dependencydescriptor_test.go b/pkg/sfu/videolayerselector/dependencydescriptor_test.go index 925c017dd..b5bfc4d36 100644 --- a/pkg/sfu/videolayerselector/dependencydescriptor_test.go +++ b/pkg/sfu/videolayerselector/dependencydescriptor_test.go @@ -15,6 +15,7 @@ package videolayerselector import ( + "encoding/hex" "slices" "testing" @@ -23,6 +24,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/livekit-server/pkg/sfu/utils" "github.com/livekit/protocol/logger" ) @@ -409,3 +411,71 @@ func createDDFrames(maxLayer buffer.VideoLayer, startFrameNumber uint16) []*buff return frames } + +// same capture as the dependency descriptor package's marshal fixture, an L3T3 +// key frame descriptor with the full dependency structure attached +const dependencyDescriptorFixture = "c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d" + +func TestVideoLayerSelectorResultDependencyDescriptor(t *testing.T) { + raw, err := hex.DecodeString(dependencyDescriptorFixture) + require.NoError(t, err) + + descriptor := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &descriptor}).Unmarshal(raw) + require.NoError(t, err) + require.NotNil(t, descriptor.AttachedStructure) + structure := descriptor.AttachedStructure + + // a key frame descriptor carries the full structure and spills to the heap + keyFrame := VideoLayerSelectorResult{} + require.NoError(t, keyFrame.marshalDependencyDescriptorExtension(&dd.DependencyDescriptorExtension{ + Descriptor: &descriptor, + Structure: structure, + })) + require.Zero(t, keyFrame.DDBytesLen) + require.Greater(t, len(keyFrame.DDBytesSpill), dd.MaxInlineExtensionSize) + + // a per-packet descriptor is held inline, byte for byte what Marshal returns + perPacket := descriptor + perPacket.AttachedStructure = nil + perPacket.ActiveDecodeTargetsBitmask = nil + ddExtension := &dd.DependencyDescriptorExtension{Descriptor: &perPacket, Structure: structure} + + result := VideoLayerSelectorResult{} + require.NoError(t, result.marshalDependencyDescriptorExtension(ddExtension)) + require.Nil(t, result.DDBytesSpill) + require.NotZero(t, result.DDBytesLen) + require.LessOrEqual(t, result.DDBytesLen, dd.MaxInlineExtensionSize) + + want, err := ddExtension.Marshal() + require.NoError(t, err) + require.Equal(t, want, result.DDBytes[:result.DDBytesLen]) +} + +func TestDependencyDescriptorSelectAllocations(t *testing.T) { + for _, rewriteFrameNumber := range []bool{false, true} { + selector := NewDependencyDescriptor(logger.GetLogger()) + selector.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + selector.SetRequestSpatial(0) + frames := createDDFrames(buffer.VideoLayer{Spatial: 0, Temporal: 0}, 100) + require.True(t, selector.Select(frames[0], 0).IsSelected) + require.NotNil(t, selector.activeDecodeTargetsBitmask) + if rewriteFrameNumber { + selector.fnWrapper.offset = 6000 + } + packet := frames[1] + packet.DependencyDescriptor.ExtFrameNum-- + packet.DependencyDescriptor.Descriptor.FrameNumber-- + + selected := true + allocs := testing.AllocsPerRun(1000, func() { + packet.DependencyDescriptor.ExtFrameNum++ + packet.DependencyDescriptor.Descriptor.FrameNumber++ + selected = selected && selector.Select(packet, 0).IsSelected + }) + require.True(t, selected) + if !utils.RaceEnabled { + require.Equal(t, 0.0, allocs, "allocations per selected packet, rewriteFrameNumber=%v", rewriteFrameNumber) + } + } +} diff --git a/pkg/sfu/videolayerselector/videolayerselector.go b/pkg/sfu/videolayerselector/videolayerselector.go index 72a6e2c81..795a058e4 100644 --- a/pkg/sfu/videolayerselector/videolayerselector.go +++ b/pkg/sfu/videolayerselector/videolayerselector.go @@ -15,18 +15,43 @@ package videolayerselector import ( + "errors" + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/videolayerselector/temporallayerselector" "github.com/livekit/protocol/logger" ) type VideoLayerSelectorResult struct { - IsSelected bool - IsRelevant bool - IsSwitching bool - IsResuming bool - RTPMarker bool - DependencyDescriptorExtension []byte + IsSelected bool + IsRelevant bool + IsSwitching bool + IsResuming bool + RTPMarker bool + + // marshaled dependency descriptor extension, held inline when it fits and + // spilled to the heap when it does not, the way the sequencer keeps its copy + DDBytes [dd.MaxInlineExtensionSize]byte + DDBytesLen int + DDBytesSpill []byte +} + +// marshalDependencyDescriptorExtension marshals ddExtension into the result, +// inline if it fits and on the heap otherwise. +func (v *VideoLayerSelectorResult) marshalDependencyDescriptorExtension(ddExtension *dd.DependencyDescriptorExtension) error { + ddBytesLen, err := ddExtension.MarshalTo(v.DDBytes[:]) + if err == nil { + v.DDBytesLen = ddBytesLen + return nil + } + if !errors.Is(err, dd.ErrBufferTooSmall) { + return err + } + + // descriptors carrying the full dependency structure do not fit inline + v.DDBytesSpill, err = ddExtension.Marshal() + return err } type VideoLayerSelector interface { From edb19c9e8f1daf1f752850b393aa96eee8f5fb10 Mon Sep 17 00:00:00 2001 From: He Chen Date: Thu, 1 Oct 2026 11:06:35 -0700 Subject: [PATCH 074/130] fix: reject instead of drop for no dispatch rule (#4927) --- pkg/service/ioservice_sip.go | 2 +- pkg/service/ioservice_sip_test.go | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pkg/service/ioservice_sip.go b/pkg/service/ioservice_sip.go index 386baef4f..43124d8da 100644 --- a/pkg/service/ioservice_sip.go +++ b/pkg/service/ioservice_sip.go @@ -113,7 +113,7 @@ func (s *IOInfoService) EvaluateSIPDispatchRules(ctx context.Context, req *rpc.E if e := (*sip.ErrNoDispatchMatched)(nil); errors.As(err, &e) { return &rpc.EvaluateSIPDispatchRulesResponse{ SipTrunkId: trunkID, - Result: rpc.SIPDispatchResult_DROP, + Result: rpc.SIPDispatchResult_REJECT, }, nil } return nil, err diff --git a/pkg/service/ioservice_sip_test.go b/pkg/service/ioservice_sip_test.go index 3c34ccbc8..0cb585a56 100644 --- a/pkg/service/ioservice_sip_test.go +++ b/pkg/service/ioservice_sip_test.go @@ -24,6 +24,7 @@ import ( "github.com/livekit/livekit-server/pkg/service" "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" "github.com/livekit/psrpc/pkg/bus/redisbus" ) @@ -121,3 +122,18 @@ func TestSIPRuleSelect(t *testing.T) { }) } } + +func TestSIPDispatchNoRuleRejects(t *testing.T) { + ctx := context.Background() + s, rs := ioStoreDocker(t) + require.NoError(t, rs.StoreSIPInboundTrunk(ctx, &livekit.SIPInboundTrunkInfo{SipTrunkId: "A", Numbers: []string{"+1000"}})) + + for number, trunk := range map[string]string{"+1000": "A", "+2000": ""} { + resp, err := s.EvaluateSIPDispatchRules(ctx, &rpc.EvaluateSIPDispatchRulesRequest{ + Call: &rpc.SIPCall{SourceIp: "1.1.1.1", From: &livekit.SIPUri{User: "+3000"}, To: &livekit.SIPUri{User: number}}, + }) + require.NoError(t, err) + require.Equal(t, rpc.SIPDispatchResult_REJECT, resp.Result, number) + require.Equal(t, trunk, resp.SipTrunkId) + } +} From c2339b2b8561c9b16047d2b520dcd125c9e6f1f3 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 4 Oct 2026 21:20:28 +0530 Subject: [PATCH 075/130] fix: skip RTX/FEC when picking a fallback video codec (#4930) selectAlternativeVideoCodec walked the enabled codecs and returned the first one with a video/ prefix. video/rtx and video/flexfec-03 both have that prefix, so when the requested codec was not enabled the track could fall back to RTX. RTX is a retransmission stream, not a real codec, so the track got registered as video/rtx and subscribers saw a blank tile. Skip RTX and FEC entries so the fallback is always a valid video codec. Co-authored-by: Claude Opus 4.8 --- pkg/rtc/mediaengine.go | 4 ++++ pkg/rtc/mediaengine_test.go | 17 +++++++++++++++ pkg/rtc/participant_internal_test.go | 32 ++++++++++++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/pkg/rtc/mediaengine.go b/pkg/rtc/mediaengine.go index 8f44593f7..3ac28b43f 100644 --- a/pkg/rtc/mediaengine.go +++ b/pkg/rtc/mediaengine.go @@ -162,6 +162,10 @@ func isCodecEnabledWithFmtp(codecs []*livekit.Codec, cap webrtc.RTPCodecCapabili func selectAlternativeVideoCodec(enabledCodecs []*livekit.Codec) string { for _, c := range enabledCodecs { + // RTX and FEC are not real codecs, skip them + if mime.IsMimeTypeStringRTX(c.Mime) || mime.IsMimeTypeStringFlexFEC03(c.Mime) { + continue + } if mime.IsMimeTypeStringVideo(c.Mime) { return c.Mime } diff --git a/pkg/rtc/mediaengine_test.go b/pkg/rtc/mediaengine_test.go index 0b7424394..b4648112b 100644 --- a/pkg/rtc/mediaengine_test.go +++ b/pkg/rtc/mediaengine_test.go @@ -73,6 +73,23 @@ func TestIsCodecEnabled(t *testing.T) { }) } +func TestSelectAlternativeVideoCodec(t *testing.T) { + // RTX and FEC have the video/ prefix but are not real codecs. The fallback must skip them. + t.Run("skips rtx and fec, picks real codec", func(t *testing.T) { + enabled := []*livekit.Codec{ + {Mime: mime.MimeTypeRTX.String()}, + {Mime: mime.MimeTypeFlexFEC03.String()}, + {Mime: mime.MimeTypeH264.String()}, + } + require.Equal(t, mime.MimeTypeH264.String(), selectAlternativeVideoCodec(enabled)) + }) + + t.Run("falls back to vp8 when only rtx is enabled", func(t *testing.T) { + enabled := []*livekit.Codec{{Mime: mime.MimeTypeRTX.String()}} + require.Equal(t, mime.MimeTypeVP8.String(), selectAlternativeVideoCodec(enabled)) + }) +} + type offeredCodec struct { payloadType webrtc.PayloadType name string diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index c2167eb53..5827d3f9f 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -79,6 +79,38 @@ func TestIsReady(t *testing.T) { } } +// Disabling a codec can leave the publish list with only RTX and FEC. The +// fallback must still pick a real codec, or the track registers as RTX and +// subscribers see a blank tile. +func TestPublishCodecFallbackSkipsRTX(t *testing.T) { + h264 := &livekit.Codec{Mime: mime.MimeTypeH264.String()} + rtx := &livekit.Codec{Mime: mime.MimeTypeRTX.String()} + + newParticipant := func() *ParticipantImpl { + return &ParticipantImpl{params: ParticipantParams{Logger: logger.GetLogger()}} + } + + t.Run("h264 enabled, rtx is last and not chosen", func(t *testing.T) { + p := newParticipant() + p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, nil) + + require.Equal(t, mime.MimeTypeH264.String(), selectAlternativeVideoCodec(p.enabledPublishCodecs)) + }) + + t.Run("h264 disabled leaves rtx only, fallback avoids rtx", func(t *testing.T) { + p := newParticipant() + disabled := &livekit.DisabledCodecs{Publish: []*livekit.Codec{{Mime: mime.MimeTypeH264.String()}}} + p.setupEnabledCodecs([]*livekit.Codec{h264, rtx}, nil, disabled) + + require.Len(t, p.enabledPublishCodecs, 1) + require.True(t, mime.IsMimeTypeStringRTX(p.enabledPublishCodecs[0].Mime)) + + alt := selectAlternativeVideoCodec(p.enabledPublishCodecs) + require.False(t, mime.IsMimeTypeStringRTX(alt), "fallback must not be rtx, got %q", alt) + require.Equal(t, mime.MimeTypeVP8.String(), alt) + }) +} + func TestSupportsMoving(t *testing.T) { t.Run("current protocol version", func(t *testing.T) { p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: types.CurrentProtocol}) From 44b791ad507d9814ee816f550df407b348d38b4b Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 4 Oct 2026 23:54:14 +0530 Subject: [PATCH 076/130] test: give mock participants a connection quality, close test rooms (#4933) * test: give mock participants a connection quality A real participant never returns a nil connection quality. The mock did, which can crash code that does not expect nil. Co-Authored-By: Claude Opus 5.5 * test: close test rooms when each test ends Most room tests left their rooms open, so each room's workers kept running for the rest of the package. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/rtc/room_test.go | 1 + pkg/rtc/testutils.go | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pkg/rtc/room_test.go b/pkg/rtc/room_test.go index 7a565aba5..3c828ed6b 100644 --- a/pkg/rtc/room_test.go +++ b/pkg/rtc/room_test.go @@ -906,6 +906,7 @@ func newRoomWithParticipants(t *testing.T, opts testRoomOpts) *Room { telemetry.NewTelemetryService(n, &telemetryfakes.FakeAnalyticsService{}), nil, nil, nil, ) + t.Cleanup(func() { rm.Close(types.RoomCloseReasonUnknown) }) for i := 0; i < opts.num+opts.numHidden; i++ { identity := livekit.ParticipantIdentity(fmt.Sprintf("p%d", i)) participant := NewMockParticipant(identity, opts.protocol, i >= opts.num, true, rm.LocalParticipantListener()) diff --git a/pkg/rtc/testutils.go b/pkg/rtc/testutils.go index 085582113..253707c9c 100644 --- a/pkg/rtc/testutils.go +++ b/pkg/rtc/testutils.go @@ -23,6 +23,7 @@ import ( "github.com/livekit/livekit-server/pkg/rtc/types" "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/livekit-server/pkg/sfu/connectionquality" ) func NewMockParticipant( @@ -54,6 +55,11 @@ func NewMockParticipant( State: livekit.ParticipantInfo_JOINED, IsPublisher: publisher, }, utils.TimedVersion(0)) + p.GetConnectionQualityReturns(&livekit.ConnectionQualityInfo{ + ParticipantSid: sid, + Quality: livekit.ConnectionQuality_EXCELLENT, + Score: connectionquality.MaxMOS, + }) p.SetMetadataCalls(func(m string) { participantListener.OnParticipantUpdate(p) From 4c3f8432f55fffabafcee4122b19a84ad462f173 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 5 Oct 2026 14:53:33 +0530 Subject: [PATCH 077/130] fix: do not read invalid delta (#4935) * fix: do not read invalid delta In TWCC feedback, only the small delta and large delta symbols carry a receive delta. The send side BWE loop used a delta for every received symbol, so the reserved symbol read a delta that does not exist. Skip that symbol and do not read past the last delta. Co-Authored-By: Claude Opus 5.5 * test: assert loss count and delta use for the reserved symbol Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../bwe/sendsidebwe/congestion_detector.go | 8 +- .../sendsidebwe/congestion_detector_test.go | 107 ++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go diff --git a/pkg/sfu/bwe/sendsidebwe/congestion_detector.go b/pkg/sfu/bwe/sendsidebwe/congestion_detector.go index 720d4adc2..868d63555 100644 --- a/pkg/sfu/bwe/sendsidebwe/congestion_detector.go +++ b/pkg/sfu/bwe/sendsidebwe/congestion_detector.go @@ -689,13 +689,17 @@ func (c *congestionDetector) HandleTWCCFeedback(report *rtcp.TransportLayerCC) { processSymbol := func(symbol uint16) { recvTime := int64(0) isLost := false - if symbol != rtcp.TypeTCCPacketNotReceived { + if symbol == rtcp.TypeTCCPacketNotReceived { + isLost = true + } else if (symbol == rtcp.TypeTCCPacketReceivedSmallDelta || symbol == rtcp.TypeTCCPacketReceivedLargeDelta) && deltaIdx < len(report.RecvDeltas) { recvRefTime += report.RecvDeltas[deltaIdx].Delta deltaIdx++ recvTime = recvRefTime } else { - isLost = true + // the reserved symbol carries no receive delta, nothing to record + sequenceNumber++ + return } pi, sendDelta, recvDelta := c.packetTracker.RecordPacketIndicationFromRemote(sequenceNumber, recvTime) if pi.sendTime != 0 { diff --git a/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go b/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go new file mode 100644 index 000000000..1d36a144d --- /dev/null +++ b/pkg/sfu/bwe/sendsidebwe/congestion_detector_test.go @@ -0,0 +1,107 @@ +// 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 sendsidebwe + +import ( + "testing" + + "github.com/pion/rtcp" + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/mono" + + "github.com/livekit/livekit-server/pkg/sfu/ccutils" +) + +// the reserved symbol has no receive delta, so its packet is neither acked nor lost, +// and the next received packet uses the next delta +func TestHandleTWCCFeedbackReservedSymbol(t *testing.T) { + // small delta, reserved, reserved, small delta, not received + for name, chunks := range map[string][]rtcp.PacketStatusChunk{ + "status vector": { + &rtcp.StatusVectorChunk{ + SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit, + SymbolList: []uint16{ + rtcp.TypeTCCPacketReceivedSmallDelta, + rtcp.TypeTCCPacketReceivedWithoutDelta, + rtcp.TypeTCCPacketReceivedWithoutDelta, + rtcp.TypeTCCPacketReceivedSmallDelta, + rtcp.TypeTCCPacketNotReceived, + rtcp.TypeTCCPacketNotReceived, + rtcp.TypeTCCPacketNotReceived, + }, + }, + }, + "run length": { + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedWithoutDelta, RunLength: 2}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta, RunLength: 1}, + &rtcp.RunLengthChunk{PacketStatusSymbol: rtcp.TypeTCCPacketNotReceived, RunLength: 1}, + }, + } { + t.Run(name, func(t *testing.T) { + c := newCongestionDetector(congestionDetectorParams{ + Config: defaultCongestionDetectorConfig, + Logger: logger.GetLogger(), + }) + // the first packet sent is the send time base and never joins a group, so keep it out of the report + now := mono.UnixMicro() + c.RecordPacketSendAndGetSequenceNumber(now, 1200, false, ccutils.ProbeClusterIdInvalid, false) + sns := make([]uint16, 5) + for i := range sns { + sns[i] = c.RecordPacketSendAndGetSequenceNumber(now+int64(i+1)*1000, 1200, false, ccutils.ProbeClusterIdInvalid, false) + } + + fb := &rtcp.TransportLayerCC{ + Header: rtcp.Header{ + Count: rtcp.FormatTCC, + Type: rtcp.TypeTransportSpecificFeedback, + }, + MediaSSRC: 1, + BaseSequenceNumber: sns[0], + PacketStatusCount: uint16(len(sns)), + ReferenceTime: 100, + FbPktCount: 1, + PacketChunks: chunks, + RecvDeltas: []*rtcp.RecvDelta{ + {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 1000}, + {Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 2000}, + }, + } + fb.Header.Padding = fb.MarshalSize()%4 != 0 + fb.Header.Length = uint16(fb.MarshalSize()/4 - 1) + + // round trip so the report is exactly what a remote peer can deliver + raw, err := fb.Marshal() + require.NoError(t, err) + pkts, err := rtcp.Unmarshal(raw) + require.NoError(t, err) + + require.NotPanics(t, func() { c.HandleTWCCFeedback(pkts[0].(*rtcp.TransportLayerCC)) }) + + // receive time is relative to the first received packet + require.Equal(t, int64(2000), c.packetTracker.getPacketInfoExisting(sns[3]).recvTime) + + acked, lost := 0, 0 + for _, pg := range c.packetGroups { + acked += pg.acked.numPackets() + lost += pg.lost.numPackets() + } + require.Equal(t, 2, acked) + require.Equal(t, 1, lost) + }) + } +} From af2959d2f8d617bdbfdf092c4f35ffd94c16bd38 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 5 Oct 2026 15:11:42 +0530 Subject: [PATCH 078/130] fix: use the fast debounce for the first negotiation (#4936) * fix: use the fast debounce for the first negotiation A new transport set lastNegotiate to its creation time. A non-forced Negotiate soon after creation then waited for the 150 ms debounce instead of 10 ms, although nothing was negotiated yet. Start from the zero time so that the first offer uses the fast debounce. Co-Authored-By: Claude Opus 5.5 * test: check the first negotiation condition without a deadline Co-Authored-By: Claude Opus 5.5 * test: check that the first offer goes out before a debounced one Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/rtc/transport.go | 1 - pkg/rtc/transport_test.go | 44 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 4baf2e6e0..dd27aa4b9 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -566,7 +566,6 @@ func NewPCTransport(params TransportParams) (*PCTransport, error) { previousTrackDescription: make(map[string]*trackDescription), canReuseTransceiver: true, connectionDetails: types.NewICEConnectionDetails(params.Transport, params.Logger), - lastNegotiate: time.Now(), } t.localOfferId.Store(uint32(rand.Intn(1<<8) + 1)) diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index b586d16e6..3eeef2d5c 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -207,6 +207,50 @@ func TestNegotiationTiming(t *testing.T) { transportB.Close() } +func TestFirstNegotiationUsesFastDebounce(t *testing.T) { + offered := make(chan string, 2) + newOfferer := func(name string) *PCTransport { + handler := &transportfakes.FakeHandler{} + handler.OnOfferCalls(func(webrtc.SessionDescription, uint32, map[string]string) error { + select { + case offered <- name: + default: + } + return nil + }) + transport, err := NewPCTransport(TransportParams{ + Config: &WebRTCConfig{}, + IsOfferer: true, + Handler: handler, + }) + require.NoError(t, err) + t.Cleanup(transport.Close) + _, err = transport.pc.CreateDataChannel(ReliableDataChannel, nil) + require.NoError(t, err) + return transport + } + + // a transport that negotiated just now waits for the full debounce + recent := newOfferer("recent") + recent.lock.Lock() + recent.updateLastNegotiateLocked() + recent.lock.Unlock() + recent.Negotiate(false) + + // a new transport starts later, but its first offer uses the fast debounce and goes out first + time.Sleep(20 * time.Millisecond) + newOfferer("fresh").Negotiate(false) + + for _, want := range []string{"fresh", "recent"} { + select { + case got := <-offered: + require.Equal(t, want, got) + case <-time.After(5 * time.Second): + t.Fatalf("no offer from %s", want) + } + } +} + func TestFirstOfferMissedDuringICERestart(t *testing.T) { params := TransportParams{ Config: &WebRTCConfig{}, From d2d89011e251192d6132ac826c696ab765211744 Mon Sep 17 00:00:00 2001 From: Martin Njalale Date: Mon, 5 Oct 2026 14:18:16 +0300 Subject: [PATCH 079/130] config: parse generated duration flags as durations (#4939) GenerateCLIFlags created an Int64Flag for every time.Duration config field. Setting one through its LIVEKIT_* env var or --flag with a duration string such as "30s" (the format the YAML config and the generated help text use) made the server exit at startup with a parse error. A value without a unit was accepted and read as nanoseconds. Use a DurationFlag for these fields, so they take the same values as in the YAML config. --- pkg/config/config.go | 30 +++++++++++++++++++-------- pkg/config/config_test.go | 43 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 9 deletions(-) diff --git a/pkg/config/config.go b/pkg/config/config.go index 2f5e5f8fe..2f39c3e76 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -934,10 +934,11 @@ func GenerateCLIFlags(existingFlags []cli.Flag, hidden bool) ([]cli.Flag, error) defaultConfig := &DefaultConfig flags := make([]cli.Flag, 0) for name, value := range (defaultConfig).ToCLIFlagNames(existingFlags) { - kind := value.Kind() - if kind == reflect.Ptr { - kind = value.Type().Elem().Kind() + valueType := value.Type() + if valueType.Kind() == reflect.Ptr { + valueType = valueType.Elem() } + kind := valueType.Kind() var flag cli.Flag envVar := fmt.Sprintf("LIVEKIT_%s", strings.ToUpper(strings.ReplaceAll(name, ".", "_"))) @@ -969,12 +970,23 @@ func GenerateCLIFlags(existingFlags []cli.Flag, hidden bool) ([]cli.Flag, error) Hidden: hidden, } case reflect.Int64: - flag = &cli.Int64Flag{ - Name: name, - Sources: cli.EnvVars(envVar), - Usage: generatedCLIFlagUsage, - DefaultText: defaultText, - Hidden: hidden, + if valueType == reflect.TypeOf(time.Duration(0)) { + // parse values like "30s", as the YAML config does + flag = &cli.DurationFlag{ + Name: name, + Sources: cli.EnvVars(envVar), + Usage: generatedCLIFlagUsage, + DefaultText: defaultText, + Hidden: hidden, + } + } else { + flag = &cli.Int64Flag{ + Name: name, + Sources: cli.EnvVars(envVar), + Usage: generatedCLIFlagUsage, + DefaultText: defaultText, + Hidden: hidden, + } } case reflect.Uint8, reflect.Uint16, reflect.Uint32: flag = &cli.UintFlag{ diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index d4117cea5..244bf84a7 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -15,9 +15,11 @@ package config import ( + "context" "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/require" "github.com/urfave/cli/v3" @@ -123,6 +125,47 @@ func TestGeneratedFlags(t *testing.T) { require.False(t, *conf.RTC.ReconnectOnSubscriptionError) } +func TestGeneratedDurationFlags(t *testing.T) { + // run a command with the generated flags, as cmd/server does, so that flag and env var values are parsed + run := func(t *testing.T, args ...string) (*Config, error) { + generatedFlags, err := GenerateCLIFlags(nil, true) + require.NoError(t, err) + + var conf *Config + c := &cli.Command{ + Name: "test", + Flags: generatedFlags, + Action: func(_ context.Context, c *cli.Command) error { + var err error + conf, err = NewConfig("", true, c, nil) + return err + }, + } + err = c.Run(context.Background(), append([]string{"test"}, args...)) + return conf, err + } + + t.Run("flag", func(t *testing.T) { + conf, err := run(t, "--api.execution_timeout=5s") + require.NoError(t, err) + require.Equal(t, 5*time.Second, conf.API.ExecutionTimeout) + }) + + t.Run("env var", func(t *testing.T) { + t.Setenv("LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT", "30s") + conf, err := run(t) + require.NoError(t, err) + require.Equal(t, 30*time.Second, conf.Room.CreateRoomTimeout) + }) + + t.Run("value without a unit", func(t *testing.T) { + // rejected, as in the YAML config, instead of being read as nanoseconds + t.Setenv("LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT", "30") + _, err := run(t) + require.ErrorContains(t, err, "LIVEKIT_ROOM_CREATE_ROOM_TIMEOUT") + }) +} + func TestYAMLTag(t *testing.T) { require.NoError(t, configtest.CheckYAMLTags(Config{})) } From 8fac34bdc2403e5d5d57e5f9f21adee9094f2a3b Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 5 Oct 2026 17:54:02 +0530 Subject: [PATCH 080/130] fix: skip DTLS HelloVerifyRequest (#4937) --- pkg/rtc/transport.go | 3 ++ pkg/rtc/transport_test.go | 73 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index dd27aa4b9..350fffe43 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -421,6 +421,9 @@ func newPeerConnection( // As Firefox does not support migration, ICE Lite can be disabled. se.SetLite(false) } + // ICE checks already prove the remote address, so the HelloVerifyRequest cookie only adds a round trip. + // pion applies this only when this side ends up as the DTLS server. + se.SetDTLSInsecureSkipHelloVerify(true) se.SetDTLSRetransmissionInterval(dtlsRetransmissionInterval) se.SetDTLSConnectContextMaker(func() (context.Context, func()) { return context.WithTimeout(context.Background(), dtlsHandshakeTimeout) diff --git a/pkg/rtc/transport_test.go b/pkg/rtc/transport_test.go index 3eeef2d5c..a2654ac09 100644 --- a/pkg/rtc/transport_test.go +++ b/pkg/rtc/transport_test.go @@ -21,6 +21,7 @@ import ( "testing" "time" + "github.com/pion/dtls/v3/pkg/protocol/handshake" "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" @@ -251,6 +252,78 @@ func TestFirstNegotiationUsesFastDebounce(t *testing.T) { } } +func TestDTLSHandshakeSkipsHelloVerify(t *testing.T) { + // the SFU offers as on the subscriber connection, and a plain pion peer answers active like a browser, + // so the SFU is the DTLS server and only the peer sends ClientHello + handler := &transportfakes.FakeHandler{} + sfu, err := NewPCTransport(TransportParams{Config: &WebRTCConfig{}, IsOfferer: true, Handler: handler}) + require.NoError(t, err) + defer sfu.Close() + _, err = sfu.pc.CreateDataChannel(ReliableDataChannel, nil) + require.NoError(t, err) + + var clientHellos atomic.Int32 + se := webrtc.SettingEngine{} + require.NoError(t, se.SetAnsweringDTLSRole(webrtc.DTLSRoleClient)) + se.SetDTLSClientHelloMessageHook(func(m handshake.MessageClientHello) handshake.Message { + clientHellos.Inc() + return &m + }) + client, err := webrtc.NewAPI(webrtc.WithSettingEngine(se)).NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer client.Close() + + var lock sync.Mutex + var pending []webrtc.ICECandidateInit + hasOffer := false + handler.OnICECandidateCalls(func(c *webrtc.ICECandidate, _ livekit.SignalTarget) error { + if c == nil { + return nil + } + lock.Lock() + defer lock.Unlock() + if !hasOffer { + pending = append(pending, c.ToJSON()) + return nil + } + return client.AddICECandidate(c.ToJSON()) + }) + client.OnICECandidate(func(c *webrtc.ICECandidate) { + if c != nil { + sfu.AddICECandidate(c.ToJSON()) + } + }) + handler.OnOfferCalls(func(offer webrtc.SessionDescription, offerId uint32, _ map[string]string) error { + lock.Lock() + if err := client.SetRemoteDescription(offer); err != nil { + lock.Unlock() + return err + } + hasOffer = true + for _, c := range pending { + _ = client.AddICECandidate(c) + } + lock.Unlock() + + answer, err := client.CreateAnswer(nil) + if err != nil { + return err + } + if err := client.SetLocalDescription(answer); err != nil { + return err + } + return sfu.HandleRemoteDescription(answer, offerId) + }) + sfu.Negotiate(true) + + require.Eventually(t, func() bool { + return sfu.IsEstablished() && client.ConnectionState() == webrtc.PeerConnectionStateConnected + }, 10*time.Second, 10*time.Millisecond, "transports did not connect") + + // a HelloVerifyRequest from the SFU makes the peer send its ClientHello a second time, with the cookie + require.Equal(t, int32(1), clientHellos.Load()) +} + func TestFirstOfferMissedDuringICERestart(t *testing.T) { params := TransportParams{ Config: &WebRTCConfig{}, From f3e13ef27fe7bcce7691ed32c38b476b4e9077b7 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 5 Oct 2026 19:21:30 +0530 Subject: [PATCH 081/130] fix: use the most common recent packet rate in connection quality (#4940) * fix: use the most common recent packet rate in connection quality The loop that finds the mode packet rate compared a bin's count with the index of the bin picked so far, so it did not find the most common rate. Take the bin with the highest count, skipping rates below 10 pps so that idle, DTX and static content rates do not become the reference. Halve the counts at each mode update so that the mode follows a lasting change of rate, and keep the previous mode through a long stretch of low rates. Co-Authored-By: Claude Opus 5.5 * fix: start the mode timer from the start time Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../connectionquality/connectionstats_test.go | 63 +++++++++++++++++++ pkg/sfu/connectionquality/scorer.go | 29 ++++++--- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/pkg/sfu/connectionquality/connectionstats_test.go b/pkg/sfu/connectionquality/connectionstats_test.go index 13b61a645..5adb322c8 100644 --- a/pkg/sfu/connectionquality/connectionstats_test.go +++ b/pkg/sfu/connectionquality/connectionstats_test.go @@ -901,3 +901,66 @@ func TestConnectionQuality(t *testing.T) { } }) } + +func TestPacketRateMode(t *testing.T) { + type phase struct { + minutes float64 + pps float64 + } + for name, tc := range map[string]struct { + weight float64 + histogram map[int]int // bin -> count + phases []phase + last *windowStat + mode int // pps + quality livekit.ConnectionQuality + }{ + // 120 windows on the 250 pps layer, 600 windows on the 40 pps layer + "most common bin": { + weight: 10.0, + histogram: map[int]int{125: 120, 20: 600}, + last: &windowStat{packets: 200, packetsLost: 8, duration: 5 * time.Second}, + mode: 40, + quality: livekit.ConnectionQuality_GOOD, + }, + // 4% loss on the layer now watched is not EXCELLENT + "follows a layer switch": { + weight: 10.0, + phases: []phase{{10, 250}, {5, 40}}, + last: &windowStat{packets: 200, packetsLost: 8, duration: 5 * time.Second}, + mode: 40, + quality: livekit.ConnectionQuality_GOOD, + }, + // idle and DTX rates do not become the reference, so a lost DTX packet counts less + "keeps the speech rate through a long silence": { + weight: 8.0, + phases: []phase{{5, 50}, {10, 0}, {30, 2.5}}, + last: &windowStat{packets: 12, packetsLost: 1, duration: 5 * time.Second}, + mode: 50, + quality: livekit.ConnectionQuality_EXCELLENT, + }, + } { + t.Run(name, func(t *testing.T) { + q := newQualityScorer(qualityScorerParams{Logger: logger.GetLogger()}) + // a timeline in the past, the mode must follow update times, not the wall clock + at := time.Now().Add(-time.Hour) + q.StartAt(tc.weight, at) + for bin, count := range tc.histogram { + q.ppsHistogram[bin] = count + q.numPPSReadings += count + } + for _, p := range tc.phases { + for range int(p.minutes * 12) { + at = at.Add(5 * time.Second) + q.UpdateAt(&windowStat{packets: uint32(p.pps * 5), duration: 5 * time.Second}, at) + } + } + at = at.Add(tc.last.duration) + q.UpdateAt(tc.last, at) + + require.Equal(t, tc.mode, int(float64(q.ppsMode)*cPPSQuantization)) + _, quality := q.GetScoreAndQuality() + require.Equal(t, tc.quality, quality) + }) + } +} diff --git a/pkg/sfu/connectionquality/scorer.go b/pkg/sfu/connectionquality/scorer.go index 471b291b6..57aa3e2e9 100644 --- a/pkg/sfu/connectionquality/scorer.go +++ b/pkg/sfu/connectionquality/scorer.go @@ -42,6 +42,7 @@ const ( cPPSQuantization = float64(2) cPPSMinReadings = 10 + cPPSModeMinBin = 5 // 10 pps cModeCalculationInterval = 2 * time.Minute ) @@ -235,13 +236,13 @@ func newQualityScorer(params qualityScorerParams) *qualityScorer { layerDistance: utils.NewTimedAggregator[float64](utils.TimedAggregatorParams{ CapNegativeValues: true, }), - modeCalculatedAt: time.Now().Add(-cModeCalculationInterval), } } func (q *qualityScorer) startAtLocked(packetLossWeight float64, at time.Time) { q.packetLossWeight = packetLossWeight q.lastUpdateAt = at + q.modeCalculatedAt = at.Add(-cModeCalculationInterval) } func (q *qualityScorer) StartAt(packetLossWeight float64, at time.Time) { @@ -410,7 +411,7 @@ func (q *qualityScorer) updateAtLocked(stat *windowStat, at time.Time) { return } - aplw := q.getAdjustedPacketLossWeight(stat) + aplw := q.getAdjustedPacketLossWeight(stat, at) reason := "none" var score, packetScore, bitrateScore, layerScore float64 if stat.packets+stat.packetsPadding == 0 { @@ -544,7 +545,7 @@ func (q *qualityScorer) isPaused() bool { return !q.pausedAt.IsZero() && (q.resumedAt.IsZero() || q.pausedAt.After(q.resumedAt)) } -func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat) float64 { +func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat, at time.Time) float64 { if stat == nil || stat.duration <= 0 { return q.packetLossWeight } @@ -566,14 +567,24 @@ func (q *qualityScorer) getAdjustedPacketLossWeight(stat *windowStat) float64 { // calculate mode sparingly, do it under the following conditions // 1. minimum number of readings available (AND) // 2. enough time has elapsed since last calculation - if q.numPPSReadings > cPPSMinReadings && time.Since(q.modeCalculatedAt) > cModeCalculationInterval { - q.ppsMode = 0 - for i := range len(q.ppsHistogram) { - if q.ppsHistogram[i] > q.ppsMode { - q.ppsMode = i + if q.numPPSReadings > cPPSMinReadings && at.Sub(q.modeCalculatedAt) > cModeCalculationInterval { + // the most common rate, ignoring rates as low as DTX or static content so they do not become the reference, + // keep the previous mode through a long stretch of only low rates, for example a long silence + mode, maxCount := 0, 0 + for i := cPPSModeMinBin; i < len(q.ppsHistogram); i++ { + if q.ppsHistogram[i] > maxCount { + mode, maxCount = i, q.ppsHistogram[i] } } - q.modeCalculatedAt = time.Now() + if maxCount > 0 { + q.ppsMode = mode + } + + // halve the counts so that the mode follows a lasting change of rate, for example a layer switch + for i := range q.ppsHistogram { + q.ppsHistogram[i] >>= 1 + } + q.modeCalculatedAt = at q.params.Logger.Debugw("updating pps mode", "expected", stat.packets, "duration", stat.duration.Seconds(), "pps", pps, "ppsMode", q.ppsMode) } From 16f5275fe9f9581543a3d8207b354dcc279eac94 Mon Sep 17 00:00:00 2001 From: Gordnho Date: Mon, 5 Oct 2026 16:58:27 +0200 Subject: [PATCH 082/130] Remove deprecatred way of setting prometheus from the config file (#4928) * Update prometheus configuration in config-sample.yaml Remove the deprecated way of setting prometheus port. --- config-sample.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/config-sample.yaml b/config-sample.yaml index 059de7beb..c22716ca5 100644 --- a/config-sample.yaml +++ b/config-sample.yaml @@ -187,7 +187,8 @@ rtc: # datachannel_data_track_target_latency: 100ms # when enabled, LiveKit will expose prometheus metrics on :6789/metrics -# prometheus_port: 6789 +# prometheus: +# port: 6789 # expose /debug/pprof (and /debug/goroutine, /debug/rooms) on a dedicated port, # separate from the public signalling port. only enabled when port is set. From 5111420e91610e3347b3a52d10c26030c148db01 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 5 Oct 2026 21:38:01 +0530 Subject: [PATCH 083/130] fix: copy RED history packets instead of keeping pointers (#4941) RedReceiver kept pointers to the last forwarded packets for its redundant blocks, but their payload is in the read buffer that forwardRTP reuses for every packet, so the blocks carried the current packet's bytes. Copy the history packets into slots RedReceiver owns. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/redreceiver.go | 30 ++++++++++++++++++---- pkg/sfu/redreceiver_test.go | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/pkg/sfu/redreceiver.go b/pkg/sfu/redreceiver.go index ad0c54b8a..1788c9ef3 100644 --- a/pkg/sfu/redreceiver.go +++ b/pkg/sfu/redreceiver.go @@ -17,6 +17,7 @@ package sfu import ( "encoding/binary" "fmt" + "slices" "go.uber.org/atomic" @@ -50,7 +51,11 @@ type RedReceiver struct { logger logger.Logger closed atomic.Bool pktBuff [maxRedCount]*rtp.Packet - redPayloadBuf [mtuSize]byte + // copies that pktBuff points at, as a forwarded packet's payload is in a read buffer reused for the next packet, + // one more slot than pktBuff so that a new copy never overwrites a redundant block of the packet being encoded + histPkts [maxRedCount + 1]rtp.Packet + histPayloads [maxRedCount + 1][maxRedPayload]byte + redPayloadBuf [mtuSize]byte // forwarded packet, reused like redPayloadBuf since ForwardRTP runs on one goroutine // and down tracks do not keep the packet past WriteRTP redExtPkt buffer.ExtPacket @@ -189,17 +194,22 @@ func (r *RedReceiver) encodeRedForPrimary(pkt *rtp.Packet, redPayload []byte) (i redPkts = append(redPkts, prev) } - // insert primary packet in history buffer - // NOTE: packet is copied from retransmission buffer and used in forwarding path. So, not making another - // copy here and just maintaining pointer to the packet as the forwarding path should not alter the packet. + // insert a copy of the primary packet in history buffer for i := redLength - 1; i >= 0; i-- { if r.pktBuff[i] == nil || // history is empty pkt.SequenceNumber-r.pktBuff[i].SequenceNumber < (1<<15) { // received packet has more recent sequence number + // pick the slot before aging out, as the aged out packet can be a redundant block of this packet + slot := r.freeHistSlot() // age out older ones for j := 0; j < i; j++ { r.pktBuff[j] = r.pktBuff[j+1] } - r.pktBuff[i] = pkt + n := copy(r.histPayloads[slot][:], pkt.Payload) + r.histPkts[slot] = rtp.Packet{ + Header: rtp.Header{SequenceNumber: pkt.SequenceNumber, Timestamp: pkt.Timestamp}, + Payload: r.histPayloads[slot][:n], + } + r.pktBuff[i] = &r.histPkts[slot] break } } @@ -207,6 +217,16 @@ func (r *RedReceiver) encodeRedForPrimary(pkt *rtp.Packet, redPayload []byte) (i return encodeRedForPrimary(redPkts, pkt, redPayload) } +// freeHistSlot returns a history slot that pktBuff does not point at, there is always one as there is a spare slot +func (r *RedReceiver) freeHistSlot() int { + for slot := range r.histPkts { + if !slices.Contains(r.pktBuff[:], &r.histPkts[slot]) { + return slot + } + } + return 0 +} + func encodeRedForPrimary(redPkts []*rtp.Packet, primary *rtp.Packet, redPayload []byte) (int, error) { payloadSize := len(primary.Payload) + 1 for _, p := range redPkts { diff --git a/pkg/sfu/redreceiver_test.go b/pkg/sfu/redreceiver_test.go index 06df4974c..f98824d0a 100644 --- a/pkg/sfu/redreceiver_test.go +++ b/pkg/sfu/redreceiver_test.go @@ -15,6 +15,7 @@ package sfu import ( + "bytes" "testing" "github.com/pion/rtp" @@ -22,6 +23,7 @@ import ( "github.com/stretchr/testify/require" "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/mediatransportutil/pkg/bucket" "github.com/livekit/protocol/logger" ) @@ -234,6 +236,55 @@ func TestRedReceiver(t *testing.T) { }) } +// reads every packet into one reused buffer, the way ReceiverBase.forwardRTP does +func TestRedReceiverReusedReadBuffer(t *testing.T) { + opus := webrtc.RTPCodecParameters{ + RTPCodecCapability: webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus, ClockRate: 48000, Channels: 2}, + PayloadType: 111, + } + buff := buffer.NewBuffer(0x5678, 500, 200) + require.NoError(t, buff.Bind(webrtc.RTPParameters{Codecs: []webrtc.RTPCodecParameters{opus}}, opus.RTPCodecCapability, 0)) + + w := &WebRTCReceiver{ + ReceiverBase: &ReceiverBase{ + params: ReceiverBaseParams{ + Kind: webrtc.RTPCodecTypeAudio, + Logger: logger.GetLogger(), + }, + }, + } + red := w.GetRedReceiver().(*RedReceiver) + dt := &dummyDowntrack{TrackSender: &DownTrack{}} + require.NoError(t, red.AddDownTrack(dt)) + + var payloads [][]byte + pktBuf := make([]byte, bucket.RTPMaxPktSize) + for i := range 6 { + payload := bytes.Repeat([]byte{byte(0x10 * (i + 1))}, 60) + payloads = append(payloads, payload) + pkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 111, SequenceNumber: uint16(100 + i), Timestamp: uint32(960 * i), SSRC: 0x5678}, + Payload: payload, + } + raw, err := pkt.Marshal() + require.NoError(t, err) + _, err = buff.Write(raw) + require.NoError(t, err) + extPkt, err := buff.ReadExtended(pktBuf) + require.NoError(t, err) + red.ForwardRTP(extPkt, 0) + buffer.ReleaseExtPacket(extPkt) + } + + // the last RED packet carries the two packets before it as redundant blocks + blocks, err := extractPktsFromRed(dt.lastReceivedPkt, 0xFF) + require.NoError(t, err) + require.Len(t, blocks, 3) + for i, block := range blocks { + require.Equal(t, payloads[3+i], block.Payload) + } +} + func verifyRedEncodings(t *testing.T, red *rtp.Packet, redPkts []*rtp.Packet) { solidPkts := make([]*rtp.Packet, 0, len(redPkts)) for _, pkt := range redPkts { From 5c83ec3ee9ae11ec0a9358424e304584b6b78bf5 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Tue, 6 Oct 2026 00:35:32 +0800 Subject: [PATCH 084/130] update warp dep (#4943) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 10b2b6b18..12b686a79 100644 --- a/go.mod +++ b/go.mod @@ -161,7 +161,7 @@ tool ( golang.org/x/tools/cmd/goimports ) -replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4 +replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5 replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.3 diff --git a/go.sum b/go.sum index e18a12d10..edbb15a7d 100644 --- a/go.sum +++ b/go.sum @@ -168,8 +168,8 @@ github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d h1:zE0V9Qt210T github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4 h1:9vmK8uymfZs2dFc6Wib+52X0TedlCEqCaBi6Kve1Nr0= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.4/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5 h1:3na5DnAUJtXsrBHWdcUY0tGh9JYu5ppeVPOV4ll/AWA= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= From 6816ccdd9ca8fbedc00605f900648dfae815c44f Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 00:58:49 +0530 Subject: [PATCH 085/130] fix: keep one subscription when first subscribes race (#4944) * fix: keep one subscription when first subscribes race SubscribeToTrack and SubscribeToDataTrack looked up the subscription, then created one and stored it under a separate lock. Two concurrent first subscribes, or a subscribe and a settings update, could each store their own entry. The replaced entry could still add the down track, which nothing owned after that, and the settings could land on the replaced entry. Find or create the subscription and set it as desired under one lock. Co-Authored-By: Claude Opus 5.5 * fix: keep early settings and a racing data track resubscribe A settings or options update before the first subscribe creates an entry that is not desired. Cleanup removed it on the next pass, so the subscribe created a new entry without the settings. Keep such an entry for notFoundTimeout. After a data track unsubscribe, the entry was deleted even if a subscribe set it as desired during the removal. Delete it only if it is still not desired, else reconcile it again. Cleanup deletes only the entry it checked. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/rtc/subscriptionmanager.go | 63 +++++++---- pkg/rtc/subscriptionmanager_test.go | 166 ++++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 19 deletions(-) diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index 38d939f22..b5edddca1 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -189,19 +189,19 @@ func (m *SubscriptionManager) SubscribeToTrack(trackID livekit.TrackID, isSync b return } - sub, desireChanged := m.setDesired(trackID, true) - if sub == nil { + // find or create and set desired under one lock, so that a concurrent subscribe, + // settings update or cleanup cannot replace or remove the subscription in between + m.lock.Lock() + sub, ok := m.subscriptions[trackID] + if !ok { sLogger := m.params.Logger.WithValues( "trackID", trackID, ) sub = newMediaTrackSubscription(m.params.Participant.ID(), trackID, sLogger) - - m.lock.Lock() m.subscriptions[trackID] = sub - m.lock.Unlock() - - sub, desireChanged = m.setDesired(trackID, true) } + desireChanged := sub.setDesired(true) + m.lock.Unlock() if desireChanged { sub.logger.Debugw("subscribing to track") } @@ -234,19 +234,18 @@ func (m *SubscriptionManager) SubscribeToDataTrack(trackID livekit.TrackID) { return } - sub, desireChanged := m.setDataTrackDesired(trackID, true) - if sub == nil { + // same as SubscribeToTrack, find or create and set desired under one lock + m.lock.Lock() + sub, ok := m.dataTrackSubscriptions[trackID] + if !ok { sLogger := m.params.Logger.WithValues( "trackID", trackID, ) sub = newDataTrackSubscription(m.params.Participant.ID(), trackID, sLogger) - - m.lock.Lock() m.dataTrackSubscriptions[trackID] = sub - m.lock.Unlock() - - sub, desireChanged = m.setDataTrackDesired(trackID, true) } + desireChanged := sub.setDesired(true) + m.lock.Unlock() if desireChanged { sub.logger.Debugw("subscribing to data track") } @@ -389,6 +388,7 @@ func (m *SubscriptionManager) UpdateSubscribedTrackSettings(trackID livekit.Trac sub = newMediaTrackSubscription(m.params.Participant.ID(), trackID, sLogger) m.subscriptions[trackID] = sub } + sub.keepForSubscribe() m.lock.Unlock() sub.setSettings(settings) @@ -404,6 +404,7 @@ func (m *SubscriptionManager) UpdateDataTrackSubscriptionOptions(trackID livekit sub = newDataTrackSubscription(m.params.Participant.ID(), trackID, sLogger) m.dataTrackSubscriptions[trackID] = sub } + sub.keepForSubscribe() m.lock.Unlock() sub.setSubscriptionOptions(subscriptionOptions) @@ -601,7 +602,7 @@ func (m *SubscriptionManager) reconcileSubscription(s *mediaTrackSubscription) { } m.lock.Lock() - if s.needsCleanup() { + if m.subscriptions[s.trackID] == s && s.needsCleanup() { s.logger.Debugw("cleanup removing subscription") delete(m.subscriptions, s.trackID) } @@ -673,15 +674,22 @@ func (m *SubscriptionManager) reconcileDataTrackSubscription(s *dataTrackSubscri s.logger.Warnw("failed to unsubscribe", err) } + // a subscribe during the removal sets the entry as desired again, keep it then m.lock.Lock() - delete(m.dataTrackSubscriptions, s.trackID) + removed := m.dataTrackSubscriptions[s.trackID] == s && !s.isDesired() + if removed { + delete(m.dataTrackSubscriptions, s.trackID) + } m.lock.Unlock() m.notifyDataTrackSubscriberHandles() + if !removed { + m.queueReconcileDataTrack(s.trackID) + } return } m.lock.Lock() - cleanedUp := s.needsCleanup() + cleanedUp := m.dataTrackSubscriptions[s.trackID] == s && s.needsCleanup() if cleanedUp { s.logger.Debugw("cleanup removing data track subscription") delete(m.dataTrackSubscriptions, s.trackID) @@ -1280,6 +1288,10 @@ type trackSubscription struct { // the timestamp when the subscription was started, will be reset when downtrack is closed with expected resume subscribeAt atomic.Pointer[time.Time] + + // an entry that is not desired is not cleaned up before this time, + // so that settings sent before a subscribe are there when the subscribe comes + keepUntil time.Time } // set permission and return true if it has changed @@ -1331,6 +1343,7 @@ func (s *trackSubscription) setDesired(desired bool) bool { t := time.Now() s.subStartedAt.Store(&t) s.subscribeAt.Store(&t) + s.keepUntil = time.Time{} } if s.desired == desired { @@ -1372,6 +1385,18 @@ func (s *trackSubscription) getNumAttempts() int32 { return s.numAttempts.Load() } +func (s *trackSubscription) keepForSubscribe() { + s.lock.Lock() + defer s.lock.Unlock() + if !s.desired { + s.keepUntil = time.Now().Add(notFoundTimeout) + } +} + +func (s *trackSubscription) isKeptLocked() bool { + return time.Now().Before(s.keepUntil) +} + func (s *trackSubscription) durationSinceStart() time.Duration { t := s.subStartedAt.Load() if t == nil { @@ -1604,7 +1629,7 @@ func (s *mediaTrackSubscription) needsBind() bool { func (s *mediaTrackSubscription) needsCleanup() bool { s.lock.RLock() defer s.lock.RUnlock() - return !s.desired && s.subscribedTrack == nil + return !s.desired && s.subscribedTrack == nil && !s.isKeptLocked() } // ----------------------------------------------------------------- @@ -1645,7 +1670,7 @@ func (s *dataTrackSubscription) needsUnsubscribe() bool { func (s *dataTrackSubscription) needsCleanup() bool { s.lock.RLock() defer s.lock.RUnlock() - return !s.desired && s.dataDownTrack == nil + return !s.desired && s.dataDownTrack == nil && !s.isKeptLocked() } func (s *dataTrackSubscription) setDataDownTrack(dataDownTrack types.DataDownTrack) { diff --git a/pkg/rtc/subscriptionmanager_test.go b/pkg/rtc/subscriptionmanager_test.go index 15ecc1096..b5305e32d 100644 --- a/pkg/rtc/subscriptionmanager_test.go +++ b/pkg/rtc/subscriptionmanager_test.go @@ -353,6 +353,141 @@ func TestUpdateSettingsBeforeSubscription(t *testing.T) { require.Equal(t, settings.Height, applied.Height) } +func TestConcurrentFirstSubscribe(t *testing.T) { + settings := &livekit.UpdateTrackSettings{Width: 100, Height: 100} + for range 200 { + sm := newTestSubscriptionManager() + + var lock sync.Mutex + var subscribed bool + mt := &typesfakes.FakeMediaTrack{} + mt.IDReturns("track") + mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) { + lock.Lock() + defer lock.Unlock() + if subscribed { + return nil, errAlreadySubscribed + } + subscribed = true + st := &typesfakes.FakeSubscribedTrack{} + st.IDReturns("track") + st.MediaTrackReturns(mt) + return st, nil + }) + sm.params.TrackResolver = func(types.LocalParticipant, livekit.TrackID) types.MediaResolverResult { + return types.MediaResolverResult{ + Track: mt, + HasPermission: true, + PublisherID: "pubID", + PublisherIdentity: "pub", + TrackChangedNotifier: utils.NewChangeNotifier(), + TrackRemovedNotifier: utils.NewChangeNotifier(), + } + } + + // two first subscribes and a settings update race to create the subscription + var start, done sync.WaitGroup + start.Add(1) + for _, f := range []func(){ + func() { sm.SubscribeToTrack("track", false) }, + func() { sm.SubscribeToTrack("track", false) }, + func() { sm.UpdateSubscribedTrackSettings("track", settings) }, + } { + done.Add(1) + go func() { + defer done.Done() + start.Wait() + f() + }() + } + start.Done() + done.Wait() + + sm.lock.RLock() + s := sm.subscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "the subscription that is kept should own the down track") + st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack) + require.Eventually(t, func() bool { + n := st.UpdateSubscriberSettingsCallCount() + if n == 0 { + return false + } + applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1) + return applied == settings + }, subSettleTimeout, subCheckInterval, "the down track should get the settings") + + sm.Close(false) + } +} + +func TestSettingsKeptForSubscribe(t *testing.T) { + t.Run("media", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestResolver(true, true, "pub", "pubID") + sm.params.TrackResolver = resolver.Resolve + + settings := &livekit.UpdateTrackSettings{Disabled: true} + sm.UpdateSubscribedTrackSettings("track", settings) + // a cleanup pass between the settings and the subscribe must not drop the settings + sm.reconcileSubscriptions() + sm.SubscribeToTrack("track", false) + + sm.lock.RLock() + s := sm.subscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "track should be subscribed") + st := s.getSubscribedTrack().(*typesfakes.FakeSubscribedTrack) + require.Eventually(t, func() bool { + n := st.UpdateSubscriberSettingsCallCount() + if n == 0 { + return false + } + applied, _ := st.UpdateSubscriberSettingsArgsForCall(n - 1) + return applied == settings + }, subSettleTimeout, subCheckInterval, "the down track should get the settings") + + // settings for a track that is never subscribed are cleaned up after notFoundTimeout + sm.UpdateSubscribedTrackSettings("other", settings) + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + _, ok := sm.subscriptions["other"] + return !ok + }, subSettleTimeout, subCheckInterval, "unused settings should be cleaned up") + }) + + t.Run("data", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + fps := uint32(5) + options := &livekit.DataTrackSubscriptionOptions{TargetFps: &fps} + sm.UpdateDataTrackSubscriptionOptions("track", options) + // a cleanup pass between the options and the subscribe must not drop the options + sm.reconcileDataTrackSubscriptions() + sm.SubscribeToDataTrack("track") + + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return s.getDataDownTrack() != nil + }, subSettleTimeout, subCheckInterval, "data track should be subscribed") + ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack) + n := ddt.UpdateSubscriptionOptionsCallCount() + require.NotZero(t, n) + require.Equal(t, options, ddt.UpdateSubscriptionOptionsArgsForCall(n-1)) + }) +} + func TestSubscriptionLimits(t *testing.T) { sm := newTestSubscriptionManagerWithParams(testSubscriptionParams{ SubscriptionLimitAudio: 1, @@ -527,6 +662,37 @@ func TestSubscribeDataTrack(t *testing.T) { require.Equal(t, 2, resolver.dataTrack.AddSubscriberCallCount()) }) + t.Run("subscribe again during unsubscribe", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + sm.SubscribeToDataTrack("track") + sm.lock.RLock() + s := sm.dataTrackSubscriptions["track"] + sm.lock.RUnlock() + require.Eventually(t, func() bool { + return !s.needsSubscribe() + }, subSettleTimeout, subCheckInterval, "should be subscribed") + + // the client subscribes again while the removal runs, + // then the down track closes, as DataTrack.RemoveSubscriber does + ddt := s.getDataDownTrack().(*typesfakes.FakeDataDownTrack) + resolver.dataTrack.RemoveSubscriberCalls(func(livekit.ParticipantID) { + sm.SubscribeToDataTrack("track") + ddt.OnCloseArgsForCall(0)() + }) + sm.UnsubscribeFromDataTrack("track") + + require.Eventually(t, func() bool { + return resolver.dataTrack.AddSubscriberCallCount() == 2 && s.getDataDownTrack() != nil + }, subSettleTimeout, subCheckInterval, "should be subscribed again") + sm.lock.RLock() + require.Same(t, s, sm.dataTrackSubscriptions["track"]) + sm.lock.RUnlock() + }) + t.Run("unsubscribe before data track resolves", func(t *testing.T) { sm := newTestSubscriptionManager() defer sm.Close(false) From c264da5b8a602dae3fd8952d98c6b418e619270e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Monnom?= Date: Mon, 5 Oct 2026 20:56:07 -0700 Subject: [PATCH 086/130] agent: HTTP endpoints data plane (#4779) * agent: HTTP endpoints data plane Serve worker-declared FastAPI routes at /agents/{deployment}/{path} without any worker-side listener: workers dial a fixed pool of wires speaking AgentHttp.Frame, the server opens multiplexed streams carrying one opaque HTTP/1.1 exchange each (two-level credit flow control, prioritized write scheduler, attach epoch fencing). The front does starlette-exact manifest matching with per-endpoint public access, typed 401/404/405, a retry table, and SSE/WebSocket passthrough; a pluggable fallback hook lets multi-node deployments resolve misses elsewhere. Includes a conformance client, a manual sidecar, and the acceptance suite. The auth middleware now reads access_token from the query string only: FormValue consumed the bodies of proxied url-encoded POSTs. * agent: attach adopter hook for wires landing on foreign nodes Behind a load balancer a data wire may reach a node other than the one holding the worker's registration; the adopter lets that layer (cloud) install a local satellite registration before the attach is retried locally. * agent: distinguish wrong-epoch attach so a multi-node adopter can refresh A wire whose worker is registered but under a different epoch now returns ErrWrongEpoch (not ErrAttachRejected); pool-full/closed stay ErrAttachRejected. The attach adopter runs on unknown-worker OR wrong-epoch, so a satellite left behind by a reconnected worker is refreshed by re-validating against the registration holder instead of shadowing the new epoch until it is swept. * agent/endpoint: Manifest.Version() digest for skew detection Order-independent fnv64a of the route set so a multi-node layer can tell whether the nodes serving a deployment agree on the manifest (safe to route without inspecting the path) or are mid rolling-deploy (fall back to a path-aware resolve). * agent/endpoint: carry raw endpoints on the registration A multi-node layer can now replicate a node's route set and match request paths against it locally, instead of a manifest digest. Drops the digest helper added for that (superseded). * agent/endpoint: canonical route matcher (typed tokens + pruned walk) Shape-matches a request path against a set of route templates via canonical typed-wildcard tokens and a left-to-right pruned walk: linear in path depth (no 2^segments), type-aware (int/float/uuid/str/path), glob catch-alls, and a PrefixIndex interface so the same walk runs over an exact map (local) or a compact replicated backing (multi-node). Worker stays authoritative for the exact convertor/method match. * agent/endpoint: fix glob matching and enforce route depth - a path convertor anywhere in a template (non-terminal, or mixed with literal text in a segment) now canonicalizes to a terminal glob that spans slashes, instead of being dropped or narrowed to a single-segment str - the old behavior silently failed to match such routes (a false negative -> misroute). - enforce MaxRouteDepth at ParseManifest (wires up RouteDepth), bounding the replicated filter's prefix count and the matcher's walk. * agent endpoints: P2C worker balancing, routing-miss sentinel, api-key-scoped attach - worker selection uses power-of-two-choices over live in-flight streams (InflightStreams / SpareStreams) instead of weighted-random over a reported load; the conn pick stays least-loaded. In-flight is observed locally so it is correct for adopted wires, and the reported Load hook is dropped from the endpoint path. - the front tags its own routing misses (X-Livekit-Endpoint-Miss) so a relay caller can distinguish them from a worker app's own 404/405 and retry past them across the remaining candidates. - a data-wire attach is bound to the connecting grant's api key, so a leaked attach token cannot bind a conn to another project's worker. - remove the dead scope-hook machinery (it only fed the removed bus resolve) and rename the request identity from "scope" to apiKey. * agent endpoints: drop the sidecar cmd, make the conformance client test infra - remove cmd/agent-endpoint-client: the Python SDK has landed, so the manual sidecar for non-SDK workers is no longer needed. - move pkg/agent/endpoint/client to pkg/agent/endpoint/conformance (package conformance) so it reads as the test harness it is, not a product client SDK; only the acceptance suite drives it. * agent endpoints: pool the per-request bridge buffers - reuse the 32KiB response-copy buffer and the response-head reader from sync.Pools; ~27% fewer bytes allocated per small request. - reuse a per-conn marshal buffer in WriteFrame instead of allocating one per frame; ~19% fewer bytes on a 1MiB upload. Safe because stream.Write copies the payload and gorilla WriteMessage does not retain it. * agent endpoints: reuse the read buffer and bound the data wire - ReadFrame reads each frame into a reused per-conn buffer via NextReader instead of letting ReadMessage allocate one per frame; ~41% fewer bytes on a 1MiB download. Safe: proto.Unmarshal copies the payload out and readLoop is the sole reader. - the data wire's websocket read is bounded by its own max message size (a full frame + framing overhead), independent of the signalling message limit, so a peer cannot OOM the server with an oversized frame even when signalling limits are disabled. Drops the old coupling that clamped MaxFrameSize under the signalling limit. * agent endpoints: frame large uploads at MaxFrameSize and pool the buffers Stream now implements io.ReaderFrom, so http.Request.Write's bufio.Writer delegates the body to it and a large upload flows in MaxFrameSize frames instead of ~4KiB ones - 16x fewer frames, marshals, scheduler cycles and websocket messages. Large frame buffers (>= 16KiB) are drawn from a per-conn sync.Pool and recycled by the scheduler after each write, so a big upload stops allocating a buffer per frame; small/webhook bodies stay below the threshold and use a right-sized make, so the average request is unaffected. Measured on a 1MiB upload: -82% allocations, -66% latency, -17% bytes; a small GET/POST is unchanged. * agent endpoints: drop client WebSocket route support, repin protocol * agent endpoints: repin protocol for the enum renumber * agent endpoints: WebTransport data plane Replace the capsule mux / credit / attach-pool machinery with one WebTransport (QUIC) session per worker: a control stream carries registration and status, and each HTTP exchange rides its own QUIC stream (native mux, per-stream flow control, half-close, and reset). Encode the HTTP method into the cross-node route filter so candidate selection is method-aware. Keep the legacy WebSocket control transport for backward compatibility. * agent endpoints: tidy front helpers and constants Use endpoint.CurrentProtocol and a named SessionCloseOK instead of bare literals, factor the repeated 503 and header-copy blocks into helpers, and drop a few restating test comments and a redundant assertion. No behavior change. * agent endpoints: assert deployment scoping in the registry test * agent endpoints: drop file-narration header comments * agent endpoints: normalize trailing slashes during routing instead of 307 A slash mismatch (e.g. /hook/ for a route registered as /hook) is now rewritten to the registered form and served directly, rather than 307-redirected. Webhook clients often don't follow redirects, and a redirect from the final routing hop resends the body and pays the whole routing path twice. Exact form is matched first, so a route registered with a trailing slash is served as-is. * agent endpoints: route at deployment granularity, drop the route filter Route presence like a reverse proxy keyed on (project, agent_name, deployment): any of a deployment's workers is a candidate and the worker's own router returns the real status (a 404 for a path it doesn't serve during a rolling deploy is returned, not re-relayed). This removes the whole per-node route-advertisement path: the cuckoo route filter and matcher (routematch.go), the miss-tagging / retry-past-miss machinery, and the route-depth cap that only existed to bound the filter. Text routing is now the same shape as voice job dispatch. * agent endpoints: audit cleanups (drop dead Registration.Endpoints, test the fallback flow) Remove the write-only Registration.Endpoints field (matching goes through Manifest; the multi-node layer no longer replicates the raw route set). Add Front-level fallback tests (fires with the resolved identity, declined -> 404 with a local worker or 503 without), and fix two stale comments. * agent endpoints: byte-transparent HTTP/1.1 exchange Replace the protobuf frame layer on exchange streams with a length-prefixed StreamPreamble followed by opaque HTTP/1.1 bytes. QUIC already provides the multiplexing and per-stream flow control, so the frame layer only re-encoded a message both peers can already parse, and every SDK had to re-materialize HTTP from it. The front serializes a canonical request head from its own parsed *http.Request and never forwards the client's bytes, which is what keeps request smuggling out of the worker. Responses are read with http.ReadResponse, so informational heads need no httptrace hook and the conformance worker loses its HTTP reconstruction entirely. Completion splits by outcome: a body ends by its own framing, a failure after bytes have flowed travels in x-lk-completion / x-lk-error trailers, and a failure before any byte resets the stream with an HttpStreamResetCode. REFUSED stays distinct as the retry-safety signal, and the retry/idempotence rules are unchanged. The x-lk- prefix is reserved for this signalling: it is stripped from client-supplied request headers so a caller cannot forge an outcome, and from responses so it never reaches the end client. Exchange-stream targets are split from the escaped path so a percent-encoded '?' or '/' cannot change which resource the worker routes to. Per-request state moves to attempt.go so the Front and attempt lifecycles stop interleaving; the remaining files are grouped type-first with free helpers last. Those moves are ordering only. * go.mod: pin protocol with the agent endpoints preamble Picks up livekit/protocol 134fb913, which replaces the HTTP framing messages with StreamPreamble and renumbers HttpStreamResetCode. The previous pin predates AgentHttp entirely, so the endpoint packages only built through the local go.work. * agent endpoints: require a scoped grant for non-public routes The front gated non-public routes on a bool that was true for any valid token in the project, so a roomJoin token minted for an end user reached every non-public endpoint the project's agents expose. Replace the resolver's (apiKey, authenticated) pair with an Access value and give it the agent and deployment from the URL, so the scope rules stay with the grant in protocol/auth and this package consumes only the verdict. Credentialed now separates a caller with no credential from one whose credential lacks the grant: the first is challenged with 401, the second gets 403 and no WWW-Authenticate, since a retry cannot succeed. A denied request still reaches the fallback. Another node's worker may declare the same path public, and that node is the authority. * go.mod: pin protocol with the agent endpoint grant * agent endpoints: accept any agent name, keep request escaping intact The name is percent-encoded into its URL path segment, so it needs no charset. Only "_", "." and ".." are refused, and only for workers declaring endpoints: all three are unreserved, so no encoded form addresses them distinctly. "_" now addresses a worker registered with an empty agent name, which the implicit-dispatch default leaves unset. Two escaping fixes the escaped-path split depends on: - the trailing-slash alternate treated a decoded slash as literal, so /known%2F normalized onto route /known and the worker was handed a path it does not serve. - RemoveDoubleSlashes trimmed URL.Path and left RawPath, which makes EscapedPath() re-encode from Path and lose every escape in the request. Request logs now carry the encoded path, since a decoded %0A forges a log line. * agent endpoints: return the abort from bridge rather than panicking inside it A committed response that cannot be completed is torn down with panic(http.ErrAbortHandler): the head is on the wire, so no status can report the failure, and this is the only teardown net/http offers. HTTP/1.1 closes without the final chunk and HTTP/2 sends RST_STREAM, so a short body cannot read as whole. bridge raised it from inside the response copy loop, through a helper whose doc had to note that it does not return. It now reports a bridgeOutcome and ServeHTTP performs the teardown at the handler boundary, where the constraint that nothing may recover the sentinel is visible to a reader. The outcome replaces the (done, retryable) pair, whose four combinations only ever carried three states. No behavior change. * agent endpoints: serve the front on its own middleware chain The front was mounted on the API mux and served through the API middleware chain, which is shaped for handlers that buffer a request and write one response. Four consequences. negroni.NewRecovery() recovers every panic with no http.ErrAbortHandler exemption, and NewRecovery sets PrintStack. On an abort it wrote "PANIC: ..." and a goroutine stack into the already-committed body, then returned normally, so net/http terminated the chunked stream cleanly. A truncated response reached the client as complete, with a stack trace appended. Detection was already correct; delivery was not, and the x-lk- trailers that would otherwise signal it are stripped before the response leaves. A Content-Length response still failed safe, since net/http enforces the declared length itself, so the gap was the chunked and trailer paths. Endpoints.Disabled documents that it turns the front off, but only registrations were refused; the mount was unconditional. The CORS method list omits PUT, which a manifest may declare. The API body limiter capped request bodies at MaxAPIRequestBodySize, though the front streams a body through a pooled buffer and never holds one. NewHTTPHandler now routes the prefix to a chain carrying AgentRecovery, a CORS list matching the methods a manifest may declare, and the api-key auth middleware the front resolves a caller's access from. The mount is built only when endpoints are enabled, so the prefix otherwise falls through and 404s. RemoveDoubleSlashes moves above the split, so routing and both chains see one path form. Taking the front off the mux also stops ServeMux rewriting the paths it is handed: "//x", "/../" and interior "//" were answered with a redirect rather than proxied, which a byte-transparent exchange cannot do. The endpoint stack tests now build the production handler, so they run on the chain the node serves on. * agent endpoints: build the front and registration from params Front is an http.Handler, so fallback, identity and singleKeyFallback were read on every request with no synchronization while WithFallback, WithIdentity and WithSingleKeyFallback wrote them on the live object. Each returned the same pointer, so the chain read as though it built a value. Every call site happens to run before the handler is mounted, so nothing races today, but nothing in the type prevents it, and WithSingleKeyFallback took no argument and could only be turned on. NewFront now takes FrontParams and configuration is fixed at construction. NewWorkerRegisterer takes its EndpointSettingsFunc directly, and HandshakeAgentWorker takes one in place of a variadic of raw closures that existed only to reach the setter it replaced. Registration gains NewRegistration and RegistrationParams, moving the Draining callback off an exported mutable field and folding SetSession into construction. IsDraining absorbs the nil check at both call sites. Access carried a three-state ladder as two bools, with "granted implies credentialed" documented but unenforced. It is now an ordered AccessLevel, so callers compare a rank rather than combining flags and the invariant holds by construction. Registry.Register returned an error that was always nil, with a dead branch at each call site. Both registry maps and the per-registration session are read-heavy, so they take RWMutex. CopyBody returned two errors to separate a source failure from a destination one. It returns one, wrapping a source failure in *SourceError, which is what the caller discriminates on. NewWebTransportServer took a callback to break the handler/server init cycle; the caller assigns wt.H3.Handler after construction instead. StartWebTransport reads Development off the service rather than taking a bool, and returns a nil stop function where it starts no listener. Smaller: slices.Sort for sort.Strings, for range for an unused counter, a nil slice for regs[:0:0], p2c generic over its slice so the call site passes a method expression rather than allocating a closure per request, and streamCode deduplicated into wire.StreamCode so both peers map reset codes in one place. Comments on the touched declarations drop remote behavior, migration narration and contrastive framing, keeping the constraints and invariants. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: serve webtransport from the node's server set StartWebTransport built the node's only HTTP/3 listener from inside AgentService: it minted the TLS config, bound the UDP socket, assembled an h3 mux and returned a stop closure LivekitServer held in agentWTStop. Every other listener is constructed in NewLivekitServer and delegates to a service, and agents are the first consumer of HTTP/3 rather than the last. webtransport.go now carries the transport with no agent references: WebTransportTLS, NewWebTransportServer, NewWebTransportHandler, ListenWebTransport, and WithWebTransportServer/GetWebTransportServer, which put the serving server in the request context so a route that upgrades is a conventional http.Handler instead of a closure over the server. The listener is built in NewLivekitServer beside the prometheus and debug servers, bound in Start with the other listeners, and stopped once doneChan unblocks, so no stop closure is held on LivekitServer. /agent is a route on its mux, as it is on the API mux. UpgradeWebTransport unwraps the ResponseWriter before upgrading. webtransport-go type-asserts it to http3.Settingser and http3.HTTPStreamer without checking and negroni wraps the writer on every chain, so the h3 listener could not carry a middleware chain at all. It now runs the same recovery, api-key auth and path normalization as the TCP chain. AgentService splits into AgentWSService and AgentWTService over the shared AgentHandler, and the endpoint front becomes AgentEndpointService. wire builds all four and threads one endpoint.Registry into the handler and the front. NewAgentHandler takes the config and builds its own ServerInfo, so endpointsConfig and singleAPIKey are set at construction and the psrpc server is registered against a fully formed handler; it previously bound one whose embedded *AgentHandler was still nil. The listener's port and certificate move to a top-level webtransport config block. The listener no longer reads agents.endpoints.disabled, which continues to refuse endpoint registrations. ListenWebTransport binds one socket per bind address, where the h3 listener ignored bind_addresses that every TCP listener honoured. NewWorkerRegisterer takes a variadic WorkerRegisterHandler, each handler reading the request and filling its part of the response and the registration, replacing the single EndpointSettingsFunc threaded through HandleRegister. EndpointRegisterHandler is the endpoint declaration's turn, and endpoint.NegotiateSettings holds the protocol negotiation the OSS and cloud handshakes each carried a copy of. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: accept webtransport connections rather than racing Serve with Close ListenWebTransport ran wt.Serve on a goroutine per socket and returned a stop func calling wt.Close. Serve takes a reference on the server's refCount WaitGroup and Close waits on it, so the pair breaks the WaitGroup's own rule that an Add starting from zero must happen before a Wait. The race detector models that rule as a read of wg.sema in Add against a write in Wait, and reports it whenever Wait observes a non-zero counter: a listener stopped before its serve goroutine has run at all, which is every test that builds a stack and tears it down without a worker connecting. sync is compiled without instrumentation, so the report names the two ListenWebTransport call sites with no frame in between. webtransport-go v0.13.0 carries the same code. The accept loop moves here. quic.ListenEarly builds the listener Serve would have built, with the datagram and partial-delivery options the session layer requires, and each connection goes to Server.ServeQUICConn, which touches no part of that WaitGroup. The stop func keeps the order Close established: Close first, so every CONNECTION_CLOSE frame is transmitted while the sockets are still open, then the accept loops are cancelled and drained, then the listeners and sockets close. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: sequence the endpoint tests on the registry handleConnection writes the register response from inside HandshakeAgentWorker and installs the worker's routes after it returns, so a worker learns it is registered a moment before the node can route to it. The conformance worker's WaitRegistered unblocks on that response and startWorker returned straight into the first request, which on a loaded runner arrived while the registry was still empty and came back 503. TestAgentEndpointsStatusMapping saw that as 503 for the first few mapped statuses, TestAgentEndpointsHOL as an unexpected EOF, reading 1024 bytes out of a short error body. The stack keeps the registry it builds and startNamedWorker waits for the worker's registration to appear in it, which is the condition the requests actually depend on. TestAgentEndpointsRetrySafety builds its unreachable worker by hand and waits the same way. The HOL test asserts its status before reading the body, so a 503 there reports as a status mismatch rather than a truncated read. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: log the deployment with worker registration Workers are keyed by deployment alongside agent name, namespace and job type. The registered and last-deregistered lines named every part of that key except the deployment. * agent endpoints: add a path router that matches in declaration order Manifest matching runs a compiled regex per route over a linear scan, once per candidate registration, on the serving path. At the 256-route cap that is up to 256 RE2 executions per replica per request. The router compiles a manifest into a compressed trie instead. Starlette selects the first route declared that matches, so the trie cannot resolve static before wildcard the way net/http's does: every node carries the lowest route index in its subtree, the walk tracks the lowest full match found and prunes any subtree that cannot improve on it, and inserting in declaration order leaves edges and leaves sorted by that index with no sort pass. Params are not segment-aligned - starlette allows /f/{name}.{ext}, a {p:path} anywhere, and a float whose fraction backtracks - so the walk must search. An edge is single when nothing below it can start with a byte its own convertor could have consumed, which is a property of the target node and so is settled at build time; a single edge takes its greedy run and descends once. Templates whose params are segment-aligned are entirely single and never search. What remains is bounded by a step budget, and exhausting it returns ResultOverBudget rather than a route the cut-short search cannot vouch for. Templates parse without regexp. Scanning the grammar by hand keeps a brace that opens nothing well-formed as an ordinary literal, which is what starlette's finditer does. Anchoring is `\n?$` rather than `$`: python's '$' matches before one trailing newline where Go's does not, and [^/] accepts a newline where . refuses one, so the two convertors diverge in opposite directions on a decoded %0A. The regex implementation moves into the tests as an oracle, carrying its own parser so it references the hand-rolled scanner as well as the trie. FuzzMatchAgainstOracle generates a table and a request and asserts the two agree on both the winning template and the result. Matching allocates nothing, asserted by AllocsPerRun on hit, 405, miss and on the searching path. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: route manifests through the path router ParseManifest builds a router.Router rather than a slice of compiled templates, Manifest aliases it, and Route keeps the template and the public flag the front reads. Declared methods become a mask at build time, so a method no route could declare masks to 0 and matches nothing, as an undeclared one already did. Three behavior changes ride along. Templates anchor as `\n?$`. A manifest declaring private /files/{p:path} before public /files/{p} routed GET /files/x%0A to the public route here and to the private one on the worker, because the path convertor refuses a newline where str accepts it. The access gate reads the public flag of the route the front picked, so it opened on a route starlette would not have selected. slashAlternatePaths derives the escaped form from the transform it applied rather than from the result's suffix. A request path of // trimmed to /, the caller saw a trailing slash on that result, took the append branch, and handed the worker a request line of /// against a decoded path of /. The decoded path is capped at MaxPathLength, answering 414 above it. Matching runs before anything sizes the request head, so net/http's own limit was the only bound on what the matcher was handed. A table too ambiguous to decide now dispatches rather than 404s: the worker's own router still knows, and the preamble carries no route for it to be told about. Nothing here knows whether that route is public, so the request needs a grant. Dispatch keys on the match set, since a candidate can now join it without resolving a route. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: log the manifest as a structured object Router implements zapcore.ObjectMarshaler, so registration logs the table's size and the templates whose shape forces the matcher to search under one key rather than assembling fields at the call site. Which templates those are is settled when the manifest is parsed, so it is worth reporting before any request is served; what a single match cost belongs to that request instead. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: key templates by shape and export their ambiguity Canonical renders a template as its literals and convertor kinds, so two templates the trie cannot tell apart share a key. Param names reach the trie only as a kind, so /x/{a} and /x/{b} accept the same paths; anything keying routes by the declared spelling splits them apart. Literals are length-prefixed because a brace that opens no well-formed param is literal text: /x/{:str} matches itself and must not collide with /x/{a}. ambiguousTemplate becomes Template.Ambiguous. It reads only the template's own elements, so it answers for a manifest that was validated but never compiled into a table of its own. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: merge worker manifests into one table per deployment A deployment owns its route table. A route is one template shape and one method together with the workers that declared it; registration takes the union of the table and the arriving worker's manifest, deregistration retracts the worker and drops the routes nothing is left declaring. Manifest stops being a compiled router and becomes the ordered list a worker declared, so a fleet holds one table rather than one per replica, and a request resolves in a single walk. A mixed fleet is the point. Matching each worker's own table in turn already served heterogeneous methods, but the route the front reported was the first candidate's while the worker it dispatched to was any of them, and partial and denied were accumulated across workers whose manifests disagreed. Both become properties of one table, so the status a request gets and the template the worker is told are decided together. Merge order is the lowest position a route holds in any declaring manifest, tied by shape then method. A fleet declaring one manifest reproduces that manifest's order, and the result reads off the current set of manifests, so nodes holding the same fleet agree. This does not preserve any one worker's internal order: a worker can be handed a request the merged table resolved to a public route while its own table routes that path to a private one. Merging the declaration chains in order would hold the property and is not done here; TestRouteTableMergeOrderInversion carries the case. Routes are keyed by shape, so /x/{a} and /x/{b} are one route and a renamed path param does not halve the fleet serving it. Each worker keeps the spelling it declared, and the preamble carries that, since it is re-serialized per attempt. The tree holds routes by pointer and is rebuilt only when the route set or its order moves, so a worker joining or leaving an existing route swaps nothing. A supersede runs the removal and the addition as one transaction: an unchanged manifest would otherwise empty every route, drop it, and build a new one under the same key, leaving the published tree naming routes nothing can reach and no change to the key set to show for it. A reconnect can also land on a different agent name or deployment, so the retiring epoch is retracted from its own table. Ambiguity is now a property of the merged tree, and one worker's template shape can carry the whole deployment's matcher over its step budget, which leaves no route decided and so no public flag to clear an ungranted request. Co-authored-by: Claude Opus 5 (1M context) * agent endpoints: scope the registry, let the embedder own tenancy The registry was doing three jobs at once: fencing worker epochs by id, listing a deployment's candidates, and holding its merged route table. The last two are per-deployment state, so it keyed them on (api key, agent name, deployment) and grew a tenancy concept that only an embedder can actually define. Cloud has to lie to it, passing a project id in a field named APIKey. Split them. Scope is one deployment's serving state and stores no identity at all; whoever embeds the package keys a map of scopes however its own tenancy works, and hands the front a resolved one. Registry keeps only the worker-id fence, which is genuinely node-wide: worker ids are server-issued, so an epoch is superseded wherever it was scoped. The front loses its registry, its SingleKeyFallback and FallbackRequest: the resolver now returns the scope and a fallback already curried on the deployment, plus an ok that carries the 401-vs-503 split the empty api key used to encode. routeTable drops its key and takes the scope's logger, so identity is curried in rather than stored. pkg/service takes ownership of the "api key is the tenant" rule, which is true there and nowhere else, and of releasing a scope once nothing holds it. Behavior is unchanged, including serving public routes to an unauthenticated caller when one configured key or one attached tenant makes the key unambiguous. * agent endpoints: give the http3 listener a server type ListenWebTransport returned a stop closure, so every caller had to park it next to the server it belonged to. In cloud that meant a second field on the node base, written at start and read at stop, for one listener. WebTransportServer embeds webtransport.Server and owns the sockets and accept loops, so Listen and Shutdown are methods and the shape matches http.Server. Shutdown takes a context: it stops accepting, sends GOAWAY, and closes whatever has not drained by the deadline, where the closure closed everything at once with no deadline of its own. It is safe on a server that never listened, which is what lets a holder key teardown off the field alone. The embedded Close still releases sessions without releasing the sockets; that is what the type comment warns about. * agent endpoints: fold the ident-start test into a helper staticcheck QF1001 flagged the negated disjunction in scanIdent, which was failing the lint gate and so keeping the test job from running at all. The positive helper reads better than the De Morgan form and lets the continue condition drop its own '_' case. * go.mod: bump protocol to the merged branch head * rtc tests: give the logger its own logging config init() handed the logger &config.DefaultConfig.Logging, so the leveler's mutex lived inside the global that NewConfig yaml-marshals. Marshalling reflectively copies the whole struct, mutex included, which raced the component-level lookups pion makes from its own goroutines and failed TestPreferMediaCodecForPublisher under -race. The values are the same; only the storage is now test-local. The integration helper aliased the same global and gets the same treatment. * rtc tests: drop the logging config comment * auth: name the call the query-string read avoids The token comes from RawQuery so the body reaches a proxied agent endpoint intact. That only holds as long as nobody reaches for FormValue, which is the obvious way to read a param and the one that drains the body, so the comment names it. * agent endpoints: tunnel WebSocket upgrades to workers An exchange stream already carries opaque HTTP/1.1, so a WebSocket is an ordinary GET route: the front forwards an HTTP/1.1 Upgrade: websocket handshake, the worker's HTTP stack decides, and on a 101 the front hijacks the client connection and splices it onto the stream. No protocol change. - Only HTTP/1.x GET with Connection: upgrade and Upgrade: websocket (and no body) is an upgrade; every other Upgrade token is still stripped. A 101 is accepted only for such a request and only with Upgrade: websocket; anything else stays a protocol error. - A non-101 answer to an upgrade is an ordinary response; the stream then ends with a FIN rather than a reset. - Retries are unchanged until the 101; after it the attempt is committed. - Each worker takes at most MaxTunnelsPerWorker tunnels (default half its session's MaxStreams) so WebSockets cannot starve its HTTP routes; an upgrade no worker can take falls to the fallback, then 503. - Drain: a tunnel outlives its worker starting to drain by TunnelDrainTimeout (default 30s), and Front.Drain closes tunnels once its context is done. Both close with a 1001 frame when the client-bound side is between frames, abruptly otherwise. The server drains tunnels while participants leave, since http.Server.Shutdown cannot see them. - access_token is dropped from the target and a LiveKit bearer token from Authorization for every request: the worker gets identity from the preamble, and the token must not reach application code. - Full duplex: enable it before the first flush, so an HTTP/1 client can keep streaming its body while the response streams back. Config: agents.endpoints.max_tunnels, agents.endpoints.tunnel_drain_timeout. * agent endpoints: keep one value per CORS header The agent chain's CORS middleware writes Access-Control-* and Vary before the front runs. A worker's head was added on top, duplicating Access-Control-Allow-Origin, and the clear after a 1xx head dropped the middleware's headers from the final response. - A worker's Access-Control-* and Vary values replace the middleware's; other headers are still added (MergeWorkerHeaders). - Headers set before the front ran are restored after each 1xx head. - Preflights stay with the middleware; a plain OPTIONS still reaches a declared route. NewAgentEndpointCORS holds the agent chain's CORS middleware so other chains can share it. * agent endpoints: withhold endpoint settings from sessionless workers A worker registering over WebSocket has no data plane, yet the handshake negotiated endpoint settings, telling it its routes were live while every request 503'd. Negotiate only when a WebTransport session exists, per the endpoint_settings contract, and keep the worker registered for jobs. * deps: pin protocol to the agent endpoints branch head * deps: pin merged protocol --------- Co-authored-by: Paul Wells Co-authored-by: Claude Opus 5 (1M context) --- go.mod | 8 +- go.sum | 16 +- pkg/agent/client.go | 6 +- pkg/agent/config.go | 24 + pkg/agent/endpoint/attempt.go | 418 ++++++++++ pkg/agent/endpoint/completion_test.go | 387 ++++++++++ pkg/agent/endpoint/conformance/worker.go | 302 ++++++++ pkg/agent/endpoint/front.go | 676 +++++++++++++++++ pkg/agent/endpoint/front_test.go | 409 ++++++++++ pkg/agent/endpoint/headerbound_test.go | 81 ++ pkg/agent/endpoint/largebody_test.go | 151 ++++ pkg/agent/endpoint/manifest.go | 156 ++++ pkg/agent/endpoint/manifest_test.go | 120 +++ pkg/agent/endpoint/pick_test.go | 42 ++ pkg/agent/endpoint/registry.go | 263 +++++++ pkg/agent/endpoint/registry_test.go | 95 +++ pkg/agent/endpoint/router/alloc_test.go | 26 + pkg/agent/endpoint/router/bench_test.go | 88 +++ pkg/agent/endpoint/router/build.go | 177 +++++ pkg/agent/endpoint/router/fuzz_test.go | 91 +++ pkg/agent/endpoint/router/match.go | 129 ++++ pkg/agent/endpoint/router/oracle_test.go | 86 +++ pkg/agent/endpoint/router/router.go | 118 +++ pkg/agent/endpoint/router/router_test.go | 254 +++++++ pkg/agent/endpoint/router/scan.go | 130 ++++ pkg/agent/endpoint/router/template.go | 201 +++++ pkg/agent/endpoint/router/template_test.go | 94 +++ pkg/agent/endpoint/routes.go | 257 +++++++ pkg/agent/endpoint/routes_test.go | 365 +++++++++ pkg/agent/endpoint/scope.go | 123 +++ pkg/agent/endpoint/session.go | 72 ++ pkg/agent/endpoint/truncation_test.go | 261 +++++++ pkg/agent/endpoint/tunnel.go | 305 ++++++++ pkg/agent/endpoint/tunnel_test.go | 233 ++++++ pkg/agent/endpoint/tunnels.go | 108 +++ pkg/agent/endpoint/websocket_test.go | 299 ++++++++ pkg/agent/endpoint/webtransport.go | 138 ++++ pkg/agent/endpoint/webtransport_test.go | 254 +++++++ pkg/agent/endpoint/wire/control.go | 73 ++ pkg/agent/endpoint/wire/exchange.go | 342 +++++++++ pkg/agent/endpoint/wire/exchange_test.go | 272 +++++++ pkg/agent/endpoint/wire/protocol.go | 26 + pkg/agent/endpoint/wire/streamcode.go | 28 + pkg/agent/testutils/server.go | 5 +- pkg/agent/worker.go | 82 +- pkg/agent/worker_test.go | 78 ++ pkg/config/config.go | 10 + pkg/service/agentendpoint.go | 65 ++ pkg/service/agentendpoint_protocol_test.go | 80 ++ pkg/service/agentendpoint_test.go | 711 ++++++++++++++++++ pkg/service/agentendpointchain_test.go | 358 +++++++++ pkg/service/agentendpointmiddleware.go | 57 ++ pkg/service/agentendpointscope.go | 117 +++ .../{agentservice.go => agenthandler.go} | 273 ++++--- pkg/service/agentws.go | 102 +++ pkg/service/agentwt.go | 143 ++++ pkg/service/auth.go | 7 +- pkg/service/server.go | 179 +++-- pkg/service/utils.go | 15 +- pkg/service/utils_test.go | 28 + pkg/service/webtransport.go | 251 +++++++ pkg/service/webtransport_test.go | 47 ++ pkg/service/webtransportlisten_test.go | 38 + pkg/service/wire.go | 8 +- pkg/service/wire_gen.go | 22 +- 65 files changed, 10170 insertions(+), 210 deletions(-) create mode 100644 pkg/agent/endpoint/attempt.go create mode 100644 pkg/agent/endpoint/completion_test.go create mode 100644 pkg/agent/endpoint/conformance/worker.go create mode 100644 pkg/agent/endpoint/front.go create mode 100644 pkg/agent/endpoint/front_test.go create mode 100644 pkg/agent/endpoint/headerbound_test.go create mode 100644 pkg/agent/endpoint/largebody_test.go create mode 100644 pkg/agent/endpoint/manifest.go create mode 100644 pkg/agent/endpoint/manifest_test.go create mode 100644 pkg/agent/endpoint/pick_test.go create mode 100644 pkg/agent/endpoint/registry.go create mode 100644 pkg/agent/endpoint/registry_test.go create mode 100644 pkg/agent/endpoint/router/alloc_test.go create mode 100644 pkg/agent/endpoint/router/bench_test.go create mode 100644 pkg/agent/endpoint/router/build.go create mode 100644 pkg/agent/endpoint/router/fuzz_test.go create mode 100644 pkg/agent/endpoint/router/match.go create mode 100644 pkg/agent/endpoint/router/oracle_test.go create mode 100644 pkg/agent/endpoint/router/router.go create mode 100644 pkg/agent/endpoint/router/router_test.go create mode 100644 pkg/agent/endpoint/router/scan.go create mode 100644 pkg/agent/endpoint/router/template.go create mode 100644 pkg/agent/endpoint/router/template_test.go create mode 100644 pkg/agent/endpoint/routes.go create mode 100644 pkg/agent/endpoint/routes_test.go create mode 100644 pkg/agent/endpoint/scope.go create mode 100644 pkg/agent/endpoint/session.go create mode 100644 pkg/agent/endpoint/truncation_test.go create mode 100644 pkg/agent/endpoint/tunnel.go create mode 100644 pkg/agent/endpoint/tunnel_test.go create mode 100644 pkg/agent/endpoint/tunnels.go create mode 100644 pkg/agent/endpoint/websocket_test.go create mode 100644 pkg/agent/endpoint/webtransport.go create mode 100644 pkg/agent/endpoint/webtransport_test.go create mode 100644 pkg/agent/endpoint/wire/control.go create mode 100644 pkg/agent/endpoint/wire/exchange.go create mode 100644 pkg/agent/endpoint/wire/exchange_test.go create mode 100644 pkg/agent/endpoint/wire/protocol.go create mode 100644 pkg/agent/endpoint/wire/streamcode.go create mode 100644 pkg/agent/worker_test.go create mode 100644 pkg/service/agentendpoint.go create mode 100644 pkg/service/agentendpoint_protocol_test.go create mode 100644 pkg/service/agentendpoint_test.go create mode 100644 pkg/service/agentendpointchain_test.go create mode 100644 pkg/service/agentendpointmiddleware.go create mode 100644 pkg/service/agentendpointscope.go rename pkg/service/{agentservice.go => agenthandler.go} (73%) create mode 100644 pkg/service/agentws.go create mode 100644 pkg/service/agentwt.go create mode 100644 pkg/service/webtransport.go create mode 100644 pkg/service/webtransport_test.go create mode 100644 pkg/service/webtransportlisten_test.go diff --git a/go.mod b/go.mod index 12b686a79..ae6f54822 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d + github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3 github.com/livekit/psrpc v0.8.0 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 @@ -43,6 +43,8 @@ require ( github.com/pires/go-proxyproto v0.15.0 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.24.1 + github.com/quic-go/quic-go v0.61.0 + github.com/quic-go/webtransport-go v0.12.0 github.com/redis/go-redis/v9 v9.22.0 github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.12.1 @@ -69,6 +71,7 @@ require ( github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/distribution/reference v0.6.0 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect github.com/fatih/color v1.19.0 // indirect github.com/felixge/httpsnoop v1.1.0 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -86,6 +89,7 @@ require ( github.com/olekukonko/ll v0.1.8 // indirect github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect + github.com/quic-go/qpack v0.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/otel v1.46.0 // indirect @@ -145,7 +149,7 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect go.uber.org/zap/exp v0.3.0 // indirect golang.org/x/crypto v0.56.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/net v0.58.0 golang.org/x/sys v0.47.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/tools v0.49.0 // indirect diff --git a/go.sum b/go.sum index edbb15a7d..686118d0f 100644 --- a/go.sum +++ b/go.sum @@ -54,6 +54,8 @@ github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elliotchance/orderedmap/v3 v3.1.1 h1:eV7lfZ5fVL8d36b8Wogqi/eqm7R/kZcftA9Yiyj+63M= @@ -164,8 +166,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d h1:zE0V9Qt210TlPMZb0ocT3GVBNqKmiGUFgTb73ECssJE= -github.com/livekit/protocol v1.52.2-0.20260925171938-8c8c356c106d/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= +github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3 h1:prnZIg7PRRO4QyHVKODibufoUZHjmMLphQaGSLPtmBo= +github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5 h1:3na5DnAUJtXsrBHWdcUY0tGh9JYu5ppeVPOV4ll/AWA= @@ -282,6 +284,14 @@ github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0o github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= 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/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= +github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA= +github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs= +github.com/quic-go/webtransport-go v0.12.0 h1:CpnKNwZvdV0LD73xoHO8QaR0NI3llqpWRwnazdZS0sE= +github.com/quic-go/webtransport-go v0.12.0/go.mod h1:GHne8aRFJ24h73pAMrcywXtuaz/ShBXCLXLvG/NPFdU= github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= @@ -341,6 +351,8 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= diff --git a/pkg/agent/client.go b/pkg/agent/client.go index 788da9846..383729f29 100644 --- a/pkg/agent/client.go +++ b/pkg/agent/client.go @@ -62,9 +62,9 @@ type JobRequest struct { // only set for participant jobs Participant *livekit.ParticipantInfo Metadata string - AgentName string - Deployment string - Attributes map[string]string + AgentName string + Deployment string + Attributes map[string]string } type agentClient struct { diff --git a/pkg/agent/config.go b/pkg/agent/config.go index ee529884e..1bbcfbc25 100644 --- a/pkg/agent/config.go +++ b/pkg/agent/config.go @@ -1,9 +1,33 @@ package agent +import "time" + const DefaultTargetLoad = 0.7 type Config struct { EnableUserDataRecording bool `yaml:"enable_user_data_recording"` EnableUserDataRedaction bool `yaml:"enable_user_data_redaction"` TargetLoad float32 `yaml:"target_load,omitempty"` + + // agent HTTP endpoints data plane; zero values take the endpoint package + // defaults + Endpoints EndpointsConfig `yaml:"endpoints,omitempty"` +} + +type EndpointsConfig struct { + // Disabled turns off the /agents/{agent_name}/{deployment}/... front and + // rejects registrations that declare endpoints. + Disabled bool `yaml:"disabled,omitempty"` + + // MaxStreams is the soft per-session concurrent-stream cap used for capacity + // weighting; 0 takes the endpoint package default. + MaxStreams uint32 `yaml:"max_streams,omitempty"` + + // MaxTunnels caps one worker's concurrent WebSocket tunnels, reserving the + // rest of its streams for HTTP routes; 0 takes half of MaxStreams. + MaxTunnels uint32 `yaml:"max_tunnels,omitempty"` + + // TunnelDrainTimeout is how long a WebSocket tunnel outlives its worker or + // this node starting to drain; 0 takes the endpoint package default. + TunnelDrainTimeout time.Duration `yaml:"tunnel_drain_timeout,omitempty"` } diff --git a/pkg/agent/endpoint/attempt.go b/pkg/agent/endpoint/attempt.go new file mode 100644 index 000000000..6c3c64cef --- /dev/null +++ b/pkg/agent/endpoint/attempt.go @@ -0,0 +1,418 @@ +// 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 endpoint + +import ( + "bufio" + "errors" + "fmt" + "io" + "maps" + "net" + "net/http" + "net/url" + "slices" + "strings" + "sync/atomic" + "time" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/guid" +) + +// attempt is the per-request state shared across worker attempts. +type attempt struct { + req *http.Request + // escPath is the percent-encoded path; a decoded %0A would forge a log line + escPath string + // target is the origin-form request target, still percent-encoded + target string + requestID string + granted bool + body io.Reader + pools *bridgePools + preamble *livekit.AgentHttp_StreamPreamble + // the serialized canonical request head, built once and replayed verbatim + head []byte + // client body bytes consumed when this attempt began + before int64 + // a status line reached the client, so nothing may be retried past here + committed bool + // on a 101 the stream becomes a tunnel + webSocket bool +} + +// replayable reports whether the request body can be sent again from the start. +// writeRequest never reads the body when ContentLength is 0, so an attempt that +// passes here leaves the counter where it found it and the next one replays too. +func (a *attempt) replayable() bool { + return a.before == 0 && a.req.ContentLength == 0 +} + +// retryable implements the retry table: nothing once a status line has reached +// the client; before that, proven non-dispatch is safe for any method, and an +// outcome where the application ran is never safe. +func (a *attempt) retryable(err error) bool { + if a.committed || !a.replayable() { + return false + } + var ce *wire.CompletionError + if errors.As(err, &ce) { + switch ce.Completion { + case wire.CompletionRefused: + return true + case wire.CompletionTimeout, wire.CompletionProtocol, wire.CompletionUnknown: + return idempotentMethod(a.req.Method) + default: + return false // the application ran + } + } + // dispatch is unknown + return idempotentMethod(a.req.Method) +} + +// newPreamble carries what the worker cannot derive from the HTTP bytes and +// must not infer from them - authenticated above all. +func (a *attempt) newPreamble() *livekit.AgentHttp_StreamPreamble { + scheme := "http" + if a.req.TLS != nil { + scheme = "https" + } + clientAddr, _, _ := net.SplitHostPort(a.req.RemoteAddr) + + p := &livekit.AgentHttp_StreamPreamble{ + Kind: livekit.AgentHttp_AEK_HTTP, + RequestId: a.requestID, + Authorized: a.granted, + ClientAddr: clientAddr, + Scheme: scheme, + } + return p +} + +// requestHeader builds the header set the worker sees. +func (a *attempt) requestHeader() http.Header { + r := a.req + h := r.Header.Clone() + if h == nil { + h = http.Header{} + } + removeHopByHopHeaders(h) + if a.webSocket { + h.Set("Connection", "Upgrade") + h.Set("Upgrade", "websocket") + } + // identity travels as preamble.authorized; the token must never reach the + // worker + if isLiveKitBearer(h.Get("Authorization")) { + h.Del("Authorization") + } + // the signalling namespace is the node's to write: a client able to set + // x-lk-completion could tell the worker a body it cut short was whole + wire.StripReservedHeaders(h) + h.Del("Expect") // the front owns 100-continue semantics client-side + + if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { + if prior := h.Get("X-Forwarded-For"); prior != "" { + h.Set("X-Forwarded-For", prior+", "+host) + } else { + h.Set("X-Forwarded-For", host) + } + } + return h +} + +// buildHead serializes the canonical request head. It is byte-identical across +// attempts, so it is built once; only the preamble's shrinking deadline varies. +func (a *attempt) buildHead() error { + head, err := wire.BuildRequestHead(a.req.Method, a.target, a.req.Host, a.requestHeader(), bodyLength(a.req)) + if err != nil { + return err + } + if len(head) > wire.MaxRequestHeadSize { + return errRequestHeadTooLarge + } + a.head = head + return nil +} + +// refreshTimeout restates the remaining budget: the preamble is built once, but +// a retry must not hand the worker time already spent. +func (a *attempt) refreshTimeout() { + deadline, ok := a.req.Context().Deadline() + if !ok { + return + } + ms := time.Until(deadline).Milliseconds() + if ms < 1 { + ms = 1 // 0 means "no deadline", which an expired budget is not + } + a.preamble.TimeoutMs = uint32(ms) +} + +// writeRequest writes the preamble, then the canonical head and body as opaque +// HTTP/1.1. A failure reading the client's body still ends the body explicitly +// where the framing allows it. +func (a *attempt) writeRequest(w io.Writer) error { + if err := wire.WritePreamble(w, a.preamble); err != nil { + return err + } + if _, err := w.Write(a.head); err != nil { + return err + } + cl := bodyLength(a.req) + if a.body == nil || cl == 0 { + return nil + } + + // a declared length is framed as declared; only an unknown length needs + // chunking, the one framing that can carry a reason for a short body. + var bw wire.BodyWriter + if cl > 0 { + bw = wire.NewIdentityBody(w) + } else { + bw = wire.NewChunkedBody(w) + } + + bufp := a.pools.getBuf() + defer a.pools.putBuf(bufp) + + n, err := wire.CopyBody(bw, a.body, *bufp) + var srcErr *wire.SourceError + switch { + case errors.As(err, &srcErr): + return bw.Close(wire.CompletionPeerGone, srcErr.Err.Error()) + case err != nil: + return err + case cl > 0 && n < cl: + return bw.Close(wire.CompletionTruncated, fmt.Sprintf("declared %d bytes, read %d", cl, n)) + } + return bw.Close(wire.CompletionUnknown, "") +} + +// readResponse reads the worker's response head, relaying any informational +// heads that precede it. 1xx carries no body, so each returns as soon as it +// lands. +func (a *attempt) readResponse( + w http.ResponseWriter, + br *bufio.Reader, + lim *headLimiter, + stream Stream, +) (*http.Response, error) { + // A deadline only bounds waiting, so failing to set or clear one must never + // discard a response the peer has already delivered: a transport reports an + // error here as soon as the stream is closed, which a worker that answered + // in full and hung up has done. + _ = stream.SetReadDeadline(time.Now().Add(responseHeadTimeout)) + // headers middleware set before the front ran; no 1xx head may consume them + var base http.Header + for i := 0; ; i++ { + if i > maxInformationalHeads { + return nil, errTooManyInformational + } + resp, err := http.ReadResponse(br, a.req) + if err != nil { + return nil, err + } + if resp.StatusCode < 100 || resp.StatusCode > 599 { + return nil, errBadStatus + } + if resp.StatusCode == http.StatusSwitchingProtocols { + if !a.webSocket || !strings.EqualFold(resp.Header.Get("Upgrade"), "websocket") { + return nil, errProtocolSwitch + } + } + if resp.StatusCode >= 200 || resp.StatusCode == http.StatusSwitchingProtocols { + lim.release() + // bodies (SSE, long streams) are unbounded; only the head is deadlined + _ = stream.SetReadDeadline(time.Time{}) + return resp, nil + } + h := w.Header() + if base == nil { + base = h.Clone() + } + copyResponseHeaders(h, resp.Header) + w.WriteHeader(resp.StatusCode) + clear(h) + maps.Copy(h, base) + a.committed = true + } +} + +// requestID resolves the request's idempotence token. It is client-chosen, so it +// is never rewritten - only accepted, or refused so the caller can retry under a +// token the front will honor. Being client-chosen it is neither unique nor +// trusted: two callers may present the same value, so log queries scope by +// worker and deployment. +func requestID(r *http.Request) (string, bool) { + v := r.Header.Values("X-Request-Id") + switch { + case len(v) == 0: + return guid.New("AER_"), true + case len(v) > 1: + return "", false // a token naming two requests dedups neither + } + if !validRequestID(v[0]) { + return "", false + } + return v[0], true +} + +// validRequestID accepts visible ASCII within the length bound, so UUID, ULID, +// base64 and base64url pass unchanged while control characters do not. +func validRequestID(id string) bool { + if len(id) == 0 || len(id) > maxRequestIDLen { + return false + } + for i := 0; i < len(id); i++ { + if id[i] < 0x21 || id[i] > 0x7e { + return false + } + } + return true +} + +func idempotentMethod(m string) bool { + switch m { + case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace: + return true + } + return false +} + +// bodyLength is the framing the head declares: a known length, 0 for no body, +// or -1 when only the client knows where it ends. +func bodyLength(r *http.Request) int64 { + if r.Body == nil || r.Body == http.NoBody { + return 0 + } + return r.ContentLength +} + +func requestTarget(escPath, rawQuery string) string { + rawQuery = stripAccessToken(rawQuery) + if rawQuery == "" { + return escPath + } + return escPath + "?" + rawQuery +} + +func stripAccessToken(rawQuery string) string { + if rawQuery == "" { + return "" + } + // the kept pairs must keep the client's encoding byte for byte, so the query + // is never decoded and re-encoded + pairs := strings.Split(rawQuery, "&") + kept := pairs[:0] + for _, p := range pairs { + k, _, _ := strings.Cut(p, "=") + if uk, err := url.QueryUnescape(k); err == nil && uk == "access_token" { + continue + } + kept = append(kept, p) + } + return strings.Join(kept, "&") +} + +// isWebSocketUpgrade admits only an HTTP/1.x websocket handshake; every other +// protocol switch, h2c included, must stay blocked. +func isWebSocketUpgrade(r *http.Request) bool { + return r.ProtoMajor == 1 && r.Method == http.MethodGet && bodyLength(r) == 0 && + connectionHasToken(r.Header, "upgrade") && + strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") +} + +func connectionHasToken(h http.Header, token string) bool { + for _, v := range h.Values("Connection") { + for _, t := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(t), token) { + return true + } + } + } + return false +} + +func isLiveKitBearer(v string) bool { + tok, ok := strings.CutPrefix(v, "Bearer ") + if !ok || tok == "" { + return false + } + t, err := auth.ParseAPIToken(tok) + return err == nil && t.APIKey() != "" +} + +// hop-by-hop headers per RFC 9110; Connection-nominated headers are dropped too. +func removeHopByHopHeaders(h http.Header) { + for _, f := range h.Values("Connection") { + for _, sf := range strings.Split(f, ",") { + if sf = strings.TrimSpace(sf); sf != "" { + h.Del(sf) + } + } + } + for _, k := range []string{ + "Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", + "Te", "Trailer", "Transfer-Encoding", "Upgrade", + } { + h.Del(k) + } +} + +// copyResponseHeaders forwards what the worker sent, minus hop-by-hop fields (a +// forwarded Transfer-Encoding makes the client de-chunk an already-decoded body) +// and minus the reserved namespace, which is not the client's to see. +func copyResponseHeaders(dst, src http.Header) { + MergeWorkerHeaders(dst, src) + removeHopByHopHeaders(dst) + wire.StripReservedHeaders(dst) +} + +// MergeWorkerHeaders adds a worker's response head to dst, which may hold +// headers set by serving middleware. A worker's Access-Control-* and Vary +// values replace the middleware's. Preflights never reach the worker: they +// carry no credential to admit them to a private route. +func MergeWorkerHeaders(dst, worker http.Header) { + for k, vs := range worker { + if k == "Vary" || strings.HasPrefix(k, "Access-Control-") { + // dst's slices may be shared by middleware and must not be written through + dst[k] = slices.Clone(vs) + continue + } + for _, v := range vs { + dst.Add(k, v) + } + } +} + +// countingReader tracks how much of the client body has been consumed. The +// counter is atomic because it is read while the request writer goroutine holds +// the reader. +type countingReader struct { + r io.Reader + n *atomic.Int64 +} + +func (c *countingReader) Read(p []byte) (int, error) { + n, err := c.r.Read(p) + if n > 0 { + c.n.Add(int64(n)) + } + return n, err +} diff --git a/pkg/agent/endpoint/completion_test.go b/pkg/agent/endpoint/completion_test.go new file mode 100644 index 000000000..83a52e82f --- /dev/null +++ b/pkg/agent/endpoint/completion_test.go @@ -0,0 +1,387 @@ +// 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 endpoint + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// scriptedSession drives the front with a scripted worker writing raw HTTP/1.1. +type scriptedSession struct { + script func(r io.Reader, w *workerSide) +} + +func (s *scriptedSession) OpenStreams() int { return 0 } +func (s *scriptedSession) MaxStreams() int { return DefaultMaxStreams } +func (s *scriptedSession) Close(string) {} + +func (s *scriptedSession) OpenStream(context.Context) (Stream, error) { + toWorker, fromFront := net.Pipe() + toFront, fromWorker := net.Pipe() + st := &pipeStream{w: fromFront, r: toFront} + + go func() { + s.script(toWorker, &workerSide{WriteCloser: fromWorker, st: st}) + }() + return st, nil +} + +// pipeStream is a Stream over a pair of net.Pipes, carrying a reset code the way +// a QUIC stream does. +type pipeStream struct { + w io.WriteCloser + r io.ReadCloser + + mu sync.Mutex + closed bool + peerReset *livekit.AgentHttp_HttpStreamResetCode +} + +func (p *pipeStream) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + if err != nil { + p.mu.Lock() + code := p.peerReset + p.mu.Unlock() + if code != nil { + return n, &StreamResetError{Code: *code} + } + } + return n, err +} + +func (p *pipeStream) Write(b []byte) (int, error) { return p.w.Write(b) } +func (p *pipeStream) CloseWrite() error { return p.w.Close() } +func (p *pipeStream) SetReadDeadline(t time.Time) error { + type deadliner interface{ SetReadDeadline(time.Time) error } + if d, ok := p.r.(deadliner); ok { + return d.SetReadDeadline(t) + } + return nil +} + +func (p *pipeStream) Reset(livekit.AgentHttp_HttpStreamResetCode, string) { _ = p.Close() } + +func (p *pipeStream) Close() error { + p.mu.Lock() + defer p.mu.Unlock() + if p.closed { + return nil + } + p.closed = true + _ = p.w.Close() + _ = p.r.Close() + return nil +} + +// workerSide is the scripted worker's end of the exchange. +type workerSide struct { + io.WriteCloser + st *pipeStream +} + +// Reset ends the exchange the way a worker's RESET_STREAM does: the front's next +// read reports the code. The code is recorded before the close so the front +// cannot observe the EOF without it. +func (ws *workerSide) Reset(code livekit.AgentHttp_HttpStreamResetCode) { + ws.st.mu.Lock() + ws.st.peerReset = &code + ws.st.mu.Unlock() + _ = ws.WriteCloser.Close() +} + +func (ws *workerSide) WriteString(s string) { _, _ = io.WriteString(ws.WriteCloser, s) } + +// rawScriptedFront serves a front backed by one scripted worker whose side of +// the exchange is raw bytes. +func rawScriptedFront(t *testing.T, script func(r io.Reader, w *workerSide)) *httptest.Server { + t.Helper() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/stream", Methods: []string{"GET", "POST"}, Public: true}, + }) + require.NoError(t, err) + + g, s := NewRegistry(), NewScope(logger.GetLogger()) + r := NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &scriptedSession{script: script}}) + g.Register(s, r) + + ts := httptest.NewServer(NewFront(FrontParams{ResolveAccess: grantedTo(s), Logger: logger.GetLogger()})) + t.Cleanup(ts.Close) + return ts +} + +// drainRequest reads the preamble and the whole request so the worker script can +// respond. net.Pipe is unbuffered, so a script that skips this deadlocks the +// front's writer. +func drainRequest(r io.Reader) *livekit.AgentHttp_StreamPreamble { + pre, err := wire.ReadPreamble(r) + if err != nil { + return nil + } + req, err := http.ReadRequest(bufio.NewReader(r)) + if err != nil { + return pre + } + _, _ = io.Copy(io.Discard, req.Body) + return pre +} + +func chunk(p []byte) string { return fmt.Sprintf("%x\r\n%s\r\n", len(p), p) } + +const chunkedHead = "HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n" + +// A worker reports an incomplete body that the HTTP framing does not reveal: the +// chunked body terminates cleanly, so only the trailer carries the outcome. +func TestTrailersReportTruncationWithoutContentLength(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk(make([]byte, 1000))) + w.WriteString("0\r\nx-lk-completion: truncated\r\nx-lk-error: generator raised mid-stream\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body the worker reported as truncated must not read as complete") +} + +// A chunked body that never reaches its terminator is incomplete, whatever the +// stream does next. +func TestMissingTerminatorAbortsTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk(make([]byte, 1000))) + // no terminal 0-chunk + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "an unterminated body must not read as a clean EOF") +} + +// A chunk header that overstates its payload must not reach the client as a +// complete body. +func TestTruncatedMidChunkAbortsTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString("3e8\r\n") // announces 1000 bytes + w.WriteString(strings.Repeat("x", 10)) // delivers 10 + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body cut short mid-chunk must not read as complete") +} + +// A body that terminates with no completion trailer is exactly success. +func TestUnlengthedCompleteResponseSucceeds(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString(chunkedHead) + w.WriteString(chunk([]byte("streamed"))) + w.WriteString("0\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "streamed", string(got)) +} + +// A refusal before any byte flowed is retryable and exhausts into a 503. +func TestRefusalBeforeHeadIsRetryable(t *testing.T) { + var attempts int + var mu sync.Mutex + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + mu.Lock() + attempts++ + mu.Unlock() + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_REFUSED) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, 1, attempts, "one registration means one attempt, then the 503") +} + +// A post-dispatch failure before the head is a 502 and is never retried. +func TestInternalBeforeHeadIsBadGateway(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_INTERNAL) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// A deadline overrun maps to 504. POST is not retryable, so the mapping reaches +// the client. +func TestTimeoutBeforeHeadIsGatewayTimeout(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_TIMEOUT) + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusGatewayTimeout, resp.StatusCode) +} + +// 101 must not reach the client as a switch the stream never performs. +func TestSwitchingProtocolsIsRejected(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\n") + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode) +} + +// A retryable state with no other worker to try exhausts into a 503. +func TestRetryableStateExhaustsTo503(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_TIMEOUT) + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + require.Equal(t, "1", resp.Header.Get("Retry-After")) +} + +// A reset with no code carries no claim that nothing was applied, so it is +// retried only for an idempotent method. +func TestAbortWithoutCodeIsIdempotentOnly(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + drainRequest(r) + w.Reset(livekit.AgentHttp_HSR_ABORT) + }) + + resp, err := http.Post(ts.URL+PathPrefix+"a/d/stream", "text/plain", nil) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusBadGateway, resp.StatusCode, + "a POST must not be retried on an unexplained reset") +} + +// The signalling namespace is the node's: a client that could set +// x-lk-completion could tell the worker a body it cut short was whole. +func TestClientSuppliedReservedHeadersAreStripped(t *testing.T) { + seen := make(chan string, 1) + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + _, _ = wire.ReadPreamble(r) + req, err := http.ReadRequest(bufio.NewReader(r)) + if err != nil { + seen <- "read error" + return + } + seen <- req.Header.Get("X-Lk-Completion") + w.WriteString("HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + }) + + req, err := http.NewRequest(http.MethodGet, ts.URL+PathPrefix+"a/d/stream", nil) + require.NoError(t, err) + req.Header.Set("X-Lk-Completion", "internal") + req.Header.Set("X-Lk-Error", "forged") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Empty(t, <-seen, "a client must not be able to forge a completion") +} + +// Signalling between node and worker is not the client's to see. +func TestReservedResponseHeadersDoNotReachTheClient(t *testing.T) { + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nx-lk-internal: leaked\r\n\r\nok") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "ok", string(body)) + require.Empty(t, resp.Header.Get("X-Lk-Internal")) +} + +// Header values are octets: a latin-1 filename must survive the hop. +func TestNonUTF8HeaderValueSurvives(t *testing.T) { + const v = "attachment; filename=\"caf\xe9.txt\"" + ts := rawScriptedFront(t, func(r io.Reader, w *workerSide) { + defer w.Close() + drainRequest(r) + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\nContent-Disposition: " + v + "\r\n\r\n") + }) + + resp, err := http.Get(ts.URL + PathPrefix + "a/d/stream") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, v, resp.Header.Get("Content-Disposition")) +} diff --git a/pkg/agent/endpoint/conformance/worker.go b/pkg/agent/endpoint/conformance/worker.go new file mode 100644 index 000000000..9d0b7641e --- /dev/null +++ b/pkg/agent/endpoint/conformance/worker.go @@ -0,0 +1,302 @@ +// 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 conformance + +import ( + "context" + "crypto/tls" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + "github.com/livekit/protocol/utils/guid" +) + +type Config struct { + // ServerURL is the https:// URL of the /agent endpoint (WebTransport). + ServerURL string + APIKey string + APISecret string + + AgentName string + Deployment string + Endpoints []*livekit.AgentHttp_AgentEndpoint + + // TargetAddr is the host:port of the local HTTP server streams bridge into. + TargetAddr string + + // Insecure skips TLS verification (self-signed certs in tests). + Insecure bool + + Logger logger.Logger +} + +// Worker is one registration epoch: a single WebTransport session carrying the +// control stream and every HTTP-exchange stream. +type Worker struct { + cfg Config + instanceID string + + mu sync.Mutex + sess *webtransport.Session + workerID string + protocol uint32 + closed bool + registered chan struct{} +} + +func New(cfg Config) *Worker { + if cfg.Logger == nil { + cfg.Logger = logger.GetLogger() + } + return &Worker{ + cfg: cfg, + instanceID: guid.New("AEI_"), + registered: make(chan struct{}), + } +} + +// Start dials the WebTransport session, registers on the control stream, and +// begins serving node-opened streams. It returns once registration completes. +func (w *Worker) Start(ctx context.Context) error { + token, err := w.mintToken() + if err != nil { + return err + } + + tr := &webtransport.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: w.cfg.Insecure, //nolint:gosec // test/self-signed only + NextProtos: []string{http3.NextProtoH3}, + }, + } + hdr := http.Header{} + hdr.Set("Authorization", "Bearer "+token) + _, sess, err := tr.Dial(ctx, w.cfg.ServerURL, hdr) + if err != nil { + return fmt.Errorf("dial webtransport: %w", err) + } + w.mu.Lock() + w.sess = sess + w.mu.Unlock() + + // control stream: the worker opens it first, then registers + control, err := sess.OpenStreamSync(ctx) + if err != nil { + return fmt.Errorf("open control stream: %w", err) + } + if err := wire.WriteControlMessage(control, &livekit.WorkerMessage{ + Message: &livekit.WorkerMessage_Register{ + Register: &livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: w.cfg.AgentName, + Version: "endpoint-conformance-client", + PingInterval: 30, + Deployment: w.cfg.Deployment, + Endpoints: w.cfg.Endpoints, + InstanceId: w.instanceID, + EndpointProtocol: wire.CurrentProtocol, + }, + }, + }); err != nil { + return fmt.Errorf("write register: %w", err) + } + + var resp livekit.ServerMessage + if err := wire.ReadControlMessage(control, &resp); err != nil { + return fmt.Errorf("read register response: %w", err) + } + reg := resp.GetRegister() + if reg == nil { + return fmt.Errorf("expected register response, got %T", resp.GetMessage()) + } + settings := reg.GetEndpointSettings() + if settings == nil { + return fmt.Errorf("server accepted the registration without endpoint settings") + } + if p := settings.GetProtocol(); p < wire.MinProtocol || p > wire.CurrentProtocol { + return fmt.Errorf("server negotiated endpoint protocol %d, this worker speaks %d..%d", + p, wire.MinProtocol, wire.CurrentProtocol) + } + + w.mu.Lock() + w.workerID = reg.GetWorkerId() + w.protocol = settings.GetProtocol() + close(w.registered) + w.mu.Unlock() + + go w.controlLoop(control) + // the serve loop lives as long as the session, not the dial context (which + // the caller may cancel as soon as Start returns) + go w.serveLoop(sess) + return nil +} + +// WaitRegistered blocks until registration completes or ctx is done. +func (w *Worker) WaitRegistered(ctx context.Context) error { + select { + case <-w.registered: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-time.After(10 * time.Second): + return fmt.Errorf("timed out waiting for registration") + } +} + +// WorkerID returns the id assigned by the server (valid after registration). +func (w *Worker) WorkerID() string { + w.mu.Lock() + defer w.mu.Unlock() + return w.workerID +} + +// Close tears down the session. +func (w *Worker) Close() { + w.mu.Lock() + if w.closed { + w.mu.Unlock() + return + } + w.closed = true + sess := w.sess + w.mu.Unlock() + if sess != nil { + _ = sess.CloseWithError(wire.SessionCloseOK, "worker closed") + } +} + +func (w *Worker) mintToken() (string, error) { + at := auth.NewAccessToken(w.cfg.APIKey, w.cfg.APISecret). + SetVideoGrant(&auth.VideoGrant{Agent: true}). + SetValidFor(24 * time.Hour) + return at.ToJWT() +} + +// controlLoop drains further control messages (availability requests are +// declined; the conformance worker takes no jobs). +func (w *Worker) controlLoop(control *webtransport.Stream) { + for { + var msg livekit.ServerMessage + if err := wire.ReadControlMessage(control, &msg); err != nil { + return + } + if a := msg.GetAvailability(); a != nil { + _ = wire.WriteControlMessage(control, &livekit.WorkerMessage{ + Message: &livekit.WorkerMessage_Availability{ + Availability: &livekit.AvailabilityResponse{ + JobId: a.GetJob().GetId(), + Available: false, + }, + }, + }) + } + } +} + +// serveLoop accepts node-opened streams (one HTTP exchange each) and bridges +// them to the local target. It runs until the session ends. +func (w *Worker) serveLoop(sess *webtransport.Session) { + ctx := sess.Context() + for { + stream, err := sess.AcceptStream(ctx) + if err != nil { + return + } + go w.serve(ctx, stream) + } +} + +// serve bridges one HTTP exchange to the local target. After the preamble the +// stream is opaque HTTP/1.1 in both directions. +func (w *Worker) serve(ctx context.Context, stream *webtransport.Stream) { + defer stream.Close() + + pre, err := wire.ReadPreamble(stream) + if err != nil { + resetStream(stream, livekit.AgentHttp_HSR_PROTOCOL) + return + } + if pre.GetKind() != livekit.AgentHttp_AEK_HTTP { + w.cfg.Logger.Infow("agent endpoint stream kind not served", + "kind", pre.GetKind().String(), "requestID", pre.GetRequestId()) + resetStream(stream, livekit.AgentHttp_HSR_PROTOCOL) + return + } + + if ms := pre.GetTimeoutMs(); ms > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, time.Duration(ms)*time.Millisecond) + defer cancel() + } + + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", w.cfg.TargetAddr) + if err != nil { + // the application never observed the request, so refused is sound here. + // The code carries no detail, so the reason is logged against the + // request ID instead. + w.cfg.Logger.Infow("agent endpoint target dial failed", + "error", err, "target", w.cfg.TargetAddr, "requestID", pre.GetRequestId()) + resetStream(stream, livekit.AgentHttp_HSR_REFUSED) + return + } + defer conn.Close() + + // the deadline and the session ending both have to reach a blocked copy + stop := context.AfterFunc(ctx, func() { + _ = conn.Close() + stream.CancelRead(wire.StreamCode(livekit.AgentHttp_HSR_ABORT)) + }) + defer stop() + + pipe(stream, conn) +} + +// pipe copies the exchange in both directions until the target has finished +// answering. Neither direction is parsed. +func pipe(stream *webtransport.Stream, conn net.Conn) { + reqDone := make(chan struct{}) + go func() { + defer close(reqDone) + _, _ = io.Copy(conn, stream) + // the request ended; the target needs the EOF to answer a body it read + // to completion + if tc, ok := conn.(*net.TCPConn); ok { + _ = tc.CloseWrite() + } + }() + + _, _ = io.Copy(stream, conn) + // the target is done answering, so nothing more of the request is wanted + stream.CancelRead(wire.StreamCode(livekit.AgentHttp_HSR_ABORT)) + <-reqDone +} + +// resetStream reports an outcome that happened before any HTTP bytes flowed, +// the only point at which a reset can carry one without racing them. +func resetStream(stream *webtransport.Stream, c livekit.AgentHttp_HttpStreamResetCode) { + stream.CancelWrite(wire.StreamCode(c)) + stream.CancelRead(wire.StreamCode(c)) +} diff --git a/pkg/agent/endpoint/front.go b/pkg/agent/endpoint/front.go new file mode 100644 index 000000000..ca0dc51a8 --- /dev/null +++ b/pkg/agent/endpoint/front.go @@ -0,0 +1,676 @@ +// 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 endpoint + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "math/rand/v2" + "net/http" + "net/url" + "os" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +const ( + // PathPrefix is the public route namespace: /agents/{agent_name}/{deployment}/{path...} + PathPrefix = "/agents/" + + // MaxPathLength caps the escaped route path. Matching runs before the + // request head is sized, so this is the only bound on it. + MaxPathLength = 8 << 10 + + // responseHeadTimeout bounds the wait for the worker's response head. Bodies + // (SSE, long streams) are unbounded; the head never legitimately takes this + // long. + responseHeadTimeout = 90 * time.Second + + // maxAttempts bounds worker retries per request + maxAttempts = 3 + + // maxRequestIDLen bounds the client's idempotence token: it reaches this + // node's logs and the worker's, so it cannot be unbounded. + maxRequestIDLen = 128 + + // maxInformationalHeads bounds 1xx responses before the final head, so a + // worker cannot hold a client open by trickling them forever. + maxInformationalHeads = 8 + + maxResponseHeadSize = 1 << 20 + responseBufSize = 8 << 10 +) + +var ( + errNotEndpointPath = errors.New("endpoint: not an agent endpoint path") + errMalformedPath = errors.New("endpoint: malformed agent endpoint path") + errPathTooLong = errors.New("endpoint: agent endpoint path too long") + errRequestHeadTooLarge = errors.New("endpoint: request head too large") + errProtocolSwitch = errors.New("endpoint: worker switched protocols on an HTTP exchange") + errTooManyInformational = errors.New("endpoint: too many informational responses") + errBadStatus = errors.New("endpoint: response status out of range") + errHeadTooLarge = errors.New("endpoint: response head too large") +) + +// AccessLevel is how far a request's caller is trusted. Callers compare against +// it, so a new level must be inserted at its correct rank. +type AccessLevel int + +const ( + // AccessNone presented no credential. + AccessNone AccessLevel = iota + // AccessCredentialed presented a valid token carrying no agent-endpoint + // grant for the addressed agent and deployment. + AccessCredentialed + // AccessGranted presented a token whose agent-endpoint grant covers the + // addressed agent and deployment. + AccessGranted +) + +func (a AccessLevel) String() string { + switch a { + case AccessNone: + return "none" + case AccessCredentialed: + return "credentialed" + case AccessGranted: + return "granted" + default: + return fmt.Sprintf("%d", int(a)) + } +} + +// Access is what the front knows about a request's caller, together with the +// serving state it resolved to. The front never keys anything itself: whatever +// scopes a request - a tenant, an api key, a project - is resolved by the +// embedder and arrives here already looked up. +type Access struct { + // Scope is the deployment's serving state this request is placed against. + // nil means no worker here holds it; Fallback may still place it elsewhere. + Scope *Scope + // Fallback serves the request elsewhere (e.g. a multi-node relay), already + // curried on the deployment it resolved. nil means local misses are final. + Fallback Fallback + Level AccessLevel +} + +// AccessResolver maps an inbound request, plus the agent and deployment its URL +// addresses, to the caller's access. ok is false when the request cannot be +// placed at all - no credential, or an unknown tenant - and the front +// challenges. +type AccessResolver func(r *http.Request, agentName, deployment string) (access Access, ok bool) + +type Front struct { + params FrontParams + pools *bridgePools + tunnels *Tunnels +} + +// Identity resolves the agent and deployment a request addresses. Reporting +// false leaves them to the URL. +type Identity func(r *http.Request) (agentName, deployment string, ok bool) + +// FrontParams configures a Front. Fields are read on every request once the +// Front is serving, so none may change after construction. +type FrontParams struct { + ResolveAccess AccessResolver + Logger logger.Logger + Identity Identity + + // MaxTunnelsPerWorker caps one worker's concurrent WebSocket tunnels. 0 + // means half its session's MaxStreams. + MaxTunnelsPerWorker int + // TunnelDrainTimeout is how long a tunnel outlives its worker starting to + // drain. 0 means DefaultTunnelDrainTimeout. + TunnelDrainTimeout time.Duration +} + +func NewFront(params FrontParams) *Front { + params.Logger = params.Logger.WithComponent("agents.endpoint") + return &Front{params: params, pools: newBridgePools(), tunnels: NewTunnels()} +} + +// Fallback serves a request elsewhere (e.g. a multi-node relay); it reports +// whether a response was written. Returning false falls back to the local +// status mapping. It is curried on the deployment it was resolved for, so it +// carries no scope arguments. +type Fallback func(w http.ResponseWriter, r *http.Request, level AccessLevel) bool + +// writeUnavailable writes a 503 with a Retry-After hint: no local worker can +// serve the request and no fallback placed it elsewhere. +func (f *Front) writeUnavailable(w http.ResponseWriter, msg string) { + w.Header().Set("Retry-After", "1") + http.Error(w, msg, http.StatusServiceUnavailable) +} + +func (f *Front) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ep, err := splitEndpointPath(r.URL) + if err != nil { + if errors.Is(err, errMalformedPath) { + http.Error(w, "bad request path", http.StatusBadRequest) + return + } + if errors.Is(err, errPathTooLong) { + http.Error(w, "uri too long", http.StatusRequestURITooLong) + return + } + http.NotFound(w, r) + return + } + agentName, deployment, path, escPath := ep.agentName, ep.deployment, ep.path, ep.escPath + if f.params.Identity != nil { + // must precede resolveAccess, which may consume its source headers + if name, dep, ok := f.params.Identity(r); ok { + agentName, deployment = name, dep + } + } + + reqID, ok := requestID(r) + if !ok { + http.Error(w, "invalid X-Request-Id", http.StatusBadRequest) + return + } + + access, ok := f.params.ResolveAccess(r, agentName, deployment) + if !ok { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "authentication required", http.StatusUnauthorized) + return + } + + tbl := access.Scope.routeTable() + if tbl == nil && access.Fallback == nil { + f.writeUnavailable(w, "no workers available for deployment") + return + } + + mask := methodMask(r.Method) + granted := access.Level >= AccessGranted + matched, partial, denied := f.matchDeployment(access.Scope, tbl, path, mask, granted) + // no exact match: if only the trailing-slash alternate matches a registered + // route, normalize the path to that form and serve it directly (no client + // redirect). The exact form is tried first, so a route registered with a + // trailing slash is served as-is; this only rewrites a slash mismatch toward + // the registered form. When the request must be relayed, the serving node + // runs this same normalization, so no redirect is ever emitted. + if len(matched) == 0 && !partial && !denied { + if alt, altEsc, ok := slashAlternatePaths(tbl, path, escPath, mask); ok { + path, escPath = alt, altEsc + matched, partial, denied = f.matchDeployment(access.Scope, tbl, path, mask, granted) + } + } + if len(matched) == 0 && access.Fallback != nil { + // nothing local matched: hand off to the multi-node fallback (relay to a + // node holding the deployment) before the local status mapping. The + // serving node's relay listener installs no fallback of its own, so a + // relayed request is served or errored there and never re-relays. + if access.Fallback(w, r, access.Level) { + return + } + if tbl == nil && !denied && !partial { + f.writeUnavailable(w, "no workers available for deployment") + return + } + } + if len(matched) == 0 { + switch { + case denied: + // access does not vary across candidates, so one verdict covers them all + if access.Level >= AccessCredentialed { + http.Error(w, "forbidden", http.StatusForbidden) + } else { + w.Header().Set("WWW-Authenticate", "Bearer") + http.Error(w, "authentication required", http.StatusUnauthorized) + } + case partial: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + default: + http.Error(w, "not found", http.StatusNotFound) + } + return + } + + var bodyConsumed atomic.Int64 + a := &attempt{ + req: r, + escPath: escPath, + target: requestTarget(escPath, r.URL.RawQuery), + requestID: reqID, + granted: granted, + webSocket: isWebSocketUpgrade(r), + pools: f.pools, + } + a.body = &countingReader{r: r.Body, n: &bodyConsumed} + a.preamble = a.newPreamble() + a.refreshTimeout() + if err := a.buildHead(); err != nil { + if errors.Is(err, errRequestHeadTooLarge) { + http.Error(w, "request header fields too large", http.StatusRequestHeaderFieldsTooLarge) + return + } + f.params.Logger.Debugw("agent endpoint rejected a request head", "error", err, "requestID", reqID) + http.Error(w, "bad request", http.StatusBadRequest) + return + } + + // must precede the first write, so the request body keeps streaming once the + // response has started + if err := http.NewResponseController(w).EnableFullDuplex(); err != nil && !errors.Is(err, http.ErrNotSupported) { + f.params.Logger.Debugw("agent endpoint cannot enable full duplex", "error", err) + } + + attempted := make(map[*Registration]bool) + if a.webSocket { + f.excludeTunnelsAtLimit(matched, attempted) + } + for range maxAttempts { + picked := pickWorker(matched, attempted) + if picked == nil { + break + } + attempted[picked.reg] = true + + a.before = bodyConsumed.Load() + a.refreshTimeout() + // the preamble is re-serialized per attempt, so this reaches only the + // worker it was set for + a.preamble.Route = picked.raw + switch f.bridge(w, a, picked.reg) { + case bridgeDone: + return + case bridgeAbort: + // the head is on the wire already, so nothing can report the failure in + // band. This must reach net/http to abort the response; anything that + // recovers it completes the body. + panic(http.ErrAbortHandler) + } + } + + // the route matched locally but nothing served it (matches draining or + // conn-less, or every attempt failed before writing): the fallback may hold + // capacity elsewhere. Safe exactly while no request bytes were consumed - + // reaching this point implies it, since consuming attempts are never + // retryable. + if bodyConsumed.Load() == 0 && access.Fallback != nil { + if access.Fallback(w, r, access.Level) { + return + } + } + + f.writeUnavailable(w, "no worker could serve the request") +} + +// matchDeployment resolves a path against the deployment's merged route table. +// matched is the dispatch test: a route can be decided while nothing is left to +// serve it, and an undecidable table dispatches with no route at all. +func (f *Front) matchDeployment(scope *Scope, tbl *routeTable, path string, mask router.Mask, granted bool) (matched []routeWorker, partial, denied bool) { + if tbl == nil { + return nil, false, false + } + var res router.Result + matched, _, res, denied = tbl.match(path, mask, granted) + switch res { + case router.ResultPartial: + partial = true + case router.ResultOverBudget: + // no route was decided, so its Public flag is unknown and only a grant + // can clear the request + if !granted { + denied = true + break + } + for _, reg := range scope.Candidates() { + matched = append(matched, routeWorker{reg: reg}) + } + } + return +} + +// slashAlternatePaths looks for the trailing-slash alternate of path in the +// deployment's table, returning the decoded and escaped forms to retry. Both +// forms come out of the same transform. +func slashAlternatePaths(tbl *routeTable, path, escPath string, mask router.Mask) (string, string, bool) { + if tbl == nil || path == "/" { + return path, escPath, false + } + var alt, altEsc string + if strings.HasSuffix(path, "/") { + // %2F decodes to a slash without being a separator, so the alternate + // applies only while the slash is literal in escPath too + if !strings.HasSuffix(escPath, "/") { + return path, escPath, false + } + alt, altEsc = strings.TrimSuffix(path, "/"), strings.TrimSuffix(escPath, "/") + } else { + alt, altEsc = path+"/", escPath+"/" + } + if !tbl.serves(alt, mask) { + return path, escPath, false + } + return alt, altEsc, true +} + +// endpointPath is a request split into its routing components. +type endpointPath struct { + agentName string + deployment string + // path is decoded, for manifest matching + path string + // escPath keeps the client's encoding, for the request line the worker gets + escPath string +} + +// splitEndpointPath splits /agents/{agent_name}/{deployment}/{path...} from the +// ESCAPED path. The worker is handed a re-serialized request line, so the target +// must keep the client's encoding: a decoded %3F or %2F re-emits as a real '?' +// or '/' and changes which resource the worker routes to. +func splitEndpointPath(u *url.URL) (endpointPath, error) { + rest, ok := strings.CutPrefix(u.EscapedPath(), PathPrefix) + if !ok { + return endpointPath{}, errNotEndpointPath + } + rawAgentName, rest, found := strings.Cut(rest, "/") + if !found || rawAgentName == "" { + return endpointPath{}, errNotEndpointPath + } + rawDeployment, escPath, found := strings.Cut(rest, "/") + if !found { + escPath = "" + } + if rawDeployment == "" { + return endpointPath{}, errNotEndpointPath + } + escPath = "/" + escPath + + if len(escPath) > MaxPathLength { + return endpointPath{}, errPathTooLong + } + + agentName, err1 := url.PathUnescape(rawAgentName) + deployment, err2 := url.PathUnescape(rawDeployment) + path, err3 := url.PathUnescape(escPath) + if err1 != nil || err2 != nil || err3 != nil { + return endpointPath{}, errMalformedPath + } + // "_" and "%5F" both land here; neither is a registrable name + // (IsReservedAgentName) + if agentName == UnnamedAgentSegment { + agentName = "" + } + return endpointPath{agentName: agentName, deployment: deployment, path: path, escPath: escPath}, nil +} + +// pickWorker chooses a worker by the power of two choices: sample two eligible +// declarations at random and take the one with fewer in-flight streams. +// Eligible = not already attempted, has a live session, not draining. +func pickWorker(workers []routeWorker, ignore map[*Registration]bool) *routeWorker { + var eligible []routeWorker + for _, w := range workers { + if ignore[w.reg] || !w.reg.HasSession() || w.reg.IsDraining() { + continue + } + eligible = append(eligible, w) + } + if len(eligible) == 0 { + return nil + } + return &eligible[p2c(eligible, func(w routeWorker) int { return w.reg.InflightStreams() })] +} + +// p2c returns the index of the less-loaded of two distinct random draws from +// items, which must be non-empty. +func p2c[T any](items []T, load func(T) int) int { + n := len(items) + if n == 1 { + return 0 + } + i := rand.IntN(n) + j := rand.IntN(n - 1) + if j >= i { // fold to a distinct second draw + j++ + } + if load(items[i]) <= load(items[j]) { + return i + } + return j +} + +// bridgeOutcome is what one attempt against one worker concluded. +type bridgeOutcome int + +const ( + // nothing reached the client; another worker may still serve it + bridgeRetry bridgeOutcome = iota + // a response, or an error standing in for one, reached the client + bridgeDone + // the response was committed and cannot be completed + bridgeAbort +) + +// bridge runs one attempt against one worker. +func (f *Front) bridge(w http.ResponseWriter, a *attempt, reg *Registration) bridgeOutcome { + if a.webSocket { + if !reg.tryAcquireTunnel(f.tunnelLimit(reg)) { + return bridgeRetry + } + defer reg.releaseTunnel() + } + + ctx := a.req.Context() + stream, err := reg.OpenStream(ctx) + if err != nil { + return bridgeRetry // no session/capacity here; try another worker + } + defer stream.Close() + + stop := context.AfterFunc(ctx, func() { + stream.Reset(livekit.AgentHttp_HSR_ABORT, "client disconnected") + }) + defer stop() + + // serialize the request into the stream concurrently with response reading: + // directions are independent (full duplex within the stream) + writeErrCh := make(chan error, 1) + go func() { + err := a.writeRequest(stream) + switch { + case err == nil && a.webSocket: + // the send side stays open: on a 101 it carries the client's frames + case err == nil: + err = stream.CloseWrite() + default: + // fail fast: the worker is waiting for bytes that will never come + stream.Reset(livekit.AgentHttp_HSR_ABORT, "request write failed") + } + writeErrCh <- err + }() + + lim := &headLimiter{r: stream, n: maxResponseHeadSize} + br := f.pools.getReader(lim) + defer f.pools.putReader(br) + + resp, err := a.readResponse(w, br, lim, stream) + if err != nil { + err = completionError(err) + if !a.retryable(err) { + f.params.Logger.Warnw("agent endpoint request failed", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + writeGatewayError(w, err) + return bridgeDone + } + // join the request writer before another attempt touches the shared + // body reader (retries are bodyless per the table, so this is prompt) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "retrying elsewhere") + <-writeErrCh + return bridgeRetry + } + // resp.Body must not be Closed: net/http's Close drains whatever the head + // declared and the body has not delivered, blocking on a stream that is + // about to be reset. stream.Close owns the underlying resource. + + if resp.StatusCode == http.StatusSwitchingProtocols { + f.spliceWebSocket(w, a, reg, stream, br, resp, writeErrCh) + return bridgeDone + } + + // a response head arrived: from here every failure is surfaced + copyResponseHeaders(w.Header(), resp.Header) + w.WriteHeader(resp.StatusCode) + a.committed = true + + rc := http.NewResponseController(w) + bufp := f.pools.getBuf() + buf := *bufp + defer f.pools.putBuf(bufp) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + stream.Reset(livekit.AgentHttp_HSR_ABORT, "client write failed") + return bridgeDone + } + _ = rc.Flush() + } + if rerr == io.EOF { + break + } + if rerr != nil { + // never expose a clean-looking short body + return f.aborted(rerr, reg, a, writeErrCh) + } + } + // framing complete; the sender may still report a short body in trailers + if ce := wire.CompletionFromTrailers(resp.Trailer); ce != nil { + return f.aborted(ce, reg, a, writeErrCh) + } + if a.webSocket { + // the request writer left the send side open; a declined upgrade ends + // with a FIN + if err := <-writeErrCh; err == nil { + _ = stream.CloseWrite() + } + } + return bridgeDone +} + +// aborted logs why a committed response cannot be completed. +func (f *Front) aborted(err error, reg *Registration, a *attempt, writeErrCh <-chan error) bridgeOutcome { + f.logAborted(err, reg, a) + select { + case werr := <-writeErrCh: + f.params.Logger.Debugw("request write result after response failure", "error", werr) + default: + } + return bridgeAbort +} + +func (f *Front) logAborted(err error, reg *Registration, a *attempt) { + var ce *wire.CompletionError + if errors.As(err, &ce) { + f.params.Logger.Infow("agent endpoint response aborted", + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID, + "completion", string(ce.Completion), "reason", ce.Reason) + return + } + f.params.Logger.Infow("agent endpoint response aborted", + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID, "error", err) +} + +// completionError normalizes a failure into the protocol's outcome vocabulary. +// A peer reset code becomes the outcome; anything else leaves dispatch unknown. +func completionError(err error) error { + var sre *StreamResetError + if errors.As(err, &sre) { + return &wire.CompletionError{Completion: wire.CompletionFromResetCode(sre.Code)} + } + return err +} + +// writeGatewayError maps a terminal state to a status for a request whose +// response never reached the client. +func writeGatewayError(w http.ResponseWriter, err error) { + var ce *wire.CompletionError + if errors.As(err, &ce) && ce.Completion == wire.CompletionTimeout { + http.Error(w, "gateway timeout", http.StatusGatewayTimeout) + return + } + if errors.Is(err, os.ErrDeadlineExceeded) { + http.Error(w, "gateway timeout", http.StatusGatewayTimeout) + return + } + http.Error(w, "bad gateway", http.StatusBadGateway) +} + +// bridgePools holds the per-request scratch one Front reuses: a body buffer and +// the buffered reader http.ReadResponse parses through. +type bridgePools struct { + bodyBuf sync.Pool + readers sync.Pool +} + +func newBridgePools() *bridgePools { + return &bridgePools{ + bodyBuf: sync.Pool{New: func() any { b := make([]byte, wire.BodyChunkSize); return &b }}, + readers: sync.Pool{New: func() any { return bufio.NewReaderSize(nil, responseBufSize) }}, + } +} + +func (p *bridgePools) getBuf() *[]byte { return p.bodyBuf.Get().(*[]byte) } +func (p *bridgePools) putBuf(b *[]byte) { p.bodyBuf.Put(b) } + +func (p *bridgePools) getReader(r io.Reader) *bufio.Reader { + br := p.readers.Get().(*bufio.Reader) + br.Reset(r) + return br +} + +// putReader drops the stream reference so a pooled reader never pins a dead one. +func (p *bridgePools) putReader(br *bufio.Reader) { + br.Reset(nil) + p.readers.Put(br) +} + +// headLimiter caps the bytes a response head may make this node buffer. release +// lifts the cap once the head is parsed; the body behind it is unbounded. +type headLimiter struct { + r io.Reader + n int64 // remaining head budget; negative once the head has been parsed +} + +func (h *headLimiter) Read(p []byte) (int, error) { + if h.n == 0 { + return 0, errHeadTooLarge + } + if h.n > 0 && int64(len(p)) > h.n { + p = p[:h.n] + } + n, err := h.r.Read(p) + if h.n > 0 { + h.n -= int64(n) + } + return n, err +} + +func (h *headLimiter) release() { h.n = -1 } diff --git a/pkg/agent/endpoint/front_test.go b/pkg/agent/endpoint/front_test.go new file mode 100644 index 000000000..089c692b5 --- /dev/null +++ b/pkg/agent/endpoint/front_test.go @@ -0,0 +1,409 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// grantedTo resolves every request to scope with full access. +func grantedTo(scope *Scope) AccessResolver { + return func(*http.Request, string, string) (Access, bool) { + return Access{Scope: scope, Level: AccessGranted}, true + } +} + +// resolveTo resolves every request to a fixed access. +func resolveTo(a Access) AccessResolver { + return func(*http.Request, string, string) (Access, bool) { return a, true } +} + +func fallbackFront(t *testing.T, fb Fallback, withWorker bool) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + if withWorker { + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/known", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &fakeSession{}})) + } + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Fallback: fb, Level: AccessGranted}), + Logger: logger.GetLogger(), + }) +} + +func serveFront(f *Front, path string) *httptest.ResponseRecorder { + w := httptest.NewRecorder() + f.ServeHTTP(w, httptest.NewRequest(http.MethodGet, PathPrefix+"a/d"+path, nil)) + return w +} + +// a path no local worker matches hands off to the fallback the resolver curried, +// which is given the caller's access level; when the fallback serves, the front +// writes nothing itself. +func TestFrontFallbackFires(t *testing.T) { + got := AccessLevel(-1) + f := fallbackFront(t, func(w http.ResponseWriter, _ *http.Request, level AccessLevel) bool { + got = level + w.WriteHeader(http.StatusTeapot) // stands in for a relayed response + return true + }, true) + + w := serveFront(f, "/unknown") + require.Equal(t, http.StatusTeapot, w.Code) + require.Equal(t, AccessGranted, got) +} + +// a declined fallback with a local worker present falls through to the front's +// own 404 for the unmatched path. +func TestFrontFallbackDeclinedMapsStatus(t *testing.T) { + f := fallbackFront(t, func(http.ResponseWriter, *http.Request, AccessLevel) bool { return false }, true) + require.Equal(t, http.StatusNotFound, serveFront(f, "/unknown").Code) +} + +// a declined fallback with no local worker for the deployment falls through to +// 503. +func TestFrontFallbackDeclinedNoCandidates(t *testing.T) { + f := fallbackFront(t, func(http.ResponseWriter, *http.Request, AccessLevel) bool { return false }, false) + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/unknown").Code) +} + +// a resolver that cannot place the request at all challenges, and never reaches +// a scope. This is the path that used to ride an empty api key. +func TestFrontUnresolvedAccessIsChallenged(t *testing.T) { + f := NewFront(FrontParams{ + ResolveAccess: func(*http.Request, string, string) (Access, bool) { return Access{}, false }, + Logger: logger.GetLogger(), + }) + w := serveFront(f, "/anything") + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, "Bearer", w.Header().Get("WWW-Authenticate")) +} + +// a resolved caller whose deployment no worker holds here, with no fallback, is +// 503 - distinct from the 401 above, and no longer distinguished by a string. +func TestFrontResolvedButNoScopeIsUnavailable(t *testing.T) { + f := NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Level: AccessGranted}), + Logger: logger.GetLogger(), + }) + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/anything").Code) +} + +func TestRequestIDAcceptsOrRefuses(t *testing.T) { + atBound := strings.Repeat("x", maxRequestIDLen) + cases := []struct { + name string + values []string + want string + ok bool + }{ + {name: "absent mints one", ok: true}, + {name: "uuid", values: []string{"f81d4fae-7dec-11d0-a765-00a0c91e6bf6"}, want: "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", ok: true}, + {name: "base64url", values: []string{"a-B_c9=="}, want: "a-B_c9==", ok: true}, + {name: "at the length bound", values: []string{atBound}, want: atBound, ok: true}, + {name: "past the length bound", values: []string{atBound + "x"}}, + {name: "empty value", values: []string{""}}, + {name: "newline", values: []string{"ab\ncd"}}, + {name: "space", values: []string{"ab cd"}}, + {name: "non ascii", values: []string{"abc\u00e9"}}, + {name: "two values", values: []string{"a", "b"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/x", nil) + for _, v := range tc.values { + r.Header.Add("X-Request-Id", v) + } + got, ok := requestID(r) + require.Equal(t, tc.ok, ok) + switch { + case !tc.ok: + require.Empty(t, got, "a refused token must not reach the worker or the logs") + case tc.want != "": + require.Equal(t, tc.want, got, "a client token is never rewritten") + default: + require.True(t, strings.HasPrefix(got, "AER_")) + } + }) + } +} + +// a token the front cannot honor is refused before any routing or dispatch, so +// the caller learns to retry under one it will honor instead of silently losing +// idempotence to a substitute. +func TestFrontRefusesInvalidRequestID(t *testing.T) { + f := fallbackFront(t, nil, true) + + serve := func(token string) *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodGet, PathPrefix+"a/d/known", nil) + r.Header.Set("X-Request-Id", token) + w := httptest.NewRecorder() + f.ServeHTTP(w, r) + return w + } + + require.Equal(t, http.StatusBadRequest, serve(strings.Repeat("x", maxRequestIDLen+1)).Code) + // same route, acceptable token: the request reaches dispatch (and fails + // there for want of a stream), so the 400 above came from the token alone + require.Equal(t, http.StatusServiceUnavailable, serve("f81d4fae-7dec").Code) +} + +// the preamble is built once, so a retry must not hand the worker a budget +// already spent. +func TestRefreshTimeoutTracksRemainingBudget(t *testing.T) { + newAttempt := func(ctx context.Context) *attempt { + a := &attempt{req: httptest.NewRequest(http.MethodGet, "/x", nil).WithContext(ctx)} + a.preamble = a.newPreamble() + return a + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + a := newAttempt(ctx) + a.refreshTimeout() + first := a.preamble.GetTimeoutMs() + require.NotZero(t, first) + + time.Sleep(25 * time.Millisecond) + a.refreshTimeout() + require.Less(t, a.preamble.GetTimeoutMs(), first) + + // no deadline: 0 is the proto's "no deadline" + none := newAttempt(context.Background()) + none.refreshTimeout() + require.Zero(t, none.preamble.GetTimeoutMs()) + + // an expired budget is not "no deadline" + expired, cancelExpired := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancelExpired() + spent := newAttempt(expired) + spent.refreshTimeout() + require.EqualValues(t, 1, spent.preamble.GetTimeoutMs()) +} + +// accessFront registers one worker serving a public and a non-public route, and +// resolves every request to the given access. +func accessFront(t *testing.T, level AccessLevel, fb Fallback) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/pub", Methods: []string{"GET"}, Public: true}, + {Path: "/private", Methods: []string{"GET"}, Public: false}, + }) + require.NoError(t, err) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &fakeSession{}})) + + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Fallback: fb, Level: level}), + Logger: logger.GetLogger(), + }) +} + +// fakeSession opens no stream, so a request that clears authorization reaches 503. +func TestFrontPrivateRouteAccessMapping(t *testing.T) { + anonymous, credentialed, granted := AccessNone, AccessCredentialed, AccessGranted + + t.Run("anonymous is challenged", func(t *testing.T) { + w := serveFront(accessFront(t, anonymous, nil), "/private") + require.Equal(t, http.StatusUnauthorized, w.Code) + require.Equal(t, "Bearer", w.Header().Get("WWW-Authenticate")) + }) + t.Run("credential without the grant is refused, not challenged", func(t *testing.T) { + w := serveFront(accessFront(t, credentialed, nil), "/private") + require.Equal(t, http.StatusForbidden, w.Code) + require.Empty(t, w.Header().Get("WWW-Authenticate")) + }) + t.Run("granted passes authorization", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, granted, nil), "/private").Code) + }) + t.Run("a credential without the grant keeps public access", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, credentialed, nil), "/pub").Code) + }) + t.Run("anonymous keeps public access", func(t *testing.T) { + require.Equal(t, http.StatusServiceUnavailable, serveFront(accessFront(t, anonymous, nil), "/pub").Code) + }) +} + +// the slash-normalized form of a private route is still private. +func TestFrontDeniedAppliesToNormalizedPath(t *testing.T) { + f := accessFront(t, AccessCredentialed, nil) + require.Equal(t, http.StatusForbidden, serveFront(f, "/private/").Code) +} + +// another node's worker may declare the same path public. +func TestFrontDeniedStillRelays(t *testing.T) { + got := AccessLevel(-1) + f := accessFront(t, AccessCredentialed, func(w http.ResponseWriter, _ *http.Request, level AccessLevel) bool { + got = level + w.WriteHeader(http.StatusTeapot) + return true + }) + + require.Equal(t, http.StatusTeapot, serveFront(f, "/private").Code) + require.Equal(t, AccessCredentialed, got) +} + +// the split runs before decoding, so a name or route param may carry any byte +// a percent-encoded segment can hold. +func TestSplitEndpointPath(t *testing.T) { + cases := []struct { + name string + target string + agentName string + deployment string + path string + escPath string + err error + }{ + {name: "plain", target: "/agents/a/d/x", agentName: "a", deployment: "d", path: "/x", escPath: "/x"}, + {name: "no tail", target: "/agents/a/d", agentName: "a", deployment: "d", path: "/", escPath: "/"}, + {name: "spaces", target: "/agents/LODHA%20Vayam%20Agent/d/x", agentName: "LODHA Vayam Agent", deployment: "d", path: "/x", escPath: "/x"}, + {name: "colon and star", target: "/agents/prod%3A%2A/d/x", agentName: "prod:*", deployment: "d", path: "/x", escPath: "/x"}, + {name: "at sign", target: "/agents/charlie%40v1.42.0/d/x", agentName: "charlie@v1.42.0", deployment: "d", path: "/x", escPath: "/x"}, + {name: "brackets", target: "/agents/Nathan%20%5BElara%5D/d/x", agentName: "Nathan [Elara]", deployment: "d", path: "/x", escPath: "/x"}, + {name: "slash in name", target: "/agents/a%2Fb/d/x", agentName: "a/b", deployment: "d", path: "/x", escPath: "/x"}, + {name: "non ascii raw", target: "/agents/agent-ü/d/x", agentName: "agent-ü", deployment: "d", path: "/x", escPath: "/x"}, + {name: "non ascii encoded", target: "/agents/agent-%C3%BC/d/x", agentName: "agent-ü", deployment: "d", path: "/x", escPath: "/x"}, + {name: "past the old 64 byte cap", target: "/agents/" + strings.Repeat("n", 77) + "/d/x", agentName: strings.Repeat("n", 77), deployment: "d", path: "/x", escPath: "/x"}, + {name: "deployment encoded", target: "/agents/a/prod%20us/x", agentName: "a", deployment: "prod us", path: "/x", escPath: "/x"}, + + // escPath keeps the client's encoding; path is what the manifest matches + {name: "encoded tail", target: "/agents/a/d/files/a%2Fb", agentName: "a", deployment: "d", path: "/files/a/b", escPath: "/files/a%2Fb"}, + {name: "escaped percent in tail", target: "/agents/a/d/%2541", agentName: "a", deployment: "d", path: "/%41", escPath: "/%2541"}, + + // "_" addresses the unnamed agent in either form + {name: "bare underscore is unnamed", target: "/agents/_/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, + {name: "encoded underscore is unnamed", target: "/agents/%5F/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, + {name: "double encoded underscore is a name", target: "/agents/%255F/d/x", agentName: "%5F", deployment: "d", path: "/x", escPath: "/x"}, + + {name: "not an endpoint path", target: "/other/x", err: errNotEndpointPath}, + {name: "no deployment segment", target: "/agents/a", err: errNotEndpointPath}, + {name: "empty name", target: "/agents//d/x", err: errNotEndpointPath}, + {name: "empty deployment", target: "/agents/a//x", err: errNotEndpointPath}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + u, err := url.ParseRequestURI(c.target) + require.NoError(t, err) + + ep, err := splitEndpointPath(u) + if c.err != nil { + require.ErrorIs(t, err, c.err) + return + } + require.NoError(t, err) + require.Equal(t, c.agentName, ep.agentName) + require.Equal(t, c.deployment, ep.deployment) + require.Equal(t, c.path, ep.path) + require.Equal(t, c.escPath, ep.escPath) + }) + } +} + +// a trailing %2F decodes to a slash without being a separator, so the +// trailing-slash alternate does not apply to it. +func TestFrontSlashAlternateIgnoresEncodedSlash(t *testing.T) { + f := fallbackFront(t, nil, true) + + // 503 is dispatch reached: the fake session opens no stream + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, "/known/").Code) + require.Equal(t, http.StatusNotFound, serveFront(f, "/known%2F").Code) +} + +func TestIsReservedAgentName(t *testing.T) { + for _, n := range []string{"_", ".", ".."} { + require.True(t, IsReservedAgentName(n), n) + } + for _, n := range []string{"", "a", "_x", "x_", "...", "LODHA Vayam Agent", "%5F"} { + require.False(t, IsReservedAgentName(n), n) + } +} + +// matching runs before anything sizes the request head, so the split is where +// an over-long path is refused. +func TestSplitEndpointPathLength(t *testing.T) { + long := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength) + _, err := splitEndpointPath(&url.URL{Path: long, RawPath: long}) + require.ErrorIs(t, err, errPathTooLong) + + f := fallbackFront(t, nil, true) + require.Equal(t, http.StatusRequestURITooLong, serveFront(f, "/"+strings.Repeat("x", MaxPathLength)).Code) + + ok := PathPrefix + "a/d/" + strings.Repeat("x", MaxPathLength-4) + _, err = splitEndpointPath(&url.URL{Path: ok, RawPath: ok}) + require.NoError(t, err) +} + +// budgetFront registers a worker whose routes are ambiguous enough that the +// matcher gives up deciding. +func budgetFront(t *testing.T, level AccessLevel) *Front { + g, s := NewRegistry(), NewScope(logger.GetLogger()) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/{a}{b}{c}{d}x", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + require.Len(t, m.Ambiguous(), 1) + g.Register(s, NewRegistration(RegistrationParams{ + WorkerID: "w1", Manifest: m, Session: &fakeSession{}, + })) + return NewFront(FrontParams{ + ResolveAccess: resolveTo(Access{Scope: s, Level: level}), + Logger: logger.GetLogger(), + }) +} + +// A table too ambiguous to decide forwards to the worker. No route is decided, +// so its Public flag is unknown and only a grant clears the request. +func TestFrontOverBudgetForwardsWithAGrant(t *testing.T) { + long := "/" + strings.Repeat("a", 512) + + f := budgetFront(t, AccessGranted) + // 503 is dispatch reached: the fake session opens no stream + require.Equal(t, http.StatusServiceUnavailable, serveFront(f, long).Code) + + f = budgetFront(t, AccessCredentialed) + require.Equal(t, http.StatusForbidden, serveFront(f, long).Code) + + f = budgetFront(t, AccessNone) + require.Equal(t, http.StatusUnauthorized, serveFront(f, long).Code) + + // a path the same table decides normally is unaffected + f = budgetFront(t, AccessNone) + require.Equal(t, http.StatusNotFound, serveFront(f, "/ab").Code) +} + +func TestMergeWorkerHeaders(t *testing.T) { + sharedVary := []string{"Origin"} + dst := http.Header{ + "Access-Control-Allow-Origin": {"https://server.example"}, + "Access-Control-Expose-Headers": {"*"}, + "Vary": sharedVary, + "Set-Cookie": {"a=1"}, + } + MergeWorkerHeaders(dst, http.Header{ + "Access-Control-Allow-Origin": {"https://app.example"}, + "Vary": {"Accept-Encoding"}, + "Set-Cookie": {"b=2"}, + "Link": {"; rel=preload", "; rel=preload"}, + }) + + require.Equal(t, []string{"https://app.example"}, dst.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"*"}, dst.Values("Access-Control-Expose-Headers"), "a key the worker left alone keeps the server's value") + require.Equal(t, []string{"Accept-Encoding"}, dst.Values("Vary")) + require.Equal(t, []string{"a=1", "b=2"}, dst.Values("Set-Cookie")) + require.Equal(t, []string{"; rel=preload", "; rel=preload"}, dst.Values("Link")) + require.Equal(t, []string{"Origin"}, sharedVary, "a slice the middleware shares must not be written through") +} diff --git a/pkg/agent/endpoint/headerbound_test.go b/pkg/agent/endpoint/headerbound_test.go new file mode 100644 index 000000000..7cff6ca4f --- /dev/null +++ b/pkg/agent/endpoint/headerbound_test.go @@ -0,0 +1,81 @@ +// 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 endpoint_test + +import ( + "fmt" + "io" + "net" + "net/http" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +func padHeaders(t *testing.T, url string, bytes int) *http.Request { + t.Helper() + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + const per = 8 << 10 + for i := 0; i*per < bytes; i++ { + req.Header.Set(fmt.Sprintf("X-Pad-%d", i), strings.Repeat("p", per)) + } + return req +} + +// A head too large to serialize is answered directly and reaches no worker: no +// worker could accept it, so spending an attempt on it only turns a permanent +// failure into a retryable-looking one. +func TestOversizedHeadIsRejectedWithoutReachingAWorker(t *testing.T) { + var hits atomic.Int32 + addr := rawTarget(t, func(c net.Conn) { + hits.Add(1) + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/h", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.DefaultClient.Do(padHeaders(t, base+"/h", wire.MaxRequestHeadSize+(64<<10))) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, http.StatusRequestHeaderFieldsTooLarge, resp.StatusCode, + "an unservable head must not come back as a retryable 503") + require.Empty(t, resp.Header.Get("Retry-After"), "the request can never succeed, so it must not invite a retry") + require.Zero(t, hits.Load(), "no worker attempt may be spent on a head no worker could accept") +} + +// A head comfortably inside the bound is served normally. +func TestLargeHeadWithinBoundIsServed(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/h", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.DefaultClient.Do(padHeaders(t, base+"/h", 512<<10)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "ok", string(body)) +} diff --git a/pkg/agent/endpoint/largebody_test.go b/pkg/agent/endpoint/largebody_test.go new file mode 100644 index 000000000..c00136bc7 --- /dev/null +++ b/pkg/agent/endpoint/largebody_test.go @@ -0,0 +1,151 @@ +// 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 endpoint_test + +import ( + "bufio" + "fmt" + "io" + "net" + "net/http" + "runtime" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +// filler produces an endless recognizable stream. The pattern is keyed to the +// absolute offset so it stays continuous across reads. +type filler struct{ off int } + +func (f *filler) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte('a' + f.off%26) + f.off++ + } + return len(p), nil +} + +// verify checks the pattern without retaining the body. +type verify struct { + n int64 + off int +} + +func (v *verify) Write(p []byte) (int, error) { + for _, b := range p { + if b != byte('a'+v.off%26) { + return 0, fmt.Errorf("corrupt byte at offset %d", v.n) + } + v.off++ + v.n++ + } + return len(p), nil +} + +func heapInUse() uint64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.HeapInuse +} + +// A response far larger than any buffer in the path streams to the client +// without the node's heap growing with the body. +func TestLargeResponseStreamsInConstantMemory(t *testing.T) { + if testing.Short() { + t.Skip("moves 512MiB") + } + const bodySize = 512 << 20 + + addr := rawTarget(t, func(c net.Conn) { + _, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", bodySize) + _, _ = io.CopyN(c, &filler{}, bodySize) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/big", Methods: []string{"GET"}, Public: true}, + }) + + before := heapInUse() + + resp, err := http.Get(base + "/big") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + v := &verify{} + n, err := io.Copy(v, resp.Body) + require.NoError(t, err) + require.EqualValues(t, bodySize, n) + + growth := int64(heapInUse()) - int64(before) + t.Logf("streamed %d MiB; node heap growth %d KiB", bodySize>>20, growth>>10) + require.Less(t, growth, int64(32<<20), + "the body must not be buffered: heap grew %d MiB streaming %d MiB", growth>>20, bodySize>>20) +} + +// The same in the request direction. +func TestLargeRequestStreamsInConstantMemory(t *testing.T) { + if testing.Short() { + t.Skip("moves 512MiB") + } + const bodySize = 512 << 20 + + received := make(chan int64, 1) + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + req, err := http.ReadRequest(newReqReader(c)) + if err != nil { + return + } + v := &verify{} + n, _ := io.Copy(v, req.Body) + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + received <- n + }() + + base := startFramedWorker(t, ln.Addr().String(), []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/upload", Methods: []string{"POST"}, Public: true}, + }) + + before := heapInUse() + + req, err := http.NewRequest(http.MethodPost, base+"/upload", io.LimitReader(&filler{}, bodySize)) + require.NoError(t, err) + req.ContentLength = bodySize + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + require.EqualValues(t, bodySize, <-received) + + growth := int64(heapInUse()) - int64(before) + t.Logf("uploaded %d MiB; node heap growth %d KiB", bodySize>>20, growth>>10) + require.Less(t, growth, int64(32<<20), + "the body must not be buffered: heap grew %d MiB uploading %d MiB", growth>>20, bodySize>>20) +} + +func newReqReader(c net.Conn) *bufio.Reader { return bufio.NewReader(c) } diff --git a/pkg/agent/endpoint/manifest.go b/pkg/agent/endpoint/manifest.go new file mode 100644 index 000000000..2946f986e --- /dev/null +++ b/pkg/agent/endpoint/manifest.go @@ -0,0 +1,156 @@ +// 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 endpoint + +import ( + "errors" + "fmt" + "net/http" + "strings" + + "go.uber.org/zap/zapcore" + + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" +) + +const MaxManifestRoutes = 256 + +// Manifest is one worker's validated route table, in declaration order. +type Manifest struct { + Endpoints []Endpoint + ambiguous []string +} + +// Endpoint is one validated manifest entry. Mask carries the methods exactly as +// declared, so it may name several. +type Endpoint struct { + Template *router.Template + Mask router.Mask + Public bool +} + +// Ambiguous returns the declared templates whose shape forces the matcher to +// backtrack. The result is read-only. +func (m *Manifest) Ambiguous() []string { + if m == nil { + return nil + } + return m.ambiguous +} + +// MarshalLogObject describes the manifest's shape. +func (m *Manifest) MarshalLogObject(e zapcore.ObjectEncoder) error { + if m == nil { + return nil + } + e.AddInt("routes", len(m.Endpoints)) + if len(m.ambiguous) == 0 { + return nil + } + return e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error { + for _, t := range m.ambiguous { + a.AppendString(t) + } + return nil + })) +} + +// methodMask tags a route with the methods it serves. The set is exactly the +// verbs FastAPI can route; anything else masks to 0 and matches nothing. +func methodMask(method string) router.Mask { + switch method { + case http.MethodGet: + return 1 << 0 + case http.MethodHead: + return 1 << 1 + case http.MethodPost: + return 1 << 2 + case http.MethodPut: + return 1 << 3 + case http.MethodPatch: + return 1 << 4 + case http.MethodDelete: + return 1 << 5 + case http.MethodOptions: + return 1 << 6 + case http.MethodTrace: + return 1 << 7 + } + return 0 +} + +// NegotiateSettings validates a registration's endpoint manifest and negotiates +// the data-plane protocol version. The data plane is a WebTransport session (no +// attach token, no fixed connection pool), so the version is all there is to +// agree on. +func NegotiateSettings(req *livekit.RegisterWorkerRequest) (*livekit.AgentHttp_AgentEndpointSettings, error) { + if _, err := ParseManifest(req.GetEndpoints()); err != nil { + return nil, err + } + if req.GetInstanceId() == "" { + return nil, errors.New("registrations with endpoints require an instance_id") + } + if req.GetEndpointProtocol() == 0 { + return nil, errors.New("worker declared endpoints but no endpoint protocol; upgrade the agent SDK to one that speaks the endpoint data plane") + } + // the worker frames to the version returned here, so it must be the + // negotiated one and not the server's own constant + negotiated := min(req.GetEndpointProtocol(), wire.CurrentProtocol) + if negotiated < wire.MinProtocol { + return nil, fmt.Errorf("unsupported agent endpoint protocol %d (this server serves %d..%d)", + req.GetEndpointProtocol(), wire.MinProtocol, wire.CurrentProtocol) + } + return &livekit.AgentHttp_AgentEndpointSettings{Protocol: negotiated}, nil +} + +// ParseManifest validates a registration's endpoint list. +func ParseManifest(endpoints []*livekit.AgentHttp_AgentEndpoint) (*Manifest, error) { + if len(endpoints) > MaxManifestRoutes { + return nil, fmt.Errorf("manifest exceeds %d routes", MaxManifestRoutes) + } + m := &Manifest{Endpoints: make([]Endpoint, 0, len(endpoints))} + for _, ep := range endpoints { + tpl, err := router.ParseTemplate(ep.GetPath()) + if err != nil { + return nil, err + } + if ep.GetKind() != livekit.AgentHttp_AEK_HTTP { + return nil, fmt.Errorf("endpoint %q has unsupported kind %s", ep.GetPath(), ep.GetKind()) + } + if len(ep.GetMethods()) == 0 { + return nil, fmt.Errorf("endpoint %q declares no methods", ep.GetPath()) + } + var mask router.Mask + for _, method := range ep.GetMethods() { + u := strings.ToUpper(method) + if u != method { + return nil, fmt.Errorf("endpoint %q method %q must be uppercase", ep.GetPath(), method) + } + b := methodMask(u) + if b == 0 { + return nil, fmt.Errorf("endpoint %q declares unsupported method %q", ep.GetPath(), method) + } + mask |= b + } + if tpl.Ambiguous() { + m.ambiguous = append(m.ambiguous, tpl.String()) + } + m.Endpoints = append(m.Endpoints, Endpoint{Template: tpl, Mask: mask, Public: ep.GetPublic()}) + } + return m, nil +} diff --git a/pkg/agent/endpoint/manifest_test.go b/pkg/agent/endpoint/manifest_test.go new file mode 100644 index 000000000..d81922437 --- /dev/null +++ b/pkg/agent/endpoint/manifest_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +func ep(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint { + return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public} +} + +func TestManifestFullPartialSemantics(t *testing.T) { + // POST /x registered after GET /x must still serve POSTs (starlette scans + // for a FULL match before settling for the PARTIAL 405) + tbl := tableOf(t, mustManifest(t, + ep("/x", []string{"GET"}, true), + ep("/x", []string{"POST"}, true), + )) + + _, r, res, _ := tbl.match("/x", methodMask(http.MethodPost), true) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/x", r.Template.String()) + + // the manifest carries the app's methods verbatim: FastAPI does not imply + // HEAD from GET, so neither does the matcher + _, _, res, _ = tbl.match("/x", methodMask(http.MethodHead), true) + require.Equal(t, router.ResultPartial, res) + + // PARTIAL only when no route serves the method + _, _, res, _ = tbl.match("/x", methodMask(http.MethodDelete), true) + require.Equal(t, router.ResultPartial, res) + + // an unroutable method masks to 0 + _, _, res, _ = tbl.match("/x", methodMask("BREW"), true) + require.Equal(t, router.ResultPartial, res) + + _, _, res, _ = tbl.match("/nope", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultNone, res) +} + +func TestSlashAlternatePaths(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/hook", []string{"POST"}, true), + ep("/", []string{"POST"}, true), + )) + + cases := []struct { + path, escPath string + alt, altEsc string + ok bool + }{ + // a slash-mismatched request normalizes to the registered form + {"/hook/", "/hook/", "/hook", "/hook", true}, + // no route matches either slash form + {"/other/", "/other/", "/other/", "/other/", false}, + // %2F decodes to a slash without being a separator + {"/hook/", "/hook%2F", "/hook/", "/hook%2F", false}, + // "//" trims to "/" in both forms + {"//", "//", "/", "/", true}, + // "/" has no alternate to try + {"/", "/", "/", "/", false}, + } + for _, c := range cases { + alt, altEsc, ok := slashAlternatePaths(tbl, c.path, c.escPath, methodMask(http.MethodPost)) + require.Equal(t, c.ok, ok, "%q %q", c.path, c.escPath) + require.Equal(t, c.alt, alt, "%q %q", c.path, c.escPath) + require.Equal(t, c.altEsc, altEsc, "%q %q", c.path, c.escPath) + } +} + +// Templates anchor as `\n?$`: path refuses '\n' where str accepts it, so a +// decoded %0A would otherwise change which route wins. +func TestManifestTrailingNewlineRouteIdentity(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/files/{p:path}", []string{"GET"}, false), + ep("/files/{p}", []string{"GET"}, true), + )) + + matched, r, res, denied := tbl.match("/files/x\n", methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/files/{p:path}", r.Template.String()) + require.Empty(t, matched) + require.True(t, denied, "the private route must win, as it does on the worker") +} + +func TestManifestValidation(t *testing.T) { + _, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", nil, false)}) + require.Error(t, err, "http endpoint without methods") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"get"}, false)}) + require.Error(t, err, "lowercase method") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x", []string{"GTE"}, false)}) + require.Error(t, err, "unsupported method rejected") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("no-slash", []string{"GET"}, false)}) + require.Error(t, err, "template without a leading slash") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ep("/x/{a:slug}", []string{"GET"}, false)}) + require.Error(t, err, "custom convertor") + + // the full FastAPI/starlette verb set is accepted + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + ep("/x", []string{"GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"}, false), + }) + require.NoError(t, err, "all FastAPI verbs accepted") + + _, err = ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ws", Kind: livekit.AgentHttp_AEK_TEXT, Methods: []string{"GET"}}, + }) + require.Error(t, err, "unsupported endpoint kind") +} diff --git a/pkg/agent/endpoint/pick_test.go b/pkg/agent/endpoint/pick_test.go new file mode 100644 index 000000000..ed7a54be8 --- /dev/null +++ b/pkg/agent/endpoint/pick_test.go @@ -0,0 +1,42 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestP2CChoice(t *testing.T) { + // a single candidate is always chosen + require.Equal(t, 0, p2c([]int{42}, func(int) int { return 42 })) + + // with two candidates both are always drawn, so the lower-load one wins + // deterministically regardless of the random draw + for i := 0; i < 100; i++ { + require.Equal(t, 1, p2c([]int{5, 2}, func(v int) int { return v })) + require.Equal(t, 0, p2c([]int{2, 5}, func(v int) int { return v })) + } + + // larger n: the pick is always in range + for i := 0; i < 500; i++ { + idx := p2c(make([]int, 5), func(int) int { return 0 }) + require.GreaterOrEqual(t, idx, 0) + require.Less(t, idx, 5) + } + + // one clearly-idle candidate among four busy ones: whenever it is one of the + // two draws it wins, so it is picked far more than its 1/n share and the busy + // ones are relieved (this is the whole point of power-of-two-choices) + loads := []int{0, 100, 100, 100, 100} + low := 0 + for i := 0; i < 4000; i++ { + if p2c(loads, func(v int) int { return v }) == 0 { + low++ + } + } + // P(idle drawn) = 1 - (4/5)(3/4) = 0.4, and it always wins when drawn + require.Greater(t, low, 1000) + require.Less(t, low, 2400) +} diff --git a/pkg/agent/endpoint/registry.go b/pkg/agent/endpoint/registry.go new file mode 100644 index 000000000..7fbc036ae --- /dev/null +++ b/pkg/agent/endpoint/registry.go @@ -0,0 +1,263 @@ +// 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 endpoint + +import ( + "context" + "errors" + "sync" + "sync/atomic" +) + +// DefaultDeployment is the URL segment that addresses workers registered with an +// empty deployment name (self-hosted workers typically set none). +const DefaultDeployment = "default" + +// UnnamedAgentSegment is the URL segment that addresses workers registered with +// an empty agent name. +const UnnamedAgentSegment = "_" + +// IsReservedAgentName reports whether a name cannot address a worker. These are +// unreserved characters (RFC 3986 §2.3), so percent-encoding them yields no +// distinct form to address them by. +func IsReservedAgentName(agentName string) bool { + return agentName == UnnamedAgentSegment || agentName == "." || agentName == ".." +} + +// NormalizeDeployment maps an empty deployment to the segment that addresses it +// in a URL. Every embedder must key its scopes through this, or a worker that +// registered without a deployment is unreachable from any node but its own. +func NormalizeDeployment(d string) string { + if d == "" { + return DefaultDeployment + } + return d +} + +// DefaultMaxStreams is the soft per-session concurrency cap used only for +// capacity weighting; QUIC's own stream limit is the hard bound. +const DefaultMaxStreams = 256 + +// ErrNoSession means the registration has no live data-plane session, so no +// stream can be opened toward the worker. +var ErrNoSession = errors.New("registration has no data-plane session") + +// Registration is one worker's data-plane state: its manifest and the single +// WebTransport session that carries both its control stream and the HTTP +// exchanges the node opens toward it. It lives exactly as long as that session +// (epoch fencing: a reconnecting worker forms a new registration, and the old +// session dies with it). +// +// It carries no agent name, deployment or tenant identity: those name the Scope +// it was registered into, which is the embedder's to key. +type Registration struct { + WorkerID string + Manifest *Manifest + + draining func() bool + // tunnels counts WebSocket handshakes and tunnels holding a stream here + tunnels atomic.Int32 + + lock sync.RWMutex + session Session + closed bool +} + +// RegistrationParams is fixed for the life of the registration, which lasts +// exactly as long as the session. +type RegistrationParams struct { + WorkerID string + Manifest *Manifest + + // Session is the worker's live data-plane session: the WebTransport session + // that also carries its control stream. One session per worker. + Session Session + // Draining reports that the worker is shedding; a shedding worker takes no + // new streams. + Draining func() bool +} + +func NewRegistration(params RegistrationParams) *Registration { + return &Registration{ + WorkerID: params.WorkerID, + Manifest: params.Manifest, + draining: params.Draining, + session: params.Session, + } +} + +// IsDraining is false when no drain signal was supplied. +func (r *Registration) IsDraining() bool { + return r.draining != nil && r.draining() +} + +func (r *Registration) getSession() Session { + r.lock.RLock() + defer r.lock.RUnlock() + if r.closed { + return nil + } + return r.session +} + +// OpenStream opens a data-plane stream toward the worker for one HTTP exchange. +func (r *Registration) OpenStream(ctx context.Context) (Stream, error) { + s := r.getSession() + if s == nil { + return nil, ErrNoSession + } + return s.OpenStream(ctx) +} + +// HasSession reports whether the worker has a live data-plane session, i.e. can +// serve HTTP requests right now. +func (r *Registration) HasSession() bool { + return r.getSession() != nil +} + +// InflightStreams reports the open streams on this worker's session - the +// least-outstanding-requests signal for worker selection. +func (r *Registration) InflightStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + return s.OpenStreams() +} + +// SpareStreams reports remaining stream capacity - the node's live serving +// headroom for the worker, used to weight node selection. +func (r *Registration) SpareStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + if spare := s.MaxStreams() - s.OpenStreams(); spare > 0 { + return spare + } + return 0 +} + +// maxStreams is the session's soft concurrency cap, or 0 without a session. +func (r *Registration) maxStreams() int { + s := r.getSession() + if s == nil { + return 0 + } + return s.MaxStreams() +} + +// Tunnels reports the WebSocket tunnels (and handshakes) holding a stream on +// this worker. +func (r *Registration) Tunnels() int { + return int(r.tunnels.Load()) +} + +func (r *Registration) tryAcquireTunnel(limit int) bool { + for { + n := r.tunnels.Load() + if int(n) >= limit { + return false + } + if r.tunnels.CompareAndSwap(n, n+1) { + return true + } + } +} + +func (r *Registration) releaseTunnel() { + r.tunnels.Add(-1) +} + +func (r *Registration) close() { + r.lock.Lock() + if r.closed { + r.lock.Unlock() + return + } + r.closed = true + s := r.session + r.session = nil + r.lock.Unlock() + if s != nil { + s.Close("registration closed") + } +} + +// Registry fences worker epochs on this node. Worker ids are server-issued and +// unique across the node, so this index is tenancy-blind: it exists only so a +// reconnecting worker supersedes its own stale epoch, wherever that epoch was +// scoped. +// +// It holds the outermost lock in this package: a Scope's lock may be taken while +// holding it, never the reverse. +type Registry struct { + lock sync.RWMutex + regs map[string]*regEntry // by worker id +} + +// regEntry remembers which scope an epoch was registered into, so a supersede +// can retract it from there even when the worker came back under a different +// agent name or deployment. +type regEntry struct { + reg *Registration + scope *Scope +} + +func NewRegistry() *Registry { + return &Registry{regs: make(map[string]*regEntry)} +} + +// Register records a registration in a scope. A worker id already present is +// superseded: worker ids are stable across reconnects, and the retiring session +// must not be able to strand the new epoch (its own Deregister is a no-op once +// replaced). The superseded epoch's session is closed. +func (g *Registry) Register(scope *Scope, r *Registration) { + g.lock.Lock() + e := g.regs[r.WorkerID] + var superseded, old *Registration + if e != nil { + superseded = e.reg + if e.scope == scope { + old = e.reg + } else { + // nothing pins agent name or deployment across epochs, so the + // retiring epoch's routes may live in another scope + e.scope.remove(e.reg) + } + } + // one transaction, so an unchanged manifest keeps its Route pointers + scope.replace(old, r) + g.regs[r.WorkerID] = ®Entry{reg: r, scope: scope} + g.lock.Unlock() + if superseded != nil { + superseded.close() + } +} + +// Deregister removes exactly this registration; it is a no-op when a newer epoch +// has already superseded it. +func (g *Registry) Deregister(r *Registration) { + g.lock.Lock() + e := g.regs[r.WorkerID] + if e == nil || e.reg != r { + g.lock.Unlock() + return + } + delete(g.regs, r.WorkerID) + e.scope.remove(r) + g.lock.Unlock() + r.close() +} diff --git a/pkg/agent/endpoint/registry_test.go b/pkg/agent/endpoint/registry_test.go new file mode 100644 index 000000000..ab9587ea3 --- /dev/null +++ b/pkg/agent/endpoint/registry_test.go @@ -0,0 +1,95 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +// fakeSession is a no-op Session for registry tests: it records whether it was +// closed so supersede/deregister can be asserted. +type fakeSession struct { + closed bool +} + +func (s *fakeSession) OpenStream(context.Context) (Stream, error) { return nil, ErrNoSession } +func (s *fakeSession) OpenStreams() int { return 0 } +func (s *fakeSession) MaxStreams() int { return DefaultMaxStreams } +func (s *fakeSession) Close(string) { s.closed = true } + +// worker ids are stable across reconnects: a re-registration must supersede the +// old epoch and close its session, and the retiring session's Deregister must +// not strand the new one. +func TestRegistrySupersede(t *testing.T) { + g, s := NewRegistry(), testScope() + manifest, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{ + Path: "/x", Methods: []string{"GET"}, Public: true, + }}) + require.NoError(t, err) + + mk := func() (*Registration, *fakeSession) { + sess := &fakeSession{} + return NewRegistration(RegistrationParams{ + WorkerID: "AW_1", Manifest: manifest, Session: sess, + }), sess + } + + oldReg, oldSess := mk() + g.Register(s, oldReg) + newReg, newSess := mk() + g.Register(s, newReg) + + require.Equal(t, []*Registration{newReg}, s.Candidates()) + require.True(t, oldSess.closed, "superseded epoch's session must be closed") + require.False(t, newSess.closed) + + // the old control connection tears down after the new one registered + g.Deregister(oldReg) + require.Equal(t, []*Registration{newReg}, s.Candidates(), + "the retiring epoch must not deregister its successor") + + g.Deregister(newReg) + require.Empty(t, s.Candidates()) + require.True(t, s.Empty()) + require.True(t, newSess.closed, "deregistered session must be closed") +} + +// The fence is node-wide and scope-blind: worker ids are server-issued, so an +// epoch is superseded wherever it was scoped, and scopes are otherwise +// independent. +func TestRegistryScopesAreIndependent(t *testing.T) { + g := NewRegistry() + alpha, beta := testScope(), testScope() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{ + Path: "/x", Methods: []string{"GET"}, Public: true, + }}) + require.NoError(t, err) + + a := NewRegistration(RegistrationParams{WorkerID: "AW_a", Manifest: m, Session: &fakeSession{}}) + b := NewRegistration(RegistrationParams{WorkerID: "AW_b", Manifest: m, Session: &fakeSession{}}) + g.Register(alpha, a) + g.Register(beta, b) + + require.Equal(t, []*Registration{a}, alpha.Candidates()) + require.Equal(t, []*Registration{b}, beta.Candidates()) + + // a scope nothing holds is empty, and holds no route table at all + g.Deregister(a) + require.True(t, alpha.Empty()) + require.Nil(t, alpha.routeTable()) + require.Equal(t, []*Registration{b}, beta.Candidates(), "one scope draining leaves the other") +} + +// A nil scope is the "no worker here holds this deployment" answer, and must be +// safe for the front to interrogate without a separate test. +func TestNilScopeIsEmpty(t *testing.T) { + var s *Scope + require.Nil(t, s.Candidates()) + require.Nil(t, s.routeTable()) + require.True(t, s.Empty()) +} diff --git a/pkg/agent/endpoint/router/alloc_test.go b/pkg/agent/endpoint/router/alloc_test.go new file mode 100644 index 000000000..43d6c1d2d --- /dev/null +++ b/pkg/agent/endpoint/router/alloc_test.go @@ -0,0 +1,26 @@ +// Copyright 2026 LiveKit, Inc. + +//go:build !race + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// The race runtime allocates, so this cannot run under -race. +func TestMatchZeroAlloc(t *testing.T) { + specs := benchSpecs(256) + r, _ := build(t, specs) + for _, c := range benchCases(specs) { + n := testing.AllocsPerRun(200, func() { r.Match(c.path, c.mask) }) + require.Zero(t, n, c.name) + } + + // an ambiguous table searches, and must not allocate while it does + amb, _ := build(t, get("/f/{name}.{ext}", "/{p:path}/end")) + n := testing.AllocsPerRun(200, func() { amb.Match("/f/a.b.c", mGET) }) + require.Zero(t, n, "ambiguous") +} diff --git a/pkg/agent/endpoint/router/bench_test.go b/pkg/agent/endpoint/router/bench_test.go new file mode 100644 index 000000000..40a391db1 --- /dev/null +++ b/pkg/agent/endpoint/router/bench_test.go @@ -0,0 +1,88 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "testing" +) + +// benchSpecs is shaped like a FastAPI app: a few static routes, then resource +// collections with an id param, then a catch-all. +func benchSpecs(n int) []spec { + specs := []spec{ + {"/health", mGET}, + {"/ready", mGET}, + {"/metrics", mGET}, + } + for i := len(specs); i < n-1; i += 2 { + res := fmt.Sprintf("/api/v1/res%d", i) + specs = append(specs, spec{res, mGET | mPOST}) + specs = append(specs, spec{res + "/{id:int}", mGET | mPUT}) + } + specs = append(specs, spec{"/static/{p:path}", mGET}) + return specs[:min(n, len(specs))] +} + +type benchCase struct { + name, path string + mask Mask +} + +func benchCases(specs []spec) []benchCase { + last := specs[len(specs)-2].tpl + return []benchCase{ + {"hit_first", "/health", mGET}, + {"hit_last", last[:len(last)-len("/{id:int}")] + "/42", mGET}, + {"method_not_allowed", "/health", mPOST}, + {"miss", "/api/v1/nope/42", mGET}, + } +} + +func BenchmarkMatch(b *testing.B) { + for _, n := range []int{8, 64, 256} { + specs := benchSpecs(n) + r, _ := build(b, specs) + for _, c := range benchCases(specs) { + b.Run(fmt.Sprintf("routes=%d/%s", n, c.name), func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + r.Match(c.path, c.mask) + } + }) + } + } +} + +// No shared prefixes, so the root's edge scan cannot be narrowed. +func BenchmarkMatchWideFanout(b *testing.B) { + specs := make([]spec, 0, 256) + for i := range 256 { + specs = append(specs, spec{fmt.Sprintf("/%c%c/x", 'a'+i/16, 'a'+i%16), mGET}) + } + r, _ := build(b, specs) + b.ReportAllocs() + for b.Loop() { + r.Match("/pp/x", mGET) + } +} + +func BenchmarkBuild(b *testing.B) { + specs := benchSpecs(256) + tpls := make([]*Template, len(specs)) + for i, s := range specs { + t, err := ParseTemplate(s.tpl) + if err != nil { + b.Fatal(err) + } + tpls[i] = t + } + b.ReportAllocs() + for b.Loop() { + bld := NewBuilder[string]() + for i, t := range tpls { + _ = bld.Add(t, specs[i].mask, specs[i].tpl) + } + bld.Build() + } +} diff --git a/pkg/agent/endpoint/router/build.go b/pkg/agent/endpoint/router/build.go new file mode 100644 index 000000000..f250eecf6 --- /dev/null +++ b/pkg/agent/endpoint/router/build.go @@ -0,0 +1,177 @@ +// 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 router + +import "errors" + +const MaxRoutes = 1 << 16 + +var errTooManyRoutes = errors.New("router: too many routes") + +type buildNode[T any] struct { + kids []buildEdge[T] + leaves []leaf[T] + minIdx uint32 +} + +type buildEdge[T any] struct { + lit string + to *buildNode[T] + minIdx uint32 + kind kind +} + +// Builder accumulates a route table. A route's index is its position in the +// order added. +type Builder[T any] struct { + root *buildNode[T] + n uint32 + ambiguous []string +} + +func NewBuilder[T any]() *Builder[T] { + return &Builder[T]{root: &buildNode[T]{minIdx: noIdx}} +} + +// Add appends a route. Routes must be added in declaration order: the first +// one added that matches a path wins. +func (b *Builder[T]) Add(t *Template, m Mask, v T) error { + if b.n >= MaxRoutes { + return errTooManyRoutes + } + idx := b.n + b.n++ + + if t.Ambiguous() { + b.ambiguous = append(b.ambiguous, t.raw) + } + + n := b.root + if n.minIdx == noIdx { + n.minIdx = idx + } + for _, e := range t.elements { + if e.kind == kindLiteral { + n = insertLiteral(n, e.lit, idx) + } else { + n = insertParam(n, e.kind, idx) + } + } + n.leaves = append(n.leaves, leaf[T]{idx: idx, mask: m, val: v}) + return nil +} + +// Build freezes the table, compacting its nodes into one arena. +func (b *Builder[T]) Build() *Router[T] { + r := &Router[T]{ambiguous: b.ambiguous, routes: int(b.n)} + r.nodes = make([]node[T], 0, countNodes(b.root)) + r.compact(b.root) + return r +} + +func countNodes[T any](n *buildNode[T]) int { + total := 1 + for i := range n.kids { + total += countNodes(n.kids[i].to) + } + return total +} + +func (r *Router[T]) compact(bn *buildNode[T]) uint32 { + ni := uint32(len(r.nodes)) + r.nodes = append(r.nodes, node[T]{leaves: bn.leaves, minIdx: bn.minIdx}) + + kids := make([]edge, len(bn.kids)) + for i := range bn.kids { + be := &bn.kids[i] + kids[i] = edge{lit: be.lit, minIdx: be.minIdx, kind: be.kind, single: singleRun(be)} + if be.kind == kindLiteral { + kids[i].first = be.lit[0] + } + } + // recurse before wiring: appending children may move the arena + for i := range bn.kids { + kids[i].to = r.compact(bn.kids[i].to) + } + r.nodes[ni].kids = kids + return ni +} + +// singleRun reports that only the convertor's greedy run can lead anywhere: a +// shorter run leaves at the head a byte the convertor could have consumed, and +// no child below can start with one. A terminal param qualifies vacuously. +func singleRun[T any](e *buildEdge[T]) bool { + if e.kind == kindLiteral || e.kind == kindUUID { + return true + } + for i := range e.to.kids { + k := &e.to.kids[i] + if k.kind != kindLiteral || e.kind.charset(k.lit[0]) { + return false + } + } + return true +} + +func insertLiteral[T any](n *buildNode[T], lit string, idx uint32) *buildNode[T] { + for len(lit) > 0 { + e := literalEdge(n, lit[0]) + if e == nil { + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: kindLiteral, lit: lit, to: child, minIdx: idx}) + return child + } + cp := commonPrefix(e.lit, lit) + if cp < len(e.lit) { + // the edge keeps its slot and its minIdx, so kids stay ordered + mid := &buildNode[T]{minIdx: e.minIdx} + mid.kids = append(mid.kids, buildEdge[T]{kind: kindLiteral, lit: e.lit[cp:], to: e.to, minIdx: e.minIdx}) + e.lit, e.to = e.lit[:cp], mid + } + n, lit = e.to, lit[cp:] + } + return n +} + +func insertParam[T any](n *buildNode[T], k kind, idx uint32) *buildNode[T] { + for i := range n.kids { + if n.kids[i].kind == k { + return n.kids[i].to + } + } + child := &buildNode[T]{minIdx: idx} + n.kids = append(n.kids, buildEdge[T]{kind: k, to: child, minIdx: idx}) + return child +} + +// literalEdge finds the one literal edge that can consume c. Splitting keeps +// the first bytes of a node's literal edges distinct, so there is at most one. +func literalEdge[T any](n *buildNode[T], c byte) *buildEdge[T] { + for i := range n.kids { + if e := &n.kids[i]; e.kind == kindLiteral && e.lit[0] == c { + return e + } + } + return nil +} + +func commonPrefix(a, b string) int { + n := min(len(a), len(b)) + i := 0 + for i < n && a[i] == b[i] { + i++ + } + return i +} diff --git a/pkg/agent/endpoint/router/fuzz_test.go b/pkg/agent/endpoint/router/fuzz_test.go new file mode 100644 index 000000000..cec93d1dd --- /dev/null +++ b/pkg/agent/endpoint/router/fuzz_test.go @@ -0,0 +1,91 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// fragments compose into templates. %d takes a per-template counter so +// generated param names never collide, which ParseTemplate rejects. +var fragments = []string{ + "/a", "/b", "/ab", "/abc", "/", + "/{p%d}", "/{n%d:int}", "/{f%d:float}", "/{u%d:uuid}", "/{r%d:path}", + "{q%d}", ".json", ".{e%d}", "-{s%d}", "0", "/end", "//", "\n", "{", "x", +} + +func genSpecs(script []byte) []spec { + var specs []spec + var b strings.Builder + n := 0 + flush := func() { + if b.Len() > 0 { + specs = append(specs, spec{"/" + b.String(), Mask(n%7) + 1}) + b.Reset() + } + } + for _, c := range script { + if int(c)%(len(fragments)+2) >= len(fragments) { + flush() + continue + } + fmt.Fprintf(&b, fragments[int(c)%len(fragments)], n) + n++ + } + flush() + if len(specs) > 64 { + specs = specs[:64] + } + return specs +} + +// The trie answers what a linear scan of compiled starlette patterns answers: +// the same winning template and the same result, for every table and path. +func FuzzMatchAgainstOracle(f *testing.F) { + f.Add([]byte{5, 200, 0, 5}, "/x/y", uint8(1)) + f.Add([]byte{5, 1, 200, 1, 5}, "/a/b", uint8(1)) + f.Add([]byte{9, 200, 15}, "/a/b/end", uint8(1)) + f.Add([]byte{5, 12, 200, 5}, "/f/a.b.json", uint8(1)) + f.Add([]byte{7, 11, 200, 7}, "/1.25.json", uint8(3)) + f.Add([]byte{8, 200, 8, 15}, "/123e4567e89b12d3a456426614174000", uint8(1)) + f.Add([]byte{0, 1, 2, 3, 200, 0, 200, 2}, "/ab", uint8(1)) + f.Add([]byte{5, 200, 5, 200, 5}, "/x\n", uint8(7)) + f.Add([]byte{13, 5, 200, 5}, "/a-b", uint8(1)) + + f.Fuzz(func(t *testing.T, script []byte, path string, q uint8) { + specs := genSpecs(script) + if len(specs) == 0 { + return + } + + b := NewBuilder[string]() + o := &oracle{} + added := 0 + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + if err != nil { + continue + } + require.NoError(t, o.add(s.tpl, s.mask), "oracle rejected an accepted template %q", s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + added++ + } + if added == 0 { + return + } + r := b.Build() + + mask := Mask(q) + got, res := r.Match(path, mask) + if res == ResultOverBudget { + return + } + wantTpl, wantRes := o.match(path, mask) + require.Equal(t, wantRes, res, "path %q mask %d over %v", path, mask, specs) + require.Equal(t, wantTpl, got, "path %q mask %d over %v", path, mask, specs) + }) +} diff --git a/pkg/agent/endpoint/router/match.go b/pkg/agent/endpoint/router/match.go new file mode 100644 index 000000000..3617d7e2a --- /dev/null +++ b/pkg/agent/endpoint/router/match.go @@ -0,0 +1,129 @@ +// 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 router + +import "strings" + +// state lives in Match's frame and is threaded by pointer; nothing reachable +// from it may escape. +type state[T any] struct { + path string + q Mask + val T + // best is the lowest index of a route matching both path and mask. Only a + // full match may assign it, since it gates pruning. + best uint32 + // acc is the second accepting position, len(path)-1 when the path ends in a + // newline and -1 otherwise: a template anchors as `\n?$`. + acc int + steps int + partial bool +} + +// Match returns the value of the first route in declaration order whose +// template matches path and whose mask intersects q. It returns ResultPartial +// when a template matched but no such route carried the mask, and +// ResultOverBudget when the search exceeded its step budget. +// +// Match allocates nothing. +func (r *Router[T]) Match(path string, q Mask) (T, Result) { + st := state[T]{path: path, q: q, best: noIdx, acc: -1} + if n := len(path); n > 0 && path[n-1] == '\n' { + st.acc = n - 1 + } + + if !r.walk(0, 0, &st) { + // a cut-short search may have missed a lower-indexed route, so anything + // found is not necessarily what declaration order selects + var zero T + return zero, ResultOverBudget + } + switch { + case st.best != noIdx: + return st.val, ResultFull + case st.partial: + var zero T + return zero, ResultPartial + default: + var zero T + return zero, ResultNone + } +} + +// walk explores the subtree at ni with pos bytes of the path consumed, +// reporting false when the step budget ran out. Declaration order is +// uncorrelated with depth, so a match is not a stopping condition; only the +// minIdx prune cuts the scan short. +func (r *Router[T]) walk(ni uint32, pos int, st *state[T]) bool { + n := &r.nodes[ni] + + if pos == len(st.path) || pos == st.acc { + for i := range n.leaves { + lf := &n.leaves[i] + if lf.idx >= st.best { + break + } + if lf.mask&st.q != 0 { + st.best, st.val = lf.idx, lf.val + break + } + st.partial = true + } + // a path convertor matches the empty string, so a route may still + // terminate below this node at this same position + } + + rest := st.path[pos:] + for i := range n.kids { + e := &n.kids[i] + if e.minIdx >= st.best { + break // kids ascend by minIdx, so no later one can improve on best + } + + if e.kind == kindLiteral { + if len(e.lit) > len(rest) || rest[0] != e.first || !strings.HasPrefix(rest, e.lit) { + continue + } + if !r.walk(e.to, pos+len(e.lit), st) { + return false + } + continue + } + + k := e.kind.scan(rest) + if k < 0 { + continue + } + if e.single { + if !r.walk(e.to, pos+k, st) { + return false + } + continue + } + for ; k >= 0; k = e.kind.next(rest, k) { + st.steps++ + if st.steps > maxSteps { + return false + } + if !r.walk(e.to, pos+k, st) { + return false + } + if e.minIdx >= st.best { + break + } + } + } + return true +} diff --git a/pkg/agent/endpoint/router/oracle_test.go b/pkg/agent/endpoint/router/oracle_test.go new file mode 100644 index 000000000..abec73be2 --- /dev/null +++ b/pkg/agent/endpoint/router/oracle_test.go @@ -0,0 +1,86 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "regexp" + "strings" +) + +// One compiled regex per route, scanned linearly. Transcribed from starlette's +// compile_path and carrying its own parser, so it references the hand-rolled +// scanner as well as the trie. + +var oracleConvertors = map[string]string{ + "str": `[^/]+`, + "path": `.*`, + "int": `[0-9]+`, + "float": `[0-9]+(?:\.[0-9]+)?`, + "uuid": `[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{4}-?[0-9a-fA-F]{12}`, +} + +// starlette's PARAM_REGEX +var oracleParamRegex = regexp.MustCompile(`\{([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?\}`) + +func oracleCompile(path string) (*regexp.Regexp, error) { + var pattern strings.Builder + pattern.WriteString("^") + + idx := 0 + for _, m := range oracleParamRegex.FindAllStringSubmatchIndex(path, -1) { + start, end := m[0], m[1] + convertor := "str" + if m[4] != -1 { + convertor = path[m[4]+1 : m[5]] + } + p, ok := oracleConvertors[convertor] + if !ok { + return nil, fmt.Errorf("unknown convertor %q", convertor) + } + pattern.WriteString(regexp.QuoteMeta(path[idx:start])) + pattern.WriteString("(?:") + pattern.WriteString(p) + pattern.WriteString(")") + idx = end + } + pattern.WriteString(regexp.QuoteMeta(path[idx:])) + // python's '$' is Go's `\n?\z` + pattern.WriteString("\n?$") + + return regexp.Compile(pattern.String()) +} + +type oracleRoute struct { + re *regexp.Regexp + raw string + mask Mask +} + +type oracle struct{ routes []oracleRoute } + +func (o *oracle) add(raw string, mask Mask) error { + re, err := oracleCompile(raw) + if err != nil { + return err + } + o.routes = append(o.routes, oracleRoute{re: re, raw: raw, mask: mask}) + return nil +} + +func (o *oracle) match(path string, q Mask) (string, Result) { + partial := false + for _, r := range o.routes { + if !r.re.MatchString(path) { + continue + } + if r.mask&q != 0 { + return r.raw, ResultFull + } + partial = true + } + if partial { + return "", ResultPartial + } + return "", ResultNone +} diff --git a/pkg/agent/endpoint/router/router.go b/pkg/agent/endpoint/router/router.go new file mode 100644 index 000000000..5511d54cd --- /dev/null +++ b/pkg/agent/endpoint/router/router.go @@ -0,0 +1,118 @@ +// 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 router matches request paths against an ordered table of +// starlette-style path templates. The first route declared that matches wins: +// declaration order is the entire priority rule. +package router + +import ( + "go.uber.org/zap/zapcore" +) + +// maxSteps bounds the backtracking a single Match may do. Only a non-single +// edge spends a step, so a table of unambiguous templates never reaches it. +const maxSteps = 100_000 + +// Mask is an opaque per-route tag. A route fully matches when its mask +// intersects the query mask, and partially matches when only its template does. +type Mask uint32 + +type Result uint8 + +const ( + ResultNone Result = iota + // ResultPartial means a template matched but no route carrying the queried + // mask did. + ResultPartial + ResultFull + // ResultOverBudget means the search exceeded maxSteps and was abandoned, so + // no route was decided. + ResultOverBudget +) + +func (r Result) String() string { + switch r { + case ResultNone: + return "none" + case ResultPartial: + return "partial" + case ResultFull: + return "full" + case ResultOverBudget: + return "over-budget" + } + return "unknown" +} + +// noIdx is above every route index, so an unset best never prunes. +const noIdx = ^uint32(0) + +type edge struct { + lit string // kindLiteral only + to uint32 // index into Router.nodes + minIdx uint32 // mirrors nodes[to].minIdx, so a prune dereferences no child + kind kind + first byte // kindLiteral only: lit[0] + single bool // the convertor admits one viable run at any position +} + +type leaf[T any] struct { + idx uint32 + mask Mask + val T +} + +type node[T any] struct { + kids []edge + // several routes can terminate at one node: /u/{id} and /u/{name} compile + // alike, and a path may be declared once per method + leaves []leaf[T] + minIdx uint32 +} + +// Router matches paths against a route table. It is built once and never +// mutated, so readers need no lock and a new table is published by a single +// atomic.Pointer store, which synchronises everything a reader reaches. +type Router[T any] struct { + nodes []node[T] + ambiguous []string + routes int +} + +// MarshalLogObject describes the table's shape. A Router holds no per-match +// state. +func (r *Router[T]) MarshalLogObject(e zapcore.ObjectEncoder) error { + if r == nil { + return nil + } + e.AddInt("routes", r.routes) + if len(r.ambiguous) > 0 { + err := e.AddArray("ambiguous", zapcore.ArrayMarshalerFunc(func(a zapcore.ArrayEncoder) error { + for _, t := range r.ambiguous { + a.AppendString(t) + } + return nil + })) + if err != nil { + return err + } + } + return nil +} + +// Ambiguous returns the templates whose shape forces the matcher to backtrack - +// a param that a following literal can extend, adjacent params, or a non-final +// path convertor. The result is read-only. +func (r *Router[T]) Ambiguous() []string { return r.ambiguous } diff --git a/pkg/agent/endpoint/router/router_test.go b/pkg/agent/endpoint/router/router_test.go new file mode 100644 index 000000000..c9537401f --- /dev/null +++ b/pkg/agent/endpoint/router/router_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "fmt" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" +) + +const ( + mGET Mask = 1 << iota + mPOST + mPUT +) + +type spec struct { + tpl string + mask Mask +} + +func get(tpls ...string) []spec { + s := make([]spec, len(tpls)) + for i, t := range tpls { + s[i] = spec{t, mGET} + } + return s +} + +func build(t testing.TB, specs []spec) (*Router[string], *oracle) { + t.Helper() + b := NewBuilder[string]() + o := &oracle{} + for _, s := range specs { + tpl, err := ParseTemplate(s.tpl) + require.NoError(t, err, s.tpl) + require.NoError(t, b.Add(tpl, s.mask, s.tpl)) + require.NoError(t, o.add(s.tpl, s.mask), s.tpl) + } + return b.Build(), o +} + +// Every case asserts the expected answer and that the oracle agrees. +func TestMatchAdversarial(t *testing.T) { + cases := []struct { + name string + routes []spec + path string + mask Mask + want string + res Result + }{ + // declaration order decides + {"order beats specificity", get("/u/{id}", "/u/me"), "/u/me", mGET, "/u/{id}", ResultFull}, + {"order the other way", get("/u/me", "/u/{id}"), "/u/me", mGET, "/u/me", ResultFull}, + {"order with int first", get("/x/{a:int}", "/x/{b}"), "/x/42", mGET, "/x/{a:int}", ResultFull}, + {"order falls through to str", get("/x/{a:int}", "/x/{b}"), "/x/4x", mGET, "/x/{b}", ResultFull}, + {"literal buried deep", get("/a/{x}/c", "/a/b/c"), "/a/b/c", mGET, "/a/{x}/c", ResultFull}, + + // the method dimension must not let a partial prune a later full match + {"later method wins", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"wildcard partial then literal full", []spec{{"/{a}", mGET}, {"/x", mPOST}}, "/x", mPOST, "/x", ResultFull}, + {"partial at a lower index", []spec{{"/a/{x}", mGET}, {"/a/b", mPOST}}, "/a/b", mPOST, "/a/b", ResultFull}, + {"no method matches", []spec{{"/x", mGET}, {"/x", mPOST}}, "/x", mPUT, "", ResultPartial}, + {"nothing matches", get("/x"), "/nope", mGET, "", ResultNone}, + + // mid-segment params, which the walk must search for + {"name dot ext", get("/f/{name}.{ext}"), "/f/a.b.c", mGET, "/f/{name}.{ext}", ResultFull}, + {"name dot ext needs a dot", get("/f/{name}.{ext}"), "/f/abc", mGET, "", ResultNone}, + {"suffix literal", get("/f/{name}.json"), "/f/a.b.json", mGET, "/f/{name}.json", ResultFull}, + {"prefix and suffix", get("/v{major}.{minor}"), "/v1.2", mGET, "/v{major}.{minor}", ResultFull}, + {"adjacent params", get("/{a}{b}"), "/xy", mGET, "/{a}{b}", ResultFull}, + + // greedy path, which crosses '/' + {"path backtracks to a literal", get("/{p:path}/end"), "/a/b/end/end", mGET, "/{p:path}/end", ResultFull}, + {"path matches empty", get("/files/{p:path}"), "/files/", mGET, "/files/{p:path}", ResultFull}, + {"path spans slashes", get("/files/{p:path}"), "/files/a/b/c.txt", mGET, "/files/{p:path}", ResultFull}, + {"path refuses newline", get("/files/{p:path}"), "/files/a\nb", mGET, "", ResultNone}, + {"str accepts newline", get("/files/{p}"), "/files/a\nb", mGET, "/files/{p}", ResultFull}, + + // a template anchors as `\n?$` + {"trailing newline accepted", get("/x"), "/x\n", mGET, "/x", ResultFull}, + {"two trailing newlines rejected", get("/x"), "/x\n\n", mGET, "", ResultNone}, + {"path convertor eats to the newline", get("/files/{p:path}"), "/files/a\n", mGET, "/files/{p:path}", ResultFull}, + + // float's optional fraction backtracks + {"float with fraction", get("/p/{v:float}.json"), "/p/1.25.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float without fraction", get("/p/{v:float}.json"), "/p/1.json", mGET, "/p/{v:float}.json", ResultFull}, + {"float rejects a bare dot", get("/p/{v:float}"), "/p/1.", mGET, "", ResultNone}, + {"float takes the fraction", get("/p/{v:float}"), "/p/1.25", mGET, "/p/{v:float}", ResultFull}, + + // int followed by a digit needs a shorter run + {"int backtracks off a digit", get("/{n:int}0/x"), "/120/x", mGET, "/{n:int}0/x", ResultFull}, + + // uuid admits exactly one length + {"uuid hyphenated", get("/o/{u:uuid}"), "/o/123e4567-e89b-12d3-a456-426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid bare", get("/o/{u:uuid}"), "/o/123e4567e89b12d3a456426614174000", mGET, "/o/{u:uuid}", ResultFull}, + {"uuid then literal", get("/o/{u:uuid}/z"), "/o/123e4567e89b12d3a456426614174000/z", mGET, "/o/{u:uuid}/z", ResultFull}, + {"uuid too short", get("/o/{u:uuid}"), "/o/123e4567", mGET, "", ResultNone}, + + // radix splits + {"split abc", get("/abc", "/abd", "/ab", "/a"), "/ab", mGET, "/ab", ResultFull}, + {"split abd", get("/abc", "/abd", "/ab", "/a"), "/abd", mGET, "/abd", ResultFull}, + {"split a", get("/abc", "/abd", "/ab", "/a"), "/a", mGET, "/a", ResultFull}, + {"split reversed", get("/a", "/ab", "/abd", "/abc"), "/abc", mGET, "/abc", ResultFull}, + {"split miss", get("/abc", "/abd"), "/abe", mGET, "", ResultNone}, + + // literals are byte-exact + {"case sensitive", get("/token"), "/Token", mGET, "", ResultNone}, + {"trailing slash is not implied", get("/token"), "/token/", mGET, "", ResultNone}, + {"registered with a slash", get("/token/"), "/token/", mGET, "/token/", ResultFull}, + + // degenerate paths + {"root", get("/"), "/", mGET, "/", ResultFull}, + {"double slash", get("/", "//"), "//", mGET, "//", ResultFull}, + {"empty segment", get("/a//b"), "/a//b", mGET, "/a//b", ResultFull}, + {"str will not cross a slash", get("/a/{x}"), "/a/b/c", mGET, "", ResultNone}, + {"str needs a byte", get("/a/{x}"), "/a/", mGET, "", ResultNone}, + {"a decoded %2F is just a slash", get("/files/{p}"), "/files/a/b", mGET, "", ResultNone}, + + // a brace that opens nothing well-formed is a literal + {"stray brace", get("/a{b"), "/a{b", mGET, "/a{b", ResultFull}, + {"empty convertor is literal", get("/x/{a:}"), "/x/{a:}", mGET, "/x/{a:}", ResultFull}, + {"digit-leading name is literal", get("/x/{1bad}"), "/x/{1bad}", mGET, "/x/{1bad}", ResultFull}, + {"doubled brace", get("/{{a}"), "/{x", mGET, "/{{a}", ResultFull}, + } + + for _, c := range cases { + r, o := build(t, c.routes) + + got, res := r.Match(c.path, c.mask) + require.Equal(t, c.res, res, "%s: %q", c.name, c.path) + require.Equal(t, c.want, got, "%s: %q", c.name, c.path) + + oGot, oRes := o.match(c.path, c.mask) + require.Equal(t, c.res, oRes, "oracle disagrees on %s: %q", c.name, c.path) + require.Equal(t, c.want, oGot, "oracle disagrees on %s: %q", c.name, c.path) + } +} + +// Whether a template can force a search is fixed at build time. +func TestAmbiguousClassification(t *testing.T) { + cases := []struct { + tpl string + ambiguous bool + }{ + {"/static", false}, + {"/u/{id}", false}, + {"/u/{id}/posts", false}, + {"/u/{id:int}/posts", false}, + {"/files/{p:path}", false}, + {"/o/{u:uuid}/z", false}, + {"/o/{u:uuid}{rest}", false}, + {"/f/{name}.json", true}, + {"/f/{name}.{ext}", true}, + {"/{p:path}/end", true}, + {"/p/{v:float}.json", true}, + {"/{n:int}0/x", true}, + {"/{a}{b}", true}, + {"/{n:int}/x", false}, + {"/{v:float}/x", false}, + } + for _, c := range cases { + r, _ := build(t, get(c.tpl)) + require.Equal(t, c.ambiguous, len(r.Ambiguous()) == 1, "%s", c.tpl) + } +} + +// An unambiguous table holds no edge that can search, so no path length reaches +// the budget; an ambiguous one gives up at it. +func TestStepBudget(t *testing.T) { + r, _ := build(t, get("/api/{v:int}/res/{id}/sub/{name}")) + for i := range r.nodes { + for _, e := range r.nodes[i].kids { + require.True(t, e.single, "%q", e.lit) + } + } + _, res := r.Match("/api/1/res/"+strings.Repeat("x", 4096)+"/sub/y", mGET) + require.Equal(t, ResultFull, res) + + r, _ = build(t, get("/{a}{b}{c}{d}x")) + _, res = r.Match("/"+strings.Repeat("a", 4096), mGET) + require.Equal(t, ResultOverBudget, res) +} + +func TestEmptyRouter(t *testing.T) { + r := NewBuilder[string]().Build() + got, res := r.Match("/x", mGET) + require.Equal(t, ResultNone, res) + require.Empty(t, got) +} + +// Readers match against a published table while writers swap in new ones. +func TestConcurrentSwapUnderLoad(t *testing.T) { + tables := make([]*Router[string], 4) + for i := range tables { + tables[i], _ = build(t, get( + fmt.Sprintf("/v%d/{id:int}", i), + "/f/{name}.json", + "/static/{p:path}", + "/health", + )) + } + + var h atomic.Pointer[Router[string]] + h.Store(tables[0]) + + var stop atomic.Bool + var wg sync.WaitGroup + for range 4 { + wg.Go(func() { + for i := 0; !stop.Load(); i++ { + h.Store(tables[i%len(tables)]) + } + }) + } + for range 8 { + wg.Go(func() { + for !stop.Load() { + r := h.Load() + r.Match("/v2/42", mGET) + r.Match("/f/a.b.json", mGET) + r.Match("/static/a/b/c", mGET) + r.Match("/health", mPOST) + } + }) + } + time.Sleep(200 * time.Millisecond) + stop.Store(true) + wg.Wait() +} + +func TestMarshalLogObject(t *testing.T) { + r, _ := build(t, get("/health", "/f/{name}.json")) + + enc := zapcore.NewMapObjectEncoder() + require.NoError(t, r.MarshalLogObject(enc)) + require.Equal(t, 2, enc.Fields["routes"]) + require.Equal(t, []any{"/f/{name}.json"}, enc.Fields["ambiguous"]) + + plain, _ := build(t, get("/health")) + enc = zapcore.NewMapObjectEncoder() + require.NoError(t, plain.MarshalLogObject(enc)) + require.NotContains(t, enc.Fields, "ambiguous") + + var nilRouter *Router[string] + require.NoError(t, nilRouter.MarshalLogObject(zapcore.NewMapObjectEncoder())) +} diff --git a/pkg/agent/endpoint/router/scan.go b/pkg/agent/endpoint/router/scan.go new file mode 100644 index 000000000..f5ab748d6 --- /dev/null +++ b/pkg/agent/endpoint/router/scan.go @@ -0,0 +1,130 @@ +// 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 router + +import "strings" + +// Scanning is byte-wise: every byte a convertor distinguishes ('/', '\n', and +// [0-9a-fA-F-]) is ASCII, and UTF-8 is self-synchronising, so no multi-byte +// sequence can contain one. + +// scan returns the longest run the convertor consumes at the head of s, or -1 +// when it cannot match at all. +func (k kind) scan(s string) int { + switch k { + case kindStr: + if n := runTo(s, '/'); n > 0 { + return n + } + case kindPath: + return runTo(s, '\n') + case kindInt: + if n := digitRun(s); n > 0 { + return n + } + case kindFloat: + d := digitRun(s) + if d == 0 { + break + } + if d < len(s) && s[d] == '.' { + if f := digitRun(s[d+1:]); f > 0 { + return d + 1 + f + } + } + return d + case kindUUID: + return uuidRun(s) + } + return -1 +} + +// next returns the next shorter viable run after prev, or -1 when prev was the +// last candidate. Candidates descend, so runs are enumerated greedily. +func (k kind) next(s string, prev int) int { + switch k { + case kindStr, kindInt: + if prev > 1 { + return prev - 1 + } + case kindPath: + if prev > 0 { + return prev - 1 + } + case kindFloat: + // the integer part is a digit run from the head, so a '.' can only sit + // at its end: viable runs are 1..d and d+1+1..d+1+f, never d+1 + d := digitRun(s) + switch { + case prev > d+2: + return prev - 1 + case prev == d+2: + return d + case prev > 1: + return prev - 1 + } + case kindUUID: + // every '-?' is forced by the input: skipping a present hyphen demands a + // hex digit where the hyphen is, so the shape admits one length + } + return -1 +} + +// charset reports whether a shorter run of this convertor could be followed by +// c, which is what makes an edge ambiguous. +func (k kind) charset(c byte) bool { + switch k { + case kindStr: + return c != '/' + case kindPath: + return c != '\n' + case kindInt: + return isDigit(c) + case kindFloat: + return isDigit(c) || c == '.' + } + return false +} + +func runTo(s string, c byte) int { + if i := strings.IndexByte(s, c); i >= 0 { + return i + } + return len(s) +} + +func digitRun(s string) int { + i := 0 + for i < len(s) && isDigit(s[i]) { + i++ + } + return i +} + +func uuidRun(s string) int { + i := 0 + for g, n := range [...]int{8, 4, 4, 4, 12} { + if g > 0 && i < len(s) && s[i] == '-' { + i++ + } + for range n { + if i >= len(s) || !isHex(s[i]) { + return -1 + } + i++ + } + } + return i +} diff --git a/pkg/agent/endpoint/router/template.go b/pkg/agent/endpoint/router/template.go new file mode 100644 index 000000000..d329ec4be --- /dev/null +++ b/pkg/agent/endpoint/router/template.go @@ -0,0 +1,201 @@ +// 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 router + +import ( + "fmt" + "strconv" + "strings" + "unicode/utf8" +) + +// kind is what one element of a template consumes. The convertor set is part +// of the wire contract and is closed. +type kind uint8 + +const ( + kindLiteral kind = iota + kindStr // [^/]+ + kindPath // .*, which excludes '\n' + kindInt // [0-9]+ + kindFloat // [0-9]+(?:\.[0-9]+)? + kindUUID // 8-4-4-4-12 hex, every hyphen optional +) + +func convertorKind(name string) (kind, bool) { + switch name { + case "str": + return kindStr, true + case "path": + return kindPath, true + case "int": + return kindInt, true + case "float": + return kindFloat, true + case "uuid": + return kindUUID, true + } + return 0, false +} + +// element is one step of a parsed template: literal bytes to match exactly, or +// a convertor to consume. +type element struct { + kind kind + lit string // kindLiteral only +} + +// Template is a parsed starlette-style path template. Parsing mirrors +// starlette's compile_path exactly. +type Template struct { + // the template as declared + raw string + elements []element +} + +// String returns the template as declared. +func (t *Template) String() string { return t.raw } + +// Canonical returns a key identifying what the template matches: two templates +// with the same canonical form accept exactly the same paths and differ only in +// param names. Literals are length-prefixed so that a literal brace cannot forge +// a convertor - "/x/{:str}" is literal text, and must not collide with "/x/{a}". +func (t *Template) Canonical() string { + var b strings.Builder + for _, e := range t.elements { + if e.kind == kindLiteral { + b.WriteByte('L') + b.WriteString(strconv.Itoa(len(e.lit))) + b.WriteByte(':') + b.WriteString(e.lit) + continue + } + b.WriteByte('K') + b.WriteString(strconv.Itoa(int(e.kind))) + } + return b.String() +} + +// Ambiguous reports whether the template's own shape can force the matcher to +// backtrack - a param a following literal can extend, adjacent params, or a +// non-final path convertor - independent of what other templates put in the +// tree. +func (t *Template) Ambiguous() bool { + for i, e := range t.elements { + if e.kind == kindLiteral || e.kind == kindUUID { + continue + } + if i == len(t.elements)-1 { + continue // terminal: only the greedy run can reach the end + } + next := t.elements[i+1] + if next.kind != kindLiteral || e.kind.charset(next.lit[0]) { + return true + } + } + return false +} + +// ParseTemplate parses a starlette path template. Custom convertors are +// rejected: only the five built-ins may travel over the wire. +func ParseTemplate(path string) (*Template, error) { + if !strings.HasPrefix(path, "/") { + return nil, fmt.Errorf("path template must start with '/': %q", path) + } + // literals are compared byte-wise, which agrees with rune-wise semantics + // only for valid UTF-8 + if !utf8.ValidString(path) { + return nil, fmt.Errorf("path template is not valid UTF-8: %q", path) + } + + t := &Template{raw: path} + seen := map[string]struct{}{} + lit := 0 + for i := 0; i < len(path); { + name, convertor, end, ok := scanParam(path, i) + if !ok { + i++ + continue + } + k, ok := convertorKind(convertor) + if !ok { + return nil, fmt.Errorf("unknown path convertor %q in template %q", convertor, path) + } + if _, dup := seen[name]; dup { + return nil, fmt.Errorf("duplicated param name %q in template %q", name, path) + } + seen[name] = struct{}{} + + if i > lit { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:i]}) + } + t.elements = append(t.elements, element{kind: k}) + i, lit = end, end + } + if lit < len(path) { + t.elements = append(t.elements, element{kind: kindLiteral, lit: path[lit:]}) + } + return t, nil +} + +// scanParam matches starlette's PARAM_REGEX at i: +// +// {([a-zA-Z_][a-zA-Z0-9_]*)(:[a-zA-Z_][a-zA-Z0-9_]*)?} +// +// Everything between matches is a literal, so a brace that does not open a +// well-formed param is an ordinary literal character. +func scanParam(s string, i int) (name, convertor string, end int, ok bool) { + if s[i] != '{' { + return "", "", 0, false + } + j := i + 1 + n := scanIdent(s, j) + if n == j { + return "", "", 0, false + } + name, j = s[j:n], n + + convertor = "str" + if j < len(s) && s[j] == ':' { + c := scanIdent(s, j+1) + if c == j+1 { + return "", "", 0, false + } + convertor, j = s[j+1:c], c + } + if j >= len(s) || s[j] != '}' { + return "", "", 0, false + } + return name, convertor, j + 1, true +} + +func scanIdent(s string, i int) int { + if i >= len(s) || !isIdentStart(s[i]) { + return i + } + j := i + 1 + for j < len(s) && (isIdentStart(s[j]) || isDigit(s[j])) { + j++ + } + return j +} + +func isAlpha(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } +func isIdentStart(c byte) bool { return isAlpha(c) || c == '_' } + +func isHex(c byte) bool { + return isDigit(c) || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F' +} diff --git a/pkg/agent/endpoint/router/template_test.go b/pkg/agent/endpoint/router/template_test.go new file mode 100644 index 000000000..e03f5942f --- /dev/null +++ b/pkg/agent/endpoint/router/template_test.go @@ -0,0 +1,94 @@ +// Copyright 2026 LiveKit, Inc. + +package router + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func matches(t *testing.T, tpl, path string) bool { + t.Helper() + r, o := build(t, get(tpl)) + got, res := r.Match(path, mGET) + oGot, oRes := o.match(path, mGET) + require.Equal(t, oRes, res, "oracle disagrees: %q vs %q", tpl, path) + require.Equal(t, oGot, got, "oracle disagrees: %q vs %q", tpl, path) + return res == ResultFull +} + +func TestTemplateStarletteSemantics(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/token", "/token", true}, + {"/token", "/token/", false}, + {"/token", "/Token", false}, + {"/users/{id}", "/users/42", true}, + {"/users/{id}", "/users/42/posts", false}, + {"/users/{id}", "/users/", false}, + {"/users/{id:int}", "/users/42", true}, + {"/users/{id:int}", "/users/4x2", false}, + {"/files/{p:path}", "/files/a/b/c.txt", true}, + {"/files/{p:path}", "/files/", true}, + {"/price/{v:float}", "/price/1.25", true}, + {"/price/{v:float}", "/price/1.", false}, + {"/obj/{u:uuid}", "/obj/123e4567-e89b-12d3-a456-426614174000", true}, + // starlette's uuid convertor makes every hyphen optional + {"/obj/{u:uuid}", "/obj/123e4567e89b12d3a456426614174000", true}, + {"/obj/{u:uuid}", "/obj/123e4567", false}, + {"/a/{x}/b/{y}", "/a/1/b/2", true}, + {"/a/{x}/b/{y}", "/a/1/c/2", false}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%s vs %s", c.template, c.path) + } +} + +// Vectors generated from CPython against a transcription of compile_path. +func TestTemplateTrailingNewline(t *testing.T) { + cases := []struct { + template string + path string + match bool + }{ + {"/x", "/x\n", true}, + {"/x", "/x\n\n", false}, + {"/x", "/x\nz", false}, + {"/files/{p:path}", "/files/a\n", true}, + {"/files/{p:path}", "/files/a\nb", false}, + {"/files/{p}", "/files/a\n", true}, + {"/files/{p}", "/files/a\nb", true}, + {"/n/{i:int}", "/n/42\n", true}, + {"/n/{i:int}", "/n/4\n2", false}, + {"/", "/\n", true}, + {"/a/", "/a/\n", true}, + } + for _, c := range cases { + require.Equal(t, c.match, matches(t, c.template, c.path), "%q vs %q", c.template, c.path) + } +} + +func TestParseTemplateRejects(t *testing.T) { + for _, tpl := range []string{ + "/x/{id:slug}", + "/x/{a}/{a}", + "no-slash", + "/x/\xff", + } { + _, err := ParseTemplate(tpl) + require.Error(t, err, tpl) + } +} + +// A brace that opens nothing well-formed is an ordinary literal. +func TestParseTemplateLenientBraces(t *testing.T) { + for _, tpl := range []string{"/a{b", "/x/{a:}", "/x/{1bad}", "/{{a}", "/}", "/{}"} { + tt, err := ParseTemplate(tpl) + require.NoError(t, err, tpl) + require.Equal(t, tpl, tt.String()) + } +} diff --git a/pkg/agent/endpoint/routes.go b/pkg/agent/endpoint/routes.go new file mode 100644 index 000000000..759af4315 --- /dev/null +++ b/pkg/agent/endpoint/routes.go @@ -0,0 +1,257 @@ +// 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 endpoint + +import ( + "cmp" + "slices" + "sync" + "sync/atomic" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +// routeKey identifies one route: the template shape it matches and the single +// method it serves. Templates differing only in param names share a shape, so +// /x/{a} and /x/{b} are one route. +type routeKey struct { + canonical string + method router.Mask +} + +// Route is one endpoint of a deployment and every worker that declared it. The +// compiled table holds these by pointer: a route outlives the tables built +// around it, and its worker set changes without a rebuild. +type Route struct { + Template *router.Template + Mask router.Mask + + key routeKey + // ord mirrors the merge position the published table was built from + ord uint32 + + lock sync.RWMutex + workers []routeWorker +} + +// routeWorker is one worker's declaration of a route. Declarers of one route can +// disagree about visibility, position and spelling. +type routeWorker struct { + reg *Registration + raw string // the template as this worker declared it + public bool + idx uint32 // its position in this worker's manifest +} + +// declare records one worker's claim. A registration appears at most once, and +// callers walk endpoints in declaration order, so a path and method named twice +// keeps its first position. +func (r *Route) declare(w routeWorker) { + r.lock.Lock() + defer r.lock.Unlock() + for i := range r.workers { + if r.workers[i].reg == w.reg { + return + } + } + r.workers = append(r.workers, w) +} + +// retract drops a registration's claim. Matching is by pointer: a supersede has +// both epochs live at once under one worker id. +func (r *Route) retract(reg *Registration) { + r.lock.Lock() + defer r.lock.Unlock() + r.workers = slices.DeleteFunc(r.workers, func(w routeWorker) bool { return w.reg == reg }) +} + +// position reports the route's merge position - the lowest index it holds in any +// declaring manifest - and whether anything still declares it. +func (r *Route) position() (ord uint32, live bool) { + r.lock.RLock() + defer r.lock.RUnlock() + ord = noOrder + for _, w := range r.workers { + ord = min(ord, w.idx) + } + return ord, len(r.workers) > 0 +} + +const noOrder = ^uint32(0) + +// eligible copies out the workers that may serve the request. Nothing here may +// reach into a Registration: this lock must not be held across the transport. +func (r *Route) eligible(granted bool) (workers []routeWorker, denied bool) { + r.lock.RLock() + defer r.lock.RUnlock() + for _, w := range r.workers { + if !granted && !w.public { + denied = true + continue + } + workers = append(workers, w) + } + return +} + +// routeTable is a deployment's merged route table. The tree is rebuilt only when +// the route set or its order changes, and is published by a single atomic store +// under lock. +type routeTable struct { + logger logger.Logger + + lock sync.Mutex + routes map[routeKey]*Route + tree atomic.Pointer[router.Router[*Route]] +} + +// newRouteTable takes the scope's logger as-is: whatever identifies the scope is +// curried in by the embedder, so the table holds no key of its own. +func newRouteTable(l logger.Logger) *routeTable { + return &routeTable{ + logger: l, + routes: make(map[routeKey]*Route), + } +} + +// mutate applies a removal and an addition as one transaction, republishing the +// tree if the merged table moved. Routes the removal empties are deleted only +// once the addition has run, so a registration replaced by an equivalent one +// keeps its Route pointers and the published tree stays live. +func (t *routeTable) mutate(remove, add *Registration) { + t.lock.Lock() + defer t.lock.Unlock() + + touched := make(map[*Route]struct{}) + if remove != nil { + t.retractLocked(remove, touched) + } + if add != nil { + t.declareLocked(add, touched) + } + + dirty := false + for r := range touched { + ord, live := r.position() + if !live { + delete(t.routes, r.key) + dirty = true + continue + } + // a departure can raise a route's position and an arrival lower it; + // either reorders the table + if ord != r.ord { + dirty = true + } + } + if dirty { + t.publishLocked() + } +} + +func (t *routeTable) declareLocked(reg *Registration, touched map[*Route]struct{}) { + if reg.Manifest == nil { + return + } + for i, ep := range reg.Manifest.Endpoints { + for mask := ep.Mask; mask != 0; mask &= mask - 1 { + key := routeKey{canonical: ep.Template.Canonical(), method: mask & -mask} + r := t.routes[key] + if r == nil { + r = &Route{Template: ep.Template, Mask: key.method, key: key, ord: noOrder} + t.routes[key] = r + } + r.declare(routeWorker{reg: reg, raw: ep.Template.String(), public: ep.Public, idx: uint32(i)}) + touched[r] = struct{}{} + } + } +} + +func (t *routeTable) retractLocked(reg *Registration, touched map[*Route]struct{}) { + for _, r := range t.routes { + r.retract(reg) + touched[r] = struct{}{} + } +} + +// publishLocked compiles the merged table and swaps it in. The builder assigns +// each route an index from its position in the order added, so routes must be +// added in merge order. +func (t *routeTable) publishLocked() { + ordered := make([]*Route, 0, len(t.routes)) + for _, r := range t.routes { + ordered = append(ordered, r) + } + for _, r := range ordered { + r.ord, _ = r.position() + } + // routes sharing a position are separated by the tie-break alone, so the + // comparison must be total and reach no further than the key + slices.SortFunc(ordered, func(a, b *Route) int { + if c := cmp.Compare(a.ord, b.ord); c != 0 { + return c + } + if c := cmp.Compare(a.key.canonical, b.key.canonical); c != 0 { + return c + } + return cmp.Compare(a.key.method, b.key.method) + }) + + b := router.NewBuilder[*Route]() + for i, r := range ordered { + if err := b.Add(r.Template, r.Mask, r); err != nil { + // MaxManifestRoutes caps a worker, not the merged table; what + // survives is the lowest stretch of the merge order + t.logger.Warnw("agent endpoint route table truncated", err, + "routes", len(ordered), "installed", i) + break + } + } + t.tree.Store(b.Build()) +} + +func (t *routeTable) empty() bool { + t.lock.Lock() + defer t.lock.Unlock() + return len(t.routes) == 0 +} + +// match resolves a path against the merged table. matched is the dispatch test: +// a route reached through a tree loaded before its last worker left resolves to +// an empty set. +func (t *routeTable) match(path string, mask router.Mask, granted bool) (matched []routeWorker, route *Route, res router.Result, denied bool) { + tree := t.tree.Load() + if tree == nil { + return nil, nil, router.ResultNone, false + } + rt, res := tree.Match(path, mask) + if res != router.ResultFull { + return nil, nil, res, false + } + matched, denied = rt.eligible(granted) + return matched, rt, res, denied +} + +// serves reports whether the table has a route for this exact path and method. +func (t *routeTable) serves(path string, mask router.Mask) bool { + tree := t.tree.Load() + if tree == nil { + return false + } + _, res := tree.Match(path, mask) + return res == router.ResultFull +} diff --git a/pkg/agent/endpoint/routes_test.go b/pkg/agent/endpoint/routes_test.go new file mode 100644 index 000000000..58f2aae74 --- /dev/null +++ b/pkg/agent/endpoint/routes_test.go @@ -0,0 +1,365 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "fmt" + "net/http" + "slices" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/router" +) + +func mustManifest(t *testing.T, eps ...*livekit.AgentHttp_AgentEndpoint) *Manifest { + t.Helper() + m, err := ParseManifest(eps) + require.NoError(t, err) + return m +} + +func regOf(workerID string, m *Manifest) *Registration { + return NewRegistration(RegistrationParams{ + WorkerID: workerID, Manifest: m, Session: &fakeSession{}, + }) +} + +func testScope() *Scope { return NewScope(logger.GetLogger()) } + +// tableOf registers one worker per manifest into a single scope and returns the +// deployment's merged table. +func tableOf(t *testing.T, manifests ...*Manifest) *routeTable { + t.Helper() + g, s := NewRegistry(), testScope() + for i, m := range manifests { + g.Register(s, regOf(fmt.Sprintf("w%d", i), m)) + } + tbl := s.routeTable() + require.NotNil(t, tbl) + return tbl +} + +// serving names the workers a request would be dispatched to, sorted. +func serving(tbl *routeTable, path, method string, granted bool) []string { + matched, _, _, _ := tbl.match(path, methodMask(method), granted) + ids := make([]string, 0, len(matched)) + for _, w := range matched { + ids = append(ids, w.reg.WorkerID) + } + slices.Sort(ids) + return ids +} + +// A mixed fleet serves the union of its routes, and a route only ever +// dispatches to the workers that declared it. +func TestRouteTableUnion(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v1", []string{"GET"}, true)), + mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v2", []string{"GET"}, true)), + ) + + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/health", http.MethodGet, true)) + require.Equal(t, []string{"w0"}, serving(tbl, "/v1", http.MethodGet, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/v2", http.MethodGet, true)) + require.Empty(t, serving(tbl, "/v3", http.MethodGet, true)) +} + +// A worker leaving takes only the routes nothing else declares. +func TestRouteTableDeparture(t *testing.T) { + g, s := NewRegistry(), testScope() + old := regOf("w0", mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v1", []string{"GET"}, true))) + g.Register(s, old) + g.Register(s, regOf("w1", mustManifest(t, ep("/health", []string{"GET"}, true), ep("/v2", []string{"GET"}, true)))) + tbl := s.routeTable() + + g.Deregister(old) + + require.Equal(t, []string{"w1"}, serving(tbl, "/health", http.MethodGet, true)) + require.Empty(t, serving(tbl, "/v1", http.MethodGet, true), "the departing worker's own route is gone") + require.Equal(t, []string{"w1"}, serving(tbl, "/v2", http.MethodGet, true)) + + // the last worker out drops the table with it + g.Deregister(s.Candidates()[0]) + require.Nil(t, s.routeTable(), "the last worker out drops the table, so the front reports 503 not 404") + require.True(t, s.Empty()) +} + +// Declaration order is the whole priority rule, and a homogeneous fleet keeps +// the order its manifest declared: a literal registered ahead of a param that +// would also match it must still win. +func TestRouteTableMergeOrder(t *testing.T) { + m := mustManifest(t, ep("/users/me", []string{"GET"}, true), ep("/users/{id}", []string{"GET"}, true)) + tbl := tableOf(t, m, m) + + _, r, res, _ := tbl.match("/users/me", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/users/me", r.Template.String()) + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/users/me", http.MethodGet, true)) + + _, r, _, _ = tbl.match("/users/7", methodMask(http.MethodGet), true) + require.Equal(t, "/users/{id}", r.Template.String()) +} + +// Workers may disagree about a route's visibility mid-rollout. An ungranted +// caller is narrowed to the workers that declared it public. +func TestRouteTablePerWorkerPublic(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/x", []string{"GET"}, true)), + mustManifest(t, ep("/x", []string{"GET"}, false)), + ) + + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/x", http.MethodGet, true)) + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, false)) + + matched, _, _, denied := tbl.match("/x", methodMask(http.MethodGet), false) + require.Len(t, matched, 1) + require.True(t, denied, "the private declaration is reported, not silently dropped") + + // no public declarer at all denies outright + private := tableOf(t, mustManifest(t, ep("/y", []string{"GET"}, false))) + matched, _, _, denied = private.match("/y", methodMask(http.MethodGet), false) + require.Empty(t, matched) + require.True(t, denied) +} + +// One route per method: workers declaring different verbs on one path each +// serve their own, and a verb nobody declared is partial. +func TestRouteTableMethodSplit(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/thing", []string{"GET"}, true)), + mustManifest(t, ep("/thing", []string{"POST", "DELETE"}, true)), + ) + + require.Equal(t, []string{"w0"}, serving(tbl, "/thing", http.MethodGet, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/thing", http.MethodPost, true)) + require.Equal(t, []string{"w1"}, serving(tbl, "/thing", http.MethodDelete, true)) + + _, _, res, _ := tbl.match("/thing", methodMask(http.MethodPut), true) + require.Equal(t, router.ResultPartial, res) +} + +// Param names are invisible to the matcher, so two spellings of one shape are +// one route serving both workers, each carrying its own spelling. +func TestRouteTableCanonicalMerge(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/items/{id}", []string{"GET"}, true)), + mustManifest(t, ep("/items/{key}", []string{"GET"}, true)), + ) + + require.Len(t, tbl.routes, 1, "one shape is one route") + matched, _, res, _ := tbl.match("/items/7", methodMask(http.MethodGet), true) + require.Equal(t, router.ResultFull, res) + require.Len(t, matched, 2) + + spelling := map[string]string{} + for _, w := range matched { + spelling[w.reg.WorkerID] = w.raw + } + require.Equal(t, map[string]string{"w0": "/items/{id}", "w1": "/items/{key}"}, spelling) + + // a different shape stays a different route + require.Len(t, tableOf(t, + mustManifest(t, ep("/items/{id}", []string{"GET"}, true)), + mustManifest(t, ep("/items/{id:int}", []string{"GET"}, true)), + ).routes, 2) +} + +// A literal brace is not a param, and must not collide with one. +func TestRouteTableLiteralBraceDistinctFromParam(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, ep("/x/{a}", []string{"GET"}, true)), + mustManifest(t, ep("/x/{:str}", []string{"GET"}, true)), + ) + require.Len(t, tbl.routes, 2) +} + +// A manifest may name one path and method twice; the worker joins the route +// once. +func TestRouteTableDuplicateDeclaration(t *testing.T) { + tbl := tableOf(t, mustManifest(t, + ep("/x", []string{"GET", "POST"}, true), + ep("/x", []string{"GET"}, true), + )) + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, true)) +} + +// The published tree holds routes by pointer, so a reconnect that rebuilds the +// same route set keeps those pointers and stays routable. +func TestRouteTableSupersedeStaysRoutable(t *testing.T) { + g, s := NewRegistry(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + g.Register(s, regOf("w0", m)) + tbl := s.routeTable() + before := tbl.tree.Load() + + g.Register(s, regOf("w0", m)) + + require.Equal(t, []string{"w0"}, serving(tbl, "/x", http.MethodGet, true), + "a reconnect with an unchanged manifest stays routable") + require.Same(t, before, tbl.tree.Load(), "an unchanged route set needs no rebuild") +} + +// A reconnect may land on a different agent name or deployment, i.e. a different +// scope. The retiring epoch's routes live in the old scope and must be retracted +// there. +func TestRouteTableSupersedeAcrossScopes(t *testing.T) { + g := NewRegistry() + from, to := testScope(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + firstSess := &fakeSession{} + first := NewRegistration(RegistrationParams{WorkerID: "w0", Manifest: m, Session: firstSess}) + g.Register(from, first) + + g.Register(to, regOf("w0", m)) + + require.Nil(t, from.routeTable(), "the old scope's table is gone") + require.True(t, from.Empty()) + require.Equal(t, []string{"w0"}, serving(to.routeTable(), "/x", http.MethodGet, true)) + require.True(t, firstSess.closed, "the superseded epoch's session is closed") + + // the retiring control connection tears down afterwards and must not strand + // the new epoch + g.Deregister(first) + require.Equal(t, []string{"w0"}, serving(to.routeTable(), "/x", http.MethodGet, true)) +} + +// The tree is rebuilt exactly when the merged route set or its order moves. +func TestRouteTableRebuildTrigger(t *testing.T) { + g, s := NewRegistry(), testScope() + m := mustManifest(t, ep("/x", []string{"GET"}, true)) + g.Register(s, regOf("w0", m)) + tbl := s.routeTable() + + before := tbl.tree.Load() + g.Register(s, regOf("w1", m)) + require.Same(t, before, tbl.tree.Load(), "a worker joining an existing route changes no route") + + g.Register(s, regOf("w2", mustManifest(t, ep("/x", []string{"GET"}, true), ep("/y", []string{"GET"}, true)))) + require.NotSame(t, before, tbl.tree.Load(), "a new route rebuilds") +} + +// A departure can raise a route's merge position and an arrival lower it; +// either reorders the table. +func TestRouteTableOrderFollowsDeparture(t *testing.T) { + g, s := NewRegistry(), testScope() + first := regOf("w0", mustManifest(t, + ep("/a/{x}", []string{"GET"}, true), + ep("/a/b", []string{"GET"}, true), + )) + g.Register(s, first) + g.Register(s, regOf("w1", mustManifest(t, + ep("/a/b", []string{"GET"}, true), + ep("/a/{x}", []string{"GET"}, true), + ))) + tbl := s.routeTable() + + // both routes hold position 0 - w0 declares the param first, w1 the literal + // - so the tie-break decides, and the param shadows the literal + _, r, _, _ := tbl.match("/a/b", methodMask(http.MethodGet), true) + require.Equal(t, "/a/{x}", r.Template.String()) + + // w0 leaving raises the param to w1's position 1, putting the literal ahead + // of it + g.Deregister(first) + _, r, _, _ = tbl.match("/a/b", methodMask(http.MethodGet), true) + require.Equal(t, "/a/b", r.Template.String()) +} + +// Ambiguity is a property of the merged tree: one worker's shape can carry the +// whole deployment's matcher over its step budget, and an undecided route has no +// Public flag left to clear an ungranted request. +func TestRouteTableAmbiguityIsContagious(t *testing.T) { + long := "/" + strings.Repeat("a", 512) + + open := tableOf(t, mustManifest(t, ep("/{p:path}", []string{"GET"}, true))) + _, _, res, _ := open.match(long, methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res, "alone, the catch-all decides") + + merged := tableOf(t, + mustManifest(t, ep("/{a}{b}{c}{d}x", []string{"GET"}, true)), + mustManifest(t, ep("/{p:path}", []string{"GET"}, true)), + ) + _, _, res, _ = merged.match(long, methodMask(http.MethodGet), false) + require.Equal(t, router.ResultOverBudget, res) +} + +// KNOWN LIMITATION: the merge takes each route's lowest position across the +// fleet, which does not preserve any one worker's internal order. A worker can +// be handed a request the merged table resolved to a public route while its own +// table routes that path to a private one. An order-preserving merge of the +// declaration chains would hold the property. +func TestRouteTableMergeOrderInversion(t *testing.T) { + tbl := tableOf(t, + mustManifest(t, + ep("/x/{z:int}", []string{"GET"}, false), + ep("/x/{b}", []string{"GET"}, true), + ), + mustManifest(t, ep("/x/{b}", []string{"GET"}, true)), + ) + + _, r, res, _ := tbl.match("/x/7", methodMask(http.MethodGet), false) + require.Equal(t, router.ResultFull, res) + require.Equal(t, "/x/{b}", r.Template.String()) + require.Equal(t, []string{"w0", "w1"}, serving(tbl, "/x/7", http.MethodGet, false), + "w0 is reachable anonymously while its own table routes /x/7 to a private route") +} + +// Registrations churn while requests resolve; run under -race. +func TestRouteTableConcurrentChurn(t *testing.T) { + g, sc := NewRegistry(), testScope() + stable := regOf("stable", mustManifest(t, ep("/x", []string{"GET"}, true))) + g.Register(sc, stable) + + var wg sync.WaitGroup + stop := make(chan struct{}) + for i := range 4 { + wg.Add(1) + go func() { + defer wg.Done() + m := mustManifest(t, ep("/x", []string{"GET"}, true), ep(fmt.Sprintf("/w%d", i), []string{"GET"}, true)) + for n := 0; ; n++ { + select { + case <-stop: + return + default: + } + r := regOf(fmt.Sprintf("w%d", i), m) + g.Register(sc, r) + g.Deregister(r) + } + }() + } + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + } + if tbl := sc.routeTable(); tbl != nil { + tbl.match("/x", methodMask(http.MethodGet), true) + tbl.serves("/x/", methodMask(http.MethodGet)) + } + } + }() + } + for range 2000 { + if tbl := sc.routeTable(); tbl != nil { + tbl.match("/x", methodMask(http.MethodGet), false) + } + } + close(stop) + wg.Wait() + + require.Contains(t, serving(sc.routeTable(), "/x", http.MethodGet, true), "stable") +} diff --git a/pkg/agent/endpoint/scope.go b/pkg/agent/endpoint/scope.go new file mode 100644 index 000000000..a04f8da82 --- /dev/null +++ b/pkg/agent/endpoint/scope.go @@ -0,0 +1,123 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package endpoint + +import ( + "slices" + "sync" + + "github.com/livekit/protocol/logger" +) + +// Scope is one deployment's serving state on this node: the registrations that +// hold it and their merged route table. Who a scope belongs to is the embedder's +// business - nothing here stores a tenancy identity, and the embedder owns the +// map that scopes it. +// +// Registrations and routes move together under lock, so a worker present in the +// candidate set always has its routes installed. +type Scope struct { + logger logger.Logger + + lock sync.RWMutex + regs []*Registration + table *routeTable // nil while nothing declares a route +} + +// NewScope takes a logger the embedder has already curried with whatever +// identifies the scope; the package adds no identity of its own. +func NewScope(l logger.Logger) *Scope { + return &Scope{logger: l} +} + +// Candidates returns the registrations holding this scope. A nil scope holds +// none, so a caller that could not resolve one needs no separate test. +func (s *Scope) Candidates() []*Registration { + if s == nil { + return nil + } + s.lock.RLock() + defer s.lock.RUnlock() + return slices.Clone(s.regs) +} + +// Empty reports that no registration holds this scope, so the embedder may drop +// it. +func (s *Scope) Empty() bool { + if s == nil { + return true + } + s.lock.RLock() + defer s.lock.RUnlock() + return len(s.regs) == 0 +} + +// routeTable returns the merged table, or nil when nothing declares a route. A +// scope whose last worker left must report nil rather than an empty tree: the +// front maps a missing table to "no worker for this deployment" (503) and an +// empty one to "no such path" (404). +func (s *Scope) routeTable() *routeTable { + if s == nil { + return nil + } + s.lock.RLock() + defer s.lock.RUnlock() + return s.table +} + +// replace swaps one registration for another as a single transaction. Routes the +// removal empties are dropped only once the addition has run, so a registration +// replaced by an equivalent one keeps its Route pointers and the published tree +// stays live. Either side may be nil. +func (s *Scope) replace(remove, add *Registration) { + s.lock.Lock() + defer s.lock.Unlock() + if remove != nil { + s.unlinkLocked(remove) + } + if add != nil { + s.regs = append(s.regs, add) + } + s.mutateLocked(remove, add) +} + +// remove drops a registration and its routes. +func (s *Scope) remove(r *Registration) { + s.lock.Lock() + defer s.lock.Unlock() + s.unlinkLocked(r) + s.mutateLocked(r, nil) +} + +func (s *Scope) unlinkLocked(r *Registration) { + if i := slices.Index(s.regs, r); i != -1 { + s.regs = slices.Delete(s.regs, i, i+1) + } +} + +// mutateLocked applies the route change, creating the table on first use and +// dropping it once it empties. +func (s *Scope) mutateLocked(remove, add *Registration) { + if s.table == nil { + if add == nil { + return + } + s.table = newRouteTable(s.logger) + } + s.table.mutate(remove, add) + if s.table.empty() { + s.table = nil + } +} diff --git a/pkg/agent/endpoint/session.go b/pkg/agent/endpoint/session.go new file mode 100644 index 000000000..e3ccbfc85 --- /dev/null +++ b/pkg/agent/endpoint/session.go @@ -0,0 +1,72 @@ +// 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 endpoint + +import ( + "context" + "fmt" + "io" + "time" + + "github.com/livekit/protocol/livekit" +) + +// Session is the node's handle to one worker's data plane: a single +// WebTransport (QUIC) session that also carries the worker's control stream. +// The node opens a fresh Stream per HTTP exchange; QUIC provides the +// multiplexing and per-stream flow control, so there is no capsule layer. +type Session interface { + // OpenStream opens a bidirectional stream toward the worker for one HTTP + // exchange. It blocks until a stream can be opened (QUIC stream-limit + // backpressure) or ctx is done. + OpenStream(ctx context.Context) (Stream, error) + // OpenStreams reports the streams currently open on this session - the + // least-outstanding-requests signal for worker selection. + OpenStreams() int + // MaxStreams is the soft cap on concurrent streams, used to derive spare + // capacity for node-selection weighting. + MaxStreams() int + // Close tears the session down. + Close(reason string) +} + +// Stream is one HTTP exchange over a Session: a preamble, then opaque HTTP/1.1 +// bytes in each direction. +type Stream interface { + io.Reader + io.Writer + // CloseWrite half-closes the send side (QUIC stream FIN). A FIN arriving + // before the body framing says the body is complete is truncation. + CloseWrite() error + // Reset aborts the stream in both directions (QUIC RESET_STREAM / + // STOP_SENDING). The code reaches the peer and is the only outcome signal + // available once no bytes can flow; the reason is local only. + Reset(code livekit.AgentHttp_HttpStreamResetCode, reason string) + // Close releases the stream after a completed exchange. + Close() error + // SetReadDeadline bounds the wait for more bytes. + SetReadDeadline(t time.Time) error +} + +// StreamResetError reports that the peer reset the stream, carrying the code it +// sent. Transports translate their own reset errors into this at the Stream +// boundary. +type StreamResetError struct { + Code livekit.AgentHttp_HttpStreamResetCode +} + +func (e *StreamResetError) Error() string { + return fmt.Sprintf("endpoint: stream reset by peer (%s)", e.Code) +} diff --git a/pkg/agent/endpoint/truncation_test.go b/pkg/agent/endpoint/truncation_test.go new file mode 100644 index 000000000..fc9742f21 --- /dev/null +++ b/pkg/agent/endpoint/truncation_test.go @@ -0,0 +1,261 @@ +// 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 endpoint_test + +import ( + "bufio" + "context" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// rawTarget speaks HTTP/1.1 by hand so a response can end in ways net/http will +// not produce. +func rawTarget(t *testing.T, respond func(net.Conn)) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + if line == "\r\n" { + break + } + } + respond(c) + }() + } + }() + return ln.Addr().String() +} + +func startFramedWorker(t *testing.T, targetAddr string, eps []*livekit.AgentHttp_AgentEndpoint) string { + t.Helper() + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + front := endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + }) + ts := httptest.NewUnstartedServer(front) + // raised past net/http's 1 MiB default so the front's own head bound is what + // rejects an oversized head + ts.Config.MaxHeaderBytes = 4 << 20 + ts.Start() + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: targetAddr, + Insecure: true, + Endpoints: eps, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + return ts.URL + "/agents/myagent/production" +} + +// A chunked response cut short must not reach the client as complete. +func TestTruncatedChunkedResponseIsDetected(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n") + _, _ = fmt.Fprintf(c, "%x\r\n", 1000) + _, _ = c.Write(make([]byte, 1000)) + _, _ = io.WriteString(c, "\r\n") + // closes without the terminating 0-length chunk + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/partial", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/partial") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a truncated chunked body must not read as a clean EOF") +} + +// A response short of its declared length must not reach the client as complete. +func TestShortContentLengthResponseIsDetected(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\n\r\n") + _, _ = c.Write(make([]byte, 1000)) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/short", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/short") + require.NoError(t, err) + defer resp.Body.Close() + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body short of its declared length must not read as complete") +} + +// An unreachable application never dispatched the request, so it is retryable; +// with no healthy worker the attempts exhaust into a 503. +func TestRefusedExhaustsTo503(t *testing.T) { + base := startFramedWorker(t, "127.0.0.1:1", []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/json", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode, + "a refused request is retryable, so it must exhaust into 503 rather than 502") +} + +// A complete response reads as complete. +func TestCompleteResponseStillCompletes(t *testing.T) { + body := strings.Repeat("y", 40<<10) + addr := rawTarget(t, func(c net.Conn) { + _, _ = fmt.Fprintf(c, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\n", len(body)) + _, _ = io.WriteString(c, body) + }) + + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ok", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/ok") + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, body, string(got)) +} + +// A bodiless response declares a length it never delivers and is not truncated. +func TestHeadAndNoContentAreNotTruncated(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 204 No Content\r\n\r\n") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/empty", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/empty") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNoContent, resp.StatusCode) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) +} + +// Duplicate response headers must survive the proto hop. +func TestDuplicateHeadersSurvive(t *testing.T) { + addr := rawTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nContent-Length: 2\r\n\r\nhi") + }) + base := startFramedWorker(t, addr, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/cookies", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/cookies") + require.NoError(t, err) + defer resp.Body.Close() + require.ElementsMatch(t, []string{"a=1", "b=2"}, resp.Header.Values("Set-Cookie")) +} + +// The request head reaches the application with path, query and forwarding +// headers intact. +func TestRequestMetadataReachesWorker(t *testing.T) { + seen := make(chan string, 1) + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + go func() { + c, err := ln.Accept() + if err != nil { + return + } + defer c.Close() + br := bufio.NewReader(c) + var head strings.Builder + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + head.WriteString(line) + if line == "\r\n" { + break + } + } + seen <- head.String() + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }() + + base := startFramedWorker(t, ln.Addr().String(), []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/items/{id}", Methods: []string{"GET"}, Public: true}, + }) + + resp, err := http.Get(base + "/items/42?q=1") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + select { + case h := <-seen: + require.Contains(t, h, "GET /items/42?q=1 HTTP/1.1") + require.Contains(t, strings.ToLower(h), "x-forwarded-for:") + case <-time.After(5 * time.Second): + t.Fatal("worker never reached the application") + } +} diff --git a/pkg/agent/endpoint/tunnel.go b/pkg/agent/endpoint/tunnel.go new file mode 100644 index 000000000..f73a427ce --- /dev/null +++ b/pkg/agent/endpoint/tunnel.go @@ -0,0 +1,305 @@ +// 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 endpoint + +import ( + "bufio" + "bytes" + "context" + "encoding/binary" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/livekit/protocol/livekit" +) + +const ( + // DefaultTunnelDrainTimeout is how long a WebSocket tunnel outlives its + // worker starting to drain. + DefaultTunnelDrainTimeout = 30 * time.Second + + // bounds how late a worker's drain is noticed; draining is polled + workerDrainPollInterval = time.Second + + // a client that stopped reading must not hold a drain open + closeFrameWriteTimeout = time.Second + + // bounds a client that keeps its side open after the worker has closed + tunnelLinger = 5 * time.Second +) + +// a server-to-client close frame carrying 1001 (going away) +var wsGoingAwayCloseFrame = []byte{0x88, 0x02, 0x03, 0xe9} + +func (f *Front) tunnelLimit(reg *Registration) int { + if f.params.MaxTunnelsPerWorker > 0 { + return f.params.MaxTunnelsPerWorker + } + return max(reg.maxStreams()/2, 1) +} + +// excludeTunnelsAtLimit marks full workers as attempted, which sends an upgrade +// with no admissible worker to the fallback and then 503. A draining front +// admits none. +func (f *Front) excludeTunnelsAtLimit(workers []routeWorker, attempted map[*Registration]bool) { + draining := f.tunnels.IsDraining() + for _, w := range workers { + if draining || w.reg.Tunnels() >= f.tunnelLimit(w.reg) { + attempted[w.reg] = true + } + } +} + +// Drain refuses new WebSocket upgrades, lets open tunnels finish until ctx is +// done, then closes the rest with 1001 (going away). It returns once no tunnel +// remains and is safe to call repeatedly. +func (f *Front) Drain(ctx context.Context) { + f.tunnels.Drain(ctx) +} + +// OpenTunnels reports the WebSocket tunnels this front is splicing. +func (f *Front) OpenTunnels() int { + return f.tunnels.Len() +} + +// spliceWebSocket blocks until the tunnel is done: bridge's deferred stream and +// reader cleanup must not run under a live tunnel. +func (f *Front) spliceWebSocket( + w http.ResponseWriter, + a *attempt, + reg *Registration, + stream Stream, + br *bufio.Reader, + resp *http.Response, + writeErrCh <-chan error, +) { + // the request writer must be done before the client's bytes follow its head + if err := <-writeErrCh; err != nil { + f.params.Logger.Warnw("agent endpoint upgrade request write failed", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "request write failed") + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + conn, crw, err := http.NewResponseController(w).Hijack() + if err != nil { + f.params.Logger.Warnw("agent endpoint cannot hijack the client connection", err, + "workerID", reg.WorkerID, "path", a.escPath, "requestID", a.requestID) + stream.Reset(livekit.AgentHttp_HSR_ABORT, "hijack failed") + http.Error(w, "bad gateway", http.StatusBadGateway) + return + } + defer conn.Close() + // server deadlines must not bound the tunnel + _ = conn.SetDeadline(time.Time{}) + + t := &tunnel{conn: conn, stream: stream, done: make(chan struct{})} + admitted := f.tunnels.Add(t) + defer f.tunnels.Remove(t) + defer close(t.done) + + if _, err := conn.Write(webSocketSwitchHead(resp.Header)); err != nil { + t.abort("client write failed") + return + } + if !admitted { + _ = t.Close() + } else { + go t.watch(reg, f.tunnels.Draining(), f.params.TunnelDrainTimeout) + } + + bufp := f.pools.getBuf() + defer f.pools.putBuf(bufp) + t.pipe(br, crw.Reader, *bufp) +} + +func webSocketSwitchHead(workerHeader http.Header) []byte { + h := http.Header{} + copyResponseHeaders(h, workerHeader) + h.Set("Connection", "Upgrade") + h.Set("Upgrade", "websocket") + var b bytes.Buffer + b.WriteString("HTTP/1.1 101 Switching Protocols\r\n") + _ = h.Write(&b) + b.WriteString("\r\n") + return b.Bytes() +} + +// watch covers worker drain only; once the front drains, Front.Drain owns the +// deadline. +func (t *tunnel) watch(reg *Registration, frontDraining <-chan struct{}, timeout time.Duration) { + tick := time.NewTicker(workerDrainPollInterval) + defer tick.Stop() + for !reg.IsDraining() { + select { + case <-t.done: + return + case <-frontDraining: + return + case <-tick.C: + } + } + if timeout <= 0 { + timeout = DefaultTunnelDrainTimeout + } + timer := time.NewTimer(timeout) + defer timer.Stop() + select { + case <-t.done: + case <-timer.C: + _ = t.Close() + } +} + +type tunnel struct { + conn net.Conn + stream Stream + done chan struct{} + + // mu serializes client-bound writes, so a close frame never lands mid-frame + mu sync.Mutex + closed bool + frames wsFrameTracker +} + +// pipe returns once both directions are done. EOF on one side half-closes the +// other; an error on either side tears down both. +func (t *tunnel) pipe(fromWorker, fromClient io.Reader, buf []byte) { + upDone := make(chan struct{}) + go func() { + defer close(upDone) + if _, err := io.Copy(t.stream, fromClient); err != nil { + t.abort("client read failed") + return + } + _ = t.stream.CloseWrite() + }() + + if err := t.toClient(fromWorker, buf); err != io.EOF { + t.abort("worker read failed") + } else if cw, ok := t.conn.(interface{ CloseWrite() error }); ok { + _ = cw.CloseWrite() + _ = t.conn.SetReadDeadline(time.Now().Add(tunnelLinger)) + } else { + _ = t.conn.Close() + } + <-upDone +} + +func (t *tunnel) toClient(src io.Reader, buf []byte) error { + for { + n, rerr := src.Read(buf) + if n > 0 { + t.mu.Lock() + if t.closed { + t.mu.Unlock() + return net.ErrClosed + } + nw, werr := t.conn.Write(buf[:n]) + t.frames.advance(buf[:nw]) + t.mu.Unlock() + if werr != nil { + return werr + } + } + if rerr != nil { + return rerr + } + } +} + +// Close sends the 1001 close frame only at a frame boundary, since a frame +// injected mid-frame corrupts the stream. +func (t *tunnel) Close() error { + _ = t.conn.SetWriteDeadline(time.Now().Add(closeFrameWriteTimeout)) + t.mu.Lock() + if !t.closed { + t.closed = true + if t.frames.atBoundary() { + _, _ = t.conn.Write(wsGoingAwayCloseFrame) + } + } + t.mu.Unlock() + t.abort("tunnel drained") + return nil +} + +func (t *tunnel) abort(reason string) { + t.stream.Reset(livekit.AgentHttp_HSR_ABORT, reason) + _ = t.conn.Close() +} + +// wsFrameTracker follows the worker's framing (RFC 6455 §5.2) only far enough +// to find frame boundaries. +type wsFrameTracker struct { + hdr [14]byte + hlen int + remaining uint64 +} + +func (f *wsFrameTracker) atBoundary() bool { + return f.hlen == 0 && f.remaining == 0 +} + +func (f *wsFrameTracker) advance(p []byte) { + for len(p) > 0 { + if f.remaining > 0 { + n := min(uint64(len(p)), f.remaining) + f.remaining -= n + p = p[n:] + continue + } + f.hdr[f.hlen] = p[0] + f.hlen++ + p = p[1:] + if f.hlen < wsFrameHeaderLen(f.hdr[:f.hlen]) { + continue + } + f.remaining = wsFramePayloadLen(f.hdr[:f.hlen]) + f.hlen = 0 + } +} + +// wsFrameHeaderLen can grow as more of the header arrives. +func wsFrameHeaderLen(h []byte) int { + if len(h) < 2 { + return 2 + } + n := 2 + switch h[1] & 0x7f { + case 126: + n += 2 + case 127: + n += 8 + } + if h[1]&0x80 != 0 { + n += 4 + } + return n +} + +func wsFramePayloadLen(h []byte) uint64 { + switch l := h[1] & 0x7f; l { + case 126: + return uint64(binary.BigEndian.Uint16(h[2:4])) + case 127: + return binary.BigEndian.Uint64(h[2:10]) + default: + return uint64(l) + } +} diff --git a/pkg/agent/endpoint/tunnel_test.go b/pkg/agent/endpoint/tunnel_test.go new file mode 100644 index 000000000..fdbe944fa --- /dev/null +++ b/pkg/agent/endpoint/tunnel_test.go @@ -0,0 +1,233 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint + +import ( + "bufio" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +const wsSwitchingHead = "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n" + +// scriptedTunnelFront serves one scripted worker on a GET route, with a drain +// signal the test controls. +func scriptedTunnelFront(t *testing.T, draining *atomic.Bool, params FrontParams, script func(r io.Reader, w *workerSide)) *httptest.Server { + t.Helper() + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{ + {Path: "/ws", Methods: []string{"GET"}, Public: true}, + }) + require.NoError(t, err) + g, s := NewRegistry(), NewScope(logger.GetLogger()) + g.Register(s, NewRegistration(RegistrationParams{ + WorkerID: "w1", + Manifest: m, + Session: &scriptedSession{script: script}, + Draining: draining.Load, + })) + params.ResolveAccess = grantedTo(s) + params.Logger = logger.GetLogger() + ts := httptest.NewServer(NewFront(params)) + t.Cleanup(ts.Close) + return ts +} + +// readWorkerRequest parses the request the worker was handed. +func readWorkerRequest(r io.Reader) (*http.Request, error) { + if _, err := wire.ReadPreamble(r); err != nil { + return nil, err + } + return http.ReadRequest(bufio.NewReader(r)) +} + +// rawWebSocketHandshake sends a handshake with the given Upgrade token and returns the +// connection positioned after the response head. +func rawWebSocketHandshake(t *testing.T, ts *httptest.Server, upgrade string) (net.Conn, *bufio.Reader, *http.Response) { + t.Helper() + c, err := net.Dial("tcp", strings.TrimPrefix(ts.URL, "http://")) + require.NoError(t, err) + t.Cleanup(func() { _ = c.Close() }) + _, err = io.WriteString(c, "GET "+PathPrefix+"a/d/ws HTTP/1.1\r\nHost: x\r\n"+ + "Connection: Upgrade\r\nUpgrade: "+upgrade+"\r\n"+ + "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n") + require.NoError(t, err) + _ = c.SetReadDeadline(time.Now().Add(10 * time.Second)) + br := bufio.NewReader(c) + resp, err := http.ReadResponse(br, nil) + require.NoError(t, err) + return c, br, resp +} + +func TestUpgradeHeadersReachTheWorker(t *testing.T) { + seen := make(chan http.Header, 1) + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString(wsSwitchingHead) + }) + _, _, resp := rawWebSocketHandshake(t, ts, "WebSocket") + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + h := <-seen + require.Equal(t, "Upgrade", h.Get("Connection")) + require.Equal(t, "websocket", h.Get("Upgrade")) + require.Equal(t, "dGhlIHNhbXBsZSBub25jZQ==", h.Get("Sec-WebSocket-Key")) + require.Equal(t, "13", h.Get("Sec-WebSocket-Version")) +} + +// only websocket is tunneled: any other token is stripped like any hop-by-hop +// header. +func TestNonWebSocketUpgradeIsStripped(t *testing.T) { + seen := make(chan http.Header, 1) + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + }) + _, _, resp := rawWebSocketHandshake(t, ts, "h2c") + require.Equal(t, http.StatusOK, resp.StatusCode) + h := <-seen + require.Empty(t, h.Values("Connection")) + require.Empty(t, h.Values("Upgrade")) +} + +// HTTP/2 has no Upgrade: the same headers on an h2 request are a plain GET. +func TestHTTP2UpgradeHeadersArePlainGET(t *testing.T) { + seen := make(chan http.Header, 1) + m, err := ParseManifest([]*livekit.AgentHttp_AgentEndpoint{{Path: "/ws", Methods: []string{"GET"}, Public: true}}) + require.NoError(t, err) + g, s := NewRegistry(), NewScope(logger.GetLogger()) + g.Register(s, NewRegistration(RegistrationParams{WorkerID: "w1", Manifest: m, Session: &scriptedSession{ + script: func(r io.Reader, w *workerSide) { + defer w.Close() + req, err := readWorkerRequest(r) + if err != nil { + return + } + seen <- req.Header + w.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }, + }})) + f := NewFront(FrontParams{ResolveAccess: grantedTo(s), Logger: logger.GetLogger()}) + + r := httptest.NewRequest(http.MethodGet, PathPrefix+"a/d/ws", nil) + r.Proto, r.ProtoMajor, r.ProtoMinor = "HTTP/2.0", 2, 0 + r.Header.Set("Connection", "Upgrade") + r.Header.Set("Upgrade", "websocket") + rec := httptest.NewRecorder() + f.ServeHTTP(rec, r) + require.Equal(t, http.StatusOK, rec.Code) + require.Equal(t, "ok", rec.Body.String()) + h := <-seen + require.Empty(t, h.Values("Connection")) + require.Empty(t, h.Values("Upgrade")) +} + +// a switch the front did not ask for is a protocol error; GET is idempotent, so +// with one worker it exhausts its retries into a 503. +func TestUnrequestedSwitchIsProtocolError(t *testing.T) { + cases := map[string]struct { + upgrade bool + resp string + }{ + "101 to a plain GET": {resp: wsSwitchingHead}, + "101 to another protocol": {upgrade: true, resp: "HTTP/1.1 101 Switching Protocols\r\nUpgrade: h2c\r\nConnection: Upgrade\r\n\r\n"}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + ts := scriptedTunnelFront(t, &atomic.Bool{}, FrontParams{}, func(r io.Reader, w *workerSide) { + defer w.Close() + if _, err := readWorkerRequest(r); err != nil { + return + } + w.WriteString(tc.resp) + }) + if tc.upgrade { + _, _, resp := rawWebSocketHandshake(t, ts, "websocket") + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + return + } + resp, err := http.Get(ts.URL + PathPrefix + "a/d/ws") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + }) + } +} + +// a tunnel outlives its worker starting to drain by the drain timeout, then +// closes with 1001. +func TestWorkerDrainClosesTunnelWithGoingAway(t *testing.T) { + var draining atomic.Bool + ts := scriptedTunnelFront(t, &draining, FrontParams{TunnelDrainTimeout: 10 * time.Millisecond}, + func(r io.Reader, w *workerSide) { + defer w.Close() + if _, err := readWorkerRequest(r); err != nil { + return + } + // one complete text frame "hi", then the tunnel idles + w.WriteString(wsSwitchingHead + "\x81\x02hi") + _, _ = io.Copy(io.Discard, r) + }) + _, br, resp := rawWebSocketHandshake(t, ts, "websocket") + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + + frame := make([]byte, 4) + _, err := io.ReadFull(br, frame) + require.NoError(t, err) + require.Equal(t, "\x81\x02hi", string(frame)) + + draining.Store(true) + _, err = io.ReadFull(br, frame) + require.NoError(t, err) + require.Equal(t, wsGoingAwayCloseFrame, frame) + _, err = br.ReadByte() + require.Error(t, err) +} + +func TestWSFrameTrackerBoundaries(t *testing.T) { + var f wsFrameTracker + require.True(t, f.atBoundary()) + + small := []byte{0x81, 0x03, 'a', 'b', 'c'} + for i := range small { + f.advance(small[i : i+1]) + require.Equal(t, i == len(small)-1, f.atBoundary(), "byte %d", i) + } + + // 16-bit length, split mid-header and mid-payload + medium := append([]byte{0x82, 126, 0x01, 0x00}, make([]byte, 256)...) + f.advance(medium[:3]) + require.False(t, f.atBoundary()) + f.advance(medium[3:100]) + require.False(t, f.atBoundary()) + f.advance(medium[100:]) + require.True(t, f.atBoundary()) + + // 64-bit length with the mask bit, back to back with a small frame + large := append([]byte{0x82, 0xff, 0, 0, 0, 0, 0, 0, 0x01, 0x00, 1, 2, 3, 4}, make([]byte, 65536)...) + f.advance(append(large, small...)) + require.True(t, f.atBoundary()) + + f.advance([]byte{0x88, 0x00}) + require.True(t, f.atBoundary()) +} diff --git a/pkg/agent/endpoint/tunnels.go b/pkg/agent/endpoint/tunnels.go new file mode 100644 index 000000000..43f0ec953 --- /dev/null +++ b/pkg/agent/endpoint/tunnels.go @@ -0,0 +1,108 @@ +// 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 endpoint + +import ( + "context" + "io" + "sync" +) + +// Tunnels tracks hijacked connections so a drain can close them. +type Tunnels struct { + mu sync.Mutex + live map[io.Closer]struct{} + idle chan struct{} // closed when live empties; nil while nobody waits + draining chan struct{} + once sync.Once +} + +func NewTunnels() *Tunnels { + return &Tunnels{live: make(map[io.Closer]struct{}), draining: make(chan struct{})} +} + +// Add tracks c. It reports false once a drain has begun: c is tracked +// regardless, so the drain still waits for it, but the caller must close it. +func (s *Tunnels) Add(c io.Closer) bool { + s.mu.Lock() + defer s.mu.Unlock() + s.live[c] = struct{}{} + return !s.IsDraining() +} + +func (s *Tunnels) Remove(c io.Closer) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.live, c) + if len(s.live) == 0 && s.idle != nil { + close(s.idle) + s.idle = nil + } +} + +func (s *Tunnels) Len() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.live) +} + +// Draining is closed once a drain has begun. +func (s *Tunnels) Draining() <-chan struct{} { + return s.draining +} + +func (s *Tunnels) IsDraining() bool { + select { + case <-s.draining: + return true + default: + return false + } +} + +// Drain lets the tracked connections end until ctx is done, then closes the +// rest. It returns once none remain and is safe to call repeatedly. +func (s *Tunnels) Drain(ctx context.Context) { + s.once.Do(func() { close(s.draining) }) + select { + case <-s.idleCh(): + return + case <-ctx.Done(): + } + s.mu.Lock() + open := make([]io.Closer, 0, len(s.live)) + for c := range s.live { + open = append(open, c) + } + s.mu.Unlock() + for _, c := range open { + _ = c.Close() + } + <-s.idleCh() +} + +func (s *Tunnels) idleCh() <-chan struct{} { + s.mu.Lock() + defer s.mu.Unlock() + if len(s.live) == 0 { + c := make(chan struct{}) + close(c) + return c + } + if s.idle == nil { + s.idle = make(chan struct{}) + } + return s.idle +} diff --git a/pkg/agent/endpoint/websocket_test.go b/pkg/agent/endpoint/websocket_test.go new file mode 100644 index 000000000..10428157e --- /dev/null +++ b/pkg/agent/endpoint/websocket_test.go @@ -0,0 +1,299 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint_test + +import ( + "bufio" + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// startWSFront serves app behind a front and a conformance worker over a real +// WebTransport session. Every request is granted. +func startWSFront( + t *testing.T, + app http.Handler, + eps []*livekit.AgentHttp_AgentEndpoint, + tune func(*endpoint.FrontParams), +) (*endpoint.Front, string) { + t.Helper() + target := httptest.NewServer(app) + t.Cleanup(target.Close) + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + params := endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope, Level: endpoint.AccessGranted}, true + }, + Logger: logger.GetLogger(), + } + if tune != nil { + tune(¶ms) + } + front := endpoint.NewFront(params) + ts := httptest.NewServer(front) + t.Cleanup(ts.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: eps, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + return front, ts.URL + "/agents/myagent/production" +} + +func wsURL(httpURL string) string { return "ws" + strings.TrimPrefix(httpURL, "http") } + +var wsRoute = []*livekit.AgentHttp_AgentEndpoint{{Path: "/ws", Methods: []string{"GET"}, Public: true}} + +// wsEchoApp echoes every message on /ws and reports the close code the client sent. +func wsEchoApp(closed chan<- int) http.Handler { + up := websocket.Upgrader{} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + for { + mt, msg, err := c.ReadMessage() + if err != nil { + if closed != nil { + var ce *websocket.CloseError + if errors.As(err, &ce) { + closed <- ce.Code + } else { + closed <- -1 + } + } + return + } + if err := c.WriteMessage(mt, msg); err != nil { + return + } + } + }) +} + +func TestWebSocketEchoThroughFront(t *testing.T) { + closed := make(chan int, 1) + front, base := startWSFront(t, wsEchoApp(closed), wsRoute, nil) + + c, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.Equal(t, 1, front.OpenTunnels()) + + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("hello"))) + mt, msg, err := c.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.TextMessage, mt) + require.Equal(t, "hello", string(msg)) + + big := []byte(strings.Repeat("b", 200<<10)) + require.NoError(t, c.WriteMessage(websocket.BinaryMessage, big)) + mt, msg, err = c.ReadMessage() + require.NoError(t, err) + require.Equal(t, websocket.BinaryMessage, mt) + require.Equal(t, big, msg) + + require.NoError(t, c.WriteMessage(websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "bye"))) + select { + case code := <-closed: + require.Equal(t, websocket.CloseNormalClosure, code) + case <-time.After(5 * time.Second): + t.Fatal("the client's close never reached the app") + } + _ = c.Close() + require.Eventually(t, func() bool { return front.OpenTunnels() == 0 }, 5*time.Second, 10*time.Millisecond) +} + +// an app refusing the handshake answers like any route. +func TestWebSocketRefusedUpgradeIsOrdinaryResponse(t *testing.T) { + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusForbidden) + }) + front, base := startWSFront(t, app, wsRoute, nil) + + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.ErrorIs(t, err, websocket.ErrBadHandshake) + require.Equal(t, http.StatusForbidden, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, "nope\n", string(body)) + require.Equal(t, 0, front.OpenTunnels()) +} + +func TestWebSocketDrainSendsGoingAway(t *testing.T) { + front, base := startWSFront(t, wsEchoApp(nil), wsRoute, nil) + + c, _, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + defer c.Close() + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("x"))) + _, _, err = c.ReadMessage() + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + done := make(chan struct{}) + go func() { + front.Drain(ctx) + close(done) + }() + + _ = c.SetReadDeadline(time.Now().Add(5 * time.Second)) + _, _, err = c.ReadMessage() + require.True(t, websocket.IsCloseError(err, websocket.CloseGoingAway), "got %v", err) + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("drain did not return") + } + require.Equal(t, 0, front.OpenTunnels()) + + // a draining front admits no new tunnel + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.Error(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +func TestWebSocketTunnelLimit(t *testing.T) { + _, base := startWSFront(t, wsEchoApp(nil), wsRoute, func(p *endpoint.FrontParams) { + p.MaxTunnelsPerWorker = 1 + }) + + c, _, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.NoError(t, err) + defer c.Close() + + _, resp, err := websocket.DefaultDialer.Dial(wsURL(base)+"/ws", nil) + require.Error(t, err) + require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) +} + +// the worker gets identity from the preamble; the caller's LiveKit token must +// not reach application code. +func TestLiveKitCredentialsDoNotReachTheWorker(t *testing.T) { + type seen struct { + auth string + query string + } + got := make(chan seen, 2) + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- seen{auth: r.Header.Get("Authorization"), query: r.URL.RawQuery} + }) + _, base := startWSFront(t, app, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/who", Methods: []string{"GET"}, Public: true}, + }, nil) + + jwt, err := auth.NewAccessToken("APIkey", "secret-that-is-long-enough-to-sign"). + SetIdentity("caller").SetValidFor(time.Minute).ToJWT() + require.NoError(t, err) + + req, _ := http.NewRequest(http.MethodGet, base+"/who?access_token="+jwt+"&x=1&b=%2F", nil) + req.Header.Set("Authorization", "Bearer "+jwt) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + s := <-got + require.Empty(t, s.auth) + require.Equal(t, "x=1&b=%2F", s.query) + + req, _ = http.NewRequest(http.MethodGet, base+"/who", nil) + req.Header.Set("Authorization", "Bearer app-owned") + resp, err = http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, "Bearer app-owned", (<-got).auth) +} + +// an HTTP/1 client keeps streaming its body after the response has started. +func TestFullDuplexOverHTTP1(t *testing.T) { + app := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = http.NewResponseController(w).EnableFullDuplex() + _, _ = io.WriteString(w, "ready\n") + _ = http.NewResponseController(w).Flush() + br := bufio.NewReader(r.Body) + for { + line, err := br.ReadString('\n') + if line != "" { + _, _ = io.WriteString(w, "echo "+line) + _ = http.NewResponseController(w).Flush() + } + if err != nil { + return + } + } + }) + _, base := startWSFront(t, app, []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/duplex", Methods: []string{"POST"}, Public: true}, + }, nil) + + pr, pw := io.Pipe() + req, _ := http.NewRequest(http.MethodPost, base+"/duplex", pr) + var ( + resp *http.Response + err error + wg sync.WaitGroup + ) + wg.Add(1) + go func() { + defer wg.Done() + // bounds the test if the second echo never comes + resp, err = (&http.Client{Timeout: 10 * time.Second}).Do(req) + }() + _, _ = io.WriteString(pw, "one\n") + wg.Wait() + require.NoError(t, err) + defer resp.Body.Close() + rr := bufio.NewReader(resp.Body) + + line, err := rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "ready\n", line) + line, err = rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "echo one\n", line) + + // the response is streaming; the request body is still open + _, err = io.WriteString(pw, "two\n") + require.NoError(t, err) + line, err = rr.ReadString('\n') + require.NoError(t, err) + require.Equal(t, "echo two\n", line) + + require.NoError(t, pw.Close()) + rest, err := io.ReadAll(rr) + require.NoError(t, err) + require.Empty(t, string(rest)) +} diff --git a/pkg/agent/endpoint/webtransport.go b/pkg/agent/endpoint/webtransport.go new file mode 100644 index 000000000..b61ae9dd5 --- /dev/null +++ b/pkg/agent/endpoint/webtransport.go @@ -0,0 +1,138 @@ +// 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 endpoint + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "time" + + "github.com/quic-go/webtransport-go" + + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +// wtSession adapts a WebTransport session to Session: the node opens one stream +// per HTTP exchange and QUIC multiplexes them. +type wtSession struct { + sess *webtransport.Session + maxStreams int + open atomic.Int64 +} + +// NewWebTransportSession wraps a live WebTransport session as a data-plane +// Session. maxStreams is the soft concurrency cap used only for capacity +// weighting; QUIC's own stream limit is the hard bound. +func NewWebTransportSession(sess *webtransport.Session, maxStreams int) Session { + return &wtSession{sess: sess, maxStreams: maxStreams} +} + +func (s *wtSession) OpenStream(ctx context.Context) (Stream, error) { + qs, err := s.sess.OpenStreamSync(ctx) + if err != nil { + return nil, err + } + s.open.Add(1) + return &wtStream{sess: s, qs: qs}, nil +} + +func (s *wtSession) OpenStreams() int { return int(s.open.Load()) } +func (s *wtSession) MaxStreams() int { return s.maxStreams } +func (s *wtSession) Close(reason string) { _ = s.sess.CloseWithError(wire.SessionCloseOK, reason) } + +// wtStream is one HTTP exchange over a WebTransport bidi stream. +type wtStream struct { + sess *wtSession + qs *webtransport.Stream + + mu sync.Mutex + done bool + sendClosed bool // send side FIN'd (CloseWrite) or reset +} + +func (s *wtStream) Read(p []byte) (int, error) { + n, err := s.qs.Read(p) + return n, translateStreamError(err) +} + +func (s *wtStream) SetReadDeadline(t time.Time) error { return s.qs.SetReadDeadline(t) } + +func (s *wtStream) Write(p []byte) (int, error) { + n, err := s.qs.Write(p) + return n, translateStreamError(err) +} + +// CloseWrite sends STREAM FIN on the send side. +func (s *wtStream) CloseWrite() error { + s.mu.Lock() + s.sendClosed = true + s.mu.Unlock() + return s.qs.Close() +} + +func (s *wtStream) Reset(code livekit.AgentHttp_HttpStreamResetCode, _ string) { + s.mu.Lock() + s.sendClosed = true + s.mu.Unlock() + c := wire.StreamCode(code) + s.qs.CancelWrite(c) + s.qs.CancelRead(c) + s.release() +} + +func (s *wtStream) Close() error { + // If the send side was never cleanly FIN'd (CloseWrite) or reset, a writer + // goroutine may still be blocked in Write because the peer stopped reading: + // cancel the send side to unblock it (else the goroutine and QUIC stream leak). + // After a clean FIN we must NOT reset: the body's own terminator followed by + // FIN is the completion signal, and a reset destroys it. + s.mu.Lock() + cancelWrite := !s.sendClosed + s.sendClosed = true + s.mu.Unlock() + abort := wire.StreamCode(livekit.AgentHttp_HSR_ABORT) + if cancelWrite { + s.qs.CancelWrite(abort) + } + s.qs.CancelRead(abort) + s.release() + return nil +} + +// release decrements the session's open-stream count exactly once. +func (s *wtStream) release() { + s.mu.Lock() + if s.done { + s.mu.Unlock() + return + } + s.done = true + s.mu.Unlock() + s.sess.open.Add(-1) +} + +// translateStreamError turns a peer reset into the protocol's own error. Only a +// remote reset carries meaning: cancelling this side says nothing about what the +// worker did with the request. +func translateStreamError(err error) error { + var se *webtransport.StreamError + if errors.As(err, &se) && se.Remote { + return &StreamResetError{Code: livekit.AgentHttp_HttpStreamResetCode(se.ErrorCode)} + } + return err +} diff --git a/pkg/agent/endpoint/webtransport_test.go b/pkg/agent/endpoint/webtransport_test.go new file mode 100644 index 000000000..f6cc8082f --- /dev/null +++ b/pkg/agent/endpoint/webtransport_test.go @@ -0,0 +1,254 @@ +// Copyright 2026 LiveKit, Inc. + +package endpoint_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +func selfSignedTLS(t *testing.T) *tls.Config { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + } +} + +// startWTServer runs a WebTransport /agent server that registers each session's +// worker (read off the control stream) into reg and keeps the session alive. +func startWTServer(t *testing.T, reg *endpoint.Registry, scope *endpoint.Scope) string { + udp, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 0}) + require.NoError(t, err) + + mux := http.NewServeMux() + srv := &webtransport.Server{H3: &http3.Server{TLSConfig: selfSignedTLS(t), Handler: mux}} + mux.HandleFunc("/agent", func(w http.ResponseWriter, r *http.Request) { + sess, err := srv.Upgrade(w, r) + if err != nil { + return + } + go handleSession(reg, scope, sess) + }) + go func() { _ = srv.Serve(udp) }() + t.Cleanup(func() { _ = srv.Close(); _ = udp.Close() }) + return "https://" + udp.LocalAddr().String() + "/agent" +} + +func handleSession(reg *endpoint.Registry, scope *endpoint.Scope, sess *webtransport.Session) { + ctx := sess.Context() + control, err := sess.AcceptStream(ctx) + if err != nil { + return + } + var msg livekit.WorkerMessage + if err := wire.ReadControlMessage(control, &msg); err != nil { + return + } + rw := msg.GetRegister() + if rw == nil { + return + } + manifest, err := endpoint.ParseManifest(rw.GetEndpoints()) + if err != nil { + return + } + registration := endpoint.NewRegistration(endpoint.RegistrationParams{ + WorkerID: rw.GetInstanceId(), + Manifest: manifest, + Session: endpoint.NewWebTransportSession(sess, endpoint.DefaultMaxStreams), + }) + reg.Register(scope, registration) + _ = wire.WriteControlMessage(control, &livekit.ServerMessage{ + Message: &livekit.ServerMessage_Register{ + Register: &livekit.RegisterWorkerResponse{ + WorkerId: registration.WorkerID, + EndpointSettings: &livekit.AgentHttp_AgentEndpointSettings{ + Protocol: wire.CurrentProtocol, + }, + }, + }, + }) + // keep the session alive; drain further control messages until it dies + for { + var m livekit.WorkerMessage + if err := wire.ReadControlMessage(control, &m); err != nil { + return + } + } +} + +func TestWebTransportEndpointRoundTrip(t *testing.T) { + // the worker's local app + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/hello": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"ok":true}`) + case "/hello-private": + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"private":true}`) + case "/echo": + body, _ := io.ReadAll(r.Body) + _, _ = w.Write(body) + default: + http.NotFound(w, r) + } + })) + defer target.Close() + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + front := endpoint.NewFront(endpoint.FrontParams{ + // anonymous: the scope resolves, but no grant, so only public routes serve + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + }) + ts := httptest.NewServer(front) + defer ts.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/hello", Methods: []string{"GET"}, Public: true}, + {Path: "/echo", Methods: []string{"POST"}, Public: true}, + {Path: "/hello-private", Methods: []string{"GET"}, Public: false}, + }, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + agentBase := ts.URL + "/agents/myagent/production" + + t.Run("GET json", func(t *testing.T) { + resp, err := http.Get(agentBase + "/hello") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.JSONEq(t, `{"ok":true}`, string(body)) + }) + + t.Run("POST echo", func(t *testing.T) { + payload := strings.Repeat("x", 64<<10) + resp, err := http.Post(agentBase+"/echo", "application/octet-stream", strings.NewReader(payload)) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, _ := io.ReadAll(resp.Body) + require.Equal(t, payload, string(body)) + }) + + t.Run("unknown path 404", func(t *testing.T) { + resp, err := http.Get(agentBase + "/nope") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) + }) +} + +// the grant is enforced across a real WebTransport session. +func TestWebTransportPrivateEndpointRequiresGrant(t *testing.T) { + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, `{"private":true}`) + })) + defer target.Close() + + reg, scope := endpoint.NewRegistry(), endpoint.NewScope(logger.GetLogger()) + base := startWTServer(t, reg, scope) + + anonymous := httptest.NewServer(endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope}, true + }, + Logger: logger.GetLogger(), + })) + defer anonymous.Close() + + granted := httptest.NewServer(endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(*http.Request, string, string) (endpoint.Access, bool) { + return endpoint.Access{Scope: scope, Level: endpoint.AccessGranted}, true + }, + Logger: logger.GetLogger(), + })) + defer granted.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + w := conformance.New(conformance.Config{ + ServerURL: base, + APIKey: "APIkey", + APISecret: "secret-that-is-long-enough-to-sign", + AgentName: "myagent", + Deployment: "production", + TargetAddr: strings.TrimPrefix(target.URL, "http://"), + Insecure: true, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{ + {Path: "/secret", Methods: []string{"GET"}, Public: false}, + }, + }) + require.NoError(t, w.Start(ctx)) + t.Cleanup(w.Close) + require.NoError(t, w.WaitRegistered(ctx)) + + const path = "/agents/myagent/production/secret" + + resp, err := http.Get(anonymous.URL + path) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + + resp2, err := http.Get(granted.URL + path) + require.NoError(t, err) + defer resp2.Body.Close() + require.Equal(t, http.StatusOK, resp2.StatusCode) + body, _ := io.ReadAll(resp2.Body) + require.JSONEq(t, `{"private":true}`, string(body)) +} diff --git a/pkg/agent/endpoint/wire/control.go b/pkg/agent/endpoint/wire/control.go new file mode 100644 index 000000000..3a77d98a1 --- /dev/null +++ b/pkg/agent/endpoint/wire/control.go @@ -0,0 +1,73 @@ +// 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 wire + +import ( + "encoding/binary" + "fmt" + "io" + + "google.golang.org/protobuf/proto" +) + +// MaxControlMessageSize bounds one framed control message so a malformed length +// prefix cannot make the reader allocate without bound. +const MaxControlMessageSize = 1 << 20 + +// WriteControlMessage writes a length-delimited protobuf on the control stream: +// a 4-byte big-endian length followed by the marshaled message. The stream +// carries the WorkerMessage/ServerMessage exchange. +func WriteControlMessage(w io.Writer, m proto.Message) error { + return writeLenPrefixed(w, m) +} + +// ReadControlMessage reads one length-delimited protobuf written by +// WriteControlMessage into m. +func ReadControlMessage(r io.Reader, m proto.Message) error { + return readLenPrefixed(r, m, MaxControlMessageSize) +} + +// writeLenPrefixed is the package's one length-delimited protobuf encoding: +// 4-byte big-endian length, then the marshaled message. The control stream and +// the per-exchange preamble share it. +func writeLenPrefixed(w io.Writer, m proto.Message) error { + b, err := proto.Marshal(m) + if err != nil { + return err + } + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(b))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err = w.Write(b) + return err +} + +func readLenPrefixed(r io.Reader, m proto.Message, max uint32) error { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return err + } + n := binary.BigEndian.Uint32(hdr[:]) + if n > max { + return fmt.Errorf("message too large: %d bytes", n) + } + b := make([]byte, n) + if _, err := io.ReadFull(r, b); err != nil { + return err + } + return proto.Unmarshal(b, m) +} diff --git a/pkg/agent/endpoint/wire/exchange.go b/pkg/agent/endpoint/wire/exchange.go new file mode 100644 index 000000000..b6299fe5d --- /dev/null +++ b/pkg/agent/endpoint/wire/exchange.go @@ -0,0 +1,342 @@ +// 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 wire + +import ( + "fmt" + "io" + "net/http" + "slices" + "strconv" + "strings" + + "golang.org/x/net/http/httpguts" + + "github.com/livekit/protocol/livekit" +) + +const ( + // MaxPreambleSize bounds the one allocation a peer can size before any HTTP + // bytes are parsed. + MaxPreambleSize = 64 << 10 + + MaxRequestHeadSize = 1 << 20 + BodyChunkSize = 32 << 10 + + // ReservedHeaderPrefix namespaces the in-band signalling below. + ReservedHeaderPrefix = "x-lk-" + + completionHeader = "x-lk-completion" + errorHeader = "x-lk-error" +) + +// Completion is how one direction of an exchange ended, spelled as the literal +// x-lk-completion trailer token. The empty value carries no information, so a +// sender that omits the field cannot thereby claim success; success is the +// body's own framing reaching its end and is never announced. +type Completion string + +const ( + CompletionUnknown Completion = "" + CompletionTruncated Completion = "truncated" + CompletionPeerGone Completion = "peer_gone" + CompletionRefused Completion = "refused" + CompletionInternal Completion = "internal" + CompletionTimeout Completion = "timeout" + CompletionCanceled Completion = "canceled" + CompletionProtocol Completion = "protocol" +) + +// CompletionError is a terminal state other than success. +type CompletionError struct { + Completion Completion + Reason string +} + +func (e *CompletionError) Error() string { + c := string(e.Completion) + if c == "" { + c = "unknown" + } + if e.Reason == "" { + return fmt.Sprintf("endpoint: exchange ended %s", c) + } + return fmt.Sprintf("endpoint: exchange ended %s: %s", c, e.Reason) +} + +// ResetCode is the QUIC reset code carrying this completion when it happened +// before any HTTP bytes flowed. Outcomes that can only arise mid-body have no +// code of their own and collapse to HSR_ABORT. +func (c Completion) ResetCode() livekit.AgentHttp_HttpStreamResetCode { + switch c { + case CompletionRefused: + return livekit.AgentHttp_HSR_REFUSED + case CompletionInternal: + return livekit.AgentHttp_HSR_INTERNAL + case CompletionTimeout: + return livekit.AgentHttp_HSR_TIMEOUT + case CompletionProtocol: + return livekit.AgentHttp_HSR_PROTOCOL + default: + return livekit.AgentHttp_HSR_ABORT + } +} + +// CompletionFromResetCode is the inverse. HSR_ABORT carries no claim about +// whether the request was applied. +func CompletionFromResetCode(code livekit.AgentHttp_HttpStreamResetCode) Completion { + switch code { + case livekit.AgentHttp_HSR_REFUSED: + return CompletionRefused + case livekit.AgentHttp_HSR_INTERNAL: + return CompletionInternal + case livekit.AgentHttp_HSR_TIMEOUT: + return CompletionTimeout + case livekit.AgentHttp_HSR_PROTOCOL: + return CompletionProtocol + default: + return CompletionUnknown + } +} + +// CompletionFromTrailers reports the outcome a peer announced, or nil when it +// announced none. No announcement, the body framing having reached its end, is +// success. +func CompletionFromTrailers(t http.Header) *CompletionError { + if len(t) == 0 { + return nil + } + c := Completion(strings.ToLower(strings.TrimSpace(t.Get(completionHeader)))) + if c == CompletionUnknown { + return nil + } + return &CompletionError{Completion: c, Reason: t.Get(errorHeader)} +} + +// WritePreamble writes the stream preamble. Everything after it is opaque +// HTTP/1.1. +func WritePreamble(w io.Writer, p *livekit.AgentHttp_StreamPreamble) error { + return writeLenPrefixed(w, p) +} + +func ReadPreamble(r io.Reader) (*livekit.AgentHttp_StreamPreamble, error) { + p := &livekit.AgentHttp_StreamPreamble{} + if err := readLenPrefixed(r, p, MaxPreambleSize); err != nil { + return nil, err + } + return p, nil +} + +// StripReservedHeaders removes the in-band signalling fields. Every header set +// crossing between client and worker must pass through here: a client-set +// x-lk-completion would otherwise forge an outcome, and a worker-set one would +// reach the end client. +func StripReservedHeaders(h http.Header) { + for k := range h { + if strings.HasPrefix(strings.ToLower(k), ReservedHeaderPrefix) { + delete(h, k) + } + } +} + +// BuildRequestHead serializes the canonical origin-form request head. +// +// Its inputs must come from an already-parsed request, never from the client's +// own bytes: emitting a head this side has parsed is what keeps request +// smuggling out of the worker. +// +// contentLength >= 0 is framed with Content-Length; a negative length is framed +// chunked, the only framing that can carry completion trailers. +func BuildRequestHead(method, target, host string, h http.Header, contentLength int64) ([]byte, error) { + if !httpguts.ValidHeaderFieldName(method) { + return nil, fmt.Errorf("endpoint: invalid method %q", method) + } + if !strings.HasPrefix(target, "/") { + return nil, fmt.Errorf("endpoint: request target must be origin-form, got %q", target) + } + if strings.ContainsAny(target, " \r\n") { + return nil, fmt.Errorf("endpoint: invalid request target %q", target) + } + if !httpguts.ValidHostHeader(host) { + return nil, fmt.Errorf("endpoint: invalid host %q", host) + } + + var b strings.Builder + b.WriteString(method) + b.WriteByte(' ') + b.WriteString(target) + b.WriteString(" HTTP/1.1\r\nHost: ") + b.WriteString(host) + b.WriteString("\r\n") + + // the only framing header on the request; isFramingHeader drops any the + // caller supplied + if contentLength >= 0 { + b.WriteString("Content-Length: ") + b.WriteString(strconv.FormatInt(contentLength, 10)) + b.WriteString("\r\n") + } else { + b.WriteString("Transfer-Encoding: chunked\r\n") + } + + keys := make([]string, 0, len(h)) + for k := range h { + if isFramingHeader(k) { + continue + } + keys = append(keys, k) + } + slices.Sort(keys) // deterministic across attempts and across nodes + for _, k := range keys { + if !httpguts.ValidHeaderFieldName(k) { + return nil, fmt.Errorf("endpoint: invalid header name %q", k) + } + for _, v := range h[k] { + if !httpguts.ValidHeaderFieldValue(v) { + return nil, fmt.Errorf("endpoint: invalid value for header %q", k) + } + b.WriteString(k) + b.WriteString(": ") + b.WriteString(v) + b.WriteString("\r\n") + } + } + b.WriteString("\r\n") + return []byte(b.String()), nil +} + +// isFramingHeader reports the fields whose presence would let the head declare a +// second, conflicting body framing. +func isFramingHeader(k string) bool { + switch http.CanonicalHeaderKey(k) { + case "Host", "Content-Length", "Transfer-Encoding", "Trailer": + return true + } + return false +} + +// BodyWriter frames one body onto a stream. Close ends the body; a completion +// other than success is reported in trailers where the framing allows it. +type BodyWriter interface { + io.Writer + Close(c Completion, reason string) error +} + +// NewChunkedBody frames a body with chunked transfer coding, the only framing +// that can carry a completion trailer. +func NewChunkedBody(w io.Writer) BodyWriter { return &chunkedBody{w: w} } + +// NewIdentityBody writes a body whose length the head already declared. It +// cannot carry trailers: a short body is surfaced by the FIN arriving before +// the declared length is met. +func NewIdentityBody(w io.Writer) BodyWriter { return &identityBody{w: w} } + +type identityBody struct{ w io.Writer } + +func (b *identityBody) Write(p []byte) (int, error) { return b.w.Write(p) } +func (b *identityBody) Close(Completion, string) error { return nil } + +type chunkedBody struct { + w io.Writer + buf []byte +} + +func (b *chunkedBody) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil // a zero-length chunk would terminate the body + } + b.buf = strconv.AppendUint(b.buf[:0], uint64(len(p)), 16) + b.buf = append(b.buf, '\r', '\n') + if _, err := b.w.Write(b.buf); err != nil { + return 0, err + } + if _, err := b.w.Write(p); err != nil { + return 0, err + } + if _, err := b.w.Write([]byte("\r\n")); err != nil { + return 0, err + } + return len(p), nil +} + +// Close writes the terminal 0-chunk and, for a non-success outcome, the +// completion trailers. Success is the absence of them. +func (b *chunkedBody) Close(c Completion, reason string) error { + var sb strings.Builder + sb.WriteString("0\r\n") + if c != CompletionUnknown { + sb.WriteString(completionHeader) + sb.WriteString(": ") + sb.WriteString(string(c)) + sb.WriteString("\r\n") + if reason != "" { + sb.WriteString(errorHeader) + sb.WriteString(": ") + sb.WriteString(sanitizeReason(reason)) + sb.WriteString("\r\n") + } + } + sb.WriteString("\r\n") + _, err := b.w.Write([]byte(sb.String())) + return err +} + +// sanitizeReason bounds an operator-supplied string and strips the control +// characters that would break trailer framing. +func sanitizeReason(s string) string { + const max = 256 + s = strings.Map(func(r rune) rune { + if r < 0x20 || r == 0x7f { + return ' ' + } + return r + }, s) + if len(s) > max { + s = s[:max] + } + return s +} + +// SourceError reports that the body's source failed mid-copy. The stream is +// still intact, so the caller can close the body with a completion. +type SourceError struct{ Err error } + +func (e *SourceError) Error() string { + return fmt.Sprintf("endpoint: body source failed: %s", e.Err) +} + +func (e *SourceError) Unwrap() error { return e.Err } + +// CopyBody pumps src into dst one write per read, so a streaming body stays +// incremental. A src failure is wrapped in *SourceError; any other error is +// from dst. +func CopyBody(dst BodyWriter, src io.Reader, buf []byte) (int64, error) { + var n int64 + for { + nr, rerr := src.Read(buf) + if nr > 0 { + if _, werr := dst.Write(buf[:nr]); werr != nil { + return n, werr + } + n += int64(nr) + } + if rerr == io.EOF { + return n, nil + } + if rerr != nil { + return n, &SourceError{Err: rerr} + } + } +} diff --git a/pkg/agent/endpoint/wire/exchange_test.go b/pkg/agent/endpoint/wire/exchange_test.go new file mode 100644 index 000000000..818f2f57c --- /dev/null +++ b/pkg/agent/endpoint/wire/exchange_test.go @@ -0,0 +1,272 @@ +// 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 wire + +import ( + "bufio" + "bytes" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/protocol/livekit" +) + +func TestPreambleRoundTrip(t *testing.T) { + var buf bytes.Buffer + in := &livekit.AgentHttp_StreamPreamble{ + Kind: livekit.AgentHttp_AEK_HTTP, + RequestId: "AER_abc", + Authorized: true, + Route: "/items/{id}", + TimeoutMs: 1500, + ClientAddr: "203.0.113.7", + Scheme: "https", + } + require.NoError(t, WritePreamble(&buf, in)) + + // the exchange bytes follow immediately; the reader must stop at the boundary + buf.WriteString("GET / HTTP/1.1\r\n") + + out, err := ReadPreamble(&buf) + require.NoError(t, err) + require.Equal(t, in.RequestId, out.RequestId) + require.True(t, out.Authorized) + require.Equal(t, "/items/{id}", out.Route) + require.EqualValues(t, 1500, out.TimeoutMs) + require.Equal(t, "https", out.Scheme) + require.Equal(t, "GET / HTTP/1.1\r\n", buf.String(), "the preamble must not over-read") +} + +func TestPreambleRejectsOversizedLength(t *testing.T) { + var buf bytes.Buffer + buf.Write([]byte{0xff, 0xff, 0xff, 0xff}) + _, err := ReadPreamble(&buf) + require.Error(t, err, "a malformed length must not make the reader allocate") +} + +// The head is what keeps request smuggling out of the worker, so it declares +// exactly one framing and nothing the caller supplied can add a second. +func TestBuildRequestHeadEmitsExactlyOneFraming(t *testing.T) { + h := http.Header{} + h.Set("Content-Length", "999") + h.Set("Transfer-Encoding", "chunked") + h.Set("Trailer", "x-whatever") + h.Set("Host", "evil.example") + h.Set("Accept", "text/plain") + + b, err := BuildRequestHead("POST", "/items/42?q=1", "good.example", h, 7) + require.NoError(t, err) + head := string(b) + + require.True(t, strings.HasPrefix(head, "POST /items/42?q=1 HTTP/1.1\r\n")) + require.Equal(t, 1, strings.Count(head, "Content-Length: ")) + require.Contains(t, head, "Content-Length: 7\r\n") + require.NotContains(t, head, "chunked") + require.NotContains(t, head, "evil.example") + require.NotContains(t, head, "Trailer:") + require.Contains(t, head, "Host: good.example\r\n") + require.Contains(t, head, "Accept: text/plain\r\n") + require.True(t, strings.HasSuffix(head, "\r\n\r\n")) + + // net/http parses back what we emitted + req, err := http.ReadRequest(bufio.NewReader(strings.NewReader(head + "1234567"))) + require.NoError(t, err) + require.Equal(t, "POST", req.Method) + require.Equal(t, "/items/42", req.URL.Path) + require.Equal(t, "q=1", req.URL.RawQuery) + require.Equal(t, "good.example", req.Host) + require.EqualValues(t, 7, req.ContentLength) +} + +func TestBuildRequestHeadChunkedWhenLengthUnknown(t *testing.T) { + b, err := BuildRequestHead("POST", "/x", "h", http.Header{}, -1) + require.NoError(t, err) + require.Contains(t, string(b), "Transfer-Encoding: chunked\r\n") + require.NotContains(t, string(b), "Content-Length:") +} + +func TestBuildRequestHeadRejectsInjection(t *testing.T) { + bad := http.Header{} + bad.Set("X-Evil", "a\r\nX-Injected: yes") + _, err := BuildRequestHead("GET", "/x", "h", bad, 0) + require.Error(t, err, "a header value carrying CRLF must never be serialized") + + _, err = BuildRequestHead("GET", "/x y", "h", http.Header{}, 0) + require.Error(t, err, "a target with a space would split the request line") + + _, err = BuildRequestHead("GET", "x", "h", http.Header{}, 0) + require.Error(t, err, "the target must be origin-form") + + _, err = BuildRequestHead("GET", "/x", "bad host\r\n", http.Header{}, 0) + require.Error(t, err) +} + +// Go parses what we emit, including trailers never announced in Trailer:. +func TestChunkedBodyAndTrailersParseWithNetHTTP(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, err := bw.Write([]byte("hello")) + require.NoError(t, err) + require.NoError(t, bw.Close(CompletionInternal, "boom at chunk 12")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + + ce := CompletionFromTrailers(resp.Trailer) + require.NotNil(t, ce, "an undeclared trailer must still be read") + require.Equal(t, CompletionInternal, ce.Completion) + require.Equal(t, "boom at chunk 12", ce.Reason) +} + +// Success is the absence of a completion trailer, never a value you send. +func TestCleanChunkedBodyReportsNoCompletion(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, _ = bw.Write([]byte("hello")) + require.NoError(t, bw.Close(CompletionUnknown, "")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "hello", string(body)) + require.Nil(t, CompletionFromTrailers(resp.Trailer)) +} + +// The same bytes cut before the terminator must not read as a clean body. +func TestTruncatedChunkedBodyIsDistinguishable(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + _, _ = bw.Write([]byte("hello")) + // no Close: the terminal 0-chunk never arrives + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "a body with no terminator must not read clean") +} + +func TestIdentityBodyWritesNothingExtra(t *testing.T) { + var buf bytes.Buffer + bw := NewIdentityBody(&buf) + _, err := bw.Write([]byte("hello")) + require.NoError(t, err) + // an identity body cannot carry trailers; Close must not invent framing + require.NoError(t, bw.Close(CompletionTruncated, "ignored")) + require.Equal(t, "hello", buf.String()) +} + +func TestResetCodeRoundTrip(t *testing.T) { + for _, c := range []Completion{ + CompletionRefused, CompletionInternal, CompletionTimeout, CompletionProtocol, + } { + require.Equal(t, c, CompletionFromResetCode(c.ResetCode()), string(c)) + } + // outcomes that can only arise mid-body have no code of their own + require.Equal(t, livekit.AgentHttp_HSR_ABORT, CompletionTruncated.ResetCode()) + require.Equal(t, livekit.AgentHttp_HSR_ABORT, CompletionPeerGone.ResetCode()) + // and a bare abort must not decode as a claim that nothing was applied + require.Equal(t, CompletionUnknown, CompletionFromResetCode(livekit.AgentHttp_HSR_ABORT)) + require.NotEqual(t, CompletionRefused, CompletionFromResetCode(livekit.AgentHttp_HSR_ABORT)) +} + +func TestStripReservedHeaders(t *testing.T) { + h := http.Header{} + h.Set("X-Lk-Completion", "internal") + h.Set("x-lk-error", "forged") + h.Set("X-LK-Anything", "nope") + h.Set("X-Keep", "yes") + StripReservedHeaders(h) + require.Empty(t, h.Get("X-Lk-Completion")) + require.Empty(t, h.Get("X-Lk-Error")) + require.Empty(t, h.Get("X-Lk-Anything")) + require.Equal(t, "yes", h.Get("X-Keep")) +} + +// A reason describing worker internals must not be able to break framing. +func TestCompletionReasonCannotBreakFraming(t *testing.T) { + var buf bytes.Buffer + buf.WriteString("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n") + bw := NewChunkedBody(&buf) + require.NoError(t, bw.Close(CompletionInternal, "a\r\nx-injected: yes\r\n")) + + resp, err := http.ReadResponse(bufio.NewReader(bytes.NewReader(buf.Bytes())), nil) + require.NoError(t, err) + _, err = io.ReadAll(resp.Body) + require.NoError(t, err) + require.Empty(t, resp.Trailer.Get("X-Injected")) +} + +// oneByteReader forces reassembly across read boundaries. +type oneByteReader struct{ r io.Reader } + +func (o oneByteReader) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + return o.r.Read(p[:1]) +} + +func TestCopyBodySplitReads(t *testing.T) { + src := bytes.Repeat([]byte("abcdefgh"), 1024) + var buf bytes.Buffer + bw := NewIdentityBody(&buf) + n, err := CopyBody(bw, oneByteReader{bytes.NewReader(src)}, make([]byte, 512)) + require.NoError(t, err) + require.EqualValues(t, len(src), n) + require.Equal(t, src, buf.Bytes()) +} + +type failingWriter struct{} + +func (failingWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe } + +// Only a source failure leaves the stream able to report the outcome. +func TestCopyBodySeparatesSourceAndSinkFailures(t *testing.T) { + // a destination failure is returned unwrapped + _, err := CopyBody(NewIdentityBody(failingWriter{}), strings.NewReader("xxxx"), make([]byte, 2)) + require.Error(t, err) + var srcErr *SourceError + require.False(t, errors.As(err, &srcErr)) + + // a source failure is wrapped, so the caller can still close the body + _, err = CopyBody(NewIdentityBody(io.Discard), iotestErrReader{}, make([]byte, 2)) + require.ErrorAs(t, err, &srcErr) +} + +type iotestErrReader struct{} + +func (iotestErrReader) Read([]byte) (int, error) { return 0, io.ErrUnexpectedEOF } + +// The body path is the hot path: chunk framing must not allocate per write. +func TestChunkedWriteDoesNotAllocatePerChunk(t *testing.T) { + bw := NewChunkedBody(io.Discard) + p := make([]byte, 4096) + avg := testing.AllocsPerRun(200, func() { + _, _ = bw.Write(p) + }) + require.LessOrEqual(t, avg, 1.0, "chunk framing should reuse its scratch buffer") +} diff --git a/pkg/agent/endpoint/wire/protocol.go b/pkg/agent/endpoint/wire/protocol.go new file mode 100644 index 000000000..dc5cd7fd3 --- /dev/null +++ b/pkg/agent/endpoint/wire/protocol.go @@ -0,0 +1,26 @@ +// 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 wire + +// CurrentProtocol is the data-plane protocol version this implementation speaks, +// negotiated in RegisterWorkerResponse. +const CurrentProtocol uint32 = 1 + +// MinProtocol is the oldest data-plane protocol version still served. +const MinProtocol uint32 = 1 + +// SessionCloseOK is the WebTransport application close code for a normal session +// teardown; the human-readable reason travels in the close message. +const SessionCloseOK = 0 diff --git a/pkg/agent/endpoint/wire/streamcode.go b/pkg/agent/endpoint/wire/streamcode.go new file mode 100644 index 000000000..a2fef054d --- /dev/null +++ b/pkg/agent/endpoint/wire/streamcode.go @@ -0,0 +1,28 @@ +// 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 wire + +import ( + "github.com/quic-go/webtransport-go" + + "github.com/livekit/protocol/livekit" +) + +// StreamCode converts a protocol reset code to the WebTransport code that +// carries it. HSR_ABORT is zero, so a teardown with nothing to say sends the +// plain cancel code. +func StreamCode(c livekit.AgentHttp_HttpStreamResetCode) webtransport.StreamErrorCode { + return webtransport.StreamErrorCode(c) +} diff --git a/pkg/agent/testutils/server.go b/pkg/agent/testutils/server.go index 3f490918c..30793d069 100644 --- a/pkg/agent/testutils/server.go +++ b/pkg/agent/testutils/server.go @@ -15,6 +15,7 @@ import ( "github.com/gammazero/deque" "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/service" @@ -40,7 +41,7 @@ type TestServer struct { func NewTestServer(bus psrpc.MessageBus) *TestServer { localNode, _ := routing.NewLocalNode(nil) - return NewTestServerWithService(must.Get(service.NewAgentService( + return NewTestServerWithService(must.Get(service.NewAgentHandler( &config.Config{ Region: "test", Agents: agent.Config{ @@ -50,6 +51,8 @@ func NewTestServer(bus psrpc.MessageBus) *TestServer { localNode, bus, auth.NewSimpleKeyProvider("test", "verysecretsecret"), + endpoint.NewRegistry(), + service.NewEndpointScopes(), ))) } diff --git a/pkg/agent/worker.go b/pkg/agent/worker.go index 75f80f00f..bdd5f5c40 100644 --- a/pkg/agent/worker.go +++ b/pkg/agent/worker.go @@ -25,6 +25,7 @@ import ( "go.uber.org/multierr" "google.golang.org/protobuf/proto" + "github.com/livekit/livekit-server/pkg/agent/endpoint" protoagent "github.com/livekit/protocol/agent" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -154,6 +155,12 @@ type WorkerRegistration struct { Permissions *livekit.ParticipantPermission ClientIP string Deployment string + + // agent HTTP endpoints data plane + Endpoints []*livekit.AgentHttp_AgentEndpoint + InstanceID string + EndpointSettings *livekit.AgentHttp_AgentEndpointSettings + // KindDetails, when set by the server, are stamped onto the participant // join token minted for every job assigned to this worker (e.g. marking // hosted/cloud agents so they can be distinguished from self-hosted ones in @@ -171,21 +178,52 @@ func MakeWorkerRegistration() WorkerRegistration { var _ WorkerSignalHandler = (*WorkerRegisterer)(nil) +// WorkerRegisterHandler takes a turn during a worker's registration: it reads the +// request, validates the part it owns, and fills in its part of the response and +// the registration. Returning an error fails the registration. +type WorkerRegisterHandler func(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *WorkerRegistration) error + +// EndpointRegisterHandler owns a worker's endpoint declaration: it validates the +// declaration and negotiates the data-plane settings. A registration declaring no +// endpoints passes through untouched. +func EndpointRegisterHandler(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *WorkerRegistration) error { + if len(req.GetEndpoints()) == 0 { + return nil + } + // endpoints are addressed at /agents/{agent_name}/{deployment}/..., with the + // name percent-encoded into that segment + if req.GetAgentName() == "" { + return errors.New("agent HTTP endpoints require an agent name") + } + if endpoint.IsReservedAgentName(req.GetAgentName()) { + return fmt.Errorf("agent name %q is reserved and cannot serve HTTP endpoints", req.GetAgentName()) + } + settings, err := endpoint.NegotiateSettings(req) + if err != nil { + return err + } + res.EndpointSettings = settings + reg.EndpointSettings = settings + return nil +} + type WorkerRegisterer struct { WorkerPingHandler serverInfo *livekit.ServerInfo deadline time.Time + handlers []WorkerRegisterHandler registration WorkerRegistration registered bool } -func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration) *WorkerRegisterer { +func NewWorkerRegisterer(conn SignalConn, serverInfo *livekit.ServerInfo, base WorkerRegistration, handlers ...WorkerRegisterHandler) *WorkerRegisterer { return &WorkerRegisterer{ WorkerPingHandler: WorkerPingHandler{conn: conn}, serverInfo: serverInfo, registration: base, deadline: time.Now().Add(RegisterTimeout), + handlers: handlers, } } @@ -226,15 +264,22 @@ func (h *WorkerRegisterer) HandleRegister(req *livekit.RegisterWorkerRequest) er h.registration.JobType = req.GetType() h.registration.Permissions = permissions h.registration.Deployment = req.GetDeployment() + h.registration.Endpoints = req.GetEndpoints() + h.registration.InstanceID = req.GetInstanceId() + + res := &livekit.RegisterWorkerResponse{ + WorkerId: h.registration.ID, + ServerInfo: h.serverInfo, + } + for _, handle := range h.handlers { + if err := handle(req, res, &h.registration); err != nil { + return err + } + } h.registered = true _, err := h.conn.WriteServerMessage(&livekit.ServerMessage{ - Message: &livekit.ServerMessage_Register{ - Register: &livekit.RegisterWorkerResponse{ - WorkerId: h.registration.ID, - ServerInfo: h.serverInfo, - }, - }, + Message: &livekit.ServerMessage_Register{Register: res}, }) return err } @@ -253,9 +298,11 @@ type Worker struct { cancel context.CancelFunc closed chan struct{} - mu sync.Mutex - load float32 - status livekit.WorkerStatus + mu sync.Mutex + load float32 + status livekit.WorkerStatus + statusSeq uint64 + draining bool runningJobs map[livekit.JobID]*livekit.Job availability map[livekit.JobID]chan *livekit.AvailabilityResponse @@ -591,15 +638,30 @@ func (w *Worker) HandleUpdateWorker(update *livekit.UpdateWorkerStatus) error { w.mu.Lock() defer w.mu.Unlock() + // status updates may interleave across connections; never regress newer state + if seq := update.GetSeq(); seq != 0 { + if seq <= w.statusSeq { + return nil + } + w.statusSeq = seq + } + if status := update.Status; status != nil && w.status != *status { w.status = *status w.Logger().Debugw("worker status changed", "status", w.status) } w.load = update.GetLoad() + w.draining = update.GetDraining() return nil } +func (w *Worker) Draining() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.draining +} + func (w *Worker) HandleMigrateJob(req *livekit.MigrateJobRequest) error { // TODO(theomonnom): On OSS this is not implemented // We could maybe just move a specific job to another worker diff --git a/pkg/agent/worker_test.go b/pkg/agent/worker_test.go new file mode 100644 index 000000000..5b9947892 --- /dev/null +++ b/pkg/agent/worker_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 LiveKit, Inc. + +package agent_test + +import ( + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +type nopSignalConn struct{} + +func (nopSignalConn) WriteServerMessage(*livekit.ServerMessage) (int, error) { return 0, nil } +func (nopSignalConn) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) { return nil, 0, nil } +func (nopSignalConn) SetReadDeadline(time.Time) error { return nil } +func (nopSignalConn) Close() error { return nil } +func (nopSignalConn) CloseWithReason(string) error { return nil } + +// the name is percent-encoded into a URL path segment. +func TestHandleRegisterEndpointAgentNames(t *testing.T) { + cases := []struct { + name string + agentName string + ok bool + }{ + {"plain", "test-agent", true}, + {"underscore", "my_agent", true}, + {"spaces", "LODHA Vayam Agent", true}, + {"colon and star", "prod:*", true}, + {"at sign", "charlie@v1.42.0", true}, + {"slash", "models/gemma/v2", true}, + {"brackets", "Nathan [Elara Agent]", true}, + {"non ascii", "Sehhaty الصحة", true}, + {"past the old 64 byte cap", strings.Repeat("n", 77), true}, + + {"empty", "", false}, + {"unnamed sentinel", "_", false}, + {"dot", ".", false}, + {"dot dot", "..", false}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h := agent.NewWorkerRegisterer(nopSignalConn{}, &livekit.ServerInfo{}, agent.WorkerRegistration{}, + agent.EndpointRegisterHandler) + + err := h.HandleRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: c.agentName, + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/hook", Methods: []string{"GET"}}}, + InstanceId: "AEI_test", + EndpointProtocol: wire.CurrentProtocol, + }) + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } +} + +// the reserved names constrain only workers that declare endpoints. +func TestHandleRegisterWithoutEndpointsIgnoresReservedNames(t *testing.T) { + for _, name := range []string{"", "_", ".", ".."} { + h := agent.NewWorkerRegisterer(nopSignalConn{}, &livekit.ServerInfo{}, agent.WorkerRegistration{}, agent.EndpointRegisterHandler) + require.NoError(t, h.HandleRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: name, + }), name) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 2f39c3e76..872f780d1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -82,6 +82,7 @@ type Config struct { PrometheusPort uint32 `yaml:"prometheus_port,omitempty"` Prometheus PrometheusConfig `yaml:"prometheus,omitempty"` DebugHandler DebugHandlerConfig `yaml:"debug_handler,omitempty"` + WebTransport WebTransportConfig `yaml:"webtransport,omitempty"` RTC RTCConfig `yaml:"rtc,omitempty"` Redis redisLiveKit.RedisConfig `yaml:"redis,omitempty"` Audio sfu.AudioConfig `yaml:"audio,omitempty"` @@ -117,6 +118,15 @@ type Config struct { API APIConfig `yaml:"api,omitempty"` } +// WebTransportConfig is the node's HTTP/3 listener. QUIC has no plaintext mode, +// so a certificate is required; development mode generates a self-signed one. +type WebTransportConfig struct { + // Port is the UDP port to listen on. 0 starts no listener. + Port uint32 `yaml:"port,omitempty"` + TLSCertFile string `yaml:"tls_cert_file,omitempty"` + TLSKeyFile string `yaml:"tls_key_file,omitempty"` +} + type RTCConfig struct { rtcconfig.RTCConfig `yaml:",inline"` diff --git a/pkg/service/agentendpoint.go b/pkg/service/agentendpoint.go new file mode 100644 index 000000000..ec6761a0b --- /dev/null +++ b/pkg/service/agentendpoint.go @@ -0,0 +1,65 @@ +// 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 service + +import ( + "net/http" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// AgentEndpointService is the /agents/{agent_name}/{deployment}/{path...} front +// backed by this node's attached workers. The api key comes from validated grants +// when a token is present; a non-public route additionally requires an +// agent-endpoint grant scoped to this agent and deployment. +type AgentEndpointService struct { + *endpoint.Front +} + +func NewAgentEndpointService(h *AgentHandler, scopes *EndpointScopes) *AgentEndpointService { + return &AgentEndpointService{ + Front: endpoint.NewFront(endpoint.FrontParams{ + ResolveAccess: func(r *http.Request, agentName, deployment string) (endpoint.Access, bool) { + if claims := GetGrants(r.Context()); claims != nil { + level := endpoint.AccessCredentialed + if claims.AgentEndpoint.Allows(agentName, deployment) { + level = endpoint.AccessGranted + } + return endpoint.Access{ + Scope: scopes.Scope(GetAPIKey(r.Context()), agentName, deployment), + Level: level, + }, true + } + // unauthenticated: one configured key makes the api key + // unambiguous, and failing that a single attached tenant does. + // A guessed api key confers no access, so such a request still + // reaches only routes marked public. + apiKey := h.singleAPIKey + if apiKey == "" { + var ok bool + if apiKey, ok = scopes.SingleKey(); !ok { + return endpoint.Access{}, false + } + } + return endpoint.Access{ + Scope: scopes.Scope(apiKey, agentName, deployment), + }, true + }, + Logger: h.logger, + MaxTunnelsPerWorker: int(h.endpointsConfig.MaxTunnels), + TunnelDrainTimeout: h.endpointsConfig.TunnelDrainTimeout, + }), + } +} diff --git a/pkg/service/agentendpoint_protocol_test.go b/pkg/service/agentendpoint_protocol_test.go new file mode 100644 index 000000000..40ac8439b --- /dev/null +++ b/pkg/service/agentendpoint_protocol_test.go @@ -0,0 +1,80 @@ +// 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 service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/protocol/livekit" +) + +func TestEndpointSettingsNegotiation(t *testing.T) { + req := func(protocol uint32) *livekit.RegisterWorkerRequest { + return &livekit.RegisterWorkerRequest{ + AgentName: "test-agent", + Deployment: "production", + InstanceId: "AEI_test", + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/json", Methods: []string{"GET"}}}, + EndpointProtocol: protocol, + } + } + h := &AgentHandler{} + negotiate := func(h *AgentHandler, r *livekit.RegisterWorkerRequest) (*livekit.AgentHttp_AgentEndpointSettings, error) { + res := &livekit.RegisterWorkerResponse{} + reg := &agent.WorkerRegistration{} + if err := h.endpointRegisterHandler(r, res, reg); err != nil { + return nil, err + } + require.Equal(t, res.GetEndpointSettings(), reg.EndpointSettings, + "the registration must carry what the worker was told") + return res.GetEndpointSettings(), nil + } + + // a worker predating the data plane declares endpoints with protocol 0: it + // cannot frame at all, and the error must name that rather than read as a + // version out of range + _, err := negotiate(h, req(0)) + require.ErrorContains(t, err, "no endpoint protocol") + + settings, err := negotiate(h, req(wire.CurrentProtocol)) + require.NoError(t, err) + require.Equal(t, wire.CurrentProtocol, settings.GetProtocol()) + + // a worker speaking a newer version is negotiated down to what this server + // serves; the worker frames to the version returned here + settings, err = negotiate(h, req(wire.CurrentProtocol+42)) + require.NoError(t, err) + require.Equal(t, wire.CurrentProtocol, settings.GetProtocol()) + + noInstance := req(wire.CurrentProtocol) + noInstance.InstanceId = "" + _, err = negotiate(h, noInstance) + require.ErrorContains(t, err, "instance_id") + + disabled := &AgentHandler{endpointsConfig: agent.EndpointsConfig{Disabled: true}} + _, err = negotiate(disabled, req(wire.CurrentProtocol)) + require.ErrorContains(t, err, "disabled") + + // a registration declaring no endpoints is left alone, even with endpoints off + bare := req(wire.CurrentProtocol) + bare.Endpoints = nil + settings, err = negotiate(disabled, bare) + require.NoError(t, err) + require.Nil(t, settings) +} diff --git a/pkg/service/agentendpoint_test.go b/pkg/service/agentendpoint_test.go new file mode 100644 index 000000000..a4ae1af7b --- /dev/null +++ b/pkg/service/agentendpoint_test.go @@ -0,0 +1,711 @@ +// 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 service_test + +import ( + "bufio" + "bytes" + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "io" + "math/big" + "net" + "net/http" + "net/http/httptest" + "net/url" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/quic-go/quic-go/http3" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/conformance" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/livekit-server/pkg/agent/testutils" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" + "github.com/livekit/psrpc" +) + +const ( + testKey = "test" + testSecret = "verysecretsecret" +) + +// small enough that a test body can exceed it +const testMaxAPIBodySize = 64 << 10 + +type endpointStack struct { + t *testing.T + ts *httptest.Server + handler *service.AgentHandler + scopes *service.EndpointScopes + wtURL string // https://host:port/agent (WebTransport control+data) +} + +// selfSignedTLS mints an in-memory cert for 127.0.0.1 with the h3 ALPN, for the +// node's WebTransport listener. +func selfSignedTLS(t *testing.T) *tls.Config { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + require.NoError(t, err) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + } +} + +func newEndpointStack(t *testing.T, endpointsCfg agent.EndpointsConfig) *endpointStack { + localNode, err := routing.NewLocalNode(nil) + require.NoError(t, err) + keyProvider := auth.NewSimpleKeyProvider(testKey, testSecret) + + conf := &config.Config{ + Region: "test", + Keys: map[string]string{testKey: testSecret}, + Agents: agent.Config{TargetLoad: agent.DefaultTargetLoad, Endpoints: endpointsCfg}, + } + conf.Limit.MaxAPIRequestBodySize = testMaxAPIBodySize + + scopes := service.NewEndpointScopes() + h, err := service.NewAgentHandler(conf, localNode, psrpc.NewLocalMessageBus(), keyProvider, endpoint.NewRegistry(), scopes) + require.NoError(t, err) + + // the production handler, so these tests run on the node's real middleware chain + apiMux := http.NewServeMux() + apiMux.HandleFunc("POST /api-sink", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + w.WriteHeader(http.StatusNoContent) + }) + apiMux.HandleFunc("/", http.NotFound) + + var agentFront http.Handler + if !endpointsCfg.Disabled { + agentFront = service.NewAgentEndpointService(h, scopes) + } + ts := httptest.NewServer(service.NewHTTPHandler(conf, keyProvider, apiMux, agentFront)) + t.Cleanup(ts.Close) + t.Cleanup(func() { h.DrainConnections(time.Millisecond, true) }) + + // workers connect over WebTransport (control + data on one session) + wtMux := http.NewServeMux() + wtMux.Handle("/agent", service.NewAgentWTService(h)) + wt := service.NewWebTransportServer(selfSignedTLS(t)) + wt.H3.Handler = service.NewWebTransportHandler(keyProvider, wt, wtMux) + bound, err := wt.Listen([]string{"127.0.0.1"}, 0) + require.NoError(t, err) + t.Cleanup(func() { + // t.Context() is already cancelled by the time cleanups run + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = wt.Shutdown(ctx) + }) + wtURL := "https://" + bound[0].String() + "/agent" + + return &endpointStack{t: t, ts: ts, handler: h, scopes: scopes, wtURL: wtURL} +} + +func (s *endpointStack) startWorker(target string, deployment string, endpoints []*livekit.AgentHttp_AgentEndpoint) *conformance.Worker { + return s.startNamedWorker("test-agent", target, deployment, endpoints) +} + +func (s *endpointStack) startNamedWorker(agentName, target, deployment string, endpoints []*livekit.AgentHttp_AgentEndpoint) *conformance.Worker { + w := conformance.New(conformance.Config{ + ServerURL: s.wtURL, + APIKey: testKey, + APISecret: testSecret, + AgentName: agentName, + Deployment: deployment, + Endpoints: endpoints, + TargetAddr: strings.TrimPrefix(target, "http://"), + Insecure: true, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(s.t, w.Start(ctx)) + require.NoError(s.t, w.WaitRegistered(ctx)) + s.t.Cleanup(w.Close) + s.waitRoutable(w, agentName, deployment) + return w +} + +// waitRoutable blocks until the node has installed the worker's routes. The +// register response the worker waits on is written before that, so a request +// sent the instant a worker calls itself registered can still miss it. +func (s *endpointStack) waitRoutable(w *conformance.Worker, agentName, deployment string) { + s.t.Helper() + require.Eventually(s.t, func() bool { + return slices.ContainsFunc( + s.scopes.Scope(testKey, agentName, deployment).Candidates(), + func(r *endpoint.Registration) bool { return r.WorkerID == w.WorkerID() }, + ) + }, 10*time.Second, time.Millisecond, "worker %s never reached the endpoint registry", w.WorkerID()) +} + +func (s *endpointStack) clientToken(t *testing.T) string { + at := auth.NewAccessToken(testKey, testSecret).SetVideoGrant(&auth.VideoGrant{RoomJoin: true, Room: "x"}) + tok, err := at.ToJWT() + require.NoError(t, err) + return tok +} + +func (s *endpointStack) endpointToken(t *testing.T, g *auth.AgentEndpointGrant) string { + at := auth.NewAccessToken(testKey, testSecret).SetAgentEndpointGrant(g) + tok, err := at.ToJWT() + require.NoError(t, err) + return tok +} + +func httpEP(path string, methods []string, public bool) *livekit.AgentHttp_AgentEndpoint { + return &livekit.AgentHttp_AgentEndpoint{Path: path, Methods: methods, Public: public} +} + +// newTargetApp is the local app the worker bridges into; it never listens on a +// port reachable through the stack, only via the tunnel. +func newTargetApp(t *testing.T, mux *http.ServeMux) *httptest.Server { + app := httptest.NewServer(mux) + t.Cleanup(app.Close) + return app +} + +func TestAgentEndpointsCorrectnessGate(t *testing.T) { + bigDown := make([]byte, 4<<20) + _, _ = rand.Read(bigDown) + + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ok":true}`)) + }) + mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) { + sum := sha256.New() + n, err := io.Copy(sum, r.Body) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + fmt.Fprintf(w, "%d:%x", n, sum.Sum(nil)) + }) + mux.HandleFunc("GET /big", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(bigDown) + }) + mux.HandleFunc("GET /sse", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + f := w.(http.Flusher) + for i := 0; i < 5; i++ { + fmt.Fprintf(w, "data: event-%d\n\n", i) + f.Flush() + time.Sleep(150 * time.Millisecond) + } + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/json", []string{"GET"}, true), + httpEP("/upload", []string{"POST"}, true), + httpEP("/big", []string{"GET"}, true), + httpEP("/sse", []string{"GET"}, true), + }) + + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("json round trip", func(t *testing.T) { + resp, err := http.Get(base + "/json") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.JSONEq(t, `{"ok":true}`, string(body)) + }) + + t.Run("upload byte exact", func(t *testing.T) { + up := make([]byte, 8<<20) + _, _ = rand.Read(up) + resp, err := http.Post(base+"/upload", "application/octet-stream", bytes.NewReader(up)) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, fmt.Sprintf("%d:%x", len(up), sha256.Sum256(up)), string(body)) + }) + + t.Run("download byte exact", func(t *testing.T) { + resp, err := http.Get(base + "/big") + require.NoError(t, err) + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + require.NoError(t, err) + require.True(t, bytes.Equal(bigDown, body)) + }) + + t.Run("sse events arrive one at a time", func(t *testing.T) { + start := time.Now() + resp, err := http.Get(base + "/sse") + require.NoError(t, err) + defer resp.Body.Close() + br := bufio.NewReader(resp.Body) + var arrivals []time.Duration + for { + line, err := br.ReadString('\n') + if err != nil { + break + } + if strings.HasPrefix(line, "data:") { + arrivals = append(arrivals, time.Since(start)) + } + } + require.Len(t, arrivals, 5) + // incremental delivery: the first event arrives well before the last is + // even written (5 x 150ms); buffering the whole body would collapse gaps + require.Less(t, arrivals[0], 450*time.Millisecond) + require.Greater(t, arrivals[4]-arrivals[0], 300*time.Millisecond) + }) + + t.Run("32 concurrent requests", func(t *testing.T) { + errCh := make(chan error, 32) + for i := 0; i < 32; i++ { + go func() { + resp, err := http.Get(base + "/json") + if err == nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + if resp.StatusCode != 200 { + err = fmt.Errorf("status %d", resp.StatusCode) + } + } + errCh <- err + }() + } + for i := 0; i < 32; i++ { + require.NoError(t, <-errCh) + } + }) +} + +func TestAgentEndpointsStatusMapping(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/hook", []string{"POST"}, true), + httpEP("/private", []string{"GET"}, false), + }) + + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("404 unknown path", func(t *testing.T) { + resp, _ := http.Get(base + "/nope") + resp.Body.Close() + require.Equal(t, 404, resp.StatusCode) + }) + t.Run("405 wrong method", func(t *testing.T) { + resp, _ := http.Get(base + "/hook") + resp.Body.Close() + require.Equal(t, 405, resp.StatusCode) + }) + t.Run("401 non-public without token", func(t *testing.T) { + resp, _ := http.Get(base + "/private") + resp.Body.Close() + require.Equal(t, 401, resp.StatusCode) + require.Equal(t, "Bearer", resp.Header.Get("WWW-Authenticate")) + }) + t.Run("403 non-public with token lacking the grant", func(t *testing.T) { + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.clientToken(t)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 403, resp.StatusCode) + // no challenge: the caller already presented a credential + require.Empty(t, resp.Header.Get("WWW-Authenticate")) + }) + t.Run("200 non-public with agent-endpoint grant", func(t *testing.T) { + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, &auth.AgentEndpointGrant{Call: true})) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) + t.Run("403 grant scoped to another deployment", func(t *testing.T) { + // candidates exist for this deployment; the grant scope is what denies it + g := &auth.AgentEndpointGrant{Call: true, Deployment: "staging"} + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, g)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 403, resp.StatusCode) + }) + t.Run("200 non-public with grant scoped to this agent and deployment", func(t *testing.T) { + g := &auth.AgentEndpointGrant{Call: true, AgentName: "test-agent", Deployment: "production"} + req, _ := http.NewRequest("GET", base+"/private", nil) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, g)) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) + t.Run("503 unknown deployment", func(t *testing.T) { + resp, _ := http.Get(stack.ts.URL + "/agents/test-agent/staging/hook") + resp.Body.Close() + require.Equal(t, 503, resp.StatusCode) + }) + t.Run("trailing slash normalized and served, not redirected", func(t *testing.T) { + // the route is registered as /hook (no slash); a request to /hook/ is + // normalized to /hook and served directly, never 307'd back to the client + c := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }} + resp, err := c.Post(base+"/hook/", "text/plain", nil) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + }) +} + +// two workers of the same agent+deployment serve the SAME path with DIFFERENT +// methods; each request must route to the worker whose manifest serves its +// method (method-aware candidate selection: FULL = path+method). +func TestAgentEndpointsMethodAcrossWorkers(t *testing.T) { + postMux := http.NewServeMux() + postMux.HandleFunc("POST /thing", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "post-worker") + }) + postApp := newTargetApp(t, postMux) + + getMux := http.NewServeMux() + getMux.HandleFunc("GET /thing", func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, "get-worker") + }) + getApp := newTargetApp(t, getMux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(postApp.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/thing", []string{"POST"}, true), + }) + stack.startWorker(getApp.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/thing", []string{"GET"}, true), + }) + base := stack.ts.URL + "/agents/test-agent/production" + + t.Run("GET routes to the GET worker", func(t *testing.T) { + resp, err := http.Get(base + "/thing") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "get-worker", string(body)) + }) + t.Run("POST routes to the POST worker", func(t *testing.T) { + resp, err := http.Post(base+"/thing", "text/plain", nil) + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + require.Equal(t, "post-worker", string(body)) + }) + t.Run("unserved method is 405", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodDelete, base+"/thing", nil) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 405, resp.StatusCode) + }) +} + +func TestAgentEndpointsHOL(t *testing.T) { + // every exchange rides its own QUIC stream on the one session: QUIC's + // per-stream flow control is what keeps a stalled reader from blocking its + // siblings (there is no shared socket, no credit window, no write scheduler). + mux := http.NewServeMux() + mux.HandleFunc("GET /drip", func(w http.ResponseWriter, r *http.Request) { + f := w.(http.Flusher) + buf := make([]byte, 64<<10) + for { + if _, err := w.Write(buf); err != nil { + return + } + f.Flush() + select { + case <-r.Context().Done(): + return + default: + } + } + }) + mux.HandleFunc("GET /quick", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/drip", []string{"GET"}, true), + httpEP("/quick", []string{"GET"}, true), + }) + base := stack.ts.URL + "/agents/test-agent/production" + + // a stalled client: open /drip, read a little, then stop reading entirely + resp, err := http.Get(base + "/drip") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + small := make([]byte, 1024) + _, err = io.ReadFull(resp.Body, small) + require.NoError(t, err) + // do not read further; the drip stream's QUIC flow-control window fills + time.Sleep(500 * time.Millisecond) + + // sibling streams on the SAME connection must proceed at full speed + for i := 0; i < 5; i++ { + start := time.Now() + q, err := http.Get(base + "/quick") + require.NoError(t, err) + body, _ := io.ReadAll(q.Body) + q.Body.Close() + require.Equal(t, "ok", string(body)) + require.Less(t, time.Since(start), 2*time.Second, + "sibling stream stalled behind a blocked heavy stream") + } +} + +func TestAgentEndpointsRetrySafety(t *testing.T) { + var hits atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + // a worker whose local app is unreachable REFUSES streams; the front must + // retry on the healthy worker exactly once + stack := newEndpointStack(t, agent.EndpointsConfig{}) + deadTarget := "127.0.0.1:1" // nothing listens + eps := []*livekit.AgentHttp_AgentEndpoint{httpEP("/json", []string{"GET"}, true)} + + broken := conformance.New(conformance.Config{ + ServerURL: stack.wtURL, APIKey: testKey, APISecret: testSecret, + AgentName: "test-agent", Deployment: "production", + Endpoints: eps, TargetAddr: deadTarget, Insecure: true, + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + require.NoError(t, broken.Start(ctx)) + require.NoError(t, broken.WaitRegistered(ctx)) + t.Cleanup(broken.Close) + stack.waitRoutable(broken, "test-agent", "production") + + stack.startWorker(app.URL, "production", eps) + + // run enough requests that both workers get picked first sometimes + okCount := 0 + for i := 0; i < 12; i++ { + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/json") + require.NoError(t, err) + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + if resp.StatusCode == 200 && string(body) == "ok" { + okCount++ + } + } + require.Equal(t, 12, okCount, "REFUSED streams must fall through to the healthy worker") + require.EqualValues(t, 12, hits.Load()) +} + +func TestAgentEndpointsTruncationAborts(t *testing.T) { + // a worker connection dying mid-response must abort the client connection, + // never expose a clean-looking short body + mux := http.NewServeMux() + release := make(chan struct{}) + mux.HandleFunc("GET /partial", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "1000000") + _, _ = w.Write(make([]byte, 1000)) + w.(http.Flusher).Flush() + <-release + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + w := stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/partial", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/partial") + require.NoError(t, err) + defer resp.Body.Close() + + head := make([]byte, 1000) + _, err = io.ReadFull(resp.Body, head) + require.NoError(t, err) + + w.Close() // kill the worker mid-stream + close(release) + + _, err = io.ReadAll(resp.Body) + require.Error(t, err, "truncated response must not read as clean EOF") +} + +func TestAgentEndpointsNoLocalListenerContract(t *testing.T) { + // the front never routes undeclared paths: the worker-local health/info + // routes are unreachable by construction + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("local")) }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/declared", []string{"GET"}, true), + }) + + for _, path := range []string{"/", "/worker", "/undeclared"} { + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production" + path) + require.NoError(t, err) + resp.Body.Close() + require.Equal(t, 404, resp.StatusCode, path) + } +} + +func TestAgentEndpointsNonUTF8HeaderSurvives(t *testing.T) { + // header values are octets: a latin-1 filename must reach the client + // byte-for-byte + const disposition = "attachment; filename=\"caf\xe9.txt\"" + + mux := http.NewServeMux() + mux.HandleFunc("GET /download", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", disposition) + _, _ = w.Write([]byte("data")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/download", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/download") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, 200, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "data", string(body)) + require.Equal(t, disposition, resp.Header.Get("Content-Disposition")) +} + +// the agent name and the request tail are percent-encoded path segments, and +// the worker's request line carries the client's bytes. +func TestAgentEndpointsEncodedNameAndPath(t *testing.T) { + const agentName = "LODHA Vayam/Agent" + + targets := make(chan string, 4) + mux := http.NewServeMux() + mux.HandleFunc("GET /echo/{rest...}", func(w http.ResponseWriter, r *http.Request) { + targets <- r.RequestURI + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startNamedWorker(agentName, app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/echo/{rest:path}", []string{"GET"}, true), + }) + + base := stack.ts.URL + "/agents/" + url.PathEscape(agentName) + "/production" + + get := func(t *testing.T, path string) (int, string) { + t.Helper() + resp, err := http.Get(base + path) + require.NoError(t, err) + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + select { + case target := <-targets: + return resp.StatusCode, target + case <-time.After(5 * time.Second): + return resp.StatusCode, "" + } + } + + t.Run("a name with a space and a slash addresses its worker", func(t *testing.T) { + code, target := get(t, "/echo/plain") + require.Equal(t, 200, code) + require.Equal(t, "/echo/plain", target) + }) + + t.Run("an encoded slash in the tail stays encoded", func(t *testing.T) { + code, target := get(t, "/echo/a%2Fb") + require.Equal(t, 200, code) + require.Equal(t, "/echo/a%2Fb", target, "a decoded %2F would re-emit as a separator and change the resource") + }) + + t.Run("the query is passed through verbatim", func(t *testing.T) { + code, target := get(t, "/echo/q?a=1&b=%2F%20x") + require.Equal(t, 200, code) + require.Equal(t, "/echo/q?a=1&b=%2F%20x", target) + }) +} + +func TestAgentEndpointsWithheldOverWebSocket(t *testing.T) { + server := testutils.NewTestServer(psrpc.NewLocalMessageBus()) + t.Cleanup(server.Close) + + worker := server.SimulateAgentWorker() + responses := worker.RegisterWorkerResponses.Observe() + defer responses.Stop() + worker.SendRegister(&livekit.RegisterWorkerRequest{ + Type: livekit.JobType_JT_ROOM, + AgentName: "ws-agent", + Endpoints: []*livekit.AgentHttp_AgentEndpoint{{Path: "/json", Methods: []string{"GET"}}}, + InstanceId: "ws-instance", + EndpointProtocol: wire.CurrentProtocol, + }) + + select { + case res := <-responses.Events(): + require.NotEmpty(t, res.GetWorkerId()) + require.Nil(t, res.GetEndpointSettings()) + case <-time.After(time.Second): + require.Fail(t, "registration timeout") + } +} diff --git a/pkg/service/agentendpointchain_test.go b/pkg/service/agentendpointchain_test.go new file mode 100644 index 000000000..8d028d03c --- /dev/null +++ b/pkg/service/agentendpointchain_test.go @@ -0,0 +1,358 @@ +// 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 service_test + +import ( + "bufio" + "bytes" + "fmt" + "io" + "net" + "net/http" + "net/http/httptrace" + "net/textproto" + "strings" + "sync/atomic" + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/livekit" +) + +// rawHTTPTarget speaks HTTP/1.1 by hand, so it can end a response in ways +// net/http will not produce. +func rawHTTPTarget(t *testing.T, respond func(net.Conn)) string { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + + go func() { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + br := bufio.NewReader(c) + for { + line, err := br.ReadString('\n') + if err != nil { + return + } + if line == "\r\n" { + break + } + } + respond(c) + }() + } + }() + return ln.Addr().String() +} + +// A chunked body cut short must not reach the client as complete, and nothing in +// the serving chain may recover the abort that prevents it. +func TestAgentChainDeliversTruncatedChunkedResponse(t *testing.T) { + addr := rawHTTPTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nTransfer-Encoding: chunked\r\n\r\n") + _, _ = fmt.Fprintf(c, "%x\r\n", 1000) + _, _ = c.Write(bytes.Repeat([]byte("y"), 1000)) + _, _ = io.WriteString(c, "\r\n") + // closes without the terminating 0-length chunk + }) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(addr, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/partial", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/partial") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.Error(t, err, "a truncated chunked body must not read as a clean EOF through the serving chain") + require.NotContains(t, string(body), "PANIC:", "recovery must not write panic text into a committed body") + require.NotContains(t, string(body), "goroutine ", "a stack trace must never reach the client") +} + +// The front streams request bodies, so its chain does not bound them. +func TestAgentChainExemptsAPIBodyLimiter(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("POST /upload", func(w http.ResponseWriter, r *http.Request) { + n, err := io.Copy(io.Discard, r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + fmt.Fprintf(w, "%d", n) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/upload", []string{"POST"}, true), + }) + + big := bytes.Repeat([]byte("z"), testMaxAPIBodySize*2) + + resp, err := http.Post(stack.ts.URL+"/agents/test-agent/production/upload", "application/octet-stream", bytes.NewReader(big)) + require.NoError(t, err) + defer resp.Body.Close() + got, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, fmt.Sprintf("%d", len(big)), string(got), "the whole body must reach the application") + + // the same body on an API route is still rejected + apiResp, err := http.Post(stack.ts.URL+"/api-sink", "application/octet-stream", bytes.NewReader(big)) + require.NoError(t, err) + defer apiResp.Body.Close() + require.Equal(t, http.StatusRequestEntityTooLarge, apiResp.StatusCode) +} + +// A manifest may declare PUT, so the agent chain's preflight must allow it. +func TestAgentChainCORSAllowsPUT(t *testing.T) { + stack := newEndpointStack(t, agent.EndpointsConfig{}) + + preflight := func(t *testing.T, path string) string { + t.Helper() + req, err := http.NewRequest(http.MethodOptions, stack.ts.URL+path, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPut) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + return resp.Header.Get("Access-Control-Allow-Methods") + } + + require.Contains(t, preflight(t, "/agents/test-agent/production/thing"), http.MethodPut) + require.NotContains(t, preflight(t, "/api-sink"), http.MethodPut) +} + +// A worker's CORS headers replace the chain's. +func TestAgentChainWorkerCORSReplacesServer(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /cors", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "https://app.example") + w.Header().Set("Vary", "Accept-Encoding") + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/cors", []string{"GET"}, true), + }) + + req, err := http.NewRequest(http.MethodGet, stack.ts.URL+"/agents/test-agent/production/cors", nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, []string{"https://app.example"}, resp.Header.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"Accept-Encoding"}, resp.Header.Values("Vary")) +} + +// The chain's CORS headers survive an informational head. +func TestAgentChainKeepsCORSAfterEarlyHints(t *testing.T) { + addr := rawHTTPTarget(t, func(c net.Conn) { + _, _ = io.WriteString(c, "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n") + _, _ = io.WriteString(c, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + }) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(addr, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/hints", []string{"GET"}, true), + }) + + var hints atomic.Int32 + trace := &httptrace.ClientTrace{ + Got1xxResponse: func(code int, _ textproto.MIMEHeader) error { + if code == http.StatusEarlyHints { + hints.Add(1) + } + return nil + }, + } + req, err := http.NewRequest(http.MethodGet, stack.ts.URL+"/agents/test-agent/production/hints", nil) + require.NoError(t, err) + req = req.WithContext(httptrace.WithClientTrace(req.Context(), trace)) + req.Header.Set("Origin", "https://example.com") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + require.EqualValues(t, 1, hints.Load()) + require.Equal(t, []string{"https://example.com"}, resp.Header.Values("Access-Control-Allow-Origin")) + require.Equal(t, []string{"Origin"}, resp.Header.Values("Vary")) +} + +// The chain answers preflights; a plain OPTIONS reaches a declared route. +func TestAgentChainAnswersPreflights(t *testing.T) { + var hits atomic.Int32 + mux := http.NewServeMux() + mux.HandleFunc("OPTIONS /opts", func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + w.WriteHeader(http.StatusOK) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/opts", []string{"OPTIONS"}, true), + }) + url := stack.ts.URL + "/agents/test-agent/production/opts" + + req, err := http.NewRequest(http.MethodOptions, url, nil) + require.NoError(t, err) + req.Header.Set("Origin", "https://example.com") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + preflight, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer preflight.Body.Close() + require.Equal(t, http.StatusNoContent, preflight.StatusCode) + require.Equal(t, "https://example.com", preflight.Header.Get("Access-Control-Allow-Origin")) + require.Contains(t, preflight.Header.Get("Access-Control-Allow-Methods"), http.MethodPost) + require.Zero(t, hits.Load(), "a preflight must not reach the worker") + + req, err = http.NewRequest(http.MethodOptions, url, nil) + require.NoError(t, err) + plain, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer plain.Body.Close() + require.Equal(t, http.StatusOK, plain.StatusCode) + require.EqualValues(t, 1, hits.Load()) +} + +// Disabled leaves the prefix unserved. +func TestAgentEndpointsDisabledIsNotServed(t *testing.T) { + stack := newEndpointStack(t, agent.EndpointsConfig{Disabled: true}) + + resp, err := http.Get(stack.ts.URL + "/agents/test-agent/production/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusNotFound, resp.StatusCode) +} + +// A leading "//" is folded above the split, so it still routes to the agent chain. +func TestAgentChainRoutesDoubleSlashedPath(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /json", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/json", []string{"GET"}, true), + }) + + resp, err := http.Get(stack.ts.URL + "//agents/test-agent/production/json") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, "ok", string(body)) +} + +// The front resolves access from the grants the api-key auth middleware installs, +// so the agent chain has to carry it. +func TestAgentChainStillResolvesGrants(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /private", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("ok")) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/private", []string{"GET"}, false), + }) + url := stack.ts.URL + "/agents/test-agent/production/private" + + resp, err := http.Get(url) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "a non-public route needs a grant") + + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+stack.endpointToken(t, &auth.AgentEndpointGrant{ + Call: true, AgentName: "test-agent", Deployment: "production", + })) + granted, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer granted.Body.Close() + require.Equal(t, http.StatusOK, granted.StatusCode, "a scoped grant must reach the application") +} + +// A private WebSocket route takes its grant as access_token; the tunnel must +// survive every wrapper in the chain, and the token must not reach the +// application. +func TestAgentChainWebSocketWithQueryToken(t *testing.T) { + seenQuery := make(chan string, 1) + up := websocket.Upgrader{} + mux := http.NewServeMux() + mux.HandleFunc("GET /ws", func(w http.ResponseWriter, r *http.Request) { + seenQuery <- r.URL.RawQuery + c, err := up.Upgrade(w, r, nil) + if err != nil { + return + } + defer c.Close() + mt, msg, err := c.ReadMessage() + if err != nil { + return + } + _ = c.WriteMessage(mt, msg) + }) + app := newTargetApp(t, mux) + + stack := newEndpointStack(t, agent.EndpointsConfig{}) + stack.startWorker(app.URL, "production", []*livekit.AgentHttp_AgentEndpoint{ + httpEP("/ws", []string{"GET"}, false), + }) + url := "ws" + strings.TrimPrefix(stack.ts.URL, "http") + "/agents/test-agent/production/ws" + + _, resp, err := websocket.DefaultDialer.Dial(url, nil) + require.Error(t, err) + require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "a non-public route needs a grant") + + token := stack.endpointToken(t, &auth.AgentEndpointGrant{ + Call: true, AgentName: "test-agent", Deployment: "production", + }) + c, resp, err := websocket.DefaultDialer.Dial(url+"?room=r1&access_token="+token, nil) + require.NoError(t, err) + defer c.Close() + require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) + require.Equal(t, "room=r1", <-seenQuery) + + require.NoError(t, c.WriteMessage(websocket.TextMessage, []byte("ping"))) + _, msg, err := c.ReadMessage() + require.NoError(t, err) + require.Equal(t, "ping", string(msg)) +} diff --git a/pkg/service/agentendpointmiddleware.go b/pkg/service/agentendpointmiddleware.go new file mode 100644 index 000000000..fb153eebe --- /dev/null +++ b/pkg/service/agentendpointmiddleware.go @@ -0,0 +1,57 @@ +// 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 service + +import ( + "fmt" + "net/http" + "strings" + + "github.com/urfave/negroni/v3" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// IsAgentEndpointPath reports whether an escaped request path targets the agent +// endpoint front. The path must already be normalized; see WithPathNormalization. +func IsAgentEndpointPath(escapedPath string) bool { + return strings.HasPrefix(escapedPath, endpoint.PathPrefix) +} + +// AgentRecovery recovers panics, re-panicking http.ErrAbortHandler so it reaches +// net/http and aborts the response. +func AgentRecovery(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) + } + err, ok := rec.(error) + if !ok { + err = fmt.Errorf("%v", rec) + } + logger.Errorw("panic serving agent endpoint", err, "path", r.URL.Path) + // a committed response already has its status on the wire + if nrw, ok := w.(negroni.ResponseWriter); !ok || !nrw.Written() { + http.Error(w, "internal server error", http.StatusInternalServerError) + } + }() + next(w, r) +} diff --git a/pkg/service/agentendpointscope.go b/pkg/service/agentendpointscope.go new file mode 100644 index 000000000..ee4dac12e --- /dev/null +++ b/pkg/service/agentendpointscope.go @@ -0,0 +1,117 @@ +// 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 service + +import ( + "sync" + + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent/endpoint" +) + +// endpointScopeKey is this server's tenancy for agent HTTP endpoints: the api +// key is the project identity. The endpoint package holds no tenancy identity of +// its own, so this is the one place that rule is written. +type endpointScopeKey struct { + apiKey string + agentName string + deployment string +} + +func newEndpointScopeKey(apiKey, agentName, deployment string) endpointScopeKey { + return endpointScopeKey{apiKey, agentName, endpoint.NormalizeDeployment(deployment)} +} + +// EndpointScopes maps that tenancy onto the endpoint package's scopes, one per +// key, held for as long as a worker holds it. +type EndpointScopes struct { + logger logger.Logger + + mu sync.Mutex + scopes map[endpointScopeKey]*endpointScopeEntry +} + +// endpointScopeEntry refcounts a scope by the registrations that hold it: a +// scope must outlive a worker that has acquired it but not yet registered, or a +// concurrent release would strand the new registration in an orphaned scope. +type endpointScopeEntry struct { + scope *endpoint.Scope + n int +} + +func NewEndpointScopes() *EndpointScopes { + return &EndpointScopes{ + logger: logger.GetLogger().WithComponent("agents.endpoint"), + scopes: make(map[endpointScopeKey]*endpointScopeEntry), + } +} + +// acquire returns the key's scope, creating it on first use. Every acquire must +// be matched by a release. +func (s *EndpointScopes) acquire(k endpointScopeKey) *endpoint.Scope { + s.mu.Lock() + defer s.mu.Unlock() + e := s.scopes[k] + if e == nil { + e = &endpointScopeEntry{scope: endpoint.NewScope(s.logger.WithValues( + "apiKey", k.apiKey, "agentName", k.agentName, "deployment", k.deployment))} + s.scopes[k] = e + } + e.n++ + return e.scope +} + +func (s *EndpointScopes) release(k endpointScopeKey) { + s.mu.Lock() + defer s.mu.Unlock() + e := s.scopes[k] + if e == nil { + return + } + if e.n--; e.n <= 0 { + delete(s.scopes, k) + } +} + +// Scope returns the deployment's serving state, or nil when no worker holds it - +// which the front reads as "no worker for this deployment". +func (s *EndpointScopes) Scope(apiKey, agentName, deployment string) *endpoint.Scope { + s.mu.Lock() + defer s.mu.Unlock() + if e := s.scopes[newEndpointScopeKey(apiKey, agentName, deployment)]; e != nil { + return e.scope + } + return nil +} + +// SingleKey returns the api key when every scope on this node belongs to one - +// the resolution for unauthenticated requests to public endpoints. It is sound +// only because a guessed api key confers no access: the front still serves such +// a request only against routes marked public. ok is false when zero or multiple +// keys are present. +func (s *EndpointScopes) SingleKey() (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + var key string + for k := range s.scopes { + if key == "" { + key = k.apiKey + } else if key != k.apiKey { + return "", false + } + } + return key, key != "" +} diff --git a/pkg/service/agentservice.go b/pkg/service/agenthandler.go similarity index 73% rename from pkg/service/agentservice.go rename to pkg/service/agenthandler.go index 6780eaf98..62e3b03fe 100644 --- a/pkg/service/agentservice.go +++ b/pkg/service/agenthandler.go @@ -18,81 +18,28 @@ import ( "context" "errors" "math/rand" - "net/http" "slices" "sort" - "strconv" "sync" "time" - "github.com/gorilla/websocket" "google.golang.org/protobuf/types/known/emptypb" - "github.com/livekit/livekit-server/pkg/agent" - "github.com/livekit/livekit-server/pkg/config" - "github.com/livekit/livekit-server/pkg/routing" - "github.com/livekit/livekit-server/pkg/rtc" - "github.com/livekit/livekit-server/pkg/rtc/types" - "github.com/livekit/livekit-server/version" "github.com/livekit/protocol/auth" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" "github.com/livekit/protocol/rpc" "github.com/livekit/protocol/utils" "github.com/livekit/psrpc" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/routing" + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/version" ) -type AgentSocketUpgrader struct { - websocket.Upgrader -} - -func (u AgentSocketUpgrader) Upgrade( - w http.ResponseWriter, - r *http.Request, - responseHeader http.Header, -) ( - conn *websocket.Conn, - registration agent.WorkerRegistration, - ok bool, -) { - if u.CheckOrigin == nil { - // allow connections from any origin, since script may be hosted anywhere - // security is enforced by access tokens - u.CheckOrigin = func(r *http.Request) bool { - return true - } - } - - // reject non websocket requests - if !websocket.IsWebSocketUpgrade(r) { - w.WriteHeader(404) - return - } - - // require a claim - claims := GetGrants(r.Context()) - if claims == nil || claims.Video == nil || !claims.Video.Agent { - HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) - return - } - - registration = agent.MakeWorkerRegistration() - registration.ClientIP = GetClientIP(r) - - // upgrade - conn, err := u.Upgrader.Upgrade(w, r, responseHeader) - if err != nil { - HandleError(w, r, http.StatusInternalServerError, err) - return - } - - if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { - registration.Protocol = agent.WorkerProtocolVersion(pv) - } - - return conn, registration, true -} - func DispatchAgentWorkerSignal(c agent.SignalConn, h agent.WorkerSignalHandler, l logger.Logger) bool { req, _, err := c.ReadWorkerMessage() if err != nil { @@ -112,8 +59,8 @@ func DispatchAgentWorkerSignal(c agent.SignalConn, h agent.WorkerSignalHandler, return true } -func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, registration agent.WorkerRegistration, l logger.Logger) (r agent.WorkerRegistration, ok bool) { - wr := agent.NewWorkerRegisterer(c, serverInfo, registration) +func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, registration agent.WorkerRegistration, l logger.Logger, handlers ...agent.WorkerRegisterHandler) (r agent.WorkerRegistration, ok bool) { + wr := agent.NewWorkerRegisterer(c, serverInfo, registration, handlers...) if err := c.SetReadDeadline(wr.Deadline()); err != nil { return } @@ -128,18 +75,14 @@ func HandshakeAgentWorker(c agent.SignalConn, serverInfo *livekit.ServerInfo, re return wr.Registration(), true } -type AgentService struct { - upgrader AgentSocketUpgrader - - signalMessageSizeLimit int64 - - *AgentHandler -} - type AgentHandler struct { agentServer rpc.AgentInternalServer - mu sync.Mutex - logger logger.Logger + // the server's only configured api key, when exactly one exists: the + // unauthenticated identity for public endpoints regardless of which node + // holds the workers + singleAPIKey string + mu sync.Mutex + logger logger.Logger serverInfo *livekit.ServerInfo workers map[string]*agent.Worker @@ -147,6 +90,10 @@ type AgentHandler struct { keyProvider auth.KeyProvider targetLoad float32 + endpointRegistry *endpoint.Registry + endpointScopes *EndpointScopes + endpointsConfig agent.EndpointsConfig + namespaceWorkers map[workerKey][]*agent.Worker roomKeyCount int publisherKeyCount int @@ -166,85 +113,80 @@ type workerKey struct { deployment string } -func NewAgentService( +// NewAgentHandler builds the node's agent worker handler, shared by the +// transport services. +func NewAgentHandler( conf *config.Config, currentNode routing.LocalNode, bus psrpc.MessageBus, keyProvider auth.KeyProvider, -) (*AgentService, error) { - s := &AgentService{ - signalMessageSizeLimit: conf.Limit.AgentSignalMessageSizeLimit, - } - - serverInfo := &livekit.ServerInfo{ - Edition: livekit.ServerInfo_Standard, - Version: version.Version, - Protocol: types.CurrentProtocol, - AgentProtocol: agent.CurrentProtocol, - Region: conf.Region, - NodeId: string(currentNode.NodeID()), - } - - agentServer, err := rpc.NewAgentInternalServer(s, bus) - if err != nil { - return nil, err - } - s.AgentHandler = NewAgentHandler( - agentServer, - keyProvider, - logger.GetLogger(), - serverInfo, - conf.Agents.TargetLoad, - agent.RoomAgentTopic, - agent.PublisherAgentTopic, - agent.ParticipantAgentTopic, - ) - return s, nil -} - -func (s *AgentService) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if conn, registration, ok := s.upgrader.Upgrade(w, r, nil); ok { - // bound the size of a single signalling frame so an oversized message is - // rejected by the transport before being fully buffered in memory. This - // limits the compressed bytes read off the wire; the decompressed size is - // bounded separately in WSSignalConnection. - if s.signalMessageSizeLimit > 0 { - conn.SetReadLimit(s.signalMessageSizeLimit) - } - sigConn := NewWSSignalConnection(conn, s.signalMessageSizeLimit) - defer sigConn.Close() - - s.HandleConnection(r.Context(), sigConn, registration) - } -} - -func NewAgentHandler( - agentServer rpc.AgentInternalServer, - keyProvider auth.KeyProvider, - logger logger.Logger, - serverInfo *livekit.ServerInfo, - targetLoad float32, - roomTopic string, - publisherTopic string, - participantTopic string, -) *AgentHandler { - return &AgentHandler{ - agentServer: agentServer, - logger: logger.WithComponent("agents"), + registry *endpoint.Registry, + scopes *EndpointScopes, +) (*AgentHandler, error) { + h := &AgentHandler{ + logger: logger.GetLogger().WithComponent("agents"), workers: make(map[string]*agent.Worker), jobToWorker: make(map[livekit.JobID]*agent.Worker), namespaceWorkers: make(map[workerKey][]*agent.Worker), - serverInfo: serverInfo, + serverInfo: &livekit.ServerInfo{ + Edition: livekit.ServerInfo_Standard, + Version: version.Version, + Protocol: types.CurrentProtocol, + AgentProtocol: agent.CurrentProtocol, + Region: conf.Region, + NodeId: string(currentNode.NodeID()), + }, keyProvider: keyProvider, - targetLoad: targetLoad, - roomTopic: roomTopic, - publisherTopic: publisherTopic, - participantTopic: participantTopic, + targetLoad: conf.Agents.TargetLoad, + roomTopic: agent.RoomAgentTopic, + publisherTopic: agent.PublisherAgentTopic, + participantTopic: agent.ParticipantAgentTopic, + endpointRegistry: registry, + endpointScopes: scopes, + endpointsConfig: conf.Agents.Endpoints, } + if len(conf.Keys) == 1 { + for key := range conf.Keys { + h.singleAPIKey = key + } + } + + agentServer, err := rpc.NewAgentInternalServer(h, bus) + if err != nil { + return nil, err + } + h.agentServer = agentServer + + return h, nil } +// endpointRegisterHandler negotiates a worker's endpoint settings, refusing any +// declaration when endpoints are turned off. +func (h *AgentHandler) endpointRegisterHandler(req *livekit.RegisterWorkerRequest, res *livekit.RegisterWorkerResponse, reg *agent.WorkerRegistration) error { + if len(req.GetEndpoints()) > 0 && h.endpointsConfig.Disabled { + return errors.New("agent HTTP endpoints are disabled on this server") + } + return agent.EndpointRegisterHandler(req, res, reg) +} + +// HandleConnection serves a worker's control connection with no data-plane +// session (the WebSocket path: control and job dispatch only). func (h *AgentHandler) HandleConnection(ctx context.Context, conn agent.SignalConn, registration agent.WorkerRegistration) { - registration, ok := HandshakeAgentWorker(conn, h.serverInfo, registration, h.logger) + h.handleConnection(ctx, conn, registration, nil) +} + +// handleConnection serves a worker's control connection. sess is the worker's +// data-plane session when it registered over WebTransport (control and HTTP +// exchanges share it); nil for a WebSocket control connection, which serves no +// endpoints. +func (h *AgentHandler) handleConnection(ctx context.Context, conn agent.SignalConn, registration agent.WorkerRegistration, sess endpoint.Session) { + // without a session there is no data plane, so endpoint settings are withheld + // rather than promising routes nothing will serve + var handlers []agent.WorkerRegisterHandler + if sess != nil { + handlers = append(handlers, h.endpointRegisterHandler) + } + registration, ok := HandshakeAgentWorker(conn, h.serverInfo, registration, h.logger, handlers...) if !ok { return } @@ -255,15 +197,65 @@ func (h *AgentHandler) HandleConnection(ctx context.Context, conn agent.SignalCo worker := agent.NewWorker(registration, apiKey, apiSecret, conn, h.logger) h.registerWorker(worker) + if sess == nil && len(registration.Endpoints) > 0 { + worker.Logger().Infow("endpoints ignored, connection has no data plane", + "agentName", registration.AgentName, + "deployment", registration.Deployment, + ) + } + + endpointTeardown := h.registerEndpoints(worker, sess) + handlerWorker := &agentHandlerWorker{h, worker} for ok := true; ok; { ok = DispatchAgentWorkerSignal(conn, handlerWorker, worker.Logger()) } + if endpointTeardown != nil { + endpointTeardown() + } h.deregisterWorker(worker) worker.Close() } +// registerEndpoints registers a worker's endpoint manifest into the scope its +// api key, agent name and deployment address, binding it to the worker's +// data-plane session. The registration lives exactly as long as the control +// connection, so the returned teardown must run when that connection ends; it +// is nil when the worker serves no endpoints. A nil session (WebSocket control +// path) registers nothing, since HTTP endpoints require a WebTransport session +// to serve them. +func (h *AgentHandler) registerEndpoints(w *agent.Worker, sess endpoint.Session) func() { + if sess == nil || w.EndpointSettings == nil { + return nil + } + manifest, err := endpoint.ParseManifest(w.Endpoints) + if err != nil { + // validated during the handshake; a failure here is a programming error + w.Logger().Errorw("endpoint manifest failed to re-parse", err) + return nil + } + reg := endpoint.NewRegistration(endpoint.RegistrationParams{ + WorkerID: w.ID, + Manifest: manifest, + Session: sess, + Draining: w.Draining, + }) + key := newEndpointScopeKey(w.APIKey(), w.AgentName, w.Deployment) + h.endpointRegistry.Register(h.endpointScopes.acquire(key), reg) + w.Logger().Infow("endpoints registered", + "namespace", w.Namespace, + "agentName", w.AgentName, + "deployment", w.Deployment, + "workerID", w.ID, + "manifest", manifest, + ) + return func() { + h.endpointRegistry.Deregister(reg) + h.endpointScopes.release(key) + } +} + func (h *AgentHandler) registerWorker(w *agent.Worker) { h.mu.Lock() @@ -317,6 +309,7 @@ func (h *AgentHandler) registerWorker(w *agent.Worker) { "namespace", w.Namespace, "jobType", w.JobType, "agentName", w.AgentName, + "deployment", w.Deployment, "workerID", w.ID, ) if created { @@ -352,6 +345,7 @@ func (h *AgentHandler) deregisterWorker(w *agent.Worker) { "namespace", w.Namespace, "jobType", w.JobType, "agentName", w.AgentName, + "deployment", w.Deployment, "workerID", w.ID, ) delete(h.namespaceWorkers, key) @@ -398,6 +392,7 @@ func (h *AgentHandler) JobRequest(ctx context.Context, job *livekit.Job) (*rpc.J "jobID", job.Id, "namespace", job.Namespace, "agentName", job.AgentName, + "deployment", job.Deployment, "jobType", job.Type.String(), ) if job.Room != nil { diff --git a/pkg/service/agentws.go b/pkg/service/agentws.go new file mode 100644 index 000000000..c9f0c538a --- /dev/null +++ b/pkg/service/agentws.go @@ -0,0 +1,102 @@ +// Copyright 2024 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package service + +import ( + "net/http" + "strconv" + + "github.com/gorilla/websocket" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/config" + "github.com/livekit/livekit-server/pkg/rtc" +) + +type AgentSocketUpgrader struct { + websocket.Upgrader +} + +func (u AgentSocketUpgrader) Upgrade( + w http.ResponseWriter, + r *http.Request, + responseHeader http.Header, +) ( + conn *websocket.Conn, + registration agent.WorkerRegistration, + ok bool, +) { + if u.CheckOrigin == nil { + // allow connections from any origin, since script may be hosted anywhere + // security is enforced by access tokens + u.CheckOrigin = func(r *http.Request) bool { + return true + } + } + + if !websocket.IsWebSocketUpgrade(r) { + w.WriteHeader(404) + return + } + + claims := GetGrants(r.Context()) + if claims == nil || claims.Video == nil || !claims.Video.Agent { + HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) + return + } + + registration = agent.MakeWorkerRegistration() + registration.ClientIP = GetClientIP(r) + + conn, err := u.Upgrader.Upgrade(w, r, responseHeader) + if err != nil { + HandleError(w, r, http.StatusInternalServerError, err) + return + } + + if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { + registration.Protocol = agent.WorkerProtocolVersion(pv) + } + + return conn, registration, true +} + +// AgentWSService serves a worker's WebSocket control connection on /agent: +// control and job dispatch only, with no data-plane session for HTTP endpoints. +type AgentWSService struct { + *AgentHandler + + upgrader AgentSocketUpgrader + signalMessageSizeLimit int64 +} + +func NewAgentWSService(conf *config.Config, h *AgentHandler) *AgentWSService { + return &AgentWSService{ + AgentHandler: h, + signalMessageSizeLimit: conf.Limit.AgentSignalMessageSizeLimit, + } +} + +func (s *AgentWSService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if conn, registration, ok := s.upgrader.Upgrade(w, r, nil); ok { + // bound a single signalling frame before it is buffered; 0 disables + if s.signalMessageSizeLimit > 0 { + conn.SetReadLimit(s.signalMessageSizeLimit) + } + sigConn := NewWSSignalConnection(conn, s.signalMessageSizeLimit) + defer sigConn.Close() + s.HandleConnection(r.Context(), sigConn, registration) + } +} diff --git a/pkg/service/agentwt.go b/pkg/service/agentwt.go new file mode 100644 index 000000000..d442eb4db --- /dev/null +++ b/pkg/service/agentwt.go @@ -0,0 +1,143 @@ +// 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 service + +import ( + "context" + "net/http" + "strconv" + "sync" + "time" + + "github.com/quic-go/webtransport-go" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" + + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" + "github.com/livekit/livekit-server/pkg/agent/endpoint/wire" + "github.com/livekit/livekit-server/pkg/rtc" +) + +// AgentWTService serves a worker's WebTransport session on /agent: one control +// stream, plus a node-opened QUIC stream per HTTP exchange. +type AgentWTService struct { + *AgentHandler +} + +func NewAgentWTService(h *AgentHandler) *AgentWTService { + return &AgentWTService{AgentHandler: h} +} + +func (s *AgentWTService) ServeHTTP(w http.ResponseWriter, r *http.Request) { + sess, registration, ok := UpgradeAgentWebTransport(w, r, s.logger) + if !ok { + return + } + + sigConn, epSession, ok := AcceptAgentControlStream(sess, s.endpointsConfig.MaxStreams) + if !ok { + return + } + defer sigConn.Close() + + s.handleConnection(r.Context(), sigConn, registration, epSession) +} + +// UpgradeAgentWebTransport verifies the agent grant, builds the worker's +// registration from the request and upgrades to a WebTransport session. An outer +// auth middleware must already have placed the grant in the request context. +func UpgradeAgentWebTransport(w http.ResponseWriter, r *http.Request, l logger.Logger) ( + sess *webtransport.Session, + registration agent.WorkerRegistration, + ok bool, +) { + claims := GetGrants(r.Context()) + if claims == nil || claims.Video == nil || !claims.Video.Agent { + HandleError(w, r, http.StatusUnauthorized, rtc.ErrPermissionDenied) + return + } + + registration = agent.MakeWorkerRegistration() + registration.ClientIP = GetClientIP(r) + if pv, err := strconv.Atoi(r.FormValue("protocol")); err == nil { + registration.Protocol = agent.WorkerProtocolVersion(pv) + } + + sess, err := UpgradeWebTransport(w, r) + if err != nil { + l.Warnw("agent webtransport upgrade failed", err) + return nil, registration, false + } + + return sess, registration, true +} + +// AcceptAgentControlStream waits for the worker's control stream and wraps the +// session for the data plane. The wait is bounded so a session that upgrades but +// never opens a control stream can't hold a goroutine until the QUIC idle timeout. +func AcceptAgentControlStream(sess *webtransport.Session, maxStreams uint32) (agent.SignalConn, endpoint.Session, bool) { + acceptCtx, cancel := context.WithTimeout(sess.Context(), agent.RegisterTimeout) + defer cancel() + control, err := sess.AcceptStream(acceptCtx) + if err != nil { + _ = sess.CloseWithError(wire.SessionCloseOK, "no control stream") + return nil, nil, false + } + if maxStreams == 0 { + maxStreams = endpoint.DefaultMaxStreams + } + return NewWTSignalConn(sess, control), endpoint.NewWebTransportSession(sess, int(maxStreams)), true +} + +// wtSignalConn adapts a WebTransport control stream to agent.SignalConn: a +// length-delimited WorkerMessage/ServerMessage exchange on one QUIC bidirectional +// stream. +type wtSignalConn struct { + sess *webtransport.Session + control *webtransport.Stream + writeMu sync.Mutex +} + +func NewWTSignalConn(sess *webtransport.Session, control *webtransport.Stream) agent.SignalConn { + return &wtSignalConn{sess: sess, control: control} +} + +func (c *wtSignalConn) WriteServerMessage(msg *livekit.ServerMessage) (int, error) { + c.writeMu.Lock() + defer c.writeMu.Unlock() + return 0, wire.WriteControlMessage(c.control, msg) +} + +func (c *wtSignalConn) ReadWorkerMessage() (*livekit.WorkerMessage, int, error) { + var msg livekit.WorkerMessage + if err := wire.ReadControlMessage(c.control, &msg); err != nil { + return nil, 0, err + } + return &msg, 0, nil +} + +func (c *wtSignalConn) SetReadDeadline(t time.Time) error { + return c.control.SetReadDeadline(t) +} + +func (c *wtSignalConn) Close() error { + return c.sess.CloseWithError(wire.SessionCloseOK, "") +} + +func (c *wtSignalConn) CloseWithReason(reason string) error { + return c.sess.CloseWithError(wire.SessionCloseOK, reason) +} diff --git a/pkg/service/auth.go b/pkg/service/auth.go index 9d8c1e921..d6122dde7 100644 --- a/pkg/service/auth.go +++ b/pkg/service/auth.go @@ -76,8 +76,11 @@ func (m *APIKeyAuthMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request, authToken = authHeader[len(bearerPrefix):] } else { - // attempt to find from request header - authToken = r.FormValue(accessTokenParam) + // fall back to the query string rather than a form body, which must + // survive intact for requests proxied further (agent HTTP endpoints). + if r.URL != nil { + authToken = r.URL.Query().Get(accessTokenParam) + } } if authToken != "" { diff --git a/pkg/service/server.go b/pkg/service/server.go index e26569804..421ed5872 100644 --- a/pkg/service/server.go +++ b/pkg/service/server.go @@ -16,6 +16,7 @@ package service import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -40,28 +41,30 @@ import ( "github.com/livekit/protocol/logger" "github.com/livekit/protocol/utils/xtwirp" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/version" ) type LivekitServer struct { - config *config.Config - ioService *IOInfoService - rtcService *RTCService - whipService *WHIPService - agentService *AgentService - httpServer *http.Server - promServer *http.Server - debugServer *http.Server - router routing.Router - roomManager *RoomManager - signalServer *SignalServer - turnServer *turn.Server - currentNode routing.LocalNode - running atomic.Bool - doneChan chan struct{} - closedChan chan struct{} + config *config.Config + ioService *IOInfoService + rtcService *RTCService + whipService *WHIPService + httpServer *http.Server + promServer *http.Server + debugServer *http.Server + webtransportServer *WebTransportServer + agentEndpoint *AgentEndpointService + router routing.Router + roomManager *RoomManager + signalServer *SignalServer + turnServer *turn.Server + currentNode routing.LocalNode + running atomic.Bool + doneChan chan struct{} + closedChan chan struct{} } func NewLivekitServer(conf *config.Config, @@ -73,7 +76,9 @@ func NewLivekitServer(conf *config.Config, ioService *IOInfoService, rtcService *RTCService, whipService *WHIPService, - agentService *AgentService, + agentWSService *AgentWSService, + agentWTService *AgentWTService, + agentEndpointService *AgentEndpointService, keyProvider auth.KeyProvider, router routing.Router, roomManager *RoomManager, @@ -86,7 +91,6 @@ func NewLivekitServer(conf *config.Config, ioService: ioService, rtcService: rtcService, whipService: whipService, - agentService: agentService, router: router, roomManager: roomManager, signalServer: signalServer, @@ -96,28 +100,6 @@ func NewLivekitServer(conf *config.Config, closedChan: make(chan struct{}), } - middlewares := []negroni.Handler{ - // always first - negroni.NewRecovery(), - // CORS is allowed, we rely on token authentication to prevent improper use - cors.New(cors.Options{ - AllowOriginFunc: func(origin string) bool { - return true - }, - AllowedMethods: []string{"OPTIONS", "HEAD", "GET", "POST", "PATCH", "DELETE"}, - AllowedHeaders: []string{"*"}, - ExposedHeaders: []string{"*"}, - // allow preflight to be cached for a day - MaxAge: 86400, - }), - negroni.HandlerFunc(RemoveDoubleSlashes), - // limit request body size so large messages cannot exhaust memory - NewRequestBodyLimiter(conf.Limit.MaxAPIRequestBodySize), - } - if keyProvider != nil { - middlewares = append(middlewares, NewAPIKeyAuthMiddleware(keyProvider)) - } - serverOptions := []any{ twirp.WithServerHooks(twirp.ChainHooks( TwirpLogger(), @@ -149,11 +131,18 @@ func NewLivekitServer(conf *config.Config, xtwirp.RegisterServer(mux, sipServer) rtcService.SetupRoutes(mux) whipService.SetupRoutes(mux) - mux.Handle("/agent", agentService) + mux.Handle("/agent", agentWSService) mux.HandleFunc("/", s.defaultHandler) + // NewHTTPHandler branches on a nil handler, which a typed-nil would defeat + var agentFront http.Handler + if !conf.Agents.Endpoints.Disabled { + agentFront = agentEndpointService + s.agentEndpoint = agentEndpointService + } + s.httpServer = &http.Server{ - Handler: configureMiddlewares(mux, middlewares...), + Handler: NewHTTPHandler(conf, keyProvider, mux, agentFront), } if conf.PrometheusPort > 0 { @@ -192,6 +181,19 @@ func NewLivekitServer(conf *config.Config, } } + if conf.WebTransport.Port > 0 { + var tlsConf *tls.Config + tlsConf, err = WebTransportTLS(conf.WebTransport.TLSCertFile, conf.WebTransport.TLSKeyFile, conf.Development) + if err != nil { + return + } + wtMux := http.NewServeMux() + wtMux.Handle("/agent", agentWTService) + + s.webtransportServer = NewWebTransportServer(tlsConf) + s.webtransportServer.H3.Handler = NewWebTransportHandler(keyProvider, s.webtransportServer, wtMux) + } + if err = router.RemoveDeadNodes(); err != nil { return } @@ -267,6 +269,12 @@ func (s *LivekitServer) Start() error { } } + if s.webtransportServer != nil { + if _, err := s.webtransportServer.Listen(s.config.BindAddresses, s.config.WebTransport.Port); err != nil { + return err + } + } + values := []any{ "portHttp", s.config.Port, "nodeID", s.currentNode.NodeID(), @@ -292,6 +300,9 @@ func (s *LivekitServer) Start() error { if s.config.DebugHandler.Port != 0 { values = append(values, "portDebugHandler", s.config.DebugHandler.Port) } + if s.config.WebTransport.Port != 0 { + values = append(values, "portWebTransport", s.config.WebTransport.Port) + } if s.config.Region != "" { values = append(values, "region", s.config.Region) } @@ -335,6 +346,9 @@ func (s *LivekitServer) Start() error { <-s.doneChan + // hijacked tunnels need their own drain before Shutdown + s.drainAgentTunnels(false) + // wait for shutdown ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) defer cancel() @@ -342,6 +356,9 @@ func (s *LivekitServer) Start() error { if s.debugServer != nil { _ = s.debugServer.Shutdown(ctx) } + if s.webtransportServer != nil { + _ = s.webtransportServer.Shutdown(ctx) + } if s.turnServer != nil { _ = s.turnServer.Close() @@ -356,6 +373,9 @@ func (s *LivekitServer) Start() error { } func (s *LivekitServer) Stop(force bool) { + // the tunnels' grace runs alongside the participant wait + go s.drainAgentTunnels(force) + // wait for all participants to exit s.router.Drain() partTicker := time.NewTicker(5 * time.Second) @@ -378,6 +398,23 @@ func (s *LivekitServer) Stop(force bool) { <-s.closedChan } +// drainAgentTunnels returns once no agent endpoint WebSocket tunnel remains. +func (s *LivekitServer) drainAgentTunnels(force bool) { + if s.agentEndpoint == nil { + return + } + grace := s.config.Agents.Endpoints.TunnelDrainTimeout + if grace <= 0 { + grace = endpoint.DefaultTunnelDrainTimeout + } + if force { + grace = 0 + } + ctx, cancel := context.WithTimeout(context.Background(), grace) + defer cancel() + s.agentEndpoint.Drain(ctx) +} + func (s *LivekitServer) RoomManager() *RoomManager { return s.roomManager } @@ -440,6 +477,64 @@ func (s *LivekitServer) backgroundWorker() { } } +// NewHTTPHandler builds the node's public HTTP handler: the agent endpoint prefix +// on its own middleware chain, everything else on the API chain. A nil agentFront +// leaves the prefix unserved, and those paths fall through to apiHandler. +func NewHTTPHandler(conf *config.Config, keyProvider auth.KeyProvider, apiHandler, agentFront http.Handler) http.Handler { + apiMiddlewares := []negroni.Handler{ + // always first + negroni.NewRecovery(), + // CORS is allowed, we rely on token authentication to prevent improper use + cors.New(corsOptions([]string{"OPTIONS", "HEAD", "GET", "POST", "PATCH", "DELETE"})), + // limit request body size so large messages cannot exhaust memory + NewRequestBodyLimiter(conf.Limit.MaxAPIRequestBodySize), + } + // this chain must not swallow http.ErrAbortHandler or bound the request body + agentMiddlewares := []negroni.Handler{ + negroni.HandlerFunc(AgentRecovery), + NewAgentEndpointCORS(), + } + if keyProvider != nil { + authMiddleware := NewAPIKeyAuthMiddleware(keyProvider) + apiMiddlewares = append(apiMiddlewares, authMiddleware) + agentMiddlewares = append(agentMiddlewares, authMiddleware) + } + + api := configureMiddlewares(apiHandler, apiMiddlewares...) + var dispatch http.Handler = api + if agentFront != nil { + agents := configureMiddlewares(agentFront, agentMiddlewares...) + dispatch = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if IsAgentEndpointPath(r.URL.EscapedPath()) { + agents.ServeHTTP(w, r) + return + } + api.ServeHTTP(w, r) + }) + } + return WithPathNormalization(dispatch) +} + +// NewAgentEndpointCORS is the agent endpoint chain's CORS middleware and answers +// every preflight. Worker headers override it per endpoint.MergeWorkerHeaders. +func NewAgentEndpointCORS() *cors.Cors { + // the methods a manifest may declare, less TRACE, which browsers forbid in CORS + return cors.New(corsOptions([]string{"OPTIONS", "HEAD", "GET", "POST", "PUT", "PATCH", "DELETE"})) +} + +func corsOptions(methods []string) cors.Options { + return cors.Options{ + AllowOriginFunc: func(origin string) bool { + return true + }, + AllowedMethods: methods, + AllowedHeaders: []string{"*"}, + ExposedHeaders: []string{"*"}, + // allow preflight to be cached for a day + MaxAge: 86400, + } +} + func configureMiddlewares(handler http.Handler, middlewares ...negroni.Handler) *negroni.Negroni { n := negroni.New() for _, m := range middlewares { diff --git a/pkg/service/utils.go b/pkg/service/utils.go index cc31be859..0ee1d55bd 100644 --- a/pkg/service/utils.go +++ b/pkg/service/utils.go @@ -101,12 +101,25 @@ func boolValue(s string) bool { } func RemoveDoubleSlashes(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { - if strings.HasPrefix(r.URL.Path, "//") { + // Path and RawPath must move together: once they disagree, EscapedPath() + // re-encodes from Path and every escape in the request is lost. + if strings.HasPrefix(r.URL.EscapedPath(), "//") { r.URL.Path = r.URL.Path[1:] + if r.URL.RawPath != "" { + r.URL.RawPath = r.URL.RawPath[1:] + } } next(w, r) } +// WithPathNormalization applies RemoveDoubleSlashes ahead of h, so routing and every +// middleware below it see one path form. +func WithPathNormalization(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + RemoveDoubleSlashes(w, r, h.ServeHTTP) + }) +} + // RequestBodyLimiter bounds the size of an incoming HTTP request body so that // large messages cannot exhaust memory. The Twirp handlers decode the whole // body before any grant check runs, so the limit is applied here, up front. diff --git a/pkg/service/utils_test.go b/pkg/service/utils_test.go index 1f675c6f8..2c1a43060 100644 --- a/pkg/service/utils_test.go +++ b/pkg/service/utils_test.go @@ -19,6 +19,7 @@ import ( "compress/gzip" "context" "net/http" + "net/http/httptest" "testing" "time" @@ -127,3 +128,30 @@ func TestDecompressGzip(t *testing.T) { require.Contains(t, err.Error(), "cannot read decompressed") }) } + +// URL.Path and URL.RawPath must stay in step: once they disagree, +// EscapedPath() re-encodes from Path and every escape is lost. +func TestRemoveDoubleSlashes(t *testing.T) { + cases := []struct { + name string + target string + want string + }{ + {"doubled slash is trimmed", "//agents/a/d/x", "/agents/a/d/x"}, + {"single slash untouched", "/agents/a/d/x", "/agents/a/d/x"}, + {"doubled slash keeps the encoded tail", "//agents/a/d/f%2Fg", "/agents/a/d/f%2Fg"}, + {"encoded name survives", "//agents/LODHA%20Vayam/d/x", "/agents/LODHA%20Vayam/d/x"}, + {"leading encoded slash is not doubled", "/%2Fagents/a/d/x", "/%2Fagents/a/d/x"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, c.target, nil) + var got string + service.RemoveDoubleSlashes(httptest.NewRecorder(), r, func(_ http.ResponseWriter, r *http.Request) { + got = r.URL.EscapedPath() + }) + require.Equal(t, c.want, got) + }) + } +} diff --git a/pkg/service/webtransport.go b/pkg/service/webtransport.go new file mode 100644 index 000000000..42db2c454 --- /dev/null +++ b/pkg/service/webtransport.go @@ -0,0 +1,251 @@ +// 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 service + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "errors" + "math/big" + "net" + "net/http" + "strconv" + "sync" + "time" + + "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/http3" + "github.com/quic-go/webtransport-go" + "github.com/urfave/negroni/v3" + + "github.com/livekit/protocol/auth" + "github.com/livekit/protocol/logger" +) + +// WebTransportTLS builds the listener's TLS config from cert files, or a +// generated self-signed cert in dev mode. +func WebTransportTLS(certFile, keyFile string, dev bool) (*tls.Config, error) { + if certFile != "" && keyFile != "" { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, err + } + return &tls.Config{Certificates: []tls.Certificate{cert}, NextProtos: []string{http3.NextProtoH3}}, nil + } + if !dev { + return nil, errors.New("webtransport requires webtransport.tls_cert_file/tls_key_file") + } + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, err + } + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "livekit-webtransport-dev"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(365 * 24 * time.Hour), + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback}, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + return nil, err + } + logger.Warnw("webtransport using a generated self-signed cert (dev mode); clients must skip verification", nil) + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{http3.NextProtoH3}, + }, nil +} + +// WebTransportServer's embedded Close releases sessions but not the sockets; +// Shutdown is the teardown path. +type WebTransportServer struct { + *webtransport.Server + + mu sync.Mutex + conns []*net.UDPConn + lns []*quic.EarlyListener + cancel context.CancelFunc + serving sync.WaitGroup +} + +// NewWebTransportServer wraps an HTTP/3 WebTransport server around tlsConf (the +// h3 ALPN is set here if absent). The caller must assign wt.H3.Handler. +func NewWebTransportServer(tlsConf *tls.Config) *WebTransportServer { + tlsConf = tlsConf.Clone() + if len(tlsConf.NextProtos) == 0 { + tlsConf.NextProtos = []string{http3.NextProtoH3} + } + return &WebTransportServer{Server: &webtransport.Server{H3: &http3.Server{TLSConfig: tlsConf}}} +} + +// NewWebTransportHandler builds the listener's handler: mux behind api-key auth, +// with wt in each request's context. +func NewWebTransportHandler(keyProvider auth.KeyProvider, wt *WebTransportServer, mux http.Handler) http.Handler { + middlewares := []negroni.Handler{negroni.NewRecovery()} + if keyProvider != nil { + middlewares = append(middlewares, NewAPIKeyAuthMiddleware(keyProvider)) + } + return WithWebTransportServer(wt, WithPathNormalization(configureMiddlewares(mux, middlewares...))) +} + +type webTransportServerKey struct{} + +// WithWebTransportServer puts wt in each request's context, where a route that +// upgrades reads it. Apply it outermost, ahead of the listener's middleware chain. +func WithWebTransportServer(wt *WebTransportServer, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), webTransportServerKey{}, wt))) + }) +} + +// GetWebTransportServer returns the server serving this request, or nil when the +// request did not arrive over a WebTransport listener. +func GetWebTransportServer(ctx context.Context) *WebTransportServer { + wt, _ := ctx.Value(webTransportServerKey{}).(*WebTransportServer) + return wt +} + +// UpgradeWebTransport upgrades a request on the server carried in its context. +func UpgradeWebTransport(w http.ResponseWriter, r *http.Request) (*webtransport.Session, error) { + wt := GetWebTransportServer(r.Context()) + if wt == nil { + return nil, errors.New("no webtransport server serving this request") + } + return wt.Upgrade(unwrapResponseWriter(w), r) +} + +// unwrapResponseWriter reaches the ResponseWriter net/http handed in. An upgrade +// type-asserts it to http3.Settingser and http3.HTTPStreamer without checking, so +// it must not be a middleware's wrapper. +func unwrapResponseWriter(w http.ResponseWriter) http.ResponseWriter { + for { + u, ok := w.(interface{ Unwrap() http.ResponseWriter }) + if !ok { + return w + } + w = u.Unwrap() + } +} + +// Listen binds one UDP socket per address. Empty addrs binds all interfaces. +func (s *WebTransportServer) Listen(addrs []string, port uint32) ([]net.Addr, error) { + if len(addrs) == 0 { + addrs = []string{""} + } + + // Server.Serve takes a reference on the WaitGroup Server.Close waits on; + // Server.ServeQUICConn does not. + quicConf := &quic.Config{} + if s.H3.QUICConfig != nil { + quicConf = s.H3.QUICConfig.Clone() + } + quicConf.EnableDatagrams = true + quicConf.EnableStreamResetPartialDelivery = true + + var ( + conns []*net.UDPConn + lns []*quic.EarlyListener + bound []net.Addr + ) + closeAll := func() { + for _, ln := range lns { + _ = ln.Close() + } + for _, c := range conns { + _ = c.Close() + } + } + for _, addr := range addrs { + udpAddr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(addr, strconv.Itoa(int(port)))) + if err != nil { + closeAll() + return nil, err + } + udp, err := net.ListenUDP("udp", udpAddr) + if err != nil { + closeAll() + return nil, err + } + conns = append(conns, udp) + bound = append(bound, udp.LocalAddr()) + + ln, err := quic.ListenEarly(udp, s.H3.TLSConfig, quicConf) + if err != nil { + closeAll() + return nil, err + } + lns = append(lns, ln) + } + + ctx, cancel := context.WithCancel(context.Background()) + s.mu.Lock() + s.conns, s.lns, s.cancel = conns, lns, cancel + s.mu.Unlock() + + for _, ln := range lns { + s.serving.Go(func() { s.accept(ctx, ln) }) + } + logger.Infow("webtransport listener started", "addresses", bound) + + return bound, nil +} + +// Shutdown is safe on a server that never listened, and safe to call more than +// once. Peers get a GOAWAY, then whatever has not drained by the ctx deadline is +// closed under it. +func (s *WebTransportServer) Shutdown(ctx context.Context) error { + s.mu.Lock() + cancel, conns, lns := s.cancel, s.conns, s.lns + s.cancel, s.conns, s.lns = nil, nil, nil + s.mu.Unlock() + + if cancel != nil { + cancel() + } + err := s.H3.Shutdown(ctx) + _ = s.Server.Close() + s.serving.Wait() + + // the sockets close last, so every CONNECTION_CLOSE frame still reaches its peer + for _, ln := range lns { + _ = ln.Close() + } + for _, c := range conns { + _ = c.Close() + } + return err +} + +func (s *WebTransportServer) accept(ctx context.Context, ln *quic.EarlyListener) { + for { + conn, err := ln.Accept(ctx) + if err != nil { + logger.Infow("webtransport listener stopped", "error", err) + return + } + s.serving.Go(func() { + if err := s.ServeQUICConn(conn); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Infow("webtransport connection stopped", "error", err) + } + }) + } +} diff --git a/pkg/service/webtransport_test.go b/pkg/service/webtransport_test.go new file mode 100644 index 000000000..2af6c6006 --- /dev/null +++ b/pkg/service/webtransport_test.go @@ -0,0 +1,47 @@ +// 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 service + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/urfave/negroni/v3" +) + +// An upgrade type-asserts the ResponseWriter to http3.Settingser and +// http3.HTTPStreamer without checking, so it must reach the one net/http handed +// in. Every negroni chain wraps it. +func TestUnwrapResponseWriterReachesTheOriginal(t *testing.T) { + original := httptest.NewRecorder() + + var seen http.ResponseWriter + chain := negroni.New() + chain.UseHandler(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + seen = w + })) + chain.ServeHTTP(original, httptest.NewRequest(http.MethodGet, "/agent", nil)) + + _, wrapped := seen.(interface{ Unwrap() http.ResponseWriter }) + require.True(t, wrapped, "negroni no longer wraps; this test no longer covers anything") + require.Same(t, original, unwrapResponseWriter(seen)) +} + +func TestUnwrapResponseWriterPassesThroughUnwrapped(t *testing.T) { + original := httptest.NewRecorder() + require.Same(t, original, unwrapResponseWriter(original)) +} diff --git a/pkg/service/webtransportlisten_test.go b/pkg/service/webtransportlisten_test.go new file mode 100644 index 000000000..1ed41531e --- /dev/null +++ b/pkg/service/webtransportlisten_test.go @@ -0,0 +1,38 @@ +// 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 service_test + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" +) + +// a listener stopped before it has served anything must not race its own accept +// loop: under -race this is what catches webtransport.Server.Serve running +// concurrently with Server.Close. +func TestWebTransportStopBeforeFirstConnection(t *testing.T) { + for range 20 { + wt := service.NewWebTransportServer(selfSignedTLS(t)) + wt.H3.Handler = service.NewWebTransportHandler(nil, wt, http.NewServeMux()) + _, err := wt.Listen([]string{"127.0.0.1"}, 0) + require.NoError(t, err) + require.NoError(t, wt.Shutdown(context.Background())) + } +} diff --git a/pkg/service/wire.go b/pkg/service/wire.go index f8f19759d..098ae773c 100644 --- a/pkg/service/wire.go +++ b/pkg/service/wire.go @@ -39,6 +39,7 @@ import ( "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/sfu" @@ -81,7 +82,12 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live NewRoomService, NewRTCService, NewWHIPService, - NewAgentService, + endpoint.NewRegistry, + NewEndpointScopes, + NewAgentHandler, + NewAgentWSService, + NewAgentWTService, + NewAgentEndpointService, NewAgentDispatchService, getAgentConfig, agent.NewAgentClient, diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 3308626a3..2831d1a6c 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -8,7 +8,15 @@ package service import ( "fmt" + "os" + + "github.com/pion/turn/v5" + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" + "gopkg.in/yaml.v3" + "github.com/livekit/livekit-server/pkg/agent" + "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" "github.com/livekit/livekit-server/pkg/routing" "github.com/livekit/livekit-server/pkg/sfu" @@ -23,11 +31,6 @@ import ( "github.com/livekit/psrpc" "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" - "github.com/pion/turn/v5" - "github.com/pkg/errors" - "github.com/redis/go-redis/v9" - "gopkg.in/yaml.v3" - "os" ) // Injectors from wire.go: @@ -126,10 +129,15 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - agentService, err := NewAgentService(conf, currentNode, v, keyProvider) + registry := endpoint.NewRegistry() + endpointScopes := NewEndpointScopes() + agentHandler, err := NewAgentHandler(conf, currentNode, v, keyProvider, registry, endpointScopes) if err != nil { return nil, err } + agentWSService := NewAgentWSService(conf, agentHandler) + agentWTService := NewAgentWTService(agentHandler) + agentEndpointService := NewAgentEndpointService(agentHandler, endpointScopes) agentConfig := getAgentConfig(conf) client, err := agent.NewAgentClient(v, agentConfig) if err != nil { @@ -152,7 +160,7 @@ func InitializeServer(conf *config.Config, currentNode routing.LocalNode) (*Live if err != nil { return nil, err } - livekitServer, err := NewLivekitServer(conf, roomService, agentDispatchService, egressService, ingressService, sipService, ioInfoService, rtcService, serviceWHIPService, agentService, keyProvider, router, roomManager, signalServer, server, currentNode) + livekitServer, err := NewLivekitServer(conf, roomService, agentDispatchService, egressService, ingressService, sipService, ioInfoService, rtcService, serviceWHIPService, agentWSService, agentWTService, agentEndpointService, keyProvider, router, roomManager, signalServer, server, currentNode) if err != nil { return nil, err } From b07a53732dd5a94b043b90289080664117868993 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 09:39:50 +0530 Subject: [PATCH 087/130] fix: count leftover upstream loss after RTX repairs (#4946) --- pkg/sfu/connectionquality/connectionstats.go | 3 ++ .../connectionquality/connectionstats_test.go | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/pkg/sfu/connectionquality/connectionstats.go b/pkg/sfu/connectionquality/connectionstats.go index f0c71bdbf..9006d7646 100644 --- a/pkg/sfu/connectionquality/connectionstats.go +++ b/pkg/sfu/connectionquality/connectionstats.go @@ -299,6 +299,9 @@ func (cs *ConnectionStats) updateScoreAt(at time.Time) (float32, map[uint32]*buf mos, _ := cs.scorer.GetMOSAndQuality() return mos, streams, false } + // upstream PacketsLost is already net of late arrivals (including RTX repairs), + // so the scorer must not discount out-of-order packets again + agg.PacketsOutOfOrder = 0 return cs.updateScoreWithAggregate(agg, cs.params.ReceiverProvider.GetLastSenderReportTime(), at), streams, false } diff --git a/pkg/sfu/connectionquality/connectionstats_test.go b/pkg/sfu/connectionquality/connectionstats_test.go index 5adb322c8..bcb8be6e4 100644 --- a/pkg/sfu/connectionquality/connectionstats_test.go +++ b/pkg/sfu/connectionquality/connectionstats_test.go @@ -964,3 +964,46 @@ func TestPacketRateMode(t *testing.T) { }) } } + +func TestUpstreamLossAfterRTXRepair(t *testing.T) { + // 1000 video packets, 100 lost, RTX repairs 70 in the same window, 30 (3%) never arrive + r := rtpstats.NewRTPStatsReceiver(rtpstats.RTPStatsParams{}) + r.SetClockRate(90000) + snapshotID := r.NewSnapshotId() + + packetTime := time.Now().UnixNano() + send := func(sn uint16) { + packetTime += int64(5 * time.Millisecond) + r.Update(packetTime, sn, uint32(sn)*450, false, 12, 1000, 0) + } + for sn := uint16(0); sn < 1000; sn++ { + if sn == 300 { + for repaired := uint16(100); repaired < 170; repaired++ { + send(repaired) + } + } + if sn < 100 || sn >= 200 { + send(sn) + } + } + + delta := r.DeltaInfo(snapshotID) + require.Equal(t, uint32(1000), delta.Packets) + require.Equal(t, uint32(30), delta.PacketsLost) + require.Equal(t, uint32(70), delta.PacketsOutOfOrder) + + trp := newTestReceiverProvider() + trp.setStreams(map[uint32]*buffer.StreamStatsWithLayers{1: {RTPStats: delta}}) + cs := NewConnectionStats(ConnectionStatsParams{ + ReceiverProvider: trp, + Logger: logger.GetLogger(), + }) + now := time.Now() + cs.StartAt(mime.MimeTypeVP8, false, now) + cs.updateScoreAt(now.Add(5 * time.Second)) + + // packet score 100 - 3% * 10 = 70, smoothed 0.8 * 70 + 0.2 * 100 = 76 + score, quality := cs.scorer.GetScoreAndQuality() + require.InDelta(t, 76.0, score, 0.01) + require.Equal(t, livekit.ConnectionQuality_GOOD, quality) +} From 1d7e761aad19f4c7d0ef9f5e9df57a134baf84de Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 09:57:40 +0530 Subject: [PATCH 088/130] fix: switch VP8 temporal layer up only at a layer sync frame (#4947) --- pkg/sfu/forwarder_test.go | 42 +++++++++++++++++++ .../temporallayerselector/vp8.go | 11 ++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 931c79e7e..146662802 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -1933,6 +1933,48 @@ func TestForwarderGetTranslationParamsVideo(t *testing.T) { require.Equal(t, f.lastSSRC, params.SSRC) } +func TestForwarderVP8TemporalUpSwitchAtLayerSync(t *testing.T) { + f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + + // a frame without Y may reference a dropped frame of its layer + steps := []struct { + name string + tid uint8 + y bool + expectedThis int32 + expectedCurrent int32 + }{ + {name: "TL2 without Y", tid: 2, expectedThis: 0, expectedCurrent: 0}, + {name: "TL1 without Y", tid: 1, expectedThis: 0, expectedCurrent: 0}, + {name: "TL0", tid: 0, expectedThis: 0, expectedCurrent: 0}, + {name: "TL2 with Y before TL1 sync", tid: 2, y: true, expectedThis: 0, expectedCurrent: 0}, + {name: "TL1 with Y", tid: 1, y: true, expectedThis: 1, expectedCurrent: 1}, + {name: "TL2 without Y after TL1 switch", tid: 2, expectedThis: 1, expectedCurrent: 1}, + {name: "TL2 with Y", tid: 2, y: true, expectedThis: 2, expectedCurrent: 2}, + } + for i, step := range steps { + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: uint16(i), PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: uint16(i), T: true, TID: step.tid, Y: step.y}, + ) + require.NoError(t, err) + require.Equal(t, step.expectedThis, f.vls.SelectTemporal(extPkt), step.name) + require.Equal(t, step.expectedCurrent, f.vls.GetCurrent().Temporal, step.name) + } + + // key frame refreshes all buffers, up-switch at it even without Y + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 10, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 10, T: true, TID: 0, IsKeyFrame: true}, + ) + require.NoError(t, err) + require.Equal(t, int32(2), f.vls.SelectTemporal(extPkt)) + require.Equal(t, int32(2), f.vls.GetCurrent().Temporal) +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) diff --git a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go index 39480c439..bc5679c58 100644 --- a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go +++ b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go @@ -44,7 +44,16 @@ func (v *VP8) Select(extPkt *buffer.ExtPacket, current int32, target int32) (thi tid := extPkt.Temporal if current < target { - if tid > current && tid <= target && vp8.S { + // Up-switch only where nothing dropped can be referenced: a layer sync + // (Y) frame depends only on TL0 (RFC 7741), a key frame refreshes all + // reference buffers. Key frames also cover encoders that never set Y. + // A sync frame raises one layer at a time, the layers in between would + // otherwise forward frames that reference their dropped frames. + switch { + case vp8.IsKeyFrame && tid <= target: + this = target + next = target + case tid == current+1 && tid <= target && vp8.S && vp8.Y: this = tid next = tid } From b955b799531b7972606ca3de99b9cd97a0ae4461 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 11:57:15 +0530 Subject: [PATCH 089/130] fix: reconcile everything when the reconcile queue overflows (#4948) A subscribe that found the reconcile queue full was dropped and waited for the 3 s ticker. Flag the overflow, and have the worker reconcile all subscriptions after its next queued item. Co-authored-by: Claude Opus 5.5 --- pkg/rtc/subscriptionmanager.go | 15 ++++- pkg/rtc/subscriptionmanager_test.go | 86 +++++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/pkg/rtc/subscriptionmanager.go b/pkg/rtc/subscriptionmanager.go index b5edddca1..52f52a1d0 100644 --- a/pkg/rtc/subscriptionmanager.go +++ b/pkg/rtc/subscriptionmanager.go @@ -80,6 +80,10 @@ type SubscriptionManager struct { closeCh chan struct{} doneCh chan struct{} + // set when a queued reconcile is dropped, the worker then reconciles everything + reconcileOverflow atomic.Bool + reconcileDataTrackOverflow atomic.Bool + onSubscribeStatusChanged func(publisherID livekit.ParticipantID, subscribed bool) dataTrackSubscriptions map[livekit.TrackID]*dataTrackSubscription @@ -706,7 +710,8 @@ func (m *SubscriptionManager) queueReconcile(trackID livekit.TrackID) { select { case m.reconcileCh <- trackID: default: - // queue is full, will reconcile based on timer + // queue is full, the worker reconciles everything after its next queued item + m.reconcileOverflow.Store(true) } } @@ -714,7 +719,7 @@ func (m *SubscriptionManager) queueReconcileDataTrack(trackID livekit.TrackID) { select { case m.reconcileDataTrackCh <- trackID: default: - // queue is full, will reconcile based on timer + m.reconcileDataTrackOverflow.Store(true) } } @@ -742,6 +747,9 @@ func (m *SubscriptionManager) reconcileWorker() { } else { m.reconcileSubscriptions() } + if m.reconcileOverflow.Swap(false) { + m.reconcileSubscriptions() + } case trackID := <-m.reconcileDataTrackCh: m.lock.Lock() @@ -752,6 +760,9 @@ func (m *SubscriptionManager) reconcileWorker() { } else { m.reconcileDataTrackSubscriptions() } + if m.reconcileDataTrackOverflow.Swap(false) { + m.reconcileDataTrackSubscriptions() + } } } } diff --git a/pkg/rtc/subscriptionmanager_test.go b/pkg/rtc/subscriptionmanager_test.go index b5305e32d..e184b7fae 100644 --- a/pkg/rtc/subscriptionmanager_test.go +++ b/pkg/rtc/subscriptionmanager_test.go @@ -17,6 +17,7 @@ package rtc import ( + "fmt" "sync" "testing" "time" @@ -488,6 +489,91 @@ func TestSettingsKeptForSubscribe(t *testing.T) { }) } +func TestSubscribeBurstOverflowsQueue(t *testing.T) { + // a dropped reconcile must not wait for the ticker + saved := reconcileInterval + reconcileInterval = time.Hour + defer func() { reconcileInterval = saved }() + + t.Run("media", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + + // the first subscribe holds the worker until all are queued, so the queue overflows + release := make(chan struct{}) + var holdOnce sync.Once + sm.params.TrackResolver = func(_ types.LocalParticipant, trackID livekit.TrackID) types.MediaResolverResult { + mt := &typesfakes.FakeMediaTrack{} + mt.IDReturns(trackID) + mt.AddSubscriberCalls(func(types.LocalParticipant) (types.SubscribedTrack, error) { + holdOnce.Do(func() { <-release }) + st := &typesfakes.FakeSubscribedTrack{} + st.IDReturns(trackID) + st.MediaTrackReturns(mt) + return st, nil + }) + return types.MediaResolverResult{ + Track: mt, + HasPermission: true, + PublisherID: "pubID", + PublisherIdentity: "pub", + TrackChangedNotifier: utils.NewChangeNotifier(), + TrackRemovedNotifier: utils.NewChangeNotifier(), + } + } + + numTracks := cap(sm.reconcileCh) + 10 + for i := range numTracks { + sm.SubscribeToTrack(livekit.TrackID(fmt.Sprintf("TR_%d", i)), false) + } + close(release) + + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + for _, s := range sm.subscriptions { + if s.needsSubscribe() { + return false + } + } + return len(sm.subscriptions) == numTracks + }, subSettleTimeout, subCheckInterval, "every track should be subscribed") + }) + + t.Run("data", func(t *testing.T) { + sm := newTestSubscriptionManager() + defer sm.Close(false) + resolver := newTestDataTrackResolver(true, true, "pub", "pubID") + sm.params.DataTrackResolver = resolver.Resolve + + release := make(chan struct{}) + var holdOnce sync.Once + resolver.dataTrack.AddSubscriberCalls(func(types.LocalParticipant) (types.DataDownTrack, error) { + holdOnce.Do(func() { <-release }) + ddt := &typesfakes.FakeDataDownTrack{} + ddt.PublishDataTrackReturns(resolver.dataTrack) + return ddt, nil + }) + + numTracks := cap(sm.reconcileDataTrackCh) + 10 + for i := range numTracks { + sm.SubscribeToDataTrack(livekit.TrackID(fmt.Sprintf("DTR_%d", i))) + } + close(release) + + require.Eventually(t, func() bool { + sm.lock.RLock() + defer sm.lock.RUnlock() + for _, s := range sm.dataTrackSubscriptions { + if s.needsSubscribe() { + return false + } + } + return len(sm.dataTrackSubscriptions) == numTracks + }, subSettleTimeout, subCheckInterval, "every data track should be subscribed") + }) +} + func TestSubscriptionLimits(t *testing.T) { sm := newTestSubscriptionManagerWithParams(testSubscriptionParams{ SubscriptionLimitAudio: 1, From b2d8d27a110f7f50f9a4435f041787e5c5144796 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 12:28:09 +0530 Subject: [PATCH 090/130] bump protocol to get unlock on no key fix (#4945) * bump protocol to get unlock on no key fix * update warp pointer * update protocol and psrpc deps * update protocol dep Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- go.mod | 45 +++++++++++---------- go.sum | 90 +++++++++++++++++++++-------------------- pkg/service/wire_gen.go | 12 +++--- 3 files changed, 74 insertions(+), 73 deletions(-) diff --git a/go.mod b/go.mod index ae6f54822..915aad1e1 100644 --- a/go.mod +++ b/go.mod @@ -21,8 +21,8 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3 - github.com/livekit/psrpc v0.8.0 + github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913 + github.com/livekit/psrpc v0.8.1 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 @@ -53,7 +53,7 @@ require ( github.com/twitchtv/twirp v8.1.3+incompatible github.com/ua-parser/uap-go v0.0.0-20260529044130-17c35e68e58c github.com/urfave/negroni/v3 v3.1.1 - go.uber.org/atomic v1.11.0 + go.uber.org/atomic v1.12.0 go.uber.org/multierr v1.11.0 go.uber.org/zap v1.28.0 golang.org/x/mod v0.41.0 @@ -87,26 +87,27 @@ require ( github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect github.com/olekukonko/errors v1.3.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect - github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect + github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect - go.opentelemetry.io/otel v1.46.0 // indirect + go.opentelemetry.io/otel v1.47.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 // indirect - go.opentelemetry.io/otel/metric v1.46.0 // indirect + go.opentelemetry.io/otel/log v1.47.0 // indirect + go.opentelemetry.io/otel/metric v1.47.0 // indirect go.opentelemetry.io/otel/sdk v1.46.0 // indirect - go.opentelemetry.io/otel/trace v1.46.0 // indirect + go.opentelemetry.io/otel/trace v1.47.0 // indirect go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa // indirect - golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 // indirect - golang.org/x/time v0.15.0 // indirect + golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 // indirect + golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 // indirect + golang.org/x/time v0.16.0 // indirect ) require ( - buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 // indirect + buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 // indirect buf.build/go/protovalidate v1.4.0 // indirect buf.build/go/protoyaml v0.7.0 // indirect cel.dev/expr v0.25.3 // indirect @@ -125,7 +126,7 @@ require ( 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.20.0 // indirect + github.com/klauspost/compress v1.20.1 // indirect github.com/klauspost/cpuid/v2 v2.4.0 // indirect github.com/lithammer/shortuuid/v4 v4.3.0 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -142,20 +143,20 @@ require ( github.com/pion/stun/v3 v3.1.7 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.3 // indirect - github.com/prometheus/common v0.71.0 // indirect + github.com/prometheus/common v0.72.0 // indirect github.com/prometheus/procfs v0.22.0 // indirect github.com/urfave/cli/v3 v3.11.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.56.0 // indirect - golang.org/x/net v0.58.0 - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect - golang.org/x/tools v0.49.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a // indirect - google.golang.org/grpc v1.83.2 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/net v0.59.0 + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect + golang.org/x/tools v0.51.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 // indirect + google.golang.org/grpc v1.84.0 // indirect ) tool ( @@ -165,7 +166,7 @@ tool ( golang.org/x/tools/cmd/goimports ) -replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5 +replace github.com/pion/webrtc/v4 => github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 replace github.com/pion/dtls/v3 => github.com/livekit/dtls/v3 v3.1.5-warp.3 diff --git a/go.sum b/go.sum index 686118d0f..16ddea2ec 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1 h1:Slv0uGxx219srASyiaI5C9cDlyG8kNDcXpTSYcuAeE4= -buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.1/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2 h1:NbnlmV26O7oZ1iM5tsCI+GEx+3ZSdrhvnKQ/eWSrLiY= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.12-20260825204119-511051f7f437.2/go.mod h1:TCt1lluMFnctISJXvkIQ4x3ABrPuUKCWKyjKdkJNBpw= buf.build/go/protovalidate v1.4.0 h1:UjLrYbt5VX7+TMOs2+pG5FhZhIG1mSfK4EIopbb4LcM= buf.build/go/protovalidate v1.4.0/go.mod h1:8vJfzNT6NIG2qm3uFsJDXMlRmG+bQJzbcIn1Aa0vPGs= buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38= @@ -141,8 +141,8 @@ 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.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= -github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= +github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ= +github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= 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= @@ -166,12 +166,12 @@ 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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3 h1:prnZIg7PRRO4QyHVKODibufoUZHjmMLphQaGSLPtmBo= -github.com/livekit/protocol v1.52.2-0.20261006034619-196ffef79fa3/go.mod h1:t5CV/LCsWbI9zsSTHTJKDyLtByqvvVkioSP7jgqpIGA= -github.com/livekit/psrpc v0.8.0 h1:uo3x+DL8pK2OIaCxg/s9sLUO2cIocS5HwdPhhI+m+fQ= -github.com/livekit/psrpc v0.8.0/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5 h1:3na5DnAUJtXsrBHWdcUY0tGh9JYu5ppeVPOV4ll/AWA= -github.com/livekit/webrtc-pion/v4 v4.2.18-warp.5/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= +github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913 h1:LopYipWFeZbKrjRFSYgX7GBJx6FBQ2mvtcoZU4+ynJM= +github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913/go.mod h1:EJRuXu4hCsje+SiDnPGJ9U2wpFbwTl6bzC4ZxPSdRec= +github.com/livekit/psrpc v0.8.1 h1:aeEnC2WRWB2tzNpmrifog8B+DD9+0K2Fk8xT/0kMuxg= +github.com/livekit/psrpc v0.8.1/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 h1:UaGYlQ/Ma73LN12xPDlH3OBYNfOEDTVOtHdRZbHg2dY= +github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= github.com/mackerelio/go-osstat v0.2.8/go.mod h1:SyS3XxKdoSKJnTGTkN5Yrh6VUQVuAURACfE6y+2DN4k= github.com/magefile/mage v1.17.2 h1:fyXVu1eadI8Ap1HCCNgEhJ5McIWiYhLR8uol64ZZc40= @@ -237,8 +237,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/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= -github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= +github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= 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/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= @@ -278,8 +278,8 @@ github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59u github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= github.com/prometheus/client_model v0.6.3 h1:O0jaTVAYNxTHYInEPFJt5I3+sN8zqBtVMPTB1qyxiEo= github.com/prometheus/client_model v0.6.3/go.mod h1:gpN5P9S7Rr6Yr92PiQ+Ixvhf6JZEkF1dnxsYL2aPBEM= -github.com/prometheus/common v0.71.0 h1:9KDAKb7Mj3HEVKyFCK6Dc/HIwlBzZIN2l7/lrHl3KK8= -github.com/prometheus/common v0.71.0/go.mod h1:CLJ5H8TEsGX8bl31BdMkfhIZ+QmZ9tBPPotUxUbfcmk= +github.com/prometheus/common v0.72.0 h1:tAYsE+sPJxIncDAobm4H5aQjmox9ZxEIIqPbiffa8G4= +github.com/prometheus/common v0.72.0/go.mod h1:77NWqAQ2tXT7BIK40qjJdw5Acrsrg1TlHAnsQi3i6mk= github.com/prometheus/procfs v0.22.0 h1:6q9+/JL9IKAPbCmBrv9n5O5Ty3NKnciV5X7YGw0oics= github.com/prometheus/procfs v0.22.0/go.mod h1:CvmFr/GVhIjIvWJZW3tgkODBQMRIf0EyWMQLHCHab58= github.com/puzpuzpuz/xsync/v4 v4.5.0 h1:vOSWu6b57/emh+L/Cw0BeQfvxa/cogFywXHeGUxQxAg= @@ -331,24 +331,26 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= -go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc= -go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE= +go.opentelemetry.io/otel v1.47.0 h1:j7ALJ/zgkS7Z6aeJW09p8VC9804bC+PpeTfCD4XPnOM= +go.opentelemetry.io/otel v1.47.0/go.mod h1:8wS9O2qfXrYrzp6hIF/HOYJJf/wIhFPhR2xLuP+iXQU= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= -go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= -go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o= +go.opentelemetry.io/otel/log v1.47.0 h1:cOTS1CcLbSQeZKanGJ+0JpF/+t4PELi3O3bbl2lqCcI= +go.opentelemetry.io/otel/log v1.47.0/go.mod h1:9byitSQ5pLC6PpqwGXjqdMKya6ZTswHRZh2vvXT33nw= +go.opentelemetry.io/otel/metric v1.47.0 h1:4PptaldXx3Eat1XjMZ68pPJEs5wrhlemctZE9a3UdWY= +go.opentelemetry.io/otel/metric v1.47.0/go.mod h1:ADGSXxRrXM6bjbvLo535EstVFlPpPYZm4LBKixjDHwU= go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI= go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM= go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE= go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= -go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= -go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= +go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrFNWSrM= +go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0= go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= -go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= -go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE= +go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= @@ -365,10 +367,10 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= -golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk= -golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= +golang.org/x/exp v0.0.0-20261005173118-76772065c9b0 h1:qUp3i6M39kccSm3OMMIE3tvs7YQWO9rFeCKdk1sSaFk= +golang.org/x/exp v0.0.0-20261005173118-76772065c9b0/go.mod h1:SRjTzy5WcYqRRqquLUvHeeovSwLO0CZ3xcltZNO4Fck= golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= @@ -385,8 +387,8 @@ 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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= 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.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= @@ -414,33 +416,33 @@ 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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5 h1:ZUSxONxc981v7AW7QUg+I9WwZzSTTJ019ENBYr5pV/Q= -golang.org/x/telemetry v0.0.0-20260811182544-a038080d80e5/go.mod h1:LVehoXe41cL5SCVQilsV7Gg6BNG+Js6P9PhSbYTIUkQ= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157 h1:DftxASHKl6JolFPqoN+2WrFO53JvkHwRXvFqf8gsivA= +golang.org/x/telemetry v0.0.0-20260924152758-ed294f943157/go.mod h1:i+ivNqjDnTF3WTElsdk5g9V5DTSBYgdNo7xTU9SDwYA= 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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= -golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= -golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE= +golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= -golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= +golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI= +golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA= 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-20260831171406-18b4a7587f8a h1:i3TAXhpKc7TUP1VAPiBBrv45kamjoizCC3rOC0cAbOs= -google.golang.org/genproto/googleapis/api v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:CvYJHpbzPlT0fb/PsgtAamdwru/GVxUsomFdXTpOTI8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a h1:3Dnd1cDaZlB68lziofO+bJXpjOy8UfRv8Unt+yH8tQ4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260831171406-18b4a7587f8a/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= -google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= -google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8 h1:cFbkNigDInw+wVTXc+CyhUsoHGJCw0BR6mlNoRVfre0= +google.golang.org/genproto/googleapis/api v0.0.0-20261005182115-fad411399dd8/go.mod h1:QK6p7IgD7IZEZJBhhSUCib5X2vMOzlrGCGUw2XyVDh0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8 h1:0SXBqli5dnuCKMjI/X3FM226zEaEa0GUcSCJtN1vL2o= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261005182115-fad411399dd8/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= +google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pkg/service/wire_gen.go b/pkg/service/wire_gen.go index 2831d1a6c..ca10d3e35 100644 --- a/pkg/service/wire_gen.go +++ b/pkg/service/wire_gen.go @@ -8,13 +8,6 @@ package service import ( "fmt" - "os" - - "github.com/pion/turn/v5" - "github.com/pkg/errors" - "github.com/redis/go-redis/v9" - "gopkg.in/yaml.v3" - "github.com/livekit/livekit-server/pkg/agent" "github.com/livekit/livekit-server/pkg/agent/endpoint" "github.com/livekit/livekit-server/pkg/config" @@ -31,6 +24,11 @@ import ( "github.com/livekit/psrpc" "github.com/livekit/psrpc/pkg/bus/redisbus" "github.com/livekit/psrpc/pkg/middleware/otelpsrpc" + "github.com/pion/turn/v5" + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" + "gopkg.in/yaml.v3" + "os" ) // Injectors from wire.go: From 6eb2e55388c1b1c44aaf6da94d5176593a520aba Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 15:59:06 +0530 Subject: [PATCH 091/130] fix: enable H.264 publish for desktop Linux Firefox (#4949) The static client config disabled H.264 publish for Firefox on Linux or Android. The Android workaround came from #2183, but the linux clause only ever hit desktop Linux Firefox, which publishes H.264 fine through OpenH264. Narrow the rule to Android only. Fixes #4929 Co-authored-by: Claude Opus 4.8 --- pkg/clientconfiguration/conf.go | 2 +- pkg/clientconfiguration/conf_test.go | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/pkg/clientconfiguration/conf.go b/pkg/clientconfiguration/conf.go index 7fb5ae8d5..a23537159 100644 --- a/pkg/clientconfiguration/conf.go +++ b/pkg/clientconfiguration/conf.go @@ -51,7 +51,7 @@ var StaticConfigurations = []ConfigurationItem{ }, { Match: must.Get(NewScriptMatch(`(c.device_model == "xiaomi 2201117ti" && c.os == "android") || - ((c.browser == "firefox" || c.browser == "firefox mobile") && (c.os == "linux" || c.os == "android"))`)), + ((c.browser == "firefox" || c.browser == "firefox mobile") && c.os == "android")`)), Configuration: &livekit.ClientConfiguration{ DisabledCodecs: &livekit.DisabledCodecs{ Publish: []*livekit.Codec{ diff --git a/pkg/clientconfiguration/conf_test.go b/pkg/clientconfiguration/conf_test.go index 453d57cb8..2c0294ad0 100644 --- a/pkg/clientconfiguration/conf_test.go +++ b/pkg/clientconfiguration/conf_test.go @@ -19,10 +19,33 @@ import ( "github.com/stretchr/testify/require" + "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/utils/must" ) +func TestStaticConfigurationH264(t *testing.T) { + cm := NewStaticClientConfigurationManager(StaticConfigurations) + + h264Disabled := func(info *livekit.ClientInfo) bool { + conf := cm.GetConfiguration(info) + if conf == nil || conf.DisabledCodecs == nil { + return false + } + for _, c := range conf.DisabledCodecs.Publish { + if mime.IsMimeTypeStringH264(c.Mime) { + return true + } + } + return false + } + + // desktop Linux Firefox publishes H.264 through OpenH264, so it must not be disabled + require.False(t, h264Disabled(&livekit.ClientInfo{Browser: "firefox", Os: "linux"})) + // Firefox for Android is still worked around + require.True(t, h264Disabled(&livekit.ClientInfo{Browser: "firefox mobile", Os: "android"})) +} + func TestScriptMatchConfiguration(t *testing.T) { t.Run("no merge", func(t *testing.T) { confs := []ConfigurationItem{ From f659ca53e9d293c82901c1ba97f8f01639f0cca5 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 16:16:48 +0530 Subject: [PATCH 092/130] Release v1.13.8. (#4950) Co-authored-by: Claude Opus 4.8 --- CHANGELOG.md | 71 ++++++++++++++++++++++++++++++++++++++++++++++ version/version.go | 2 +- 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa86675f3..47bd4bc0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,77 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.8] - 2026-10-06 + +### Added + +- agent: HTTP endpoints data plane (#4779) +- use sender report pass through setting to forward abs-capture-time verbatim (#4812) +- Add sdk lable to session start time and participant join (#4885) +- Add a local participant method to get recently disconnected (#4911) +- service: start room composite egress configured in a room preset (#4926) + +### Changed + +- Update github workflows (#4446) +- Reduce descriptor marshal allocations on Go 1.26.4/linux-amd64 (#4681) +- config: reject unknown send_side_bwe_pacer, warn on unwired fallback (#4804) +- Update github.com/livekit/protocol digest to a879e94 (#4864) +- Update module github.com/gotesttools/gotestfmt/v2 to v2.5.0 (#4865) +- Update module github.com/urfave/cli/v3 to v3.11.0 (#4867) +- Update module golang.org/x/mod to v0.41.0 (#4868) +- sfu: return VP8 munged header by value to avoid per-packet allocation (#4871) +- sfu: patch pacer header extensions without per-packet allocation (#4872) +- Update pion/sdp to get lesser SDP retention (#4873) +- sfu: skip pacer allocation assertion under the race detector (#4874) +- sfu: broadcast RTP to down tracks without per-packet allocations (#4875) +- Track action versions and stop quarantining our own actions (#4877) +- test: fix two data races in the test suites (#4878) +- Bump go.opentelemetry.io/otel/sdk (#4879) +- deps: update psrpc to v0.8.0 (#4882) +- Update module github.com/moby/moby/client to v0.6.0 (#4883) +- Update module golang.org/x/sync to v0.23.0 (#4884) +- Propagate SIP_TRUNK_FAILURE (#4890) +- ccutils: use millisecond resolution for probe interval backoff (#4891) +- Increase receiver loadbalance threshold when batch io enabled (#4899) +- rtc: parse each remote offer once per negotiation (#4904) +- Bump protocol (#4909) +- rtc: split EndSession out of MoveToRoom (#4917) +- telemetry: drop the stats worker re-key path (#4918) +- sfu: report forwarding latency as p90 instead of the mean (#4920) +- rtc: accept ICE Completed in ICE-restart connect assertions (#4921) +- rtc: measure data blob keys by their content, not their text form (#4923) +- Remove deprecatred way of setting prometheus from the config file (#4928) +- test: give mock participants a connection quality, close test rooms (#4933) +- update warp dep (#4943) + +### Fixed + +- Do not add down tracks after receiver close (#4857) +- Do not request media sections for transceivers that cannot send (#4892) +- Deregister agent job when the server terminates it (#4893) +- sfu: don't hold bindLock across the blank-frame flush in CloseWithFlush (#4895) +- dynacast: re-send subscribed qualities after every publisher answer (#4907) +- agent: release JobTerminate handler when job ends before registration (#4908) +- Report disconnected for older disconnected participants. (#4910) +- rtc: record a room's departure when the participant leaves it (#4913) +- update deps to fix warp issue (#4916) +- sfu: fix data stats active window bitrate and duration units (#4922) +- fix: reject instead of drop for no dispatch rule (#4927) +- fix: skip RTX/FEC when picking a fallback video codec (#4930) +- fix: do not read invalid delta (#4935) +- fix: use the fast debounce for the first negotiation (#4936) +- fix: skip DTLS HelloVerifyRequest (#4937) +- config: parse generated duration flags as durations (#4939) +- fix: use the most common recent packet rate in connection quality (#4940) +- fix: copy RED history packets instead of keeping pointers (#4941) +- fix: keep one subscription when first subscribes race (#4944) +- bump protocol to get unlock on no key fix (#4945) +- fix: count leftover upstream loss after RTX repairs (#4946) +- fix: switch VP8 temporal layer up only at a layer sync frame (#4947) +- fix: reconcile everything when the reconcile queue overflows (#4948) +- fix: enable H.264 publish for desktop Linux Firefox (#4949) + ## [1.13.7] - 2026-09-14 ### Added diff --git a/version/version.go b/version/version.go index 0486d6293..04c27db63 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.7" +const Version = "1.13.8" From 9b20c1da5766fcde1dcac1b936bb38170b9bb6c7 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 18:21:11 +0530 Subject: [PATCH 093/130] sfu/buffer: stop allocating per ingested packet (#4951) * sfu/buffer: stop allocating per ingested packet Buffer.Write allocated six objects for every RTP packet. They were the parsed rtp.Packet, a copy of it in patchExtPacket, and the flow state, whose address two rare log calls took. The rest were an AbsCaptureTime, present on about one packet per second, and the extension slice that pion grows with append. Unmarshal into a pooled scratch packet. Give ExtPacket its own backing rtp.Packet and an [8]rtp.Extension array, and copy the header into them. Patch the payload in place. If the abs-capture-time extension is present, parse it. Log a copy of the flow state. Per packet, Write plus ReadExtended. Opus: 6 allocs, 448 B -> 0. VP8: 6 allocs, 416 B -> 1 alloc, 32 B. Opus 240 -> 142 ns, VP8 269 -> 204 ns. Co-Authored-By: Claude Fable 5.1 * sfu/buffer: point extension payloads at the bucket copy on read Queued ExtPackets kept extension payload slices into the write input, which the transport reuses for the next packet. ReadExtended now parses the header again from the bucket copy, so extensions and CSRCs point into RawPacket like the payload does. The test writes every packet through one input buffer and checks the audio level of each packet read back. Co-Authored-By: Claude Fable 5.1 * sfu/buffer: test that a dropped padding packet keeps the shifted sequence number on read The bucket stores the shifted number, so re-parsing the header on read returns it. This test guards that. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/sfu/buffer/buffer.go | 9 ++- pkg/sfu/buffer/buffer_base.go | 52 +++++++++---- pkg/sfu/buffer/buffer_test.go | 137 ++++++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+), 18 deletions(-) diff --git a/pkg/sfu/buffer/buffer.go b/pkg/sfu/buffer/buffer.go index 5468b3b94..551a60b5e 100644 --- a/pkg/sfu/buffer/buffer.go +++ b/pkg/sfu/buffer/buffer.go @@ -157,7 +157,8 @@ func (b *Buffer) Bind(params webrtc.RTPParameters, codec webrtc.RTPCodecCapabili // Write adds an RTP Packet, ordering is not guaranteed, newer packets may arrive later func (b *Buffer) Write(pkt []byte) (n int, err error) { - var rtpPacket rtp.Packet + rtpPacket := rtpPacketFactory.Get().(*rtp.Packet) + defer rtpPacketFactory.Put(rtpPacket) err = rtpPacket.Unmarshal(pkt) if err != nil { return @@ -185,7 +186,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { } if b.streamInfoProbe != nil { - b.probeStreamInfoLocked(&rtpPacket) + b.probeStreamInfoLocked(rtpPacket) } // handle RTX packet @@ -197,7 +198,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { return } - pb.writeRTX(&rtpPacket, now) + pb.writeRTX(rtpPacket, now) return } @@ -235,7 +236,7 @@ func (b *Buffer) Write(pkt []byte) (n int, err error) { return } - rtcpPackets := b.calc(pkt, &rtpPacket, now, false, false) + rtcpPackets := b.calc(pkt, rtpPacket, now, false, false) b.Unlock() if len(rtcpPackets) != 0 { diff --git a/pkg/sfu/buffer/buffer_base.go b/pkg/sfu/buffer/buffer_base.go index bf373a8e5..a18847787 100644 --- a/pkg/sfu/buffer/buffer_base.go +++ b/pkg/sfu/buffer/buffer_base.go @@ -18,6 +18,7 @@ import ( "errors" "fmt" "io" + "slices" "strings" "sync" "time" @@ -50,6 +51,13 @@ var ( return &ExtPacket{} }, } + + // unmarshal scratch, a reused packet keeps its Extensions capacity + rtpPacketFactory = &sync.Pool{ + New: func() any { + return &rtp.Packet{} + }, + } ) func ReleaseExtPacket(extPkt *ExtPacket) { @@ -78,6 +86,11 @@ type ExtPacket struct { AbsCaptureTimeExt *act.AbsCaptureTime IsOutOfOrder bool IsBuffered bool + + // backing storage for Packet when the buffer creates the ext packet, + // extension and CSRC slices point into the write input until ReadExtended re-points them into RawPacket + pkt rtp.Packet + extensions [8]rtp.Extension } type BufferProvider interface { @@ -733,7 +746,8 @@ func (b *BufferBase) HandleIncomingPacketLocked( oobSequenceNumber uint16, ) (uint64, error) { if rtpPacket == nil { - rtpPacket = &rtp.Packet{} + rtpPacket = rtpPacketFactory.Get().(*rtp.Packet) + defer rtpPacketFactory.Put(rtpPacket) if err := rtpPacket.Unmarshal(rawPkt); err != nil { b.logger.Errorw("could not unmarshal RTP packet", err) return 0, err @@ -877,10 +891,12 @@ func (b *BufferBase) HandleIncomingPacketLocked( if errors.Is(err, bucket.ErrPacketTooOld) { packetTooOldCount := b.packetTooOldCount.Inc() if (packetTooOldCount-1)%100 == 0 { + // log a copy so flowState stays on the stack + fs := flowState b.logger.Warnw( "could not add packet to bucket", err, "count", packetTooOldCount, - "flowState", &flowState, + "flowState", &fs, "snAdjustment", snAdjustment, "incomingSequenceNumber", flowState.ExtSequenceNumber+snAdjustment, "rtpStats", b.rtpStats, @@ -890,9 +906,10 @@ func (b *BufferBase) HandleIncomingPacketLocked( ) } } else if err != bucket.ErrRTXPacket { + fs := flowState b.logger.Warnw( "could not add packet to bucket", err, - "flowState", &flowState, + "flowState", &fs, "snAdjustment", snAdjustment, "incomingSequenceNumber", flowState.ExtSequenceNumber+snAdjustment, "rtpStats", b.rtpStats, @@ -1025,7 +1042,7 @@ func (b *BufferBase) getExtPacket( Arrival: arrivalTime, ExtSequenceNumber: flowState.ExtSequenceNumber, ExtTimestamp: flowState.ExtTimestamp, - Packet: rtpPacket, + pkt: *rtpPacket, VideoLayer: VideoLayer{ Spatial: InvalidLayerSpatial, Temporal: InvalidLayerTemporal, @@ -1033,6 +1050,10 @@ func (b *BufferBase) getExtPacket( IsOutOfOrder: flowState.IsOutOfOrder, IsBuffered: isBuffered, } + // rtpPacket may be reused scratch, copy what it backs + ep.pkt.Extensions = append(ep.extensions[:0], rtpPacket.Extensions...) + ep.pkt.CSRC = slices.Clone(rtpPacket.CSRC) + ep.Packet = &ep.pkt if len(ep.Packet.Payload) == 0 { // padding only packet, nothing else to do @@ -1045,11 +1066,11 @@ func (b *BufferBase) getExtPacket( } if b.absCaptureTimeExtID != 0 { - extData := rtpPacket.GetExtension(b.absCaptureTimeExtID) - - var actExt act.AbsCaptureTime - if err := actExt.Unmarshal(extData); err == nil { - ep.AbsCaptureTimeExt = &actExt + if extData := rtpPacket.GetExtension(b.absCaptureTimeExtID); len(extData) != 0 { + actExt := &act.AbsCaptureTime{} + if err := actExt.Unmarshal(extData); err == nil { + ep.AbsCaptureTimeExt = actExt + } } } @@ -1201,16 +1222,19 @@ func (b *BufferBase) patchExtPacket(ep *ExtPacket, buf []byte) *ExtPacket { } ep.RawPacket = buf[:n] - // patch RTP packet to point payload to new buffer - pkt := *ep.Packet - payloadStart := ep.Packet.Header.MarshalSize() + // parse the header again from the new buffer so extension and CSRC slices point into it, + // then point the payload there too, ep owns its rtp.Packet + payloadStart, err := ep.Packet.Header.Unmarshal(buf[:n]) + if err != nil { + b.logger.Warnw("could not unmarshal header", err, "sn", ep.Packet.SequenceNumber) + return nil + } payloadEnd := payloadStart + len(ep.Packet.Payload) if payloadEnd > n { b.logger.Warnw("unexpected marshal size", nil, "max", n, "need", payloadEnd) return nil } - pkt.Payload = buf[payloadStart:payloadEnd] - ep.Packet = &pkt + ep.Packet.Payload = buf[payloadStart:payloadEnd] return ep } diff --git a/pkg/sfu/buffer/buffer_test.go b/pkg/sfu/buffer/buffer_test.go index f0f307690..39267dade 100644 --- a/pkg/sfu/buffer/buffer_test.go +++ b/pkg/sfu/buffer/buffer_test.go @@ -15,6 +15,7 @@ package buffer import ( + "encoding/binary" "fmt" "math" "sync" @@ -23,10 +24,14 @@ import ( "github.com/pion/rtcp" "github.com/pion/rtp" + "github.com/pion/sdp/v3" "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" "github.com/livekit/mediatransportutil/pkg/nack" + + act "github.com/livekit/livekit-server/pkg/sfu/rtpextension/abscapturetime" + "github.com/livekit/livekit-server/pkg/sfu/utils" ) var h265Codec = webrtc.RTPCodecParameters{ @@ -460,6 +465,138 @@ func TestCodecChange(t *testing.T) { } } +// Write unmarshals into reused scratch and callers reuse the input buffer, so each ExtPacket +// must keep its own header and extensions, and Write plus ReadExtended must not allocate per packet +func TestIngestNoAlloc(t *testing.T) { + testCases := []struct { + name string + codec webrtc.RTPCodecParameters + payload []byte + maxAllocs float64 + }{ + {"opus", opusCodec, make([]byte, 100), 0}, + // VP8 boxes the parsed descriptor into ExtPacket.Payload + {"vp8", vp8Codec, []byte{0x90, 0xe0, 0x80, 0x01, 0x05, 0x00, 0x01}, 1}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + buff := NewBuffer(123, 100, 100) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + HeaderExtensions: []webrtc.RTPHeaderExtensionParameter{ + {URI: sdp.AudioLevelURI, ID: 1}, + {URI: act.AbsCaptureTimeURI, ID: 2}, + }, + Codecs: []webrtc.RTPCodecParameters{tc.codec}, + }, tc.codec.RTPCodecCapability, 0)) + defer buff.Close() + + marshal := func(sn uint16, level byte) []byte { + p := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: uint8(tc.codec.PayloadType), + SequenceNumber: sn, + Timestamp: uint32(sn) * 960, + SSRC: 123, + }, + Payload: tc.payload, + } + require.NoError(t, p.SetExtension(1, []byte{level})) + require.NoError(t, p.SetExtension(3, []byte{0, 0})) + raw, err := p.Marshal() + require.NoError(t, err) + return raw + } + + // one input buffer for all writes, like the transport read loop + input := make([]byte, 1500) + write := func(sn uint16, level byte) { + n := copy(input, marshal(sn, level)) + _, err := buff.Write(input[:n]) + require.NoError(t, err) + } + + readBuf := make([]byte, 1500) + write(1, 10) + write(2, 20) + for _, want := range []struct { + sn uint16 + level byte + }{{1, 10}, {2, 20}} { + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + require.Equal(t, want.sn, ep.Packet.SequenceNumber) + require.Equal(t, []byte{want.level}, ep.Packet.GetExtension(1)) + require.Equal(t, tc.payload, ep.Packet.Payload) + require.Nil(t, ep.AbsCaptureTimeExt) + ReleaseExtPacket(ep) + } + + raw := marshal(3, 30) + sn := uint16(3) + ingest := func() { + binary.BigEndian.PutUint16(raw[2:], sn) + binary.BigEndian.PutUint32(raw[4:], uint32(sn)*960) + sn++ + _, err := buff.Write(raw) + require.NoError(t, err) + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + ReleaseExtPacket(ep) + } + allocs := testing.AllocsPerRun(1000, ingest) + if !utils.RaceEnabled { + require.LessOrEqual(t, allocs, tc.maxAllocs, "allocations per Write plus ReadExtended") + } + }) + } +} + +// a dropped padding only packet shifts later sequence numbers, ReadExtended must keep the shifted number +// in the header and in RawPacket +func TestIngestDroppedPaddingKeepsAdjustedSequenceNumber(t *testing.T) { + buff := NewBuffer(123, 100, 100) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + Codecs: []webrtc.RTPCodecParameters{opusCodec}, + }, opusCodec.RTPCodecCapability, 0)) + defer buff.Close() + + write := func(sn uint16, paddingOnly bool) { + p := rtp.Packet{ + Header: rtp.Header{ + Version: 2, + PayloadType: uint8(opusCodec.PayloadType), + SequenceNumber: sn, + Timestamp: uint32(sn) * 960, + SSRC: 123, + }, + } + if paddingOnly { + p.Header.Padding = true + p.Header.PaddingSize = 20 + } else { + p.Payload = make([]byte, 100) + } + raw, err := p.Marshal() + require.NoError(t, err) + _, err = buff.Write(raw) + require.NoError(t, err) + } + + write(40, false) + write(41, true) + write(42, false) + + readBuf := make([]byte, 1500) + for _, wantSN := range []uint16{40, 41} { + ep, err := buff.ReadExtended(readBuf) + require.NoError(t, err) + require.Equal(t, wantSN, ep.Packet.SequenceNumber) + require.Equal(t, wantSN, binary.BigEndian.Uint16(ep.RawPacket[2:])) + ReleaseExtPacket(ep) + } +} + func BenchmarkMemcpu(b *testing.B) { buf := make([]byte, 1500*1500*10) buf2 := make([]byte, 1500*1500*20) From 514e54b1e08a5afe39981f1e13c13190a6fb6677 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 19:12:45 +0530 Subject: [PATCH 094/130] Cap forward-jump loops in SelectorDecisionCache. (#4952) A large frame number jump made addEntity walk every skipped frame, twice. The ring holds only numEntries frames, so older ones get overwritten anyway. Both loops now start at most numEntries behind the new frame, like PacketHistory and FrameIntegrityChecker. Co-authored-by: Claude Opus 5.5 --- .../selectordecisioncache.go | 11 ++- .../selectordecisioncache_test.go | 84 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 pkg/sfu/videolayerselector/selectordecisioncache_test.go diff --git a/pkg/sfu/videolayerselector/selectordecisioncache.go b/pkg/sfu/videolayerselector/selectordecisioncache.go index ea60086b0..3571e00c6 100644 --- a/pkg/sfu/videolayerselector/selectordecisioncache.go +++ b/pkg/sfu/videolayerselector/selectordecisioncache.go @@ -128,7 +128,13 @@ func (s *SelectorDecisionCache) addEntity(entity uint64, sd selectorDecision) { return } - for e := s.last + 1; e != entity; e++ { + // only the last numEntries entities fit in the ring + floor := uint64(0) + if entity > s.numEntries { + floor = entity - s.numEntries + } + + for e := max(s.last+1, floor); e != entity; e++ { s.setEntity(e, selectorDecisionUnknown) } @@ -145,6 +151,9 @@ func (s *SelectorDecisionCache) addEntity(entity uint64, sd selectorDecision) { } else { missingEnd = s.base } + if missingEnd > missingStart+s.numEntries { + missingStart = missingEnd - s.numEntries + } if missingEnd > missingStart { for e := missingStart; e != missingEnd; e++ { s.setEntityIfUnknown(e, selectorDecisionMissing) diff --git a/pkg/sfu/videolayerselector/selectordecisioncache_test.go b/pkg/sfu/videolayerselector/selectordecisioncache_test.go new file mode 100644 index 000000000..ed62a6927 --- /dev/null +++ b/pkg/sfu/videolayerselector/selectordecisioncache_test.go @@ -0,0 +1,84 @@ +// 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" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSelectorDecisionCacheLargeJump(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + s.AddForwarded(10) + s.AddForwarded(11) + + var expected []selectorDecision + require.True(t, s.ExpectDecision(12, func(_ uint64, sd selectorDecision) { + expected = append(expected, sd) + })) + + jumpTo := uint64(11 + 65535) + s.AddDropped(jumpTo) + + // skipped entity that was expected is reported missing + require.Equal(t, []selectorDecision{selectorDecisionMissing}, expected) + + sd, err := s.GetDecision(jumpTo) + require.NoError(t, err) + require.Equal(t, selectorDecisionDropped, sd) + + // the missing range wraps the whole ring, so every other slot reads missing + for e := jumpTo - s.numEntries + 1; e < jumpTo; e++ { + sd, err = s.GetDecision(e) + require.NoError(t, err) + require.Equal(t, selectorDecisionMissing, sd, "entity %d", e) + } + + _, err = s.GetDecision(jumpTo - s.numEntries) + require.Error(t, err) +} + +func TestSelectorDecisionCacheSmallJump(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + s.AddForwarded(1000) + s.AddDropped(1100) + + // within nack window -> unknown, older skipped -> missing + for e := uint64(1001); e < 1100; e++ { + sd, err := s.GetDecision(e) + require.NoError(t, err) + if e >= 1100-80 { + require.Equal(t, selectorDecisionUnknown, sd, "entity %d", e) + } else { + require.Equal(t, selectorDecisionMissing, sd, "entity %d", e) + } + } +} + +func TestSelectorDecisionCacheJumpIsBounded(t *testing.T) { + s := NewSelectorDecisionCache(256, 80) + entity := uint64(1) + s.AddForwarded(entity) + + // uncapped, each jump walks ~2x the gap (seconds in total), capped it walks ~2x the ring + start := time.Now() + for range 10000 { + entity += 65535 + s.AddDropped(entity) + } + require.Less(t, time.Since(start), time.Second) +} From bc849eb47d4422e6b8387c21dd97cff1e9f2ad1e Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 6 Oct 2026 19:46:20 +0530 Subject: [PATCH 095/130] sfu/buffer: parse the dependency descriptor without allocating (#4953) * sfu/buffer: parse the dependency descriptor without allocating Every VP9 and AV1 SVC packet allocated six objects in the dependency descriptor parse: the descriptor, a clone of the frame template with its three slices, a resolution copy, and a frame entity that took the address of a parameter. The pooled ExtDependencyDescriptor now owns the storage for the descriptor, the template and the resolution, with fixed arrays for the template's slices. The reader copies the matching template into that storage. Delta frame parse: 6 allocs, 176 B, 174 ns -> 0 allocs, 0 B, 122 ns. Co-Authored-By: Claude Fable 5.1 * sfu/dd: never copy a parsed frame into a structure template A caller can hand Unmarshal a descriptor whose FrameDependencies points at one of the structure's templates. Copying the packet's custom fields into it would change the template for every later packet, so the reader allocates a fresh template in that case. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- pkg/sfu/buffer/dependencydescriptorparser.go | 38 ++++++--- .../buffer/dependencydescriptorparser_test.go | 48 +++++++++++ pkg/sfu/buffer/fps.go | 22 +++--- pkg/sfu/buffer/frameintegrity.go | 20 ++--- .../dependencydescriptorextension.go | 16 +++- .../dependencydescriptorextension_test.go | 79 +++++++++++++++++++ .../dependencydescriptorreader.go | 17 +++- 7 files changed, 201 insertions(+), 39 deletions(-) diff --git a/pkg/sfu/buffer/dependencydescriptorparser.go b/pkg/sfu/buffer/dependencydescriptorparser.go index 09a0d3b52..343eafc24 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser.go +++ b/pkg/sfu/buffer/dependencydescriptorparser.go @@ -107,6 +107,15 @@ type ExtDependencyDescriptor struct { // increase when the stream restarts, clear and reinitialize all dd state includes // attached structure, frame chain, decode target. RestartGeneration int + + // storage for Descriptor, zeroed with the rest of the struct on release, + // a frame with more than 8 frame diffs spills to the heap + descriptor dd.DependencyDescriptor + frameDependencies dd.FrameDependencyTemplate + resolution dd.RenderResolution + dtis [dd.MaxDecodeTargets]dd.DecodeTargetIndication + chainDiffs [dd.MaxDecodeTargets]int + frameDiffs [8]int } func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescriptor, VideoLayer, error) { @@ -120,9 +129,19 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr return nil, videoLayer, ErrDDExtentionNotFound } - var ddVal dd.DependencyDescriptor + extDD := ExtDependencyDescriptorFactory.Get().(*ExtDependencyDescriptor) + extDD.frameDependencies = dd.FrameDependencyTemplate{ + DecodeTargetIndications: extDD.dtis[:0], + FrameDiffs: extDD.frameDiffs[:0], + ChainDiffs: extDD.chainDiffs[:0], + } + ddVal := &extDD.descriptor + *ddVal = dd.DependencyDescriptor{ + FrameDependencies: &extDD.frameDependencies, + Resolution: &extDD.resolution, + } ext := &dd.DependencyDescriptorExtension{ - Descriptor: &ddVal, + Descriptor: ddVal, Structure: r.structure, } _, err := ext.Unmarshal(ddBuf) @@ -130,6 +149,7 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr if err != dd.ErrDDReaderNoStructure && err != dd.ErrDDReaderInvalidTemplateIndex { r.logger.Infow("failed to parse generic dependency descriptor", err, "payload", pkt.PayloadType, "ddbufLen", len(ddBuf)) } + ReleaseExtDependencyDescriptor(extDD) return nil, videoLayer, err } @@ -168,18 +188,16 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr "unwrappedFN", unwrapped, "frameWrapAround", r.frameWrapAround, ) + ReleaseExtDependencyDescriptor(extDD) return nil, videoLayer, ErrFrameEarlierThanKeyFrame } - r.frameChecker.AddPacket(extSeq, extFN, &ddVal) + r.frameChecker.AddPacket(extSeq, extFN, ddVal) - extDD := ExtDependencyDescriptorFactory.Get().(*ExtDependencyDescriptor) - *extDD = ExtDependencyDescriptor{ - Descriptor: &ddVal, - ExtFrameNum: extFN, - Integrity: r.frameChecker.FrameIntegrity(extFN), - RestartGeneration: r.restartGeneration, - } + extDD.Descriptor = ddVal + extDD.ExtFrameNum = extFN + extDD.Integrity = r.frameChecker.FrameIntegrity(extFN) + extDD.RestartGeneration = r.restartGeneration if ddVal.AttachedStructure != nil { if !ddVal.FirstPacketInFrame { diff --git a/pkg/sfu/buffer/dependencydescriptorparser_test.go b/pkg/sfu/buffer/dependencydescriptorparser_test.go index 652b0b637..7a09b013c 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser_test.go +++ b/pkg/sfu/buffer/dependencydescriptorparser_test.go @@ -186,3 +186,51 @@ func TestDependencyDescriptorParserExtKeyFrameNum(t *testing.T) { require.NoError(t, err) require.EqualValues(t, 4, extDD.ExtKeyFrameNum) } + +// a pooled ExtDependencyDescriptor carries the storage for its descriptor, so parsing a delta +// frame must not allocate and a reused descriptor must not show the previous packet's values +func TestDependencyDescriptorParserNoAlloc(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + structure.Resolutions = []dd.RenderResolution{{Width: 640, Height: 360}} + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.Equal(t, 0, len(extDD.Descriptor.FrameDependencies.FrameDiffs)) + require.Equal(t, dd.RenderResolution{Width: 640, Height: 360}, *extDD.Descriptor.Resolution) + ReleaseExtDependencyDescriptor(extDD) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.Equal(t, []int{1}, extDD.Descriptor.FrameDependencies.FrameDiffs) + require.Equal(t, []int{1}, extDD.Descriptor.FrameDependencies.ChainDiffs) + require.Nil(t, extDD.Descriptor.AttachedStructure) + ReleaseExtDependencyDescriptor(extDD) + + // the parsed template is a copy, the structure's template is untouched + require.Equal(t, []int{1}, structure.Templates[1].FrameDiffs) + + // marshal the packets first, only Parse runs under the allocation count + bufs := make([][]byte, 128) + for i := range bufs { + ddVal := &dd.DependencyDescriptor{ + FirstPacketInFrame: true, + LastPacketInFrame: true, + FrameNumber: uint16(i + 2), + FrameDependencies: structure.Templates[1], + } + bufs[i], err = (&dd.DependencyDescriptorExtension{Descriptor: ddVal, Structure: structure}).Marshal() + require.NoError(t, err) + } + pkt := &rtp.Packet{Header: rtp.Header{SequenceNumber: f.seq}} + require.NoError(t, pkt.SetExtension(ddTestExtID, bufs[0])) + i := 0 + allocs := testing.AllocsPerRun(100, func() { + pkt.SequenceNumber++ + require.NoError(t, pkt.SetExtension(ddTestExtID, bufs[i])) + i++ + extDD, _, err := f.parser.Parse(pkt) + require.NoError(t, err) + ReleaseExtDependencyDescriptor(extDD) + }) + require.Zero(t, allocs) +} diff --git a/pkg/sfu/buffer/fps.go b/pkg/sfu/buffer/fps.go index 9ac69cd8f..d41dfe375 100644 --- a/pkg/sfu/buffer/fps.go +++ b/pkg/sfu/buffer/fps.go @@ -26,13 +26,12 @@ import ( var minFramesForCalculation = [...]int{8, 15, 40, 60} type frameInfo struct { - startSeq uint16 - endSeq uint16 - ts uint32 - fn uint16 - spatial int32 - temporal int32 - frameDiff []int + startSeq uint16 + endSeq uint16 + ts uint32 + fn uint16 + spatial int32 + temporal int32 } type FrameRateCalculator interface { @@ -425,11 +424,10 @@ func (f *FrameRateCalculatorDD) RecvPacket(ep *ExtPacket) bool { } fi := &frameInfo{ - ts: ep.Packet.Timestamp, - fn: fn, - temporal: temporal, - spatial: spatial, - frameDiff: ep.DependencyDescriptor.Descriptor.FrameDependencies.FrameDiffs, + ts: ep.Packet.Timestamp, + fn: fn, + temporal: temporal, + spatial: spatial, } f.fnReceived[baseDiff] = fi diff --git a/pkg/sfu/buffer/frameintegrity.go b/pkg/sfu/buffer/frameintegrity.go index e2cbe8a43..9e7814a8b 100644 --- a/pkg/sfu/buffer/frameintegrity.go +++ b/pkg/sfu/buffer/frameintegrity.go @@ -19,9 +19,9 @@ import ( ) type FrameEntity struct { - startSeq *uint64 - endSeq *uint64 - integrity bool + startSeq, endSeq uint64 + hasStartSeq, hasEndSeq bool + integrity bool pktHistory *PacketHistory } @@ -32,15 +32,15 @@ func (fe *FrameEntity) AddPacket(extSeq uint64, ddVal *dd.DependencyDescriptor) return } - if fe.startSeq == nil && ddVal.FirstPacketInFrame { - fe.startSeq = &extSeq + if !fe.hasStartSeq && ddVal.FirstPacketInFrame { + fe.startSeq, fe.hasStartSeq = extSeq, true } - if fe.endSeq == nil && ddVal.LastPacketInFrame { - fe.endSeq = &extSeq + if !fe.hasEndSeq && ddVal.LastPacketInFrame { + fe.endSeq, fe.hasEndSeq = extSeq, true } - if fe.startSeq != nil && fe.endSeq != nil { - if fe.pktHistory.PacketsConsecutive(*fe.startSeq, *fe.endSeq) { + if fe.hasStartSeq && fe.hasEndSeq { + if fe.pktHistory.PacketsConsecutive(fe.startSeq, fe.endSeq) { fe.integrity = true } } @@ -48,7 +48,7 @@ func (fe *FrameEntity) AddPacket(extSeq uint64, ddVal *dd.DependencyDescriptor) func (fe *FrameEntity) Reset() { fe.integrity = false - fe.startSeq, fe.endSeq = nil, nil + fe.hasStartSeq, fe.hasEndSeq = false, false } func (fe *FrameEntity) Integrity() bool { diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go index 28812d09a..3a1bb4760 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension.go @@ -113,9 +113,10 @@ const ( // ------------------------------------------------------------------------------ type DependencyDescriptor struct { - FirstPacketInFrame bool - LastPacketInFrame bool - FrameNumber uint16 + FirstPacketInFrame bool + LastPacketInFrame bool + FrameNumber uint16 + // Unmarshal writes into FrameDependencies and Resolution when they are set, else it allocates them FrameDependencies *FrameDependencyTemplate Resolution *RenderResolution ActiveDecodeTargetsBitmask *uint32 @@ -201,6 +202,15 @@ func (t *FrameDependencyTemplate) Clone() *FrameDependencyTemplate { return t2 } +// CopyInto copies t into dst and reuses the slices of dst +func (t *FrameDependencyTemplate) CopyInto(dst *FrameDependencyTemplate) { + dst.SpatialId = t.SpatialId + dst.TemporalId = t.TemporalId + dst.DecodeTargetIndications = append(dst.DecodeTargetIndications[:0], t.DecodeTargetIndications...) + dst.FrameDiffs = append(dst.FrameDiffs[:0], t.FrameDiffs...) + dst.ChainDiffs = append(dst.ChainDiffs[:0], t.ChainDiffs...) +} + // ------------------------------------------------------------------------------ type FrameDependencyStructure struct { diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go index 95c3aba35..58efec953 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorextension_test.go @@ -17,6 +17,8 @@ package dependencydescriptor import ( "encoding/hex" "testing" + + "github.com/stretchr/testify/require" ) func TestDependencyDescriptorUnmarshal(t *testing.T) { @@ -63,3 +65,80 @@ func TestDependencyDescriptorUnmarshal(t *testing.T) { t.Log(ddVal.String()) } } + +// Unmarshal into a descriptor whose FrameDependencies and Resolution are set writes into that +// storage, gives the same result as a fresh descriptor, and leaves the structure's templates intact +func TestDependencyDescriptorUnmarshalIntoStorage(t *testing.T) { + hexes := []string{ + "c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d", + "86017340fc", + "46017340fc", + "c3017540fc", + "88017640fc", + "48017640fc", + "c2017840fc", + } + + var structure *FrameDependencyStructure + var fd FrameDependencyTemplate + var res RenderResolution + for _, h := range hexes { + buf, err := hex.DecodeString(h) + require.NoError(t, err) + + var fresh DependencyDescriptor + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &fresh}).Unmarshal(buf) + require.NoError(t, err) + + reused := DependencyDescriptor{FrameDependencies: &fd, Resolution: &res} + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &reused}).Unmarshal(buf) + require.NoError(t, err) + + require.Same(t, &fd, reused.FrameDependencies) + require.Equal(t, fresh.FrameDependencies, reused.FrameDependencies) + require.Equal(t, fresh.Resolution, reused.Resolution) + require.Equal(t, fresh.String(), reused.String()) + + if fresh.AttachedStructure != nil { + structure = fresh.AttachedStructure + } + } + + // the templates are copied out, not shared + for _, template := range structure.Templates { + require.NotSame(t, template, &fd) + } +} + +// a caller may hand Unmarshal a descriptor that still points at one of the structure's templates, +// custom fields of the packet must not change that template +func TestDependencyDescriptorUnmarshalDoesNotWriteIntoTemplate(t *testing.T) { + fixture := newDependencyDescriptorMarshalFixture(t) + structure := fixture.Structure + + // a per-packet descriptor using a template with frame diffs, with one custom diff + var template *FrameDependencyTemplate + for _, tmpl := range structure.Templates { + if len(tmpl.FrameDiffs) > 0 { + template = tmpl + break + } + } + require.NotNil(t, template) + want := append([]int(nil), template.FrameDiffs...) + + custom := template.Clone() + custom.FrameDiffs[0] += 2 + buf, err := (&DependencyDescriptorExtension{ + Descriptor: &DependencyDescriptor{FrameNumber: 7, FrameDependencies: custom}, + Structure: structure, + }).Marshal() + require.NoError(t, err) + + parsed := DependencyDescriptor{FrameDependencies: template} + _, err = (&DependencyDescriptorExtension{Structure: structure, Descriptor: &parsed}).Unmarshal(buf) + require.NoError(t, err) + require.Equal(t, custom.FrameDiffs, parsed.FrameDependencies.FrameDiffs) + require.NotSame(t, template, parsed.FrameDependencies) + require.Equal(t, want, template.FrameDiffs) +} diff --git a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go index b3baf6f36..0b7d8ac0a 100644 --- a/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go +++ b/pkg/sfu/rtpextension/dependencydescriptor/dependencydescriptorreader.go @@ -16,6 +16,7 @@ package dependencydescriptor import ( "errors" + "slices" ) var ( @@ -356,8 +357,14 @@ func (r *DependencyDescriptorReader) readFrameDependencyDefinition() error { return ErrDDReaderInvalidTemplateIndex } - // Copy all the fields from the matching template - r.descriptor.FrameDependencies = r.structure.Templates[templateIndex].Clone() + // Copy all the fields from the matching template into caller storage, + // never into one of the structure's templates, custom fields would change the template + dst := r.descriptor.FrameDependencies + if dst == nil || slices.Contains(r.structure.Templates, dst) { + dst = &FrameDependencyTemplate{} + r.descriptor.FrameDependencies = dst + } + r.structure.Templates[templateIndex].CopyInto(dst) if r.customDtisFlag { err := r.readFrameDtis() @@ -389,8 +396,10 @@ func (r *DependencyDescriptorReader) readFrameDependencyDefinition() error { r.buffer.Invalidate() return ErrDDReaderInvalidSpatialLayer } - res := r.structure.Resolutions[r.descriptor.FrameDependencies.SpatialId] - r.descriptor.Resolution = &res + if r.descriptor.Resolution == nil { + r.descriptor.Resolution = &RenderResolution{} + } + *r.descriptor.Resolution = r.structure.Resolutions[r.descriptor.FrameDependencies.SpatialId] } return nil From 80da74b7e9f0e65ce165a8f1c6dc35c399bafd15 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 7 Oct 2026 16:00:57 +0530 Subject: [PATCH 096/130] v1 path: check for subscriber_allow_pause presence before using it. (#4957) Addresses https://github.com/livekit/livekit/issues/4956 --- pkg/service/rtcservice.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/service/rtcservice.go b/pkg/service/rtcservice.go index b111e4108..bff1151d7 100644 --- a/pkg/service/rtcservice.go +++ b/pkg/service/rtcservice.go @@ -280,8 +280,10 @@ func (s *RTCService) validateInternal( pi.AdaptiveStream = joinRequest.GetConnectionSettings().GetAdaptiveStream() pi.DisableICELite = joinRequest.GetConnectionSettings().GetDisableIceLite() - subscriberAllowPause := joinRequest.GetConnectionSettings().GetSubscriberAllowPause() - pi.SubscriberAllowPause = &subscriberAllowPause + if cs := joinRequest.GetConnectionSettings(); cs != nil && cs.SubscriberAllowPause != nil { + v := cs.GetSubscriberAllowPause() + pi.SubscriberAllowPause = &v + } pi.AddTrackRequests = joinRequest.AddTrackRequests pi.PublisherOffer = joinRequest.PublisherOffer From 8fd68a5ee6a47fd3b40b23b572cef0fa672a5f99 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 7 Oct 2026 16:57:49 +0530 Subject: [PATCH 097/130] bump protocol and psrpc deps (#4958) --- go.mod | 19 ++++++++++--------- go.sum | 36 ++++++++++++++++++------------------ 2 files changed, 28 insertions(+), 27 deletions(-) diff --git a/go.mod b/go.mod index 915aad1e1..ed5de0d58 100644 --- a/go.mod +++ b/go.mod @@ -21,23 +21,23 @@ require ( github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913 - github.com/livekit/psrpc v0.8.1 + github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a + github.com/livekit/psrpc v0.8.2 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 github.com/mitchellh/go-homedir v1.1.0 github.com/moby/moby/client v0.6.0 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.5 + github.com/pion/datachannel v1.6.3 + github.com/pion/dtls/v3 v3.1.9 github.com/pion/ice/v4 v4.4.0 - github.com/pion/interceptor v0.1.47 - github.com/pion/rtcp v1.2.17 + github.com/pion/interceptor v0.1.49 + github.com/pion/rtcp v1.2.19 github.com/pion/rtp v1.10.5 - github.com/pion/sctp v1.11.1 + github.com/pion/sctp v1.12.0 github.com/pion/sdp/v3 v3.0.20 - github.com/pion/transport/v4 v4.1.0 + github.com/pion/transport/v4 v4.1.1 github.com/pion/turn/v5 v5.0.13 github.com/pion/webrtc/v4 v4.2.18 github.com/pires/go-proxyproto v0.15.0 @@ -88,6 +88,7 @@ require ( github.com/olekukonko/errors v1.3.0 // indirect github.com/olekukonko/ll v0.1.8 // indirect github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b // indirect + github.com/pion/transport/v5 v5.1.1 // indirect github.com/puzpuzpuz/xsync/v4 v4.5.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -137,7 +138,7 @@ require ( github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pion/logging v0.2.4 - github.com/pion/mdns/v2 v2.2.0 // indirect + github.com/pion/mdns/v2 v2.2.2 // indirect github.com/pion/randutil v0.1.0 // indirect github.com/pion/srtp/v3 v3.0.13 // indirect github.com/pion/stun/v3 v3.1.7 diff --git a/go.sum b/go.sum index 16ddea2ec..3d91b4691 100644 --- a/go.sum +++ b/go.sum @@ -166,10 +166,10 @@ 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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913 h1:LopYipWFeZbKrjRFSYgX7GBJx6FBQ2mvtcoZU4+ynJM= -github.com/livekit/protocol v1.52.2-0.20261006063934-0761f135e913/go.mod h1:EJRuXu4hCsje+SiDnPGJ9U2wpFbwTl6bzC4ZxPSdRec= -github.com/livekit/psrpc v0.8.1 h1:aeEnC2WRWB2tzNpmrifog8B+DD9+0K2Fk8xT/0kMuxg= -github.com/livekit/psrpc v0.8.1/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= +github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a h1:Sa9+0Kr+MVIabmJaBFtX8Yl3NYewkv02/mMDvLEKYiQ= +github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= +github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 h1:UaGYlQ/Ma73LN12xPDlH3OBYNfOEDTVOtHdRZbHg2dY= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6/go.mod h1:Kxgo6xwFzVK38XVlQTBT1zj2JU5G7R0R0pa5w5d9d/Q= github.com/mackerelio/go-osstat v0.2.8 h1:I2duicTaCGWoM53XwAwA9OIe1inu0xnVs8/pqOWWVr4= @@ -239,32 +239,32 @@ 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/petermattis/goid v0.0.0-20260918085751-abfca077860b h1:OzNsuVdSWGwXvWKTtChx9ve89k4cFJ6NxYM4Aw4/f+E= github.com/petermattis/goid v0.0.0-20260918085751-abfca077860b/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= -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/interceptor v0.1.47 h1:yw8t5pJ2f8t78NgU+8EmxhaqYLXS7uFCC/tAGOaSDBo= -github.com/pion/interceptor v0.1.47/go.mod h1:7yoRBzaIDETPC6cIN8Zj9EyGqHv1ImOpcTFPha6MuOM= +github.com/pion/datachannel v1.6.3 h1:HZAYLpunVI0xi057ZiANl558d0uSpomcWw4UEPz3qXs= +github.com/pion/datachannel v1.6.3/go.mod h1:oRmkXVVXxkUbqa8MatgdNHW29kvtQY3HUdjJQsfv2GE= +github.com/pion/interceptor v0.1.49 h1:iyBsNHRoLNNXZiJA/DdGvJgdyfS8YWDCzxIAlVjt5nY= +github.com/pion/interceptor v0.1.49/go.mod h1:MZ6PJkja/TCo350HAnBrs/rUIyad9mWjpcvytrf3ViQ= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/mdns/v2 v2.2.0 h1:AlAZ9MTUKtWgO+4itk35JdNak4sk5k7G/X4xnIBWHyA= -github.com/pion/mdns/v2 v2.2.0/go.mod h1:IJddx58QMlojqhQYjHcOUmvuBQ5MnLNetkb80VMvk2Y= +github.com/pion/mdns/v2 v2.2.2 h1:qq9Kj3PnjF0mfSwd8Icu0CXy5aoYdQKnNUIsGPeUpgk= +github.com/pion/mdns/v2 v2.2.2/go.mod h1:ZX5f0AAH1D6TOjdjvcBORZZHaZuG0t9+br78lHEwiJ4= 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.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw= -github.com/pion/rtcp v1.2.17/go.mod h1:7kBpuBJaWwax4hzc/pgexY8vkOpvh8atgYDbaKZq0iU= +github.com/pion/rtcp v1.2.19 h1:T79lR2Tfd/7zj+41nsUrftrUEee+Z03NQr9GSPSQ/WU= +github.com/pion/rtcp v1.2.19/go.mod h1:vX7Es6skWYm4oVvOSC5+4WHLYUTZHcbGNmk5wjzMGH4= github.com/pion/rtp v1.10.5 h1:ip0HhO/wYZqQ4bKS+R99KnZh/GRCmIT0jDXikub7vlE= github.com/pion/rtp v1.10.5/go.mod h1:Au8fc6cEByy8RLTwKTQTEeQqDB/SJDxwL4mZuxYA5Pk= -github.com/pion/sctp v1.11.1 h1:O4dIFyURw1KTST7w+gtD4gLeYXkhPa0xXLHMMoe/OSA= -github.com/pion/sctp v1.11.1/go.mod h1:7KFmTwLcoYgJs/Z+99nJvsWL0qDpuyloSI0RbAqlrz0= +github.com/pion/sctp v1.12.0 h1:AzUdVkVBNyGY1W4crWO1/aOWzMaW3zsVHGrNuAfFQiI= +github.com/pion/sctp v1.12.0/go.mod h1:6k1zrpkrxSwbisitn/AlzDQaa15DR49MFsrMochG/yc= github.com/pion/sdp/v3 v3.0.20 h1:TS6DViqcmp+49f0+mjw9anbr9xY3vJtsZewxAvlMCRQ= github.com/pion/sdp/v3 v3.0.20/go.mod h1:slIMXDK5OKj0nhISwjfeN18AzTBCt2LYZq9uPw0cU5Q= github.com/pion/srtp/v3 v3.0.13 h1:FmQaqgNbN1vUtMhEsmj8trldc3lNZr1xmN7nl8CyX+Q= github.com/pion/srtp/v3 v3.0.13/go.mod h1:7qR3L69t8RX0EPVQwGNwCa1Gy9keKKNDpWwQzZbeXDY= github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= -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.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= -github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/transport/v4 v4.1.1 h1:kEBtbp1sRad0tnkREDNJ3fmop/CR8p/YxXnpFbn9tqo= +github.com/pion/transport/v4 v4.1.1/go.mod h1:Oxo5oODQvjFF7NkHBBz3f4fZXLB7x03uLVuzfYeA5pE= +github.com/pion/transport/v5 v5.1.1 h1:Sauq8YJqRopgRfIgnpfKpdpKG14g0N4jWlRgpWsEfN8= +github.com/pion/transport/v5 v5.1.1/go.mod h1:Qxw6fCEjFWQkRDZOhS4Vf+neJBcihauvA3uyEa1J1F0= github.com/pion/turn/v5 v5.0.13 h1:erHOsJyxuV6QK54+PjWJhe8u1O7BM3a/US0zYJJsnx4= github.com/pion/turn/v5 v5.0.13/go.mod h1:btdOovUYdYc8iBnvt87JHN4Pa1XV5UiLaCYe4ay3o9A= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= From 6fbe7f7e648aab4b2f734d6044a416bc2cbc482c Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 7 Oct 2026 21:59:44 +0530 Subject: [PATCH 098/130] Release v1.13.9. (#4960) --- CHANGELOG.md | 13 +++++++++++++ version/version.go | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 47bd4bc0d..f8e147510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.9] - 2026-10-07 + +### Changed + +- bump protocol and psrpc deps (#4958) +- sfu/buffer: parse the dependency descriptor without allocating (#4953) +- sfu/buffer: stop allocating per ingested packet (#4951) + +### Fixed + +- v1 path: check for subscriber_allow_pause presence before using it (#4957) +- Cap forward-jump loops in SelectorDecisionCache (#4952) + ## [1.13.8] - 2026-10-06 ### Added diff --git a/version/version.go b/version/version.go index 04c27db63..27cf088c7 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.8" +const Version = "1.13.9" From 5a5a6132c7a4a31acf8e3d9169005d1a597272af Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 7 Oct 2026 16:13:44 -0700 Subject: [PATCH 099/130] Disable AV1 for Firefox clients before 157 (#4962) Firefox 155 enabled AV1 for WebRTC by default, but Firefox 155/156 decode only the base spatial layer of an AV1 SVC stream. SVC is not visible in SDP, so such a subscriber that negotiates AV1 is forwarded the publisher's AV1 SVC stream instead of its backup codec, rendering black whenever a higher layer is selected. Firefox 157 decodes the higher layers correctly (client-sdk-js#2116), so it keeps AV1. Removing AV1 from the subscriber's enabled codecs keeps it out of the SFU's offers and answers, so the down track binds to the backup codec. Firefox never publishes AV1 either, so it is disabled in both directions, the same as Safari. The Firefox for Android H.264 rule is switched to merge so it no longer drops the AV1 rule for that browser. Co-authored-by: Claude Opus 5.5 (1M context) --- pkg/clientconfiguration/conf.go | 19 +++++++++++++++- pkg/clientconfiguration/conf_test.go | 34 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/pkg/clientconfiguration/conf.go b/pkg/clientconfiguration/conf.go index a23537159..9614a0602 100644 --- a/pkg/clientconfiguration/conf.go +++ b/pkg/clientconfiguration/conf.go @@ -49,6 +49,22 @@ var StaticConfigurations = []ConfigurationItem{ }, Merge: true, }, + // Firefox 155 enabled AV1 for WebRTC by default, but 155/156 decode only the base spatial + // layer of an AV1 SVC stream. SVC is not visible in SDP, so such a subscriber negotiating AV1 + // would be forwarded the SVC stream in place of the publisher's backup codec and render + // higher layers black. Firefox 157 decodes the higher layers, so it keeps AV1. + // Firefox does not publish AV1 either (no SVC encoding), so disable it in both directions. + { + Match: must.Get(NewScriptMatch(`(c.browser == "firefox" || c.browser == "firefox mobile") && c.browser_version < "157"`)), + Configuration: &livekit.ClientConfiguration{ + DisabledCodecs: &livekit.DisabledCodecs{ + Codecs: []*livekit.Codec{ + {Mime: mime.MimeTypeAV1.String()}, + }, + }, + }, + Merge: true, + }, { Match: must.Get(NewScriptMatch(`(c.device_model == "xiaomi 2201117ti" && c.os == "android") || ((c.browser == "firefox" || c.browser == "firefox mobile") && c.os == "android")`)), @@ -59,6 +75,7 @@ var StaticConfigurations = []ConfigurationItem{ }, }, }, - Merge: false, + // merged so Firefox for Android keeps the AV1 rule above + Merge: true, }, } diff --git a/pkg/clientconfiguration/conf_test.go b/pkg/clientconfiguration/conf_test.go index 2c0294ad0..415abbbf3 100644 --- a/pkg/clientconfiguration/conf_test.go +++ b/pkg/clientconfiguration/conf_test.go @@ -46,6 +46,40 @@ func TestStaticConfigurationH264(t *testing.T) { require.True(t, h264Disabled(&livekit.ClientInfo{Browser: "firefox mobile", Os: "android"})) } +func TestStaticConfigurationAV1(t *testing.T) { + cm := NewStaticClientConfigurationManager(StaticConfigurations) + + av1Disabled := func(info *livekit.ClientInfo) bool { + conf := cm.GetConfiguration(info) + if conf == nil || conf.DisabledCodecs == nil { + return false + } + for _, c := range conf.DisabledCodecs.Codecs { + if mime.IsMimeTypeStringEqual(c.Mime, mime.MimeTypeAV1.String()) { + return true + } + } + return false + } + + // Firefox 155/156 decode only the base spatial layer of AV1 SVC + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "Firefox", BrowserVersion: "155.0", Os: "macOS"})) + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "156.0.1", Os: "windows"})) + // an unknown version is treated as affected + require.True(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", Os: "linux"})) + // Firefox 157 decodes every layer, so it keeps AV1 + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "157.0", Os: "windows"})) + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "firefox", BrowserVersion: "158.0.2", Os: "windows"})) + require.False(t, av1Disabled(&livekit.ClientInfo{Browser: "chrome", Os: "linux"})) + + // Firefox for Android keeps the AV1 rule alongside its H.264 publish rule + android := &livekit.ClientInfo{Browser: "firefox mobile", BrowserVersion: "156.0", Os: "android"} + require.True(t, av1Disabled(android)) + conf := cm.GetConfiguration(android) + require.Len(t, conf.DisabledCodecs.Publish, 1) + require.True(t, mime.IsMimeTypeStringH264(conf.DisabledCodecs.Publish[0].Mime)) +} + func TestScriptMatchConfiguration(t *testing.T) { t.Run("no merge", func(t *testing.T) { confs := []ConfigurationItem{ From 5a897c239cde995e615047be3fd5b27ca1afdad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sak=C4=B1p=20Han=20Dursun?= <100518315+sakiphan@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:07:04 +0300 Subject: [PATCH 100/130] fix(cli): return non-zero exit status on command errors (#4888) --- cmd/server/main.go | 1 + cmd/server/main_test.go | 69 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+) diff --git a/cmd/server/main.go b/cmd/server/main.go index dde5b0adc..b86503065 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -189,6 +189,7 @@ func main() { if err := cmd.Run(context.Background(), os.Args); err != nil { fmt.Println(err) + os.Exit(1) } } diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dc82bf086..b1c3669dd 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -16,11 +16,80 @@ package main import ( "os" + "os/exec" + "path/filepath" "testing" "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/version" ) +func TestMain(m *testing.M) { + if os.Getenv("LIVEKIT_TEST_RUN_MAIN") == "1" { + main() + os.Exit(0) + } + os.Exit(m.Run()) +} + +func TestMainExitCode(t *testing.T) { + testBinary, err := os.Executable() + require.NoError(t, err) + missingConfig := filepath.Join(t.TempDir(), "missing.yaml") + + for _, tt := range []struct { + name string + args []string + exitCode int + output string + }{ + { + name: "missing config", + args: []string{"--config", missingConfig, "ports"}, + exitCode: 1, + output: missingConfig, + }, + { + name: "unknown flag", + args: []string{"--unknown-flag"}, + exitCode: 1, + output: "flag provided but not defined", + }, + { + name: "invalid flag value", + args: []string{"--dev=invalid"}, + exitCode: 1, + output: "invalid value", + }, + { + name: "help", + args: []string{"--help"}, + output: "High performance WebRTC server", + }, + { + name: "version", + args: []string{"--version"}, + output: version.Version, + }, + } { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.CommandContext(t.Context(), testBinary, tt.args...) + // Run the real entry point without inheriting server configuration. + cmd.Env = []string{"LIVEKIT_TEST_RUN_MAIN=1"} + output, err := cmd.CombinedOutput() + if tt.exitCode == 0 { + require.NoError(t, err, string(output)) + } else { + var exitErr *exec.ExitError + require.ErrorAs(t, err, &exitErr, string(output)) + require.Equal(t, tt.exitCode, exitErr.ExitCode(), string(output)) + } + require.Contains(t, string(output), tt.output) + }) + } +} + type testStruct struct { configFileName string configBody string From 934250d6f9877a44a60245277c7e8ead2ee65320 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:39:00 -0700 Subject: [PATCH 101/130] chore(deps): update golang docker tag to v1.27.0 (#4847) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- cmd/test-server/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1629557ee..af4b18a75 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # This image is also the single source of truth for the Go toolchain: CI reads the # version out of this line (see .github/scripts/go-version.sh) so tests and images # always run the same runtime. -FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder +FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder ARG TARGETPLATFORM ARG TARGETARCH diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile index f0ff258cc..ab159245a 100644 --- a/cmd/test-server/Dockerfile +++ b/cmd/test-server/Dockerfile @@ -17,7 +17,7 @@ # # Pinned by digest so the build is reproducible even if the tag is republished. # The tag is kept alongside it for readability; Renovate updates both together. -FROM golang:1.26.7-alpine3.24@sha256:28d89ee9cc0ff9fec75c82ca201e6bf7fdf9a679d4b7b24dfa04f2bb766bb468 AS builder +FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder ARG TARGETARCH From b90b0ca1dfefb94c4746900e3ec55829780e064c Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 00:47:42 +0530 Subject: [PATCH 102/130] sfu: switch VP8 temporal layers up with the dependency descriptor (#4974) libwebrtc strips the VP8 payload descriptor when it sends the dependency descriptor, so the layer sync bit that #4947 waits for is never set and a subscriber that lowers its frame rate stays at the base layer until a key frame. A new temporal layer selector reads only the dependency descriptor: it switches up at a switch point whose referenced frames were all forwarded. The forwarder uses it for VP8 when the extension is negotiated and falls back to the Y-bit selector when the descriptor is not sent. Fixes #4973. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/forwarder.go | 18 +- pkg/sfu/forwarder_test.go | 58 ++++++ pkg/sfu/testutils/data.go | 60 ++++++ .../dependencydescriptor.go | 125 ++++++++++++ .../dependencydescriptor_test.go | 184 ++++++++++++++++++ 5 files changed, 443 insertions(+), 2 deletions(-) create mode 100644 pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go create mode 100644 pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index fbdc28092..eaaf367cb 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -406,7 +406,12 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ } else { f.vls = videolayerselector.NewSimulcast(f.logger) } - f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { + f.vls.SetTemporalLayerSelector(temporallayerselector.NewDependencyDescriptor(f.logger)) + } else { + f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) + } case mime.MimeTypeH264, mime.MimeTypeH265: f.codecMunger = codecmunger.NewNull(f.logger) @@ -2160,6 +2165,15 @@ func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool { // should be called with lock held func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer int32) (TranslationParams, error) { tp := TranslationParams{} + if f.isDDAvailable && f.mime == mime.MimeTypeVP8 && extPkt.DependencyDescriptor == nil && len(extPkt.Packet.Payload) != 0 { + // The buffer stopped parsing the dependency descriptor because the publisher does + // not send it, so switch temporal layers with the VP8 payload descriptor. Padding + // never carries the descriptor, so it does not count. + f.logger.Infow("turning off dependency descriptor for VP8 temporal layers", "layer", layer) + f.isDDAvailable = false + f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) + } + if !f.vls.GetTarget().IsValid() { // stream is paused by streamallocator tp.shouldDrop = true @@ -2168,7 +2182,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in result := f.vls.Select(extPkt, layer) if !result.IsSelected { - if f.isDDAvailable && extPkt.DependencyDescriptor == nil { + if f.isDDAvailable && f.mime != mime.MimeTypeVP8 && extPkt.DependencyDescriptor == nil { f.logger.Infow( "turning off dependency descriptor", "layer", layer, diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 146662802..7d4cf4d83 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -1975,6 +1975,64 @@ func TestForwarderVP8TemporalUpSwitchAtLayerSync(t *testing.T) { require.Equal(t, int32(2), f.vls.GetCurrent().Temporal) } +func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + + // libwebrtc strips the VP8 payload descriptor when it sends the dependency + // descriptor: no TID and no layer sync bit + packet := func(frameNum uint64, tid int, dtis string, frameDiffs []int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: dtis, + FrameDiffs: frameDiffs, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + require.Equal(t, int32(0), f.vls.SelectTemporal(packet(0, 0, "SSS", nil, true))) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + require.Equal(t, int32(0), f.vls.SelectTemporal(packet(1, 2, "--D", []int{1}, false))) + require.Equal(t, int32(0), f.vls.GetCurrent().Temporal) + require.Equal(t, int32(2), f.vls.SelectTemporal(packet(2, 1, "-SS", []int{2}, false))) + require.Equal(t, int32(2), f.vls.GetCurrent().Temporal) + + // padding never carries the descriptor and leaves it on + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + padding, err := testutils.GetTestExtPacket(&testutils.TestExtPacketParams{SequenceNumber: 3, PaddingSize: 20}) + require.NoError(t, err) + _, _ = f.getTranslationParamsVideo(padding, 0) + require.True(t, f.isDDAvailable) + + // a packet with a payload and without the descriptor means the buffer stopped + // parsing it, the Y bit then raises the layer + extPkt, err := testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 4, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 4, T: true, TID: 0}, + ) + require.NoError(t, err) + _, _ = f.getTranslationParamsVideo(extPkt, 0) + require.False(t, f.isDDAvailable) + + extPkt, err = testutils.GetTestExtPacketVP8( + &testutils.TestExtPacketParams{SequenceNumber: 5, PayloadSize: 20}, + &codec.VP8{S: true, I: true, PictureID: 5, T: true, TID: 1, Y: true}, + ) + require.NoError(t, err) + require.Equal(t, int32(1), f.vls.SelectTemporal(extPkt)) + require.Equal(t, int32(1), f.vls.GetCurrent().Temporal) +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index 9b8a1386e..f4a89e49d 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -21,6 +21,7 @@ import ( "github.com/pion/webrtc/v4" "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/mediatransportutil/pkg/codec" ) @@ -96,6 +97,65 @@ func GetTestExtPacketVP8(params *TestExtPacketParams, vp8 *codec.VP8) (*buffer.E // -------------------------------------- +type TestDDParams struct { + FirstPacketInFrame bool + ExtFrameNum uint64 + TemporalID int + // decode target indications as one character per decode target: S, R, D or - + DTIs string + FrameDiffs []int + DecodeTargets []buffer.DependencyDescriptorDecodeTarget +} + +// GetTestExtPacketVP8DD returns a VP8 packet with a dependency descriptor and the +// minimized VP8 payload descriptor that libwebrtc sends with it: no TID, no Y bit and +// no picture ID. +func GetTestExtPacketVP8DD(params *TestExtPacketParams, ddParams *TestDDParams) (*buffer.ExtPacket, error) { + ep, err := GetTestExtPacketVP8(params, &codec.VP8{S: ddParams.FirstPacketInFrame, IsKeyFrame: params.IsKeyFrame}) + if err != nil { + return nil, err + } + + dtis := make([]dd.DecodeTargetIndication, 0, len(ddParams.DTIs)) + for _, c := range ddParams.DTIs { + switch c { + case 'S': + dtis = append(dtis, dd.DecodeTargetSwitch) + case 'R': + dtis = append(dtis, dd.DecodeTargetRequired) + case 'D': + dtis = append(dtis, dd.DecodeTargetDiscardable) + default: + dtis = append(dtis, dd.DecodeTargetNotPresent) + } + } + ep.Temporal = int32(ddParams.TemporalID) + ep.DependencyDescriptor = &buffer.ExtDependencyDescriptor{ + Descriptor: &dd.DependencyDescriptor{ + FirstPacketInFrame: ddParams.FirstPacketInFrame, + LastPacketInFrame: params.Marker, + FrameDependencies: &dd.FrameDependencyTemplate{ + TemporalId: ddParams.TemporalID, + DecodeTargetIndications: dtis, + FrameDiffs: ddParams.FrameDiffs, + }, + }, + DecodeTargets: ddParams.DecodeTargets, + ExtFrameNum: ddParams.ExtFrameNum, + } + return ep, nil +} + +// TestVP8DecodeTargets are the decode targets of libwebrtc's three layer VP8 structure, +// sorted from high to low. +var TestVP8DecodeTargets = []buffer.DependencyDescriptorDecodeTarget{ + {Target: 2, Layer: buffer.VideoLayer{Temporal: 2}}, + {Target: 1, Layer: buffer.VideoLayer{Temporal: 1}}, + {Target: 0, Layer: buffer.VideoLayer{Temporal: 0}}, +} + +// -------------------------------------- + var TestVP8Codec = webrtc.RTPCodecCapability{ MimeType: "video/vp8", ClockRate: 90000, diff --git a/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go new file mode 100644 index 000000000..1c0641669 --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor.go @@ -0,0 +1,125 @@ +// 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 temporallayerselector + +import ( + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/logger" +) + +// frames tracked behind the highest frame number, more than temporal references reach +const ddFrameWindow = 64 + +// DependencyDescriptor switches temporal layers with the dependency descriptor alone, for +// a stream with one spatial layer. It only picks the layer, the codec munger drops the +// packets above it. libwebrtc strips the VP8 payload descriptor when it sends the +// dependency descriptor, so for VP8 the temporal ID and the layer sync bit are only here. +type DependencyDescriptor struct { + logger logger.Logger + + initialized bool + highestFrame uint64 + forwardedMask uint64 // bit i: frame highestFrame - i was forwarded +} + +func NewDependencyDescriptor(logger logger.Logger) *DependencyDescriptor { + return &DependencyDescriptor{ + logger: logger, + } +} + +func (d *DependencyDescriptor) Select(extPkt *buffer.ExtPacket, current int32, target int32) (this int32, next int32) { + this = current + next = current + + ddwdt := extPkt.DependencyDescriptor + if ddwdt == nil || ddwdt.Descriptor == nil || ddwdt.Descriptor.FrameDependencies == nil { + return + } + + // A late first packet, a retransmission for example, starts a frame whose other + // packets were already decided. A key frame is the exception: it references nothing, + // and it restarts the frame numbers on a stream restart. + isFrameStart := ddwdt.Descriptor.FirstPacketInFrame && !extPkt.IsOutOfOrder + switch { + case current < target && extPkt.IsKeyFrame: + this = target + next = target + + case current < target && isFrameStart: + // A switch point of a decode target makes all later frames of that target + // decodable if this frame is decodable, so also every frame this one references + // has to have been forwarded. + if !d.areForwarded(ddwdt.ExtFrameNum, ddwdt.Descriptor.FrameDependencies.FrameDiffs) { + break + } + dtis := ddwdt.Descriptor.FrameDependencies.DecodeTargetIndications + for _, dt := range ddwdt.DecodeTargets { // sorted from high to low + if dt.Layer.Temporal <= current || dt.Layer.Temporal > target || dt.Target >= len(dtis) { + continue + } + if dtis[dt.Target] == dd.DecodeTargetSwitch { + this = dt.Layer.Temporal + next = dt.Layer.Temporal + break + } + } + + case current > target && extPkt.Packet.Marker: + next = target + } + + if extPkt.IsKeyFrame || isFrameStart { + d.onFrame(ddwdt.ExtFrameNum, extPkt.IsKeyFrame, extPkt.Temporal <= this) + } + return +} + +func (d *DependencyDescriptor) onFrame(frame uint64, isKeyFrame bool, isForwarded bool) { + switch { + case isKeyFrame || !d.initialized || frame >= d.highestFrame+ddFrameWindow: + d.initialized = true + d.highestFrame = frame + d.forwardedMask = 0 + + case frame > d.highestFrame: + d.forwardedMask <<= frame - d.highestFrame + d.highestFrame = frame + + case d.highestFrame-frame >= ddFrameWindow: + return + } + + if isForwarded { + d.forwardedMask |= 1 << (d.highestFrame - frame) + } +} + +func (d *DependencyDescriptor) areForwarded(frame uint64, frameDiffs []int) bool { + if !d.initialized { + return false + } + for _, diff := range frameDiffs { + if diff <= 0 || uint64(diff) > frame { + return false + } + ref := frame - uint64(diff) + if ref > d.highestFrame || d.highestFrame-ref >= ddFrameWindow || d.forwardedMask&(1<<(d.highestFrame-ref)) == 0 { + return false + } + } + return true +} diff --git a/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go new file mode 100644 index 000000000..319ecc9e6 --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/dependencydescriptor_test.go @@ -0,0 +1,184 @@ +// 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 temporallayerselector + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/protocol/logger" +) + +type vp8DDFrame struct { + tid int + dtis string + frameDiffs []int + keyFrame bool +} + +// libwebrtc's three layer VP8 pattern: T0 refreshes "last", T1 refreshes "golden", and +// frame diffs point at the frames in those buffers +var vp8DDPattern = []vp8DDFrame{ + {tid: 0, dtis: "SSS", frameDiffs: []int{4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1}}, + {tid: 1, dtis: "-SS", frameDiffs: []int{2}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, + {tid: 0, dtis: "SRR", frameDiffs: []int{4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, + {tid: 1, dtis: "-DS", frameDiffs: []int{2, 4}}, + {tid: 2, dtis: "--D", frameDiffs: []int{1, 3}}, +} + +func vp8DDPacket(t *testing.T, frameNum uint64, f vp8DDFrame, first bool, marker bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20, Marker: marker, IsKeyFrame: f.keyFrame}, + &testutils.TestDDParams{ + FirstPacketInFrame: first, + ExtFrameNum: frameNum, + TemporalID: f.tid, + DTIs: f.dtis, + FrameDiffs: f.frameDiffs, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt +} + +// runDependencyDescriptor sends one packet per frame: a key frame, the pattern at the start target until +// frame switchAt, then at target 2. It returns the frame number of the up-switch. +func runDependencyDescriptor(t *testing.T, startTarget int32, switchAt uint64) (uint64, bool) { + v := NewDependencyDescriptor(logger.GetLogger()) + current := int32(0) + for frameNum := uint64(0); frameNum < 40; frameNum++ { + f := vp8DDPattern[frameNum%uint64(len(vp8DDPattern))] + if frameNum == 0 { + f = vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true} + } + target := startTarget + if frameNum >= switchAt { + target = 2 + } + this, next := v.Select(vp8DDPacket(t, frameNum, f, true, true), current, target) + if frameNum >= switchAt && next == 2 { + require.Equal(t, int32(2), this) + return frameNum, true + } + current = next + } + return 0, false +} + +func TestDependencyDescriptorSelect(t *testing.T) { + t.Run("from TL0, up-switch at the next T0 switch point", func(t *testing.T) { + // frame 14 is the "-DS" T1 frame, it references T1 frame 10 that was dropped + frameNum, ok := runDependencyDescriptor(t, 0, 12) + require.True(t, ok) + require.Equal(t, uint64(16), frameNum) + + // frame 10 is the "-SS" T1 frame, it references only T0 + frameNum, ok = runDependencyDescriptor(t, 0, 10) + require.True(t, ok) + require.Equal(t, uint64(10), frameNum) + }) + + t.Run("from TL1, the -DS frame is a switch point", func(t *testing.T) { + frameNum, ok := runDependencyDescriptor(t, 1, 14) + require.True(t, ok) + require.Equal(t, uint64(14), frameNum) + }) + + t.Run("switch point that references a dropped frame", func(t *testing.T) { + // libwebrtc L1T4: a T2 frame that is a switch point for TL2 but references the + // previous T2 frame + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 1, 1) + _, current = v.Select(vp8DDPacket(t, 1, vp8DDFrame{tid: 2, dtis: "--D", frameDiffs: []int{1}}, true, true), current, 1) + this, next := v.Select(vp8DDPacket(t, 2, vp8DDFrame{tid: 2, dtis: "--S", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(1), this) + require.Equal(t, int32(1), next) + }) + + t.Run("reference lost upstream", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + // frame 1 never arrives + this, next := v.Select(vp8DDPacket(t, 2, vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(0), this) + require.Equal(t, int32(0), next) + }) + + t.Run("up-switch only on the first packet of a frame, in order", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + f := vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}} + + _, next := v.Select(vp8DDPacket(t, 1, f, false, true), current, 2) + require.Equal(t, int32(0), next, "second packet") + + late := vp8DDPacket(t, 1, f, true, true) + late.IsOutOfOrder = true + _, next = v.Select(late, current, 2) + require.Equal(t, int32(0), next, "late first packet") + + this, next := v.Select(vp8DDPacket(t, 1, f, true, true), current, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("late key frame packet of a stream restart", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + _, current := v.Select(vp8DDPacket(t, 100, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true), 0, 0) + + // the restart moves the frame numbers back, and its key frame arrives out of order + keyFrame := vp8DDPacket(t, 5, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true) + keyFrame.IsOutOfOrder = true + _, current = v.Select(keyFrame, current, 0) + + this, next := v.Select(vp8DDPacket(t, 6, vp8DDFrame{tid: 1, dtis: "-SS", frameDiffs: []int{1}}, true, true), current, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("key frame", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + this, next := v.Select(vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, false), 0, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + }) + + t.Run("no dependency descriptor", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + extPkt := vp8DDPacket(t, 0, vp8DDFrame{tid: 0, dtis: "SSS", keyFrame: true}, true, true) + extPkt.DependencyDescriptor = nil + this, next := v.Select(extPkt, 0, 2) + require.Equal(t, int32(0), this) + require.Equal(t, int32(0), next) + }) + + t.Run("down-switch at the end of a frame", func(t *testing.T) { + v := NewDependencyDescriptor(logger.GetLogger()) + f := vp8DDFrame{tid: 2, dtis: "--D", frameDiffs: []int{1}} + this, next := v.Select(vp8DDPacket(t, 1, f, true, false), 2, 0) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + this, next = v.Select(vp8DDPacket(t, 1, f, false, true), 2, 0) + require.Equal(t, int32(2), this) + require.Equal(t, int32(0), next) + }) +} From 6656cf194f8613507588f05507518b9409cb5d3e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:18:06 -0700 Subject: [PATCH 103/130] Update module github.com/maxbrunsfeld/counterfeiter/v6 to v6.13.0 (#4915) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index ed5de0d58..d6ddedc3a 100644 --- a/go.mod +++ b/go.mod @@ -81,7 +81,7 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 // indirect + github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0 // indirect github.com/moby/moby/api v1.56.0 // indirect github.com/nyaruka/phonenumbers v1.8.1 // indirect github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect diff --git a/go.sum b/go.sum index 3d91b4691..895a278b7 100644 --- a/go.sum +++ b/go.sum @@ -182,8 +182,8 @@ github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= -github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 h1:V23nK2R2B63g2GhygF9zVGpnigmhvoZoH8d0hrZwMGY= -github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2/go.mod h1:Mr897yU9FmyKaQDPtRlVKibrjz40XXyOHUfyZBPSyZU= +github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0 h1:ybB78iZ/Hkh76F6t+MbN+DP9WFNkLX+tB5JWOctzPyY= +github.com/maxbrunsfeld/counterfeiter/v6 v6.13.0/go.mod h1:pLuSACD770jzP/sacZMoGDxz34d5uiUpo+xdj/pYF/Y= github.com/mdlayher/ethtool v0.0.0-20210210192532-2b88debcdd43/go.mod h1:+t7E0lkKfbBsebllff1xdTmyJt8lH37niI6kwFk9OTo= github.com/mdlayher/genetlink v1.0.0/go.mod h1:0rJ0h4itni50A86M2kHcgS85ttZazNt7a8H2a2cw0Gc= github.com/mdlayher/netlink v0.0.0-20190409211403-11939a169225/go.mod h1:eQB3mZE4aiYnlUsyGGCOpPETfdQq4Jhsgf1fk3cwQaA= @@ -229,8 +229,8 @@ github.com/olekukonko/ll v0.1.8 h1:ysHCJRGHYKzmBSdz9w5AySztx7lG8SQY+naTGYUbsz8= github.com/olekukonko/ll v0.1.8/go.mod h1:RPRC6UcscfFZgjo1nulkfMH5IM0QAYim0LfnMvUuozw= github.com/olekukonko/tablewriter v1.1.4 h1:ORUMI3dXbMnRlRggJX3+q7OzQFDdvgbN9nVWj1drm6I= github.com/olekukonko/tablewriter v1.1.4/go.mod h1:+kedxuyTtgoZLwif3P1Em4hARJs+mVnzKxmsCL/C5RY= -github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= -github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/onsi/gomega v1.43.0 h1:VlG/1FxqNxhSO+lq/OHBNaaqwiBK/mO8JbVkX9Y+FeU= +github.com/onsi/gomega v1.43.0/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= From 09d69dabbadf721cd7deea93f5aeb4a3afa53e99 Mon Sep 17 00:00:00 2001 From: Raphael Fakhri <153192858+RaphaelFakhri@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:39:04 +0300 Subject: [PATCH 104/130] rtc: keep update cache eviction order when listing recent disconnects (#4975) GetRecentlyDisconnectedParticipants walked the participant update cache with Get, which moves each entry to the front of the LRU. Walking from newest to oldest left the cache in reverse order, so after a reconnect the next updates evicted the most recently updated participants first. In a room where the subscriber has tracked 128 or more participants, a participant that had just disconnected was then dropped from the cache and a later reconnect did not report it as disconnected, and the out-of-order version check was lost for recently updated participants. Use Peek, which reads an entry without changing its recency. --- pkg/rtc/participant_internal_test.go | 36 ++++++++++++++++++++++++++++ pkg/rtc/participant_signal.go | 3 ++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 5827d3f9f..f3271eea4 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -305,6 +305,42 @@ func TestOutOfOrderUpdates(t *testing.T) { require.Equal(t, "second update", sent.GetUpdate().Participants[0].Metadata) } +func TestRecentlyDisconnectedKeepsUpdateOrder(t *testing.T) { + p := newParticipantForTest("test") + + // fill the update cache (128 entries), the last update is a disconnect + const cacheSize = 128 + for i := range cacheSize { + state := livekit.ParticipantInfo_ACTIVE + if i == cacheSize-1 { + state = livekit.ParticipantInfo_DISCONNECTED + } + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: fmt.Sprintf("PA_%d", i), + Identity: fmt.Sprintf("p%d", i), + Version: 1, + State: state, + }})) + } + + disconnected := p.GetRecentlyDisconnectedParticipants() + require.Len(t, disconnected, 1) + require.Equal(t, "PA_127", disconnected[0].Sid) + + // an update for a new participant evicts the least recently updated entry, + // not the disconnect that was just reported + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{{ + Sid: "PA_new", + Identity: "new", + Version: 1, + State: livekit.ParticipantInfo_ACTIVE, + }})) + + disconnected = p.GetRecentlyDisconnectedParticipants() + require.Len(t, disconnected, 1) + require.Equal(t, "PA_127", disconnected[0].Sid) +} + // after disconnection, things should continue to function and not panic func TestDisconnectTiming(t *testing.T) { t.Run("Negotiate doesn't panic after channel closed", func(t *testing.T) { diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 9202ebb91..315fbfe99 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -268,7 +268,8 @@ func (p *ParticipantImpl) GetRecentlyDisconnectedParticipants() []*livekit.Parti p.updateLock.Lock() keys := p.updateCache.Keys() for i := len(keys) - 1; i >= 0; i-- { - if info, ok := p.updateCache.Get(keys[i]); ok { + // Peek, as Get would move each entry to the front and reverse the eviction order + if info, ok := p.updateCache.Peek(keys[i]); ok { if info.updatedAt.Before(lastSignalAt) { continue } From 152328c76aca1c127fa8b8874042d5714616a0f8 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 11:16:34 +0530 Subject: [PATCH 105/130] sfu: filter VP8 temporal layers by frame when the dependency descriptor is sent (#4976) * sfu: filter VP8 temporal layers by frame when the dependency descriptor is sent With the dependency descriptor, libwebrtc sends a one byte VP8 payload descriptor without a picture ID, so the VP8 munger's picture ID based temporal drop logic stops filtering after the first gap. A new VP8DD codec munger decides once per frame from the descriptor's frame number and temporal ID and applies that decision to every packet of the frame. Co-Authored-By: Claude Opus 5.5 * sfu: use the VP8 munger when picture IDs come with the dependency descriptor VP8DD needs the one byte VP8 payload descriptor that libwebrtc sends with the dependency descriptor. If a publisher keeps picture IDs next to the descriptor, they need rewriting, so the forwarder switches to the VP8 munger and keeps switching temporal layers with the descriptor. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/sfu/codecmunger/codecmunger.go | 1 + pkg/sfu/codecmunger/vp8dd.go | 131 +++++++++++++++++++++++++++++ pkg/sfu/codecmunger/vp8dd_test.go | 100 ++++++++++++++++++++++ pkg/sfu/forwarder.go | 28 +++++- pkg/sfu/forwarder_test.go | 80 ++++++++++++++++++ 5 files changed, 337 insertions(+), 3 deletions(-) create mode 100644 pkg/sfu/codecmunger/vp8dd.go create mode 100644 pkg/sfu/codecmunger/vp8dd_test.go diff --git a/pkg/sfu/codecmunger/codecmunger.go b/pkg/sfu/codecmunger/codecmunger.go index 47566a96e..efab8d6b5 100644 --- a/pkg/sfu/codecmunger/codecmunger.go +++ b/pkg/sfu/codecmunger/codecmunger.go @@ -24,6 +24,7 @@ var ( ErrNotVP8 = errors.New("not VP8") ErrOutOfOrderVP8PictureIdCacheMiss = errors.New("out-of-order VP8 picture id not found in cache") ErrFilteredVP8TemporalLayer = errors.New("filtered VP8 temporal layer") + ErrFilteredVP8OutOfOrder = errors.New("filtered out-of-order VP8 packet") ) // MaxHeaderSize is the largest codec header the mungers produce (VP8: 6 bytes). diff --git a/pkg/sfu/codecmunger/vp8dd.go b/pkg/sfu/codecmunger/vp8dd.go new file mode 100644 index 000000000..97bea6df6 --- /dev/null +++ b/pkg/sfu/codecmunger/vp8dd.go @@ -0,0 +1,131 @@ +// 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 codecmunger + +import ( + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/mediatransportutil/pkg/codec" + "github.com/livekit/protocol/logger" +) + +// frames with a remembered decision, behind the highest frame number +const vp8DDFrameWindow = 64 + +// VP8DD filters VP8 temporal layers with the dependency descriptor. libwebrtc sends a +// one byte VP8 payload descriptor with the dependency descriptor, so there is no picture +// ID, TL0PICIDX or key index to munge, and the header goes out unchanged. Every packet +// names its frame and temporal layer, so the munger decides once per frame and applies +// that decision to all packets of the frame, a late one included. It needs the one byte +// descriptor: the forwarder uses the VP8 munger for a stream that keeps picture IDs. +type VP8DD struct { + logger logger.Logger + + initialized bool + highestFrame uint64 + decidedMask uint64 // bit i: frame highestFrame - i has a decision + forwardedMask uint64 // bit i: frame highestFrame - i is forwarded +} + +func NewVP8DD(logger logger.Logger) *VP8DD { + return &VP8DD{ + logger: logger, + } +} + +func (v *VP8DD) GetState() any { + return nil +} + +func (v *VP8DD) SeedState(_state any) { +} + +func (v *VP8DD) SetLast(_extPkt *buffer.ExtPacket) { + v.initialized = false +} + +func (v *VP8DD) UpdateOffsets(_extPkt *buffer.ExtPacket) { + // a layer switch starts a stream with its own frame numbers + v.initialized = false +} + +func (v *VP8DD) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, _snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { + var hdr [MaxHeaderSize]byte + ddwdt := extPkt.DependencyDescriptor + if ddwdt == nil { + return 0, hdr, 0, nil + } + + frame := ddwdt.ExtFrameNum + isForwarded, ok := v.decision(frame) + if !ok { + isForwarded = extPkt.Temporal <= maxTemporalLayer + if !v.record(frame, isForwarded) { + // a late packet of a frame that is too old to have a decision + isForwarded = false + } + } + + switch { + case isForwarded: + return 0, hdr, 0, nil + case snOutOfOrder: + return 0, hdr, 0, ErrFilteredVP8OutOfOrder + default: + return 0, hdr, 0, ErrFilteredVP8TemporalLayer + } +} + +func (v *VP8DD) UpdateAndGetPadding(_newPicture bool) ([]byte, error) { + // the one byte payload descriptor of a key frame that starts a partition + vp8Packet := &codec.VP8{ + FirstByte: 0x10, + IsKeyFrame: true, + HeaderSize: 1, + } + return vp8Packet.Marshal() +} + +func (v *VP8DD) decision(frame uint64) (isForwarded bool, ok bool) { + if !v.initialized || frame > v.highestFrame || v.highestFrame-frame >= vp8DDFrameWindow { + return false, false + } + bit := uint64(1) << (v.highestFrame - frame) + return v.forwardedMask&bit != 0, v.decidedMask&bit != 0 +} + +func (v *VP8DD) record(frame uint64, isForwarded bool) bool { + switch { + case !v.initialized || frame >= v.highestFrame+vp8DDFrameWindow: + v.initialized = true + v.highestFrame = frame + v.decidedMask = 0 + v.forwardedMask = 0 + + case frame > v.highestFrame: + v.decidedMask <<= frame - v.highestFrame + v.forwardedMask <<= frame - v.highestFrame + v.highestFrame = frame + + case v.highestFrame-frame >= vp8DDFrameWindow: + return false + } + + bit := uint64(1) << (v.highestFrame - frame) + v.decidedMask |= bit + if isForwarded { + v.forwardedMask |= bit + } + return true +} diff --git a/pkg/sfu/codecmunger/vp8dd_test.go b/pkg/sfu/codecmunger/vp8dd_test.go new file mode 100644 index 000000000..c8453dbf9 --- /dev/null +++ b/pkg/sfu/codecmunger/vp8dd_test.go @@ -0,0 +1,100 @@ +// 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 codecmunger + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/protocol/logger" +) + +func vp8DDPacket(t *testing.T, frameNum uint64, tid int) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: uint16(frameNum), PayloadSize: 20}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt +} + +func TestVP8DDUpdateAndGet(t *testing.T) { + t.Run("filters by temporal layer, header unchanged", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + inputSize, _, n, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) + require.NoError(t, err) + require.Zero(t, inputSize) + require.Zero(t, n) + + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + }) + + t.Run("a gap does not exempt a filtered layer", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) + require.NoError(t, err) + for frameNum := uint64(2); frameNum < 6; frameNum++ { + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, frameNum, 1), false, true, 0) + require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + } + }) + + t.Run("every packet of a frame gets the decision of its first packet", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), false, false, 1) + require.NoError(t, err) + + // the layer changed in between, late packets keep the decision of their frame + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), true, false, 2) + require.ErrorIs(t, err, ErrFilteredVP8OutOfOrder) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), true, false, 0) + require.NoError(t, err) + }) + + t.Run("late packet of a frame older than the window", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 100, 0), false, false, 2) + require.NoError(t, err) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 100-vp8DDFrameWindow, 0), true, false, 2) + require.ErrorIs(t, err, ErrFilteredVP8OutOfOrder) + }) + + t.Run("layer switch forgets decisions", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) + require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + v.UpdateOffsets(vp8DDPacket(t, 1, 0)) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 2) + require.NoError(t, err) + }) + + t.Run("padding", func(t *testing.T) { + v := NewVP8DD(logger.GetLogger()) + hdr, err := v.UpdateAndGetPadding(true) + require.NoError(t, err) + require.Equal(t, []byte{0x10}, hdr) + }) +} diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index eaaf367cb..3a2e8359c 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -29,6 +29,7 @@ import ( "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" + "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -396,7 +397,12 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ switch f.mime { case mime.MimeTypeVP8: - f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { + f.codecMunger = codecmunger.NewVP8DD(f.logger) + } else { + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + } if f.vls != nil { if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { f.vls = vls @@ -406,7 +412,6 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ } else { f.vls = videolayerselector.NewSimulcast(f.logger) } - f.isDDAvailable = ddAvailable(extensions) if f.isDDAvailable { f.vls.SetTemporalLayerSelector(temporallayerselector.NewDependencyDescriptor(f.logger)) } else { @@ -2171,8 +2176,25 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in // never carries the descriptor, so it does not count. f.logger.Infow("turning off dependency descriptor for VP8 temporal layers", "layer", layer) f.isDDAvailable = false + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + if f.started { + // the munger takes its picture ID and TL0PICIDX from the first packet it sees + f.codecMunger.SetLast(extPkt) + } f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) } + if _, ok := f.codecMunger.(*codecmunger.VP8DD); ok && extPkt.DependencyDescriptor != nil { + if vp8, ok := extPkt.Payload.(codec.VP8); ok && vp8.I { + // The publisher sends picture IDs with the dependency descriptor, so they need + // rewriting. The VP8 munger does that, the temporal layers still switch with the + // dependency descriptor. + f.logger.Infow("VP8 picture ID with dependency descriptor, munging picture IDs", "layer", layer) + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + if f.started { + f.codecMunger.SetLast(extPkt) + } + } + } if !f.vls.GetTarget().IsValid() { // stream is paused by streamallocator @@ -2260,7 +2282,7 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati ) if err != nil { tp.shouldDrop = true - if err == codecmunger.ErrFilteredVP8TemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss { + if err == codecmunger.ErrFilteredVP8TemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss || err == codecmunger.ErrFilteredVP8OutOfOrder { if err == codecmunger.ErrFilteredVP8TemporalLayer { // filtered temporal layer, update sequence number offset to prevent holes f.rtpMunger.PacketDropped(extPkt) diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 7d4cf4d83..ef657004b 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -1978,6 +1978,7 @@ func TestForwarderVP8TemporalUpSwitchAtLayerSync(t *testing.T) { func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) @@ -2023,6 +2024,7 @@ func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { require.NoError(t, err) _, _ = f.getTranslationParamsVideo(extPkt, 0) require.False(t, f.isDDAvailable) + require.IsType(t, &codecmunger.VP8{}, f.codecMunger) extPkt, err = testutils.GetTestExtPacketVP8( &testutils.TestExtPacketParams{SequenceNumber: 5, PayloadSize: 20}, @@ -2033,6 +2035,84 @@ func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { require.Equal(t, int32(1), f.vls.GetCurrent().Temporal) } +func TestForwarderVP8DDTemporalFilterAfterGap(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + // one packet per frame, the minimized VP8 payload descriptor has no picture ID + packet := func(sn uint16, frameNum uint64, tid int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{ + SequenceNumber: sn, + Timestamp: uint32(frameNum) * 3000, + SSRC: 0x12345678, + PayloadSize: 20, + Marker: true, + IsKeyFrame: isKeyFrame, + }, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + steps := []struct { + name string + sn uint16 + frameNum uint64 + tid int + isKeyFrame bool + shouldDrop bool + }{ + {name: "key frame", sn: 100, frameNum: 0, tid: 0, isKeyFrame: true}, + {name: "TL2 after a gap", sn: 102, frameNum: 2, tid: 2, shouldDrop: true}, + {name: "TL2", sn: 103, frameNum: 3, tid: 2, shouldDrop: true}, + {name: "TL0", sn: 104, frameNum: 4, tid: 0}, + {name: "TL2 after TL0", sn: 105, frameNum: 5, tid: 2, shouldDrop: true}, + } + for _, step := range steps { + tp, err := f.GetTranslationParams(packet(step.sn, step.frameNum, step.tid, step.isKeyFrame), 0) + require.NoError(t, err, step.name) + require.Equal(t, step.shouldDrop, tp.shouldDrop, step.name) + } +} + +func TestForwarderVP8DDWithPictureID(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) + + // the one byte descriptor keeps the dependency descriptor munger + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: 1, Timestamp: 3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: true}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: 1, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + _, _ = f.GetTranslationParams(extPkt, 0) + require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) + require.True(t, f.started) + + // a picture ID next to the dependency descriptor needs the VP8 munger + extPkt, err = testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: 2, Timestamp: 6000, SSRC: 0x12345678, PayloadSize: 20, Marker: true}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: 2, TemporalID: 1, DTIs: "-SS", FrameDiffs: []int{1}, DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + extPkt.Payload = codec.VP8{S: true, I: true, PictureID: 2} + _, _ = f.GetTranslationParams(extPkt, 0) + require.IsType(t, &codecmunger.VP8{}, f.codecMunger) + require.True(t, f.codecMunger.GetState().(*livekit.VP8MungerState).PictureIdUsed, "seeded from the packet") + require.True(t, f.isDDAvailable, "temporal layers still switch with the dependency descriptor") +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) From a67eb7b5604df00479f545a58dadd5e60e54a149 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 11:23:29 +0530 Subject: [PATCH 106/130] sfu: send padding in the slot of a dropped out-of-order packet (#4979) On a gap the RTP munger gives the missing sequence numbers outgoing slots. If a late packet then arrives and is dropped, nothing is ever sent in its slot, and a subscriber that finds frame references by sequence number stalls until a key frame. The forwarder now flags such drops, and the downtrack sends a one byte padding only packet in the slot. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/downtrack.go | 50 +++++++++++++++++++++++++++++++++++++++ pkg/sfu/forwarder.go | 14 ++++++++++- pkg/sfu/forwarder_test.go | 50 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index e01c96748..8c0d87977 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -85,6 +85,9 @@ var ( errPayloadOverflow = errors.New("payload overflow") ) +// padding of a dropped packet's slot, one byte: the count byte itself +var droppedSlotPadding = []byte{1} + var ( VP8KeyFrame8x8 = []byte{ 0x10, 0x02, 0x00, 0x9d, 0x01, 0x2a, 0x08, 0x00, @@ -1032,6 +1035,9 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { if err != nil { d.params.Logger.Errorw("could not get translation params", err) } + if tp.padDroppedSlot { + d.writeDroppedSlotPadding(tp.rtp.extSequenceNumber, tp.rtp.extTimestamp) + } return 0 } @@ -1226,6 +1232,50 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { return 1 } +// writeDroppedSlotPadding sends a padding only packet in the outgoing slot of a dropped +// out-of-order packet. The receiver takes it as padding for that sequence number and +// does not NACK it. The sequencer has no entry for the slot, so a NACK that still comes +// finds nothing to retransmit. +func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimestamp uint64) { + hdr := RTPHeaderFactory.Get().(*rtp.Header) + initPooledRTPHeader(hdr, rtp.Header{ + Version: 2, + Padding: true, + PaddingSize: byte(len(droppedSlotPadding)), + Marker: false, + PayloadType: uint8(d.payloadType.Load()), + SequenceNumber: uint16(extSequenceNumber), + Timestamp: uint32(extTimestamp), + SSRC: d.ssrc, + }) + d.addDummyExtensions(hdr) + + hdrSize := hdr.MarshalSize() + d.rtpStats.Update( + mono.UnixNano(), + extSequenceNumber, + extTimestamp, + hdr.Marker, + hdrSize, + 0, + len(droppedSlotPadding), + true, + ) + + pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) + *pacerPacket = pacer.Packet{ + Header: hdr, + HeaderPool: RTPHeaderFactory, + HeaderSize: hdrSize, + Payload: droppedSlotPadding, + ProbeClusterId: ccutils.ProbeClusterIdInvalid, // not probe traffic + AbsSendTimeExtID: uint8(d.absSendTimeExtID), + TransportWideExtID: uint8(d.transportWideExtID), + WriteStream: d.writeStream, + } + d.pacer.Enqueue(pacerPacket) +} + // WritePaddingRTP tries to write as many padding only RTP packets as necessary // to satisfy given size to the DownTrack func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMarker bool) int { diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 3a2e8359c..c546796fa 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -209,6 +209,11 @@ type TranslationParams struct { marker bool // end of the svc spatial layer frame isEndOfLayerFrame bool + // The packet is dropped, but the RTP munger gave its outgoing sequence number + // away when it saw the gap, so the downtrack sends padding in that slot. Without + // it the subscriber NACKs a sequence number nothing can answer, and a VP8 + // receiver without picture IDs stalls on the hole until a key frame. + padDroppedSlot bool } // codecHeader returns the munged codec header to prepend to the payload. @@ -2226,8 +2231,12 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in if f.started && result.IsRelevant { // call to update highest incoming sequence number and other internal structures if tpRTP, err := f.rtpMunger.UpdateAndGetSnTs(extPkt, result.RTPMarker); err == nil { - if tpRTP.snOrdering == SequenceNumberOrderingContiguous { + switch tpRTP.snOrdering { + case SequenceNumberOrderingContiguous: f.rtpMunger.PacketDropped(extPkt) + case SequenceNumberOrderingOutOfOrder: + tp.rtp = tpRTP + tp.padDroppedSlot = true } } } @@ -2287,6 +2296,9 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati // filtered temporal layer, update sequence number offset to prevent holes f.rtpMunger.PacketDropped(extPkt) } + if tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder { + tp.padDroppedSlot = true + } return nil } diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index ef657004b..cc7f92f0d 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -2113,6 +2113,56 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { require.True(t, f.isDDAvailable, "temporal layers still switch with the dependency descriptor") } +func TestForwarderPadDroppedSlot(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + packet := func(sn uint16, frameNum uint64, tid int, isOutOfOrder bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsOutOfOrder: isOutOfOrder}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + tp, err := f.GetTranslationParams(packet(10, 0, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + + // a gap, the munger hands out a slot for sequence number 11 + tp, err = f.GetTranslationParams(packet(12, 2, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(12), tp.rtp.extSequenceNumber) + + // the late packet is on a filtered layer, its slot gets padding + tp, err = f.GetTranslationParams(packet(11, 1, 2, true), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.True(t, tp.padDroppedSlot) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + require.Equal(t, uint64(0xabcdef), tp.rtp.extTimestamp) + + // an in-order drop rolls the slot back instead + tp, err = f.GetTranslationParams(packet(13, 3, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.False(t, tp.padDroppedSlot) + tp, err = f.GetTranslationParams(packet(14, 4, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(13), tp.rtp.extSequenceNumber) +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) From c2de273198c4f8702de6d5bfbf9b83df818aa466 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 11:25:37 +0530 Subject: [PATCH 107/130] DownTrack: arm key frame requester timer only while a layer lock is pending (#4980) The key frame requester re-armed its timer on every loop, so every video down track woke up every 200 ms to 1 s for its whole life, even when the layer was locked and there was nothing to do. Re-arm the timer only while the requested layer is not locked, or while the lock can change without an event: an acquisition grace is armed, or layers are selected with the dependency descriptor, which can lose the lock on the packet path. Post the event on resync, mute, max published layer change and packet path layer switch, since those can change the lock without an allocation. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/downtrack.go | 23 ++++++- .../downtrack_downstream_integration_test.go | 64 +++++++++++++++++++ pkg/sfu/export_test.go | 12 +++- pkg/sfu/forwarder.go | 15 +++++ pkg/sfu/forwarder_test.go | 19 ++++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 8c0d87977..d2f156e5d 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -341,6 +341,7 @@ type DownTrack struct { keyFrameRequesterChMu sync.RWMutex keyFrameRequesterCh chan struct{} keyFrameRequesterChClosed bool + keyFrameRequesterWakeups atomic.Uint32 retransmitChMu sync.RWMutex retransmitCh chan struct{} @@ -956,18 +957,20 @@ func (d *DownTrack) keyFrameRequester() { defer timer.Stop() for !d.IsClosed() { - timer.Reset(getInterval()) - select { case _, more := <-d.keyFrameRequesterCh: if !more { return } if !timer.Stop() { - <-timer.C + select { + case <-timer.C: + default: + } } case <-timer.C: } + d.keyFrameRequesterWakeups.Inc() locked, layer := d.forwarder.CheckSync() if !locked && layer != buffer.InvalidLayerSpatial && d.writable.Load() { @@ -984,6 +987,13 @@ func (d *DownTrack) keyFrameRequester() { sal.OnAvailableLayersChanged(d) } } + + // Re-arm only while there is something to wait for: a requested layer that is not locked yet, + // or a lock state that can change without an event. Once locked, sleep until the + // next event so a locked track does not wake up every interval. + if (!locked && layer != buffer.InvalidLayerSpatial) || d.forwarder.NeedsPeriodicSyncCheck() { + timer.Reset(getInterval()) + } } } @@ -1197,6 +1207,8 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { if tp.isSwitching { d.postMaxLayerNotifierEvent("switching") + // the current layer moved on the packet path, so the layer lock may have changed + d.postKeyFrameRequestEvent() } if tp.isResuming { @@ -1443,6 +1455,8 @@ func (d *DownTrack) handleMute(muted bool, changed bool) { // and that could turn on/off layers on publisher side. // d.postMaxLayerNotifierEvent("mute") + // mute resyncs the forwarder, so the layer lock has to be re-acquired on unmute + d.postKeyFrameRequestEvent() if sal := d.getStreamAllocatorListener(); sal != nil { sal.OnSubscriptionChanged(d) @@ -1659,6 +1673,8 @@ func (d *DownTrack) UpTrackBitrateAvailabilityChange() { func (d *DownTrack) UpTrackMaxPublishedLayerChange(maxPublishedLayer int32) { if d.forwarder.SetMaxPublishedLayer(maxPublishedLayer) { + // may arm the acquisition grace, which the key frame requester has to expire + d.postKeyFrameRequestEvent() if sal := d.getStreamAllocatorListener(); sal != nil { sal.OnMaxPublishedSpatialChanged(d) } @@ -1804,6 +1820,7 @@ func (d *DownTrack) Pause() VideoAllocation { func (d *DownTrack) Resync() { d.forwarder.Resync() d.flushSequencer() + d.postKeyFrameRequestEvent() } // flushSequencer discards recorded packet metadata on a stream restart so that NACK diff --git a/pkg/sfu/downtrack_downstream_integration_test.go b/pkg/sfu/downtrack_downstream_integration_test.go index 1f946a531..41282e46c 100644 --- a/pkg/sfu/downtrack_downstream_integration_test.go +++ b/pkg/sfu/downtrack_downstream_integration_test.go @@ -919,3 +919,67 @@ func TestDownTrackSendsProbePackets(t *testing.T) { }) } } + +// ----------------------------------------------------------------------------- +// keyFrameRequester: PLI only while a layer lock is pending +// ----------------------------------------------------------------------------- + +// TestDownTrackKeyFrameRequesterArmsOnlyWhilePending verifies that the key frame +// requester keeps sending PLIs while the target layer is not locked, and goes quiet +// once a key frame on the target layer locks it (no periodic wake-ups while locked). +func TestDownTrackKeyFrameRequesterArmsOnlyWhilePending(t *testing.T) { + h := vnettest.NewHosts(t) + factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() + p := pacer.NewPassThrough(logger.GetLogger(), newNullBWE()) + t.Cleanup(p.Stop) + + dh := newBoundDownTrack(t, h, factory, vp8CodecParams, p, vnettest.MediaEngineConfig{Video: true}) + + // allocate a target layer (spatial 0) without any media, so the lock is pending + var brs Bitrates + brs[0][0] = 100_000 + dh.receiver.GetLayeredBitrateReturns([]int32{0}, brs) + dh.dt.UpTrackMaxPublishedLayerChange(0) + dh.dt.SetMaxSpatialLayer(0) + alloc := dh.dt.AllocateOptimal(false, false) + require.Equal(t, int32(0), alloc.TargetLayer.Spatial, "expected target layer to be allocated") + + // unlocked: PLIs must keep coming at the key frame interval + require.Eventually(t, func() bool { + return dh.receiver.SendPLICallCount() >= 3 + }, 5*time.Second, 20*time.Millisecond, "expected repeated PLIs while layer lock is pending") + + // a key frame on the target layer locks it + vp8 := &codec.VP8{ + FirstByte: 0x10, + S: true, + PictureID: 1, + IsKeyFrame: true, + } + ep, err := testutils.GetTestExtPacketVP8(&testutils.TestExtPacketParams{ + SequenceNumber: 5000, + Timestamp: 180000, + SSRC: 0x33333333, + PayloadType: 96, + PayloadSize: 50, + IsKeyFrame: true, + Marker: true, + }, vp8) + require.NoError(t, err) + ep.Packet.Payload = distinctivePayload(0x40, 50) + require.Equal(t, int32(1), dh.dt.WriteRTP(ep, 0), "keyframe should forward") + + // let an already armed interval fire, then the requester must stay asleep + time.Sleep(2 * KeyFrameIntervalMaxForTest) + before := dh.receiver.SendPLICallCount() + wakeups := dh.dt.KeyFrameRequesterWakeupsForTest() + time.Sleep(2 * KeyFrameIntervalMaxForTest) + require.Equal(t, wakeups, dh.dt.KeyFrameRequesterWakeupsForTest(), "key frame requester must not wake up once the layer is locked") + require.Equal(t, before, dh.receiver.SendPLICallCount(), "no PLI expected once the layer is locked") + + // a resync drops the lock and must wake the requester again + dh.dt.Resync() + require.Eventually(t, func() bool { + return dh.receiver.SendPLICallCount() > before + }, 5*time.Second, 20*time.Millisecond, "expected PLI after resync") +} diff --git a/pkg/sfu/export_test.go b/pkg/sfu/export_test.go index 449289e20..b6536362a 100644 --- a/pkg/sfu/export_test.go +++ b/pkg/sfu/export_test.go @@ -14,7 +14,11 @@ package sfu -import "github.com/livekit/livekit-server/pkg/sfu/buffer" +import ( + "time" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" +) // Test-only seams that expose DownTrack internals to the external sfu_test package. // This file is only compiled into the test binary (its name ends in _test.go), so it @@ -71,3 +75,9 @@ func (d *DownTrack) RetransmitForTest( } return d.retransmitPacket(&epm, sourcePkt, false) } + +const KeyFrameIntervalMaxForTest = keyFrameIntervalMax * time.Millisecond + +func (d *DownTrack) KeyFrameRequesterWakeupsForTest() uint32 { + return d.keyFrameRequesterWakeups.Load() +} diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index c546796fa..f69e6fec5 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -360,6 +360,21 @@ func (f *Forwarder) MaybeExpireAcquireGrace() bool { return !f.vls.GetCurrent().IsValid() } +// NeedsPeriodicSyncCheck reports whether the layer lock state can change without an event, so the +// key frame requester has to keep polling it: an initial-acquisition grace deadline is armed +// (MaybeExpireAcquireGrace may still fire), or layers are selected with the dependency descriptor, +// which can lose the lock on the packet path (key frame mismatch, broken frame chain). +func (f *Forwarder) NeedsPeriodicSyncCheck() bool { + f.lock.RLock() + defer f.lock.RUnlock() + + if f.acquireDeadline != 0 { + return true + } + _, isDD := f.vls.(*videolayerselector.DependencyDescriptor) + return isDD +} + func (f *Forwarder) SetMaxTemporalLayerSeen(maxTemporalLayerSeen int32) bool { f.lock.Lock() defer f.lock.Unlock() diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index cc7f92f0d..0ee950ae6 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -2502,3 +2502,22 @@ func TestPacerScratchFitsInlineDependencyDescriptor(t *testing.T) { require.Len(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize)), dd.MaxInlineExtensionSize) require.Nil(t, p.HoldExtension(make([]byte, dd.MaxInlineExtensionSize+1))) } + +func TestForwarderNeedsPeriodicSyncCheck(t *testing.T) { + f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + require.False(t, f.NeedsPeriodicSyncCheck()) + + // VP8 with the dependency descriptor uses it only for temporal layers + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.False(t, f.NeedsPeriodicSyncCheck()) + + // a new max published layer before any layer is forwarded arms the acquisition grace + require.True(t, f.SetMaxPublishedLayer(1)) + require.True(t, f.NeedsPeriodicSyncCheck()) + + // spatial layers selected with the dependency descriptor + f = newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) + av1 := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000} + f.DetermineCodec(av1, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + require.True(t, f.NeedsPeriodicSyncCheck()) +} From 9ddcc50767c6a460e2cb8794d9e5d4fb8616b406 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 20:43:17 +0530 Subject: [PATCH 108/130] sfu: parse VP8 and VP9 payload descriptors without an allocation (#4981) * sfu: parse VP8 and VP9 payload descriptors without an allocation Replace ExtPacket.Payload with typed VP8 and VP9 pointer fields that point at backing structs inside the ExtPacket, so parsing does not allocate and a wrong codec type is a compile error. Co-Authored-By: Claude Opus 5.5 * sfu: copy the VP8 descriptor in the test packet helper Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/sfu/buffer/buffer_base.go | 19 ++++++++++++------- pkg/sfu/buffer/fps.go | 11 ++++------- pkg/sfu/buffer/fps_test.go | 2 +- pkg/sfu/codecmunger/vp8.go | 12 ++++++------ pkg/sfu/forwarder.go | 7 ++----- pkg/sfu/forwarder_test.go | 12 ++++++------ pkg/sfu/testutils/data.go | 3 ++- .../temporallayerselector/vp8.go | 5 ++--- pkg/sfu/videolayerselector/vp9.go | 6 ++---- 9 files changed, 37 insertions(+), 40 deletions(-) diff --git a/pkg/sfu/buffer/buffer_base.go b/pkg/sfu/buffer/buffer_base.go index a18847787..ec7ff6ac6 100644 --- a/pkg/sfu/buffer/buffer_base.go +++ b/pkg/sfu/buffer/buffer_base.go @@ -79,7 +79,8 @@ type ExtPacket struct { ExtSequenceNumber uint64 ExtTimestamp uint64 Packet *rtp.Packet - Payload any + VP8 *codec.VP8 // nil when not parsed + VP9 *codecs.VP9Packet // nil when not parsed IsKeyFrame bool RawPacket []byte DependencyDescriptor *ExtDependencyDescriptor @@ -91,6 +92,10 @@ type ExtPacket struct { // extension and CSRC slices point into the write input until ReadExtended re-points them into RawPacket pkt rtp.Packet extensions [8]rtp.Extension + + // backing storage for VP8 and VP9, so parsing does not allocate + vp8 codec.VP8 + vp9 codecs.VP9Packet } type BufferProvider interface { @@ -1106,7 +1111,7 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { switch b.mime { case mime.MimeTypeVP8: - vp8Packet := codec.VP8{} + vp8Packet := &ep.vp8 if err := vp8Packet.Unmarshal(ep.Packet.Payload); err != nil { b.logger.Warnw("could not unmarshal VP8 packet", err) return err @@ -1116,7 +1121,7 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { ep.Temporal = int32(vp8Packet.TID) if ep.IsKeyFrame { - if sz := codec.ExtractVP8VideoSize(&vp8Packet, ep.Packet.Payload); sz.Width > 0 && sz.Height > 0 { + if sz := codec.ExtractVP8VideoSize(vp8Packet, ep.Packet.Payload); sz.Width > 0 && sz.Height > 0 { videoSize = append(videoSize, sz) } } @@ -1124,12 +1129,12 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { // vp8 with DependencyDescriptor enabled, use the TID from the descriptor vp8Packet.TID = uint8(ep.Temporal) } - ep.Payload = vp8Packet + ep.VP8 = vp8Packet ep.Spatial = InvalidLayerSpatial // vp8 don't have spatial scalability, reset to invalid case mime.MimeTypeVP9: if ep.DependencyDescriptor == nil { - var vp9Packet codecs.VP9Packet + vp9Packet := &ep.vp9 _, err := vp9Packet.Unmarshal(ep.Packet.Payload) if err != nil { b.logger.Warnw("could not unmarshal VP9 packet", err) @@ -1139,8 +1144,8 @@ func (b *BufferBase) processVideoPacket(ep *ExtPacket) error { Spatial: int32(vp9Packet.SID), Temporal: int32(vp9Packet.TID), } - ep.Payload = vp9Packet - ep.IsKeyFrame = codec.IsVP9KeyFrame(&vp9Packet, ep.Packet.Payload) + ep.VP9 = vp9Packet + ep.IsKeyFrame = codec.IsVP9KeyFrame(vp9Packet, ep.Packet.Payload) if ep.IsKeyFrame { for i := 0; i < len(vp9Packet.Width); i++ { diff --git a/pkg/sfu/buffer/fps.go b/pkg/sfu/buffer/fps.go index d41dfe375..fa25dc584 100644 --- a/pkg/sfu/buffer/fps.go +++ b/pkg/sfu/buffer/fps.go @@ -17,9 +17,6 @@ package buffer import ( "container/list" - "github.com/pion/rtp/codecs" - - "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/logger" ) @@ -209,8 +206,8 @@ func (f *FrameRateCalculatorVP8) RecvPacket(ep *ExtPacket) bool { return true } - vp8, ok := ep.Payload.(codec.VP8) - if !ok { + vp8 := ep.VP8 + if vp8 == nil { f.logger.Debugw("no vp8 payload", "sn", ep.Packet.SequenceNumber) return false } @@ -260,8 +257,8 @@ func (f *FrameRateCalculatorVP9) RecvPacket(ep *ExtPacket) bool { return true } - vp9, ok := ep.Payload.(codecs.VP9Packet) - if !ok { + vp9 := ep.VP9 + if vp9 == nil { f.logger.Debugw("no vp9 payload", "sn", ep.Packet.SequenceNumber) return false } diff --git a/pkg/sfu/buffer/fps_test.go b/pkg/sfu/buffer/fps_test.go index 8bdacb13f..6e7979abe 100644 --- a/pkg/sfu/buffer/fps_test.go +++ b/pkg/sfu/buffer/fps_test.go @@ -36,7 +36,7 @@ type testFrameInfo struct { func (f *testFrameInfo) toVP8() *ExtPacket { return &ExtPacket{ Packet: &rtp.Packet{Header: f.header}, - Payload: codec.VP8{ + VP8: &codec.VP8{ I: true, PictureID: f.framenumber, }, diff --git a/pkg/sfu/codecmunger/vp8.go b/pkg/sfu/codecmunger/vp8.go index 4ffc7e65a..748acfcef 100644 --- a/pkg/sfu/codecmunger/vp8.go +++ b/pkg/sfu/codecmunger/vp8.go @@ -104,8 +104,8 @@ func (v *VP8) SeedState(seed any) { } func (v *VP8) SetLast(extPkt *buffer.ExtPacket) { - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } @@ -129,8 +129,8 @@ func (v *VP8) SetLast(extPkt *buffer.ExtPacket) { } func (v *VP8) UpdateOffsets(extPkt *buffer.ExtPacket) { - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } @@ -155,8 +155,8 @@ func (v *VP8) UpdateOffsets(extPkt *buffer.ExtPacket) { func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { var hdr [MaxHeaderSize]byte - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return 0, hdr, 0, ErrNotVP8 } diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index f69e6fec5..fea4a1b91 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -23,13 +23,11 @@ import ( "time" "github.com/pion/rtp" - "github.com/pion/rtp/codecs" "github.com/pion/webrtc/v4" "go.uber.org/zap/zapcore" "google.golang.org/protobuf/proto" "github.com/livekit/mediatransportutil" - "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -2180,8 +2178,7 @@ func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool { } if f.mime == mime.MimeTypeVP9 { - vp9, ok := extPkt.Payload.(codecs.VP9Packet) - return ok && vp9.E + return extPkt.VP9 != nil && extPkt.VP9.E } return false @@ -2204,7 +2201,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) } if _, ok := f.codecMunger.(*codecmunger.VP8DD); ok && extPkt.DependencyDescriptor != nil { - if vp8, ok := extPkt.Payload.(codec.VP8); ok && vp8.I { + if extPkt.VP8 != nil && extPkt.VP8.I { // The publisher sends picture IDs with the dependency descriptor, so they need // rewriting. The VP8 munger does that, the temporal layers still switch with the // dependency descriptor. diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 0ee950ae6..83682329a 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -2106,7 +2106,7 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: 2, TemporalID: 1, DTIs: "-SS", FrameDiffs: []int{1}, DecodeTargets: testutils.TestVP8DecodeTargets}, ) require.NoError(t, err) - extPkt.Payload = codec.VP8{S: true, I: true, PictureID: 2} + extPkt.VP8 = &codec.VP8{S: true, I: true, PictureID: 2} _, _ = f.GetTranslationParams(extPkt, 0) require.IsType(t, &codecmunger.VP8{}, f.codecMunger) require.True(t, f.codecMunger.GetState().(*livekit.VP8MungerState).PictureIdUsed, "seeded from the packet") @@ -2463,24 +2463,24 @@ func TestForwarderIsEndOfLayerFrame(t *testing.T) { vp9Codec := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000} f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) - require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: true}})) + require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: true}})) f = newForwarder(vp9Codec, webrtc.RTPCodecTypeVideo) - require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: true}})) - require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{Payload: codecs.VP9Packet{E: false}})) + require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: true}})) + require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{VP9: &codecs.VP9Packet{E: false}})) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{})) require.True(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{ Descriptor: &dd.DependencyDescriptor{LastPacketInFrame: true}, }, - Payload: codecs.VP9Packet{E: false}, + VP9: &codecs.VP9Packet{E: false}, })) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{ Descriptor: &dd.DependencyDescriptor{LastPacketInFrame: false}, }, - Payload: codecs.VP9Packet{E: true}, + VP9: &codecs.VP9Packet{E: true}, })) require.False(t, f.isEndOfLayerFrame(&buffer.ExtPacket{ DependencyDescriptor: &buffer.ExtDependencyDescriptor{}, diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index f4a89e49d..1056e4119 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -88,7 +88,8 @@ func GetTestExtPacketVP8(params *TestExtPacketParams, vp8 *codec.VP8) (*buffer.E } ep.IsKeyFrame = vp8.IsKeyFrame - ep.Payload = *vp8 + vp8Copy := *vp8 // keep the packet independent of later changes to vp8 + ep.VP8 = &vp8Copy if ep.DependencyDescriptor == nil { ep.Temporal = int32(vp8.TID) } diff --git a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go index bc5679c58..57162b2f0 100644 --- a/pkg/sfu/videolayerselector/temporallayerselector/vp8.go +++ b/pkg/sfu/videolayerselector/temporallayerselector/vp8.go @@ -16,7 +16,6 @@ package temporallayerselector import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" - "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/logger" ) @@ -37,8 +36,8 @@ func (v *VP8) Select(extPkt *buffer.ExtPacket, current int32, target int32) (thi return } - vp8, ok := extPkt.Payload.(codec.VP8) - if !ok { + vp8 := extPkt.VP8 + if vp8 == nil { return } diff --git a/pkg/sfu/videolayerselector/vp9.go b/pkg/sfu/videolayerselector/vp9.go index 4b11d37ab..7cb5de7cc 100644 --- a/pkg/sfu/videolayerselector/vp9.go +++ b/pkg/sfu/videolayerselector/vp9.go @@ -15,8 +15,6 @@ package videolayerselector import ( - "github.com/pion/rtp/codecs" - "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/protocol/logger" ) @@ -40,8 +38,8 @@ func (v *VP9) IsOvershootOkay() bool { } func (v *VP9) Select(extPkt *buffer.ExtPacket, _layer int32) (result VideoLayerSelectorResult) { - vp9, ok := extPkt.Payload.(codecs.VP9Packet) - if !ok { + vp9 := extPkt.VP9 + if vp9 == nil { return } From b1c801b9790cc8c06bb85749d252080e6e9464ef Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Sun, 11 Oct 2026 23:03:42 +0530 Subject: [PATCH 109/130] sfu: pad the slot of a filtered packet that follows a sequence number gap (#4983) A filtered packet that follows a sequence number gap now keeps its slot and gets padding in it. Rolling it back moved the last outgoing number below the missing packets' slots, so their retransmissions were rejected and the subscriber stalled on the hole until a key frame. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/downtrack.go | 8 ++--- pkg/sfu/forwarder.go | 17 +++++---- pkg/sfu/forwarder_test.go | 73 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 88 insertions(+), 10 deletions(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index d2f156e5d..346580149 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -1046,7 +1046,7 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { d.params.Logger.Errorw("could not get translation params", err) } if tp.padDroppedSlot { - d.writeDroppedSlotPadding(tp.rtp.extSequenceNumber, tp.rtp.extTimestamp) + d.writeDroppedSlotPadding(tp.rtp.extSequenceNumber, tp.rtp.extTimestamp, tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder) } return 0 } @@ -1245,10 +1245,10 @@ func (d *DownTrack) WriteRTP(extPkt *buffer.ExtPacket, layer int32) int32 { } // writeDroppedSlotPadding sends a padding only packet in the outgoing slot of a dropped -// out-of-order packet. The receiver takes it as padding for that sequence number and +// packet that is out of order or follows a gap. The receiver takes it as padding for that sequence number and // does not NACK it. The sequencer has no entry for the slot, so a NACK that still comes // finds nothing to retransmit. -func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimestamp uint64) { +func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimestamp uint64, isOutOfOrder bool) { hdr := RTPHeaderFactory.Get().(*rtp.Header) initPooledRTPHeader(hdr, rtp.Header{ Version: 2, @@ -1271,7 +1271,7 @@ func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimesta hdrSize, 0, len(droppedSlotPadding), - true, + isOutOfOrder, ) pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index fea4a1b91..f0eed8824 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -2246,7 +2246,9 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in switch tpRTP.snOrdering { case SequenceNumberOrderingContiguous: f.rtpMunger.PacketDropped(extPkt) - case SequenceNumberOrderingOutOfOrder: + case SequenceNumberOrderingGap, SequenceNumberOrderingOutOfOrder: + // a rollback after a gap moves the last outgoing number below the missing + // packets' slots and their late copies are rejected, so pad the slot instead tp.rtp = tpRTP tp.padDroppedSlot = true } @@ -2304,11 +2306,14 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati if err != nil { tp.shouldDrop = true if err == codecmunger.ErrFilteredVP8TemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss || err == codecmunger.ErrFilteredVP8OutOfOrder { - if err == codecmunger.ErrFilteredVP8TemporalLayer { - // filtered temporal layer, update sequence number offset to prevent holes - f.rtpMunger.PacketDropped(extPkt) - } - if tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder { + switch tp.rtp.snOrdering { + case SequenceNumberOrderingContiguous: + if err == codecmunger.ErrFilteredVP8TemporalLayer { + // filtered temporal layer, update sequence number offset to prevent holes + f.rtpMunger.PacketDropped(extPkt) + } + case SequenceNumberOrderingGap, SequenceNumberOrderingOutOfOrder: + // no rollback after a gap, see getTranslationParamsVideo tp.padDroppedSlot = true } return nil diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 83682329a..6d82db34e 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -31,6 +31,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/pacer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/livekit-server/pkg/sfu/videolayerselector" ) func disable(f *Forwarder) { @@ -2163,6 +2164,78 @@ func TestForwarderPadDroppedSlot(t *testing.T) { require.Equal(t, uint64(13), tp.rtp.extSequenceNumber) } +// dropSelector drops one packet as relevant, the result a decode target selector gives for a +// frame outside the target +type dropSelector struct { + videolayerselector.VideoLayerSelector + dropSN uint64 +} + +func (d *dropSelector) Select(extPkt *buffer.ExtPacket, layer int32) videolayerselector.VideoLayerSelectorResult { + if extPkt.ExtSequenceNumber == d.dropSN { + return videolayerselector.VideoLayerSelectorResult{IsRelevant: true} + } + return d.VideoLayerSelector.Select(extPkt, layer) +} + +func TestForwarderPadFilteredSlotAfterGap(t *testing.T) { + for _, tc := range []struct { + name string + dropBySelector bool + }{ + {"codec munger", false}, + {"layer selector", true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + if tc.dropBySelector { + f.vls = &dropSelector{VideoLayerSelector: f.vls, dropSN: 12} + } + + packet := func(sn uint16, frameNum uint64, tid int, isOutOfOrder bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsOutOfOrder: isOutOfOrder}, + &testutils.TestDDParams{ + FirstPacketInFrame: true, + ExtFrameNum: frameNum, + TemporalID: tid, + DTIs: "SSS", + DecodeTargets: testutils.TestVP8DecodeTargets, + }, + ) + require.NoError(t, err) + return extPkt + } + + tp, err := f.GetTranslationParams(packet(10, 0, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + + // 11 is lost, 12 is on a filtered layer: its slot gets padding and is not rolled back + tp, err = f.GetTranslationParams(packet(12, 2, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + require.True(t, tp.padDroppedSlot) + require.Equal(t, uint64(12), tp.rtp.extSequenceNumber) + + // the retransmission of 11 still has its slot + tp, err = f.GetTranslationParams(packet(11, 1, 0, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + + // the next packet follows the padded slot + tp, err = f.GetTranslationParams(packet(13, 3, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(13), tp.rtp.extSequenceNumber) + }) + } +} + func TestForwarderGetSnTsForPadding(t *testing.T) { f := newForwarder(testutils.TestVP8Codec, webrtc.RTPCodecTypeVideo) From 099e03fdf8838cda332ca16b086b74ced24040ac Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 12 Oct 2026 12:08:49 +0530 Subject: [PATCH 110/130] sfu: forward the dependency descriptor for VP8 simulcast (#4987) * sfu: forward the dependency descriptor for VP8 simulcast libwebrtc sends VP8 with a one byte payload descriptor when it sends the dependency descriptor, so a VP8 subscriber gets neither picture IDs nor the descriptor and finds frame references by sequence number alone. Under loss with temporal filtering it stalls until a key frame. DDForwarder rewrites the publisher's descriptor for each forwarded packet: frame numbers continue across simulcast layer switches and stream restarts, the structure goes out with the current layer's key frame, and the active decode targets follow the forwarded temporal layer. Co-Authored-By: Claude Opus 5.5 * sfu: keep VP8 descriptor frame numbers from going back Rebase frame numbers only when a new stream would go back past the last number sent: on a layer switch, a stream restart (also when it starts with a delta frame) and a new forwarder on the same downtrack, which now carries over the last number sent. Co-Authored-By: Claude Opus 5.5 * sfu: set VP8 descriptor decode targets from the packet's layer The active decode targets use the temporal layer the packet is forwarded at, not the selector's next layer on the last packet before a down switch. They also go out on key frames, combined with the publisher's own mask. Co-Authored-By: Claude Opus 5.5 * sfu: carry the current active decode targets on every DD packet The parser puts the stream's current mask on every packet, so a mask change on a dropped packet still reaches the VP8 descriptor forwarder, which no longer tracks the publisher's mask itself. Co-Authored-By: Claude Opus 5.5 * sfu: take the first DD active decode targets mask at any sequence number A publisher whose first packet has RTP sequence number 0 never had its key frame's mask applied, so every packet reported no active decode targets. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/sfu/buffer/dependencydescriptorparser.go | 13 +- .../buffer/dependencydescriptorparser_test.go | 64 ++++- pkg/sfu/forwarder.go | 20 ++ pkg/sfu/forwarder_test.go | 75 ++++++ pkg/sfu/testutils/data.go | 2 + pkg/sfu/videolayerselector/ddforwarder.go | 141 +++++++++++ .../videolayerselector/ddforwarder_test.go | 234 ++++++++++++++++++ 7 files changed, 541 insertions(+), 8 deletions(-) create mode 100644 pkg/sfu/videolayerselector/ddforwarder.go create mode 100644 pkg/sfu/videolayerselector/ddforwarder_test.go diff --git a/pkg/sfu/buffer/dependencydescriptorparser.go b/pkg/sfu/buffer/dependencydescriptorparser.go index 343eafc24..332c38a28 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser.go +++ b/pkg/sfu/buffer/dependencydescriptorparser.go @@ -69,6 +69,7 @@ type DependencyDescriptorParser struct { // repeating the same structure (e. g. screen content), valid late/retransmitted // frames would be dropped as "earlier than current structure". structureChangeExtFrameNum uint64 + hasActiveDecodeTargets bool // the first mask is taken whatever its sequence number activeDecodeTargetsExtSeq uint64 activeDecodeTargetsMask uint32 frameChecker *FrameIntegrityChecker @@ -99,8 +100,11 @@ type ExtDependencyDescriptor struct { DecodeTargets []DependencyDescriptorDecodeTarget StructureUpdated bool ActiveDecodeTargetsUpdated bool - Integrity bool - ExtFrameNum uint64 + // the stream's current active decode targets, on every packet, also when the mask + // changed on a packet the subscriber does not get + ActiveDecodeTargetsMask uint32 + Integrity bool + ExtFrameNum uint64 // the frame number of the keyframe which the current frame depends on ExtKeyFrameNum uint64 @@ -262,7 +266,8 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr // so don't need to notify max layer change here. } - if mask := ddVal.ActiveDecodeTargetsBitmask; mask != nil && extSeq > r.activeDecodeTargetsExtSeq { + if mask := ddVal.ActiveDecodeTargetsBitmask; mask != nil && (!r.hasActiveDecodeTargets || extSeq > r.activeDecodeTargetsExtSeq) { + r.hasActiveDecodeTargets = true r.activeDecodeTargetsExtSeq = extSeq if *mask != r.activeDecodeTargetsMask { r.activeDecodeTargetsMask = *mask @@ -285,6 +290,7 @@ func (r *DependencyDescriptorParser) Parse(pkt *rtp.Packet) (*ExtDependencyDescr extDD.DecodeTargets = r.decodeTargets extDD.ExtKeyFrameNum = r.structureExtFrameNum + extDD.ActiveDecodeTargetsMask = r.activeDecodeTargetsMask return extDD, videoLayer, nil } @@ -294,6 +300,7 @@ func (r *DependencyDescriptorParser) restart() { r.structure = nil r.structureExtFrameNum = 0 r.structureChangeExtFrameNum = 0 + r.hasActiveDecodeTargets = false r.activeDecodeTargetsExtSeq = 0 r.activeDecodeTargetsMask = 0 r.decodeTargets = r.decodeTargets[:0] diff --git a/pkg/sfu/buffer/dependencydescriptorparser_test.go b/pkg/sfu/buffer/dependencydescriptorparser_test.go index 7a09b013c..370c53023 100644 --- a/pkg/sfu/buffer/dependencydescriptorparser_test.go +++ b/pkg/sfu/buffer/dependencydescriptorparser_test.go @@ -75,19 +75,34 @@ func (f *ddTestFeeder) deltaFrame(frameNumber uint16) (*ExtDependencyDescriptor, return f.feed(frameNumber, f.structure.Templates[1], nil) } +// deltaFrameWithMask sends a delta frame that changes the active decode targets +func (f *ddTestFeeder) deltaFrameWithMask(frameNumber uint16, activeDecodeTargets uint32) (*ExtDependencyDescriptor, error) { + return f.feedWithMask(frameNumber, f.structure.Templates[1], nil, &activeDecodeTargets) +} + func (f *ddTestFeeder) feed( frameNumber uint16, template *dd.FrameDependencyTemplate, attachedStructure *dd.FrameDependencyStructure, +) (*ExtDependencyDescriptor, error) { + return f.feedWithMask(frameNumber, template, attachedStructure, nil) +} + +func (f *ddTestFeeder) feedWithMask( + frameNumber uint16, + template *dd.FrameDependencyTemplate, + attachedStructure *dd.FrameDependencyStructure, + activeDecodeTargets *uint32, ) (*ExtDependencyDescriptor, error) { f.t.Helper() ddVal := &dd.DependencyDescriptor{ - FirstPacketInFrame: true, - LastPacketInFrame: true, - FrameNumber: frameNumber, - FrameDependencies: template, - AttachedStructure: attachedStructure, + FirstPacketInFrame: true, + LastPacketInFrame: true, + FrameNumber: frameNumber, + FrameDependencies: template, + AttachedStructure: attachedStructure, + ActiveDecodeTargetsBitmask: activeDecodeTargets, } buf, err := (&dd.DependencyDescriptorExtension{Descriptor: ddVal, Structure: f.structure}).Marshal() require.NoError(f.t, err) @@ -187,6 +202,45 @@ func TestDependencyDescriptorParserExtKeyFrameNum(t *testing.T) { require.EqualValues(t, 4, extDD.ExtKeyFrameNum) } +// every packet carries the stream's current active decode targets, also after the mask +// changed on a packet the forwarder dropped +func TestDependencyDescriptorParserActiveDecodeTargetsMask(t *testing.T) { + f := newDDTestFeeder(t) + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrameWithMask(1, 0) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrame(2) + require.NoError(t, err) + require.EqualValues(t, 0, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.keyFrame(3, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) +} + +// a first key frame at RTP sequence number 0 still makes its structure's decode targets +// active +func TestDependencyDescriptorParserActiveDecodeTargetsMaskAtSequenceZero(t *testing.T) { + f := newDDTestFeeder(t) + f.seq = 0xffff // the first packet goes out with sequence number 0 + structure := newL1T1Structure(0) + + extDD, err := f.keyFrame(0, structure) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) + + extDD, err = f.deltaFrame(1) + require.NoError(t, err) + require.EqualValues(t, 1, extDD.ActiveDecodeTargetsMask) +} + // a pooled ExtDependencyDescriptor carries the storage for its descriptor, so parsing a delta // frame must not allocate and a reused descriptor must not show the previous packet's values func TestDependencyDescriptorParserNoAlloc(t *testing.T) { diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index f0eed8824..ef5b0548a 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -281,6 +281,11 @@ type Forwarder struct { rtpMunger *RTPMunger vls videolayerselector.VideoLayerSelector + // set for VP8 with the dependency descriptor, forwards the descriptor to the subscriber + ddForwarder *videolayerselector.DDForwarder + // temporal layer of the packet being translated, the selector's current layer is + // already the next one on the last packet before a down switch + packetTemporal int32 codecMunger codecmunger.CodecMunger } @@ -413,11 +418,15 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ return false } + prevDDForwarder := f.ddForwarder + f.ddForwarder = nil switch f.mime { case mime.MimeTypeVP8: f.isDDAvailable = ddAvailable(extensions) if f.isDDAvailable { f.codecMunger = codecmunger.NewVP8DD(f.logger) + // the downtrack keeps its SSRC, so the frame numbers continue + f.ddForwarder = videolayerselector.NewDDForwarderFromOther(prevDDForwarder, f.logger) } else { f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) } @@ -2193,6 +2202,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in // never carries the descriptor, so it does not count. f.logger.Infow("turning off dependency descriptor for VP8 temporal layers", "layer", layer) f.isDDAvailable = false + f.ddForwarder = nil f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) if f.started { // the munger takes its picture ID and TL0PICIDX from the first packet it sees @@ -2261,6 +2271,7 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in tp.ddBytes, tp.ddBytesLen, tp.ddBytesSpill = result.DDBytes, result.DDBytesLen, result.DDBytesSpill tp.marker = result.RTPMarker tp.isEndOfLayerFrame = f.isEndOfLayerFrame(extPkt) + f.packetTemporal = f.vls.GetCurrent().Temporal starting, err := f.getTranslationParamsCommon(extPkt, layer, &tp) tp.isStarting = starting @@ -2291,12 +2302,21 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in return tp, nil } + if f.ddForwarder != nil { + // after translateCodecHeader, which picks the packet's temporal layer + var ddResult videolayerselector.VideoLayerSelectorResult + if f.ddForwarder.Forward(extPkt, f.packetTemporal, &ddResult) { + tp.ddBytes, tp.ddBytesLen, tp.ddBytesSpill = ddResult.DDBytes, ddResult.DDBytesLen, ddResult.DDBytesSpill + } + } + return tp, nil } func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *TranslationParams) error { // codec specific forwarding check and any needed packet munging tl := f.vls.SelectTemporal(extPkt) + f.packetTemporal = tl inputSize, codecBytes, codecBytesLen, err := f.codecMunger.UpdateAndGet( extPkt, tp.rtp.snOrdering == SequenceNumberOrderingOutOfOrder, diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 6d82db34e..36f2e92b7 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -15,6 +15,7 @@ package sfu import ( + "encoding/hex" "testing" "github.com/pion/rtp/codecs" @@ -2114,6 +2115,80 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { require.True(t, f.isDDAvailable, "temporal layers still switch with the dependency descriptor") } +func TestForwarderVP8ForwardsDependencyDescriptor(t *testing.T) { + // an L3T3 key frame descriptor with its dependency structure, the capture the + // dependency descriptor package tests with + raw, err := hex.DecodeString("c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d") + require.NoError(t, err) + keyFrameDescriptor := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &keyFrameDescriptor}).Unmarshal(raw) + require.NoError(t, err) + require.NotNil(t, keyFrameDescriptor.AttachedStructure) + + packet := func(sn uint16, frameNum uint64, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: frameNum, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + descriptor := keyFrameDescriptor + descriptor.FrameNumber = uint16(frameNum) + if !isKeyFrame { + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + } + extPkt.DependencyDescriptor.Descriptor = &descriptor + return extPkt + } + + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + // the key frame carries the structure, too big to hold inline + tp, err := f.GetTranslationParams(packet(10, 100, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddBytesSpill) + + tp, err = f.GetTranslationParams(packet(11, 101, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddInline()) + + // a restarted upstream receiver sets the codec again, the frame numbers continue on + // the same downtrack + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + tp, err = f.GetTranslationParams(packet(12, 50, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + out := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddBytesSpill) + require.NoError(t, err) + require.Equal(t, uint16(102), out.FrameNumber) + + // the last packet of a TL2 frame that starts a down switch to TL0 is forwarded at TL2, + // its descriptor names the TL2 decode target + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 2}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + tl2 := packet(13, 51, false) + tl2.Temporal = 2 + tp, err = f.GetTranslationParams(tl2, 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, int32(0), f.vls.GetCurrent().Temporal, "the switch takes effect after this packet") + out = dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddInline()) + require.NoError(t, err) + require.Equal(t, uint32(1<<0|1<<1|1<<2), *out.ActiveDecodeTargetsBitmask) + + // without the extension the forwarder writes no descriptor + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec(testutils.TestVP8Codec, nil, livekit.VideoLayer_MODE_UNUSED) + require.Nil(t, f.ddForwarder) +} + func TestForwarderPadDroppedSlot(t *testing.T) { f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) diff --git a/pkg/sfu/testutils/data.go b/pkg/sfu/testutils/data.go index 1056e4119..6ac6a9f2b 100644 --- a/pkg/sfu/testutils/data.go +++ b/pkg/sfu/testutils/data.go @@ -143,6 +143,8 @@ func GetTestExtPacketVP8DD(params *TestExtPacketParams, ddParams *TestDDParams) }, DecodeTargets: ddParams.DecodeTargets, ExtFrameNum: ddParams.ExtFrameNum, + // all decode targets active, as the parser reports after a structure + ActiveDecodeTargetsMask: 1< d.extLastFrameNum { + d.hasLast = true + d.extLastFrameNum = extOutFrameNum + } + + // tell the subscriber which decode targets to expect: the publisher's active ones up to + // the forwarded temporal layer. On a key frame too, where the structure alone would make + // all of them active. + d.activeDecodeTargetsBitmask = 0 + for _, dt := range ddwdt.DecodeTargets { + if dt.Layer.Temporal <= temporal { + d.activeDecodeTargetsBitmask |= 1 << dt.Target + } + } + d.activeDecodeTargetsBitmask &= ddwdt.ActiveDecodeTargetsMask + d.descriptor = *descriptor + d.descriptor.FrameNumber = uint16(extOutFrameNum) + d.descriptor.ActiveDecodeTargetsBitmask = &d.activeDecodeTargetsBitmask + + if err := result.marshalDependencyDescriptorExtension(&dd.DependencyDescriptorExtension{ + Descriptor: &d.descriptor, + Structure: d.structure, + }); err != nil { + d.logger.Warnw("could not marshal dependency descriptor", err, "extFrameNum", extFrameNum) + return false + } + return true +} + +// rebase moves the frame numbers up when a new stream would go back past the last one sent +func (d *DDForwarder) rebase(extFrameNum uint64) { + if d.hasLast && extFrameNum+d.frameNumOffset <= d.extLastFrameNum { + d.frameNumOffset = d.extLastFrameNum + 1 - extFrameNum + } +} diff --git a/pkg/sfu/videolayerselector/ddforwarder_test.go b/pkg/sfu/videolayerselector/ddforwarder_test.go new file mode 100644 index 000000000..cc4a47a4a --- /dev/null +++ b/pkg/sfu/videolayerselector/ddforwarder_test.go @@ -0,0 +1,234 @@ +// 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 ( + "encoding/hex" + "testing" + + "github.com/pion/rtp" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/sfu/buffer" + dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/logger" +) + +// ddForwarderTest builds packets with the L3T3 fixture descriptor and decodes what a +// DDForwarder writes +type ddForwarderTest struct { + t *testing.T + keyFrameDescriptor dd.DependencyDescriptor + structure *dd.FrameDependencyStructure + decodeTargets []buffer.DependencyDescriptorDecodeTarget +} + +func newDDForwarderTest(t *testing.T) *ddForwarderTest { + raw, err := hex.DecodeString(dependencyDescriptorFixture) + require.NoError(t, err) + keyFrameDescriptor := dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &keyFrameDescriptor}).Unmarshal(raw) + require.NoError(t, err) + require.NotNil(t, keyFrameDescriptor.AttachedStructure) + + return &ddForwarderTest{ + t: t, + keyFrameDescriptor: keyFrameDescriptor, + structure: keyFrameDescriptor.AttachedStructure, + decodeTargets: []buffer.DependencyDescriptorDecodeTarget{ + {Target: 2, Layer: buffer.VideoLayer{Temporal: 2}}, + {Target: 1, Layer: buffer.VideoLayer{Temporal: 1}}, + {Target: 0, Layer: buffer.VideoLayer{Temporal: 0}}, + }, + } +} + +func (d *ddForwarderTest) packet(ssrc uint32, extFrameNum uint64, isKeyFrame bool, restartGeneration int) *buffer.ExtPacket { + descriptor := d.keyFrameDescriptor + descriptor.FrameNumber = uint16(extFrameNum) + if !isKeyFrame { + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + } + return &buffer.ExtPacket{ + Packet: &rtp.Packet{Header: rtp.Header{SSRC: ssrc}}, + IsKeyFrame: isKeyFrame, + DependencyDescriptor: &buffer.ExtDependencyDescriptor{ + Descriptor: &descriptor, + DecodeTargets: d.decodeTargets, + ExtFrameNum: extFrameNum, + RestartGeneration: restartGeneration, + // all decode targets active, as the parser reports after a structure + ActiveDecodeTargetsMask: 1< Date: Mon, 12 Oct 2026 12:52:19 +0530 Subject: [PATCH 111/130] sfu: keep VP8 descriptor frame numbers across migration (#4989) * sfu: keep VP8 descriptor frame numbers across migration The forwarder state carries the last dependency descriptor frame number sent, so a migrated subscriber's first key frame continues the numbers instead of being dropped as older than its last structure. Bumps protocol for RTPForwarderState.dependency_descriptor_forwarder. Co-Authored-By: Claude Opus 5.5 * sfu: pass on a pending descriptor seed when migrating again A forwarder seeded before its codec was known returns the pending seed in its state, so a second migration keeps the frame numbers continuous. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- go.mod | 2 +- go.sum | 4 +-- pkg/sfu/forwarder.go | 15 ++++++++++ pkg/sfu/forwarder_test.go | 23 +++++++++++++++ pkg/sfu/videolayerselector/ddforwarder.go | 28 +++++++++++++++++-- .../videolayerselector/ddforwarder_test.go | 24 ++++++++++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/go.mod b/go.mod index d6ddedc3a..806263a4a 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-20260821083140-f234b534b095 - github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a + github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 github.com/livekit/psrpc v0.8.2 github.com/mackerelio/go-osstat v0.2.8 github.com/magefile/mage v1.17.2 diff --git a/go.sum b/go.sum index 895a278b7..f7599ec1d 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,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-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= -github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a h1:Sa9+0Kr+MVIabmJaBFtX8Yl3NYewkv02/mMDvLEKYiQ= -github.com/livekit/protocol v1.52.2-0.20261007103317-a935cd67c29a/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= +github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 h1:oZ/dI5RG22t0GalRDU1BJWCMtNAEQVPU+odrRgycKDE= +github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= github.com/livekit/psrpc v0.8.2/go.mod h1:6KnU8vULoizX0Er9NNfeKJDV8ug87j3fuo19M0bhj6Q= github.com/livekit/webrtc-pion/v4 v4.2.18-warp.6 h1:UaGYlQ/Ma73LN12xPDlH3OBYNfOEDTVOtHdRZbHg2dY= diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index ef5b0548a..99e229d9e 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -283,6 +283,8 @@ type Forwarder struct { vls videolayerselector.VideoLayerSelector // set for VP8 with the dependency descriptor, forwards the descriptor to the subscriber ddForwarder *videolayerselector.DDForwarder + // migrated descriptor state that arrived before the codec was known + ddForwarderSeed *livekit.DependencyDescriptorForwarderState // temporal layer of the packet being translated, the selector's current layer is // already the next one on the last packet before a down switch packetTemporal int32 @@ -427,6 +429,8 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ f.codecMunger = codecmunger.NewVP8DD(f.logger) // the downtrack keeps its SSRC, so the frame numbers continue f.ddForwarder = videolayerselector.NewDDForwarderFromOther(prevDDForwarder, f.logger) + f.ddForwarder.SeedState(f.ddForwarderSeed) + f.ddForwarderSeed = nil } else { f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) } @@ -539,6 +543,12 @@ func (f *Forwarder) GetState() *livekit.RTPForwarderState { for layer, refInfo := range f.refInfos { state.SenderReportState[layer] = proto.CloneOf(refInfo.senderReport) } + if f.ddForwarder != nil { + state.DependencyDescriptorForwarder = f.ddForwarder.GetState() + } else { + // seeded, but migrating on before the codec was known + state.DependencyDescriptorForwarder = proto.CloneOf(f.ddForwarderSeed) + } return state } @@ -559,6 +569,11 @@ func (f *Forwarder) SeedState(state *livekit.RTPForwarderState) { f.rtpMunger.SeedState(state.RtpMunger) f.codecMunger.SeedState(state.CodecMunger) + if f.ddForwarder != nil { + f.ddForwarder.SeedState(state.DependencyDescriptorForwarder) + } else { + f.ddForwarderSeed = state.DependencyDescriptorForwarder + } f.started = true f.referenceLayerSpatial = state.ReferenceLayerSpatial diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 36f2e92b7..398a3ee94 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -2183,6 +2183,29 @@ func TestForwarderVP8ForwardsDependencyDescriptor(t *testing.T) { require.NoError(t, err) require.Equal(t, uint32(1<<0|1<<1|1<<2), *out.ActiveDecodeTargetsBitmask) + // a migrated subscriber: the state is seeded before the codec is known, and the new + // node's first key frame continues after the last number sent + state := f.GetState() + require.Equal(t, uint64(103), state.DependencyDescriptorForwarder.ExtLastFrameNumber) + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.SeedState(state) + + // it migrates on before its codec is known, the next node still gets the number + state = f.GetState() + require.Equal(t, uint64(103), state.DependencyDescriptorForwarder.ExtLastFrameNumber) + f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.SeedState(state) + f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + tp, err = f.GetTranslationParams(packet(20, 60, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + out = dd.DependencyDescriptor{} + _, err = (&dd.DependencyDescriptorExtension{Descriptor: &out, Structure: keyFrameDescriptor.AttachedStructure}).Unmarshal(tp.ddBytesSpill) + require.NoError(t, err) + require.Equal(t, uint16(104), out.FrameNumber) + // without the extension the forwarder writes no descriptor f = NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) f.DetermineCodec(testutils.TestVP8Codec, nil, livekit.VideoLayer_MODE_UNUSED) diff --git a/pkg/sfu/videolayerselector/ddforwarder.go b/pkg/sfu/videolayerselector/ddforwarder.go index c5394df85..f5e525579 100644 --- a/pkg/sfu/videolayerselector/ddforwarder.go +++ b/pkg/sfu/videolayerselector/ddforwarder.go @@ -17,6 +17,7 @@ package videolayerselector import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) @@ -31,8 +32,8 @@ import ( // subscriber received is dropped. So whenever the stream changes, a layer switch, a stream // restart or a new forwarder, the numbers are moved up past the last one sent if needed. // Otherwise they pass through with the current offset, so frame diffs and chain diffs stay -// valid. A migrated subscriber starts with the publisher's numbers, which continue its old -// ones in most cases but not all, as the last number sent is not part of the migrated state. +// valid. A migrated subscriber continues with the last number sent, which is part of the +// migrated forwarder state. type DDForwarder struct { logger logger.Logger @@ -66,6 +67,29 @@ func NewDDForwarderFromOther(other *DDForwarder, logger logger.Logger) *DDForwar return d } +// GetState returns the last frame number sent, nil before the first one. +func (d *DDForwarder) GetState() *livekit.DependencyDescriptorForwarderState { + if !d.hasLast { + return nil + } + return &livekit.DependencyDescriptorForwarderState{ + ExtLastFrameNumber: d.extLastFrameNum, + } +} + +// SeedState continues after the last frame number a forwarder on another node sent to the +// subscriber. Extended numbers there started from a lower or equal base, so the first key +// frame here moves past it, and the 16 bit numbers continue. +func (d *DDForwarder) SeedState(state *livekit.DependencyDescriptorForwarderState) { + if state == nil { + return + } + if !d.hasLast || state.ExtLastFrameNumber > d.extLastFrameNum { + d.hasLast = true + d.extLastFrameNum = state.ExtLastFrameNumber + } +} + // Forward marshals the descriptor of a forwarded packet into result. temporal is the // temporal layer the packet is forwarded at. It returns false when there is nothing to // send, before the first key frame of the stream for example. diff --git a/pkg/sfu/videolayerselector/ddforwarder_test.go b/pkg/sfu/videolayerselector/ddforwarder_test.go index cc4a47a4a..701fe2503 100644 --- a/pkg/sfu/videolayerselector/ddforwarder_test.go +++ b/pkg/sfu/videolayerselector/ddforwarder_test.go @@ -23,6 +23,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" dd "github.com/livekit/livekit-server/pkg/sfu/rtpextension/dependencydescriptor" + "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) @@ -197,6 +198,29 @@ func TestDDForwarderPublisherActiveDecodeTargets(t *testing.T) { require.Equal(t, uint32(1<<0|1<<1|1<<2), *d.forward(f, d.packet(1, 103, true, 0), 2).ActiveDecodeTargetsBitmask) } +func TestDDForwarderState(t *testing.T) { + d := newDDForwarderTest(t) + f := NewDDForwarder(logger.GetLogger()) + require.Nil(t, f.GetState()) + require.Equal(t, uint16(1000), d.forward(f, d.packet(1, 1000, true, 0), 0).FrameNumber) + require.Equal(t, uint16(1003), d.forward(f, d.packet(1, 1003, false, 0), 0).FrameNumber) + state := f.GetState() + require.Equal(t, uint64(1003), state.ExtLastFrameNumber) + + // a migrated subscriber whose new node's key frame is behind continues after the last + // number sent + f = NewDDForwarder(logger.GetLogger()) + f.SeedState(state) + require.Nil(t, d.forward(f, d.packet(1, 999, false, 0), 0)) + require.Equal(t, uint16(1004), d.forward(f, d.packet(1, 1001, true, 0), 0).FrameNumber) + + // the old node had wrapped its extended numbers, the new node starts from the 16 bit + // value: the 16 bit numbers still continue + f = NewDDForwarder(logger.GetLogger()) + f.SeedState(&livekit.DependencyDescriptorForwarderState{ExtLastFrameNumber: 1<<16 + 1003}) + require.Equal(t, uint16(1004), d.forward(f, d.packet(1, 1001, true, 0), 0).FrameNumber) +} + func TestDDForwarderAllocations(t *testing.T) { raw, err := hex.DecodeString(dependencyDescriptorFixture) require.NoError(t, err) From 93296ad4f662ee0d3cea081eb42bd3ff1ea4f05c Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 12 Oct 2026 16:53:09 +0530 Subject: [PATCH 112/130] sfu: filter VP9 and AV1 simulcast temporal layers with the dependency descriptor (#4991) * sfu: filter VP9 and AV1 simulcast temporal layers with the dependency descriptor Every simulcast codec gets an explicit temporal layer selector. With the dependency descriptor, VP8, VP9 and AV1 filter temporal layers by frame and forward the descriptor. Without it, a pass-through selector forwards every temporal layer and reports the target as reached, so a pending target cannot block allocation. VP8DD becomes the codec-neutral DDTemporal. Co-Authored-By: Claude Opus 5.5 * sfu: VP8-only padding header in DDTemporal, structure gate on presence DDTemporal returns the one byte VP8 payload descriptor for padding only for VP8. The descriptor forwarder takes a structure from any packet that carries one, as the parser accepts it only on the first packet of a frame. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/sfu/codecmunger/codecmunger.go | 4 +- .../codecmunger/{vp8dd.go => ddtemporal.go} | 55 ++-- .../{vp8dd_test.go => ddtemporal_test.go} | 36 +-- pkg/sfu/codecmunger/vp8.go | 2 +- pkg/sfu/codecmunger/vp8_test.go | 6 +- pkg/sfu/forwarder.go | 182 ++++++++------ pkg/sfu/forwarder_test.go | 237 ++++++++++++++++-- pkg/sfu/videolayerselector/ddforwarder.go | 3 +- .../temporallayerselector/null.go | 11 +- .../temporallayerselector/null_test.go | 35 +++ 10 files changed, 423 insertions(+), 148 deletions(-) rename pkg/sfu/codecmunger/{vp8dd.go => ddtemporal.go} (57%) rename pkg/sfu/codecmunger/{vp8dd_test.go => ddtemporal_test.go} (72%) create mode 100644 pkg/sfu/videolayerselector/temporallayerselector/null_test.go diff --git a/pkg/sfu/codecmunger/codecmunger.go b/pkg/sfu/codecmunger/codecmunger.go index efab8d6b5..add55f333 100644 --- a/pkg/sfu/codecmunger/codecmunger.go +++ b/pkg/sfu/codecmunger/codecmunger.go @@ -23,8 +23,8 @@ import ( var ( ErrNotVP8 = errors.New("not VP8") ErrOutOfOrderVP8PictureIdCacheMiss = errors.New("out-of-order VP8 picture id not found in cache") - ErrFilteredVP8TemporalLayer = errors.New("filtered VP8 temporal layer") - ErrFilteredVP8OutOfOrder = errors.New("filtered out-of-order VP8 packet") + ErrFilteredTemporalLayer = errors.New("filtered temporal layer") + ErrFilteredOutOfOrder = errors.New("filtered out-of-order packet") ) // MaxHeaderSize is the largest codec header the mungers produce (VP8: 6 bytes). diff --git a/pkg/sfu/codecmunger/vp8dd.go b/pkg/sfu/codecmunger/ddtemporal.go similarity index 57% rename from pkg/sfu/codecmunger/vp8dd.go rename to pkg/sfu/codecmunger/ddtemporal.go index 97bea6df6..7fcbd116e 100644 --- a/pkg/sfu/codecmunger/vp8dd.go +++ b/pkg/sfu/codecmunger/ddtemporal.go @@ -17,20 +17,22 @@ package codecmunger import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/mediatransportutil/pkg/codec" + "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/logger" ) // frames with a remembered decision, behind the highest frame number -const vp8DDFrameWindow = 64 +const ddTemporalFrameWindow = 64 -// VP8DD filters VP8 temporal layers with the dependency descriptor. libwebrtc sends a -// one byte VP8 payload descriptor with the dependency descriptor, so there is no picture -// ID, TL0PICIDX or key index to munge, and the header goes out unchanged. Every packet -// names its frame and temporal layer, so the munger decides once per frame and applies -// that decision to all packets of the frame, a late one included. It needs the one byte -// descriptor: the forwarder uses the VP8 munger for a stream that keeps picture IDs. -type VP8DD struct { +// DDTemporal filters the temporal layers of a simulcast stream with the dependency +// descriptor, for any codec: it leaves the payload unchanged. Every packet names its frame +// and temporal layer, so the munger decides once per frame and applies that decision to all +// packets of the frame, a late one included. For VP8, libwebrtc sends a one byte payload +// descriptor with the dependency descriptor, so there is no picture ID to munge; the +// forwarder uses the VP8 munger for a stream that keeps picture IDs. +type DDTemporal struct { logger logger.Logger + isVP8 bool // padding needs a VP8 payload descriptor initialized bool highestFrame uint64 @@ -38,29 +40,30 @@ type VP8DD struct { forwardedMask uint64 // bit i: frame highestFrame - i is forwarded } -func NewVP8DD(logger logger.Logger) *VP8DD { - return &VP8DD{ +func NewDDTemporal(mimeType mime.MimeType, logger logger.Logger) *DDTemporal { + return &DDTemporal{ logger: logger, + isVP8: mimeType == mime.MimeTypeVP8, } } -func (v *VP8DD) GetState() any { +func (v *DDTemporal) GetState() any { return nil } -func (v *VP8DD) SeedState(_state any) { +func (v *DDTemporal) SeedState(_state any) { } -func (v *VP8DD) SetLast(_extPkt *buffer.ExtPacket) { +func (v *DDTemporal) SetLast(_extPkt *buffer.ExtPacket) { v.initialized = false } -func (v *VP8DD) UpdateOffsets(_extPkt *buffer.ExtPacket) { +func (v *DDTemporal) UpdateOffsets(_extPkt *buffer.ExtPacket) { // a layer switch starts a stream with its own frame numbers v.initialized = false } -func (v *VP8DD) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, _snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { +func (v *DDTemporal) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, _snHasGap bool, maxTemporalLayer int32) (int, [MaxHeaderSize]byte, int, error) { var hdr [MaxHeaderSize]byte ddwdt := extPkt.DependencyDescriptor if ddwdt == nil { @@ -81,14 +84,18 @@ func (v *VP8DD) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, _snHas case isForwarded: return 0, hdr, 0, nil case snOutOfOrder: - return 0, hdr, 0, ErrFilteredVP8OutOfOrder + return 0, hdr, 0, ErrFilteredOutOfOrder default: - return 0, hdr, 0, ErrFilteredVP8TemporalLayer + return 0, hdr, 0, ErrFilteredTemporalLayer } } -func (v *VP8DD) UpdateAndGetPadding(_newPicture bool) ([]byte, error) { - // the one byte payload descriptor of a key frame that starts a partition +func (v *DDTemporal) UpdateAndGetPadding(_newPicture bool) ([]byte, error) { + if !v.isVP8 { + return nil, nil + } + + // the one byte VP8 payload descriptor of a key frame that starts a partition vp8Packet := &codec.VP8{ FirstByte: 0x10, IsKeyFrame: true, @@ -97,17 +104,17 @@ func (v *VP8DD) UpdateAndGetPadding(_newPicture bool) ([]byte, error) { return vp8Packet.Marshal() } -func (v *VP8DD) decision(frame uint64) (isForwarded bool, ok bool) { - if !v.initialized || frame > v.highestFrame || v.highestFrame-frame >= vp8DDFrameWindow { +func (v *DDTemporal) decision(frame uint64) (isForwarded bool, ok bool) { + if !v.initialized || frame > v.highestFrame || v.highestFrame-frame >= ddTemporalFrameWindow { return false, false } bit := uint64(1) << (v.highestFrame - frame) return v.forwardedMask&bit != 0, v.decidedMask&bit != 0 } -func (v *VP8DD) record(frame uint64, isForwarded bool) bool { +func (v *DDTemporal) record(frame uint64, isForwarded bool) bool { switch { - case !v.initialized || frame >= v.highestFrame+vp8DDFrameWindow: + case !v.initialized || frame >= v.highestFrame+ddTemporalFrameWindow: v.initialized = true v.highestFrame = frame v.decidedMask = 0 @@ -118,7 +125,7 @@ func (v *VP8DD) record(frame uint64, isForwarded bool) bool { v.forwardedMask <<= frame - v.highestFrame v.highestFrame = frame - case v.highestFrame-frame >= vp8DDFrameWindow: + case v.highestFrame-frame >= ddTemporalFrameWindow: return false } diff --git a/pkg/sfu/codecmunger/vp8dd_test.go b/pkg/sfu/codecmunger/ddtemporal_test.go similarity index 72% rename from pkg/sfu/codecmunger/vp8dd_test.go rename to pkg/sfu/codecmunger/ddtemporal_test.go index c8453dbf9..46973017f 100644 --- a/pkg/sfu/codecmunger/vp8dd_test.go +++ b/pkg/sfu/codecmunger/ddtemporal_test.go @@ -21,6 +21,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/testutils" + "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/logger" ) @@ -38,63 +39,70 @@ func vp8DDPacket(t *testing.T, frameNum uint64, tid int) *buffer.ExtPacket { return extPkt } -func TestVP8DDUpdateAndGet(t *testing.T) { +func TestDDTemporalUpdateAndGet(t *testing.T) { t.Run("filters by temporal layer, header unchanged", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) inputSize, _, n, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) require.NoError(t, err) require.Zero(t, inputSize) require.Zero(t, n) _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) }) t.Run("a gap does not exempt a filtered layer", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 0, 0), false, false, 0) require.NoError(t, err) for frameNum := uint64(2); frameNum < 6; frameNum++ { _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, frameNum, 1), false, true, 0) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) } }) t.Run("every packet of a frame gets the decision of its first packet", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), false, false, 1) require.NoError(t, err) // the layer changed in between, late packets keep the decision of their frame _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), true, false, 2) - require.ErrorIs(t, err, ErrFilteredVP8OutOfOrder) + require.ErrorIs(t, err, ErrFilteredOutOfOrder) _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 2, 1), true, false, 0) require.NoError(t, err) }) t.Run("late packet of a frame older than the window", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 100, 0), false, false, 2) require.NoError(t, err) - _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 100-vp8DDFrameWindow, 0), true, false, 2) - require.ErrorIs(t, err, ErrFilteredVP8OutOfOrder) + _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 100-ddTemporalFrameWindow, 0), true, false, 2) + require.ErrorIs(t, err, ErrFilteredOutOfOrder) }) t.Run("layer switch forgets decisions", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) _, _, _, err := v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 0) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) v.UpdateOffsets(vp8DDPacket(t, 1, 0)) _, _, _, err = v.UpdateAndGet(vp8DDPacket(t, 1, 2), false, false, 2) require.NoError(t, err) }) t.Run("padding", func(t *testing.T) { - v := NewVP8DD(logger.GetLogger()) + v := NewDDTemporal(mime.MimeTypeVP8, logger.GetLogger()) hdr, err := v.UpdateAndGetPadding(true) require.NoError(t, err) require.Equal(t, []byte{0x10}, hdr) + + // other codecs have no payload descriptor to prepend + for _, mimeType := range []mime.MimeType{mime.MimeTypeVP9, mime.MimeTypeAV1} { + hdr, err = NewDDTemporal(mimeType, logger.GetLogger()).UpdateAndGetPadding(true) + require.NoError(t, err) + require.Nil(t, hdr) + } }) } diff --git a/pkg/sfu/codecmunger/vp8.go b/pkg/sfu/codecmunger/vp8.go index 748acfcef..dc1d78b00 100644 --- a/pkg/sfu/codecmunger/vp8.go +++ b/pkg/sfu/codecmunger/vp8.go @@ -263,7 +263,7 @@ func (v *VP8) UpdateAndGet(extPkt *buffer.ExtPacket, snOutOfOrder bool, snHasGap v.pictureIdOffset += 1 } - return 0, hdr, 0, ErrFilteredVP8TemporalLayer + return 0, hdr, 0, ErrFilteredTemporalLayer } } } diff --git a/pkg/sfu/codecmunger/vp8_test.go b/pkg/sfu/codecmunger/vp8_test.go index 7c6d1a36f..79bb6ef8e 100644 --- a/pkg/sfu/codecmunger/vp8_test.go +++ b/pkg/sfu/codecmunger/vp8_test.go @@ -296,7 +296,7 @@ func TestTemporalLayerFiltering(t *testing.T) { // translate nIn, _, nOut, err := v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) require.Equal(t, 0, nOut) dropped, _ := v.droppedPictureIds.Get(13467) @@ -310,7 +310,7 @@ func TestTemporalLayerFiltering(t *testing.T) { nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) @@ -324,7 +324,7 @@ func TestTemporalLayerFiltering(t *testing.T) { nIn, _, nOut, err = v.UpdateAndGet(extPkt, false, false, 0) require.Error(t, err) - require.ErrorIs(t, err, ErrFilteredVP8TemporalLayer) + require.ErrorIs(t, err, ErrFilteredTemporalLayer) require.Equal(t, 0, nIn) require.Equal(t, 0, nOut) dropped, _ = v.droppedPictureIds.Get(13467) diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 99e229d9e..7e378ad20 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -424,95 +424,110 @@ func (f *Forwarder) DetermineCodec(codec webrtc.RTPCodecCapability, extensions [ f.ddForwarder = nil switch f.mime { case mime.MimeTypeVP8: - f.isDDAvailable = ddAvailable(extensions) - if f.isDDAvailable { - f.codecMunger = codecmunger.NewVP8DD(f.logger) - // the downtrack keeps its SSRC, so the frame numbers continue - f.ddForwarder = videolayerselector.NewDDForwarderFromOther(prevDDForwarder, f.logger) - f.ddForwarder.SeedState(f.ddForwarderSeed) - f.ddForwarderSeed = nil + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) } else { + f.isDDAvailable = false f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) - } - if f.vls != nil { - if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { - f.vls = vls - } else { - f.logger.Errorw("failed to create simulcast on codec change", nil) - } - } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } - if f.isDDAvailable { - f.vls.SetTemporalLayerSelector(temporallayerselector.NewDependencyDescriptor(f.logger)) - } else { f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) } case mime.MimeTypeH264, mime.MimeTypeH265: - f.codecMunger = codecmunger.NewNull(f.logger) - if f.vls != nil { - if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { - f.vls = vls - } else { - f.logger.Errorw("failed to create simulcast on codec change", nil) - } - } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } + f.setSimulcastSelector() + f.setUnfilteredTemporal() case mime.MimeTypeVP9: - f.codecMunger = codecmunger.NewNull(f.logger) if sfuutils.IsSimulcastMode(videoLayerMode) { - if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } - } else { - f.isDDAvailable = ddAvailable(extensions) - if f.isDDAvailable { - if f.vls != nil { - f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) - } else { - f.vls = videolayerselector.NewDependencyDescriptor(f.logger) - } - } else { - if f.vls != nil { - f.vls = videolayerselector.NewVP9FromOther(f.vls) - } else { - f.vls = videolayerselector.NewVP9(f.logger) - } + f.setUnfilteredTemporal() } + break } - case mime.MimeTypeAV1: f.codecMunger = codecmunger.NewNull(f.logger) - if sfuutils.IsSimulcastMode(videoLayerMode) { + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) } else { - f.vls = videolayerselector.NewSimulcast(f.logger) + f.vls = videolayerselector.NewDependencyDescriptor(f.logger) } } else { - f.isDDAvailable = ddAvailable(extensions) - if f.isDDAvailable { - if f.vls != nil { - f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) - } else { - f.vls = videolayerselector.NewDependencyDescriptor(f.logger) - } + if f.vls != nil { + f.vls = videolayerselector.NewVP9FromOther(f.vls) } else { - if f.vls != nil { - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) - } else { - f.vls = videolayerselector.NewSimulcast(f.logger) - } + f.vls = videolayerselector.NewVP9(f.logger) } } + // the svc selectors pick temporal layers themselves + f.vls.SetTemporalLayerSelector(nil) + + case mime.MimeTypeAV1: + if sfuutils.IsSimulcastMode(videoLayerMode) { + f.setSimulcastSelector() + if ddAvailable(extensions) { + f.setDDTemporalFilter(prevDDForwarder) + } else { + f.setUnfilteredTemporal() + } + break + } + + f.codecMunger = codecmunger.NewNull(f.logger) + f.isDDAvailable = ddAvailable(extensions) + if f.isDDAvailable { + if f.vls != nil { + f.vls = videolayerselector.NewDependencyDescriptorFromOther(f.vls) + } else { + f.vls = videolayerselector.NewDependencyDescriptor(f.logger) + } + f.vls.SetTemporalLayerSelector(nil) + } else { + // without the dependency descriptor nothing tells the temporal layers apart + f.setSimulcastSelector() + f.setUnfilteredTemporal() + } } } +// setSimulcastSelector switches to the simulcast selector, keeping the selector state +// across a codec change +func (f *Forwarder) setSimulcastSelector() { + if f.vls == nil { + f.vls = videolayerselector.NewSimulcast(f.logger) + return + } + if vls := videolayerselector.NewSimulcastFromOther(f.vls); vls != nil { + f.vls = vls + } else { + f.logger.Errorw("failed to create simulcast on codec change", nil) + } +} + +// setDDTemporalFilter filters the temporal layers of a simulcast stream by frame with the +// dependency descriptor, and forwards the descriptor to the subscriber +func (f *Forwarder) setDDTemporalFilter(prevDDForwarder *videolayerselector.DDForwarder) { + f.isDDAvailable = true + f.codecMunger = codecmunger.NewDDTemporal(f.mime, f.logger) + f.vls.SetTemporalLayerSelector(temporallayerselector.NewDependencyDescriptor(f.logger)) + // the downtrack keeps its SSRC, so the frame numbers continue + f.ddForwarder = videolayerselector.NewDDForwarderFromOther(prevDDForwarder, f.logger) + f.ddForwarder.SeedState(f.ddForwarderSeed) + f.ddForwarderSeed = nil +} + +// setUnfilteredTemporal forwards every temporal layer of a simulcast stream that has +// nothing to filter them with +func (f *Forwarder) setUnfilteredTemporal() { + f.isDDAvailable = false + f.codecMunger = codecmunger.NewNull(f.logger) + f.vls.SetTemporalLayerSelector(temporallayerselector.NewNull()) +} + func (f *Forwarder) GetState() *livekit.RTPForwarderState { f.lock.RLock() defer f.lock.RUnlock() @@ -2211,21 +2226,26 @@ func (f *Forwarder) isEndOfLayerFrame(extPkt *buffer.ExtPacket) bool { // should be called with lock held func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer int32) (TranslationParams, error) { tp := TranslationParams{} - if f.isDDAvailable && f.mime == mime.MimeTypeVP8 && extPkt.DependencyDescriptor == nil && len(extPkt.Packet.Payload) != 0 { + if f.ddForwarder != nil && extPkt.DependencyDescriptor == nil && len(extPkt.Packet.Payload) != 0 { // The buffer stopped parsing the dependency descriptor because the publisher does - // not send it, so switch temporal layers with the VP8 payload descriptor. Padding - // never carries the descriptor, so it does not count. - f.logger.Infow("turning off dependency descriptor for VP8 temporal layers", "layer", layer) - f.isDDAvailable = false + // not send it. VP8 switches temporal layers with its payload descriptor, other + // codecs forward every temporal layer. Padding never carries the descriptor, so it + // does not count. + f.logger.Infow("turning off dependency descriptor for simulcast temporal layers", "layer", layer) f.ddForwarder = nil - f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) - if f.started { - // the munger takes its picture ID and TL0PICIDX from the first packet it sees - f.codecMunger.SetLast(extPkt) + if f.mime == mime.MimeTypeVP8 { + f.isDDAvailable = false + f.codecMunger = codecmunger.NewVP8FromOther(f.codecMunger, f.logger) + if f.started { + // the munger takes its picture ID and TL0PICIDX from the first packet it sees + f.codecMunger.SetLast(extPkt) + } + f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) + } else { + f.setUnfilteredTemporal() } - f.vls.SetTemporalLayerSelector(temporallayerselector.NewVP8(f.logger)) } - if _, ok := f.codecMunger.(*codecmunger.VP8DD); ok && extPkt.DependencyDescriptor != nil { + if _, ok := f.codecMunger.(*codecmunger.DDTemporal); ok && f.mime == mime.MimeTypeVP8 && extPkt.DependencyDescriptor != nil { if extPkt.VP8 != nil && extPkt.VP8.I { // The publisher sends picture IDs with the dependency descriptor, so they need // rewriting. The VP8 munger does that, the temporal layers still switch with the @@ -2246,7 +2266,8 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in result := f.vls.Select(extPkt, layer) if !result.IsSelected { - if f.isDDAvailable && f.mime != mime.MimeTypeVP8 && extPkt.DependencyDescriptor == nil { + // the svc selector, simulcast with the dependency descriptor turns it off above + if f.isDDAvailable && f.ddForwarder == nil && extPkt.DependencyDescriptor == nil { f.logger.Infow( "turning off dependency descriptor", "layer", layer, @@ -2261,7 +2282,8 @@ func (f *Forwarder) getTranslationParamsVideo(extPkt *buffer.ExtPacket, layer in case mime.MimeTypeVP9: f.vls = videolayerselector.NewVP9FromOther(f.vls) case mime.MimeTypeAV1: - f.vls = videolayerselector.NewSimulcastFromOther(f.vls) + f.setSimulcastSelector() + f.setUnfilteredTemporal() } } tp.shouldDrop = true @@ -2340,10 +2362,10 @@ func (f *Forwarder) translateCodecHeader(extPkt *buffer.ExtPacket, tp *Translati ) if err != nil { tp.shouldDrop = true - if err == codecmunger.ErrFilteredVP8TemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss || err == codecmunger.ErrFilteredVP8OutOfOrder { + if err == codecmunger.ErrFilteredTemporalLayer || err == codecmunger.ErrOutOfOrderVP8PictureIdCacheMiss || err == codecmunger.ErrFilteredOutOfOrder { switch tp.rtp.snOrdering { case SequenceNumberOrderingContiguous: - if err == codecmunger.ErrFilteredVP8TemporalLayer { + if err == codecmunger.ErrFilteredTemporalLayer { // filtered temporal layer, update sequence number offset to prevent holes f.rtpMunger.PacketDropped(extPkt) } diff --git a/pkg/sfu/forwarder_test.go b/pkg/sfu/forwarder_test.go index 398a3ee94..567a79096 100644 --- a/pkg/sfu/forwarder_test.go +++ b/pkg/sfu/forwarder_test.go @@ -1980,7 +1980,7 @@ func TestForwarderVP8TemporalUpSwitchAtLayerSync(t *testing.T) { func TestForwarderVP8TemporalUpSwitchWithDependencyDescriptor(t *testing.T) { f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) - require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) @@ -2090,7 +2090,7 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) f.DetermineCodec(testutils.TestVP8Codec, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 2}) - require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) // the one byte descriptor keeps the dependency descriptor munger extPkt, err := testutils.GetTestExtPacketVP8DD( @@ -2099,7 +2099,7 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { ) require.NoError(t, err) _, _ = f.GetTranslationParams(extPkt, 0) - require.IsType(t, &codecmunger.VP8DD{}, f.codecMunger) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) require.True(t, f.started) // a picture ID next to the dependency descriptor needs the VP8 munger @@ -2115,30 +2115,40 @@ func TestForwarderVP8DDWithPictureID(t *testing.T) { require.True(t, f.isDDAvailable, "temporal layers still switch with the dependency descriptor") } -func TestForwarderVP8ForwardsDependencyDescriptor(t *testing.T) { - // an L3T3 key frame descriptor with its dependency structure, the capture the - // dependency descriptor package tests with +// testKeyFrameDescriptor is an L3T3 key frame descriptor with its dependency structure, the +// capture the dependency descriptor package tests with +func testKeyFrameDescriptor(t *testing.T) dd.DependencyDescriptor { raw, err := hex.DecodeString("c1017280081485214eafffaaaa863cf0430c10c302afc0aaa0063c00430010c002a000a80006000040001d954926e082b04a0941b820ac1282503157f974000ca864330e222222eca8655304224230eca877530077004200ef008601df010d") require.NoError(t, err) keyFrameDescriptor := dd.DependencyDescriptor{} _, err = (&dd.DependencyDescriptorExtension{Descriptor: &keyFrameDescriptor}).Unmarshal(raw) require.NoError(t, err) require.NotNil(t, keyFrameDescriptor.AttachedStructure) + return keyFrameDescriptor +} +// testDDPacket is a packet of frame frameNum on temporalID, with keyFrameDescriptor as its +// dependency descriptor, its structure attached only on a key frame +func testDDPacket(t *testing.T, keyFrameDescriptor dd.DependencyDescriptor, sn uint16, frameNum uint64, temporalID int, isKeyFrame bool) *buffer.ExtPacket { + extPkt, err := testutils.GetTestExtPacketVP8DD( + &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, + &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: frameNum, TemporalID: temporalID, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, + ) + require.NoError(t, err) + descriptor := keyFrameDescriptor + descriptor.FrameNumber = uint16(frameNum) + if !isKeyFrame { + descriptor.AttachedStructure = nil + descriptor.ActiveDecodeTargetsBitmask = nil + } + extPkt.DependencyDescriptor.Descriptor = &descriptor + return extPkt +} + +func TestForwarderVP8ForwardsDependencyDescriptor(t *testing.T) { + keyFrameDescriptor := testKeyFrameDescriptor(t) packet := func(sn uint16, frameNum uint64, isKeyFrame bool) *buffer.ExtPacket { - extPkt, err := testutils.GetTestExtPacketVP8DD( - &testutils.TestExtPacketParams{SequenceNumber: sn, Timestamp: 0xabcdef + uint32(frameNum)*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true, IsKeyFrame: isKeyFrame}, - &testutils.TestDDParams{FirstPacketInFrame: true, ExtFrameNum: frameNum, DTIs: "SSS", DecodeTargets: testutils.TestVP8DecodeTargets}, - ) - require.NoError(t, err) - descriptor := keyFrameDescriptor - descriptor.FrameNumber = uint16(frameNum) - if !isKeyFrame { - descriptor.AttachedStructure = nil - descriptor.ActiveDecodeTargetsBitmask = nil - } - extPkt.DependencyDescriptor.Descriptor = &descriptor - return extPkt + return testDDPacket(t, keyFrameDescriptor, sn, frameNum, 0, isKeyFrame) } f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) @@ -2692,3 +2702,192 @@ func TestForwarderNeedsPeriodicSyncCheck(t *testing.T) { f.DetermineCodec(av1, []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, livekit.VideoLayer_MODE_UNUSED) require.True(t, f.NeedsPeriodicSyncCheck()) } + +// TestForwarderSimulcastTemporalLayerAfterSwitch checks that, after a spatial layer switch on a +// key frame (which is always on temporal layer 0), the forwarder reaches its target layer so that +// GetNextHigherTransition (and hence stream allocator probing) is not blocked forever. +// +// VP9, AV1 and H.264 simulcast without the dependency descriptor forward every temporal layer of +// the selected spatial layer, so the current temporal layer has to follow the target. VP8 steps +// up through its temporal layer selector. +func TestForwarderSimulcastTemporalLayerAfterSwitch(t *testing.T) { + bitrates := Bitrates{ + {100, 150, 200, 0}, + {300, 450, 600, 0}, + {900, 1350, 1800, 0}, + } + + testCases := []struct { + name string + previousCodec *webrtc.RTPCodecCapability + codec webrtc.RTPCodecCapability + target buffer.VideoLayer + expectedCurrent buffer.VideoLayer + }{ + { + name: "VP9 simulcast, three temporal layers", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "AV1 simulcast, three temporal layers", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "VP9 simulcast, one temporal layer", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + }, + { + name: "H.264 simulcast", + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeH264.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 0}, + }, + { + // the VP8 temporal layer selector moves to the target on a key frame + name: "VP8 simulcast, three temporal layers", + codec: testutils.TestVP8Codec, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + // the VP8 temporal layer selector must not be carried over on codec change + name: "VP8 to VP9 simulcast, three temporal layers", + previousCodec: &testutils.TestVP8Codec, + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP9.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + { + name: "VP8 to AV1 simulcast, three temporal layers", + previousCodec: &testutils.TestVP8Codec, + codec: webrtc.RTPCodecCapability{MimeType: mime.MimeTypeAV1.String(), ClockRate: 90000}, + target: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + expectedCurrent: buffer.VideoLayer{Spatial: 1, Temporal: 2}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + f := NewForwarder( + webrtc.RTPCodecTypeVideo, + logger.GetLogger(), + true, // skipReferenceTS + true, // disableOpportunisticAllocation + false, // enableStartAtDesiredQuality + nil, + ) + if tc.previousCodec != nil { + f.DetermineCodec(*tc.previousCodec, nil, livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM) + } + f.DetermineCodec(tc.codec, nil, livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM) + f.SetMaxSpatialLayer(buffer.DefaultMaxLayerSpatial) + f.SetMaxTemporalLayer(buffer.DefaultMaxLayerTemporal) + f.SetMaxPublishedLayer(buffer.DefaultMaxLayerSpatial) + f.SetMaxTemporalLayerSeen(buffer.DefaultMaxLayerTemporal) + + // down-switched for bandwidth: was on the top layer, target is now lower + f.lastAllocation.IsDeficient = true + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 2, Temporal: tc.target.Temporal}) + f.vls.SetTarget(tc.target) + + // target pending (spatial switch not done yet): no higher transition + _, available := f.GetNextHigherTransition(bitrates, false) + require.False(t, available) + + // key frame of the target spatial layer, temporal layer 0 + params := &testutils.TestExtPacketParams{ + SequenceNumber: 23333, + Timestamp: 0xabcdef, + SSRC: 0x12345678, + PayloadSize: 20, + IsKeyFrame: true, + VideoLayer: buffer.VideoLayer{Spatial: tc.target.Spatial, Temporal: 0}, + } + var extPkt *buffer.ExtPacket + var err error + if tc.codec.MimeType == testutils.TestVP8Codec.MimeType { + extPkt, err = testutils.GetTestExtPacketVP8(params, &codec.VP8{ + FirstByte: 25, + I: true, + M: true, + PictureID: 13467, + L: true, + TL0PICIDX: 233, + T: true, + TID: 0, + Y: true, + K: true, + KEYIDX: 23, + HeaderSize: 6, + IsKeyFrame: true, + }) + } else { + extPkt, err = testutils.GetTestExtPacket(params) + } + require.NoError(t, err) + + tp, err := f.GetTranslationParams(extPkt, tc.target.Spatial) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.True(t, tp.isSwitching) + + require.Equal(t, tc.expectedCurrent, f.vls.GetCurrent()) + // target reached: the next higher transition (and hence probing) is available + _, available = f.GetNextHigherTransition(bitrates, false) + require.True(t, available) + }) + } +} + +// VP9 and AV1 simulcast with the dependency descriptor filter temporal layers by frame and +// forward the descriptor, as VP8 does. A publisher that stops sending the descriptor gets +// every temporal layer forwarded. +func TestForwarderSimulcastDependencyDescriptor(t *testing.T) { + for _, mimeType := range []mime.MimeType{mime.MimeTypeVP9, mime.MimeTypeAV1} { + t.Run(mimeType.String(), func(t *testing.T) { + keyFrameDescriptor := testKeyFrameDescriptor(t) + f := NewForwarder(webrtc.RTPCodecTypeVideo, logger.GetLogger(), true, true, true, nil) + f.DetermineCodec( + webrtc.RTPCodecCapability{MimeType: mimeType.String(), ClockRate: 90000}, + []webrtc.RTPHeaderExtensionParameter{{URI: dd.ExtensionURI}}, + livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM, + ) + require.IsType(t, &codecmunger.DDTemporal{}, f.codecMunger) + require.NotNil(t, f.ddForwarder) + f.vls.SetCurrent(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + f.vls.SetTarget(buffer.VideoLayer{Spatial: 0, Temporal: 0}) + + tp, err := f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 10, 100, 0, true), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.NotEmpty(t, tp.ddBytesSpill) + + // TL2 is above the target and is filtered, its slot is rolled back + tp, err = f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 11, 101, 2, false), 0) + require.NoError(t, err) + require.True(t, tp.shouldDrop) + + tp, err = f.GetTranslationParams(testDDPacket(t, keyFrameDescriptor, 12, 102, 0, false), 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Equal(t, uint64(11), tp.rtp.extSequenceNumber) + require.NotEmpty(t, tp.ddInline()) + + // the publisher stops sending the descriptor + noDD, err := testutils.GetTestExtPacket(&testutils.TestExtPacketParams{SequenceNumber: 13, Timestamp: 0xabcdef + 103*3000, SSRC: 0x12345678, PayloadSize: 20, Marker: true}) + require.NoError(t, err) + tp, err = f.GetTranslationParams(noDD, 0) + require.NoError(t, err) + require.False(t, tp.shouldDrop) + require.Nil(t, f.ddForwarder) + require.IsType(t, &codecmunger.Null{}, f.codecMunger) + require.Empty(t, tp.ddInline()) + }) + } +} diff --git a/pkg/sfu/videolayerselector/ddforwarder.go b/pkg/sfu/videolayerselector/ddforwarder.go index f5e525579..2080f34d7 100644 --- a/pkg/sfu/videolayerselector/ddforwarder.go +++ b/pkg/sfu/videolayerselector/ddforwarder.go @@ -101,7 +101,8 @@ func (d *DDForwarder) Forward(extPkt *buffer.ExtPacket, temporal int32, result * descriptor := ddwdt.Descriptor extFrameNum := ddwdt.ExtFrameNum - hasStructure := extPkt.IsKeyFrame && descriptor.AttachedStructure != nil + // the parser accepts a structure only on the first packet of a frame + hasStructure := descriptor.AttachedStructure != nil switch { case !d.initialized || extPkt.Packet.SSRC != d.ssrc: // a stream starts on a key frame, a late packet of the old layer is not described diff --git a/pkg/sfu/videolayerselector/temporallayerselector/null.go b/pkg/sfu/videolayerselector/temporallayerselector/null.go index 51f2fb591..52358b5f1 100644 --- a/pkg/sfu/videolayerselector/temporallayerselector/null.go +++ b/pkg/sfu/videolayerselector/temporallayerselector/null.go @@ -18,14 +18,17 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" ) +// Null is for a stream whose temporal layers cannot be filtered, simulcast without the +// dependency descriptor for example: every temporal layer is forwarded. It reports the +// target as the current layer, so a target below the highest layer does not stay pending +// and block further allocation. The forwarder pairs it with the null codec munger, which +// ignores the temporal layer it returns for the packet. type Null struct{} func NewNull() *Null { return &Null{} } -func Select(_extPkt *buffer.ExtPacket, current int32, _target int32) (this int32, next int32) { - this = current - next = current - return +func (n *Null) Select(_extPkt *buffer.ExtPacket, _current int32, target int32) (this int32, next int32) { + return target, target } diff --git a/pkg/sfu/videolayerselector/temporallayerselector/null_test.go b/pkg/sfu/videolayerselector/temporallayerselector/null_test.go new file mode 100644 index 000000000..a86ec71ca --- /dev/null +++ b/pkg/sfu/videolayerselector/temporallayerselector/null_test.go @@ -0,0 +1,35 @@ +// 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 temporallayerselector + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNullFollowsTarget(t *testing.T) { + n := NewNull() + + // a switch key frame on temporal layer 0 reaches a higher target right away + this, next := n.Select(nil, 0, 2) + require.Equal(t, int32(2), this) + require.Equal(t, int32(2), next) + + // and a lower one + this, next = n.Select(nil, 2, 1) + require.Equal(t, int32(1), this) + require.Equal(t, int32(1), next) +} From 5dac0aab20a7e8d296e1a4060966f3102c79a365 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Mon, 12 Oct 2026 20:22:15 +0530 Subject: [PATCH 113/130] sfu: send padding-only packets with no payload (#4993) --- pkg/sfu/downtrack.go | 31 ++++----------- .../downtrack_downstream_integration_test.go | 38 ++++++++++++------- pkg/sfu/export_test.go | 6 +++ pkg/sfu/pacer/base.go | 2 +- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 346580149..822387313 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -86,7 +86,7 @@ var ( ) // padding of a dropped packet's slot, one byte: the count byte itself -var droppedSlotPadding = []byte{1} +const droppedSlotPaddingSize = 1 var ( VP8KeyFrame8x8 = []byte{ @@ -1253,7 +1253,7 @@ func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimesta initPooledRTPHeader(hdr, rtp.Header{ Version: 2, Padding: true, - PaddingSize: byte(len(droppedSlotPadding)), + PaddingSize: droppedSlotPaddingSize, Marker: false, PayloadType: uint8(d.payloadType.Load()), SequenceNumber: uint16(extSequenceNumber), @@ -1270,7 +1270,7 @@ func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimesta hdr.Marker, hdrSize, 0, - len(droppedSlotPadding), + droppedSlotPaddingSize, isOutOfOrder, ) @@ -1279,7 +1279,6 @@ func (d *DownTrack) writeDroppedSlotPadding(extSequenceNumber uint64, extTimesta Header: hdr, HeaderPool: RTPHeaderFactory, HeaderSize: hdrSize, - Payload: droppedSlotPadding, ProbeClusterId: ccutils.ProbeClusterIdInvalid, // not probe traffic AbsSendTimeExtID: uint8(d.absSendTimeExtID), TransportWideExtID: uint8(d.transportWideExtID), @@ -1354,7 +1353,6 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa } bytesSent := 0 - payloads := make([]byte, RTPPaddingMaxPayloadSize*len(snts)) for i := range snts { hdr := RTPHeaderFactory.Get().(*rtp.Header) initPooledRTPHeader(hdr, rtp.Header{ @@ -1369,12 +1367,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa }) d.addDummyExtensions(hdr) - payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] - // last byte of padding has padding size including that byte - payload[RTPPaddingMaxPayloadSize-1] = byte(RTPPaddingMaxPayloadSize) - hdrSize := hdr.MarshalSize() - payloadSize := len(payload) d.rtpStats.Update( mono.UnixNano(), snts[i].extSequenceNumber, @@ -1382,7 +1375,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa hdr.Marker, hdrSize, 0, - payloadSize, + RTPPaddingMaxPayloadSize, false, ) @@ -1391,7 +1384,6 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa Header: hdr, HeaderPool: RTPHeaderFactory, HeaderSize: hdrSize, - Payload: payload, ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), IsProbe: true, AbsSendTimeExtID: uint8(d.absSendTimeExtID), @@ -1400,7 +1392,7 @@ func (d *DownTrack) WritePaddingRTP(bytesToSend int, paddingOnMute bool, forceMa } d.pacer.Enqueue(pacerPacket) - bytesSent += hdrSize + payloadSize + bytesSent += hdrSize + RTPPaddingMaxPayloadSize } return bytesSent @@ -2528,8 +2520,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { return 0 } - payloads := make([]byte, RTPPaddingMaxPayloadSize*num) - for i := range num { + for range num { rtxExtSequenceNumber := d.rtxSequenceNumber.Inc() hdr := RTPHeaderFactory.Get().(*rtp.Header) initPooledRTPHeader(hdr, rtp.Header{ @@ -2544,12 +2535,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { }) d.addDummyExtensions(hdr) - payload := payloads[i*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize : (i+1)*RTPPaddingMaxPayloadSize] - // last byte of padding has padding size including that byte - payload[RTPPaddingMaxPayloadSize-1] = byte(RTPPaddingMaxPayloadSize) - hdrSize := hdr.MarshalSize() - payloadSize := len(payload) d.rtpStatsRTX.Update( mono.UnixNano(), rtxExtSequenceNumber, @@ -2557,7 +2543,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { hdr.Marker, hdrSize, 0, - payloadSize, + RTPPaddingMaxPayloadSize, false, ) pacerPacket := pacer.PacketFactory.Get().(*pacer.Packet) @@ -2565,7 +2551,6 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { Header: hdr, HeaderPool: RTPHeaderFactory, HeaderSize: hdrSize, - Payload: payload, ProbeClusterId: ccutils.ProbeClusterId(d.probeClusterId.Load()), IsProbe: true, AbsSendTimeExtID: uint8(d.absSendTimeExtID), @@ -2574,7 +2559,7 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { } d.pacer.Enqueue(pacerPacket) - bytesSent += hdrSize + payloadSize + bytesSent += hdrSize + RTPPaddingMaxPayloadSize } } else { src := PacketFactory.Get().(*[]byte) diff --git a/pkg/sfu/downtrack_downstream_integration_test.go b/pkg/sfu/downtrack_downstream_integration_test.go index 41282e46c..73f2c4c43 100644 --- a/pkg/sfu/downtrack_downstream_integration_test.go +++ b/pkg/sfu/downtrack_downstream_integration_test.go @@ -584,10 +584,10 @@ func lktsTrailer() []byte { // the primary SSRC/payload type, carries the padding bit, and declares a full // padding block. // -// A padding-only packet is entirely padding; pion/srtp serializes the padding block -// from header.PaddingSize (via rtp.MarshalPacketTo), and the pion receiver strips it -// and reports both the padding bit and the padding size, so the received payload -// length equals the declared padding size (RTPPaddingMaxPayloadSize). +// A padding-only packet is entirely padding: pion/srtp serializes the padding block +// from header.PaddingSize (via rtp.MarshalPacketTo) after the payload, so the packet +// has no payload. A receiver takes a packet with any payload left after the padding +// as media. func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { h := vnettest.NewHosts(t) factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() @@ -665,12 +665,27 @@ func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { // a padding-only packet: it must declare a full padding block. require.EqualValues(t, RTPPaddingMaxPayloadSize, pkt.Header.PaddingSize, "padding-only packet must declare full padding size (sn=%d)", pkt.SequenceNumber) - require.Equal(t, RTPPaddingMaxPayloadSize, len(pkt.Payload), - "padding-only packet must serialize a full padding block (sn=%d)", pkt.SequenceNumber) + require.Empty(t, pkt.Payload, "padding-only packet must have no payload (sn=%d)", pkt.SequenceNumber) sawPadding++ } require.Equal(t, 1, sawKeyframe, "expected exactly one forwarded media keyframe") require.GreaterOrEqual(t, sawPadding, numPadding, "expected the padding-only packets to be observed") + + // the padding in the slot of a dropped packet has no payload either + dh.dt.WriteDroppedSlotPaddingForTest(6000, 190000) + var pkt *rtp.Packet + require.Eventually(t, func() bool { + for _, p := range dh.capture.all() { + if p.SequenceNumber == 6000 { + pkt = p + return true + } + } + return false + }, 10*time.Second, 20*time.Millisecond, "expected the dropped slot padding to arrive on the far side") + require.True(t, pkt.Header.Padding) + require.EqualValues(t, DroppedSlotPaddingSizeForTest, pkt.Header.PaddingSize) + require.Empty(t, pkt.Payload) } // ----------------------------------------------------------------------------- @@ -681,7 +696,6 @@ func TestDownTrackSendsPaddingOnlyPackets(t *testing.T) { type capturingProbe struct { hdr rtp.Header payloadLen int - lastByte byte } // capturedRTX records what the DownTrack handed the pacer for an RTX packet. @@ -706,9 +720,6 @@ func (c *capturingPacer) Enqueue(p *pacer.Packet) { switch { case p.IsProbe: cp := capturingProbe{hdr: *p.Header, payloadLen: len(p.Payload)} - if len(p.Payload) > 0 { - cp.lastByte = p.Payload[len(p.Payload)-1] - } c.lock.Lock() c.probes = append(c.probes, cp) c.lock.Unlock() @@ -900,16 +911,15 @@ func TestDownTrackSendsProbePackets(t *testing.T) { }, 5*time.Second, 20*time.Millisecond, "expected WriteProbePackets to emit RTX probe packets") // every probe packet must be on the RTX stream, carry the padding bit, declare - // the full padding size, and serialize a full padding block ending in the - // padding count byte. + // the full padding size, and have no payload: pion serializes the padding from + // the header. for _, pr := range cp.probePackets() { require.EqualValues(t, 2, pr.hdr.Version, "probe packet version") require.EqualValues(t, dh.dt.SSRCRTX(), pr.hdr.SSRC, "probe packet must be on the RTX stream") require.EqualValues(t, dh.dt.PayloadTypeRTXForTest(), pr.hdr.PayloadType, "probe packet must use the RTX payload type") require.True(t, pr.hdr.Padding, "probe packet must carry padding bit") require.EqualValues(t, RTPPaddingMaxPayloadSize, pr.hdr.PaddingSize, "probe packet must set header.PaddingSize") - require.Equal(t, RTPPaddingMaxPayloadSize, pr.payloadLen, "probe packet must carry a full padding block") - require.EqualValues(t, RTPPaddingMaxPayloadSize, pr.lastByte, "probe packet trailing byte must be the padding count") + require.Zero(t, pr.payloadLen, "probe packet must have no payload") } // the BWE is wired and exercised (the pacer records every send into it); diff --git a/pkg/sfu/export_test.go b/pkg/sfu/export_test.go index b6536362a..9812b2c33 100644 --- a/pkg/sfu/export_test.go +++ b/pkg/sfu/export_test.go @@ -32,6 +32,12 @@ func (d *DownTrack) PayloadTypeRTXForTest() uint32 { return d.payloadTypeRTX.Loa func (d *DownTrack) RTPStatsActiveForTest() bool { return d.rtpStats.IsActive() } +const DroppedSlotPaddingSizeForTest = droppedSlotPaddingSize + +func (d *DownTrack) WriteDroppedSlotPaddingForTest(extSequenceNumber uint64, extTimestamp uint64) { + d.writeDroppedSlotPadding(extSequenceNumber, extTimestamp, false) +} + func (d *DownTrack) LastReceiverReportTimeForTest() int64 { return d.rtpStats.LastReceiverReportTime() } diff --git a/pkg/sfu/pacer/base.go b/pkg/sfu/pacer/base.go index b5a35e9e8..b93362a00 100644 --- a/pkg/sfu/pacer/base.go +++ b/pkg/sfu/pacer/base.go @@ -110,7 +110,7 @@ func (b *Base) patchRTPHeaderExtensions(p *Packet) error { b.lastPacketSentAt.Store(sendingAt.UnixNano()) } - packetSize := p.HeaderSize + len(p.Payload) + packetSize := p.HeaderSize + len(p.Payload) + int(p.Header.PaddingSize) if p.TransportWideExtID != 0 && b.bwe != nil { twccSN := b.bwe.RecordPacketSendAndGetSequenceNumber( sendingAt.UnixMicro(), From 19fd88ecbdaa8180bd03376b17478e2585b2ab09 Mon Sep 17 00:00:00 2001 From: cnderrauber Date: Mon, 12 Oct 2026 23:29:41 +0800 Subject: [PATCH 114/130] turn: refuse TCP relays and limit relays to this host to media ports (#4992) * turn: refuse TCP relays and limit relays to this host to media ports Embedded TURN let any participant relay to arbitrary peers (#4924): - RFC 6062 TCP relays, accepted since pion/turn v5, let a client open TCP connections to any permitted address, e.g. SSH on the node itself. WebRTC clients never request them, so they are now refused for both embedded and standalone TURN. - Relays to the host's own addresses are delivered locally and bypass any firewall in front of the host. Embedded TURN now only relays to this host's RTC media UDP ports. - Carrier-grade NAT, NAT64 and a few other special-purpose ranges are added to the restricted peer ranges. The issue also suggested allowing relays only to the media node's IPs. That is not done here because, with the changes above, the remaining relays do not form a security concern: they are UDP only, to public addresses outside the restricted ranges, and reach a peer exactly as the client could from its own address (no amplification, no spoofed source). This is standard TURN behavior. Limiting destinations to media nodes would also require TURN server to know media node all endpoints, for little benefit. Co-Authored-By: Claude Opus 5.5 * solve comment --------- Co-authored-by: Claude Opus 5.5 --- pkg/service/turn.go | 29 +++- pkg/service/turnrelay.go | 189 +++++++++++++++++++++ pkg/service/turnrelay_test.go | 311 ++++++++++++++++++++++++++++++++++ 3 files changed, 520 insertions(+), 9 deletions(-) create mode 100644 pkg/service/turnrelay.go create mode 100644 pkg/service/turnrelay_test.go diff --git a/pkg/service/turn.go b/pkg/service/turn.go index 6e8da2e0e..99c0e41ad 100644 --- a/pkg/service/turn.go +++ b/pkg/service/turn.go @@ -19,6 +19,7 @@ import ( "crypto/tls" "fmt" "net" + "net/netip" "strconv" "strings" "time" @@ -109,6 +110,17 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone logValues = append(logValues, "turn.relay_range_end", turnConf.RelayPortRangeEnd) logValues = append(logValues, "turn.per_user_relay_allocation_limit", turnConf.PerUserRelayAllocationLimit) + // embedded TURN shares the host with the SFU, limit relays to this host to its media ports + var localIPs map[netip.Addr]struct{} + var mediaPorts []portRange + if !standalone { + localIPs, err = getTURNLocalIPs(&conf.RTC) + if err != nil { + return nil, errors.Wrap(err, "could not get local IPs for TURN relay policy") + } + mediaPorts = getTURNMediaPorts(&conf.RTC) + } + for _, addr := range turnConf.BindAddresses { var nodeIP string if net.ParseIP(addr).To4() != nil { @@ -127,18 +139,10 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone MaxPort: turnConf.RelayPortRangeEnd, MaxRetries: allocateRetries, } - if standalone { - relayAddrGen = telemetry.NewRelayAddressGenerator(relayAddrGen) - } permissionHandler := func(_clientAddr net.Addr, peerIP net.IP) bool { // restricted peer IP is denied by default, unless allowed by the allow list, - if peerIP.IsLoopback() || - peerIP.IsLinkLocalUnicast() || - peerIP.IsLinkLocalMulticast() || - peerIP.IsMulticast() || - peerIP.IsPrivate() || - peerIP.IsUnspecified() { + if isRestrictedPeerIP(peerIP) { allowed := false for _, ipnet := range allowRestrictedPeerCIDRs { if ipnet.Contains(peerIP) { @@ -162,6 +166,13 @@ func NewTurnServer(conf *config.Config, authHandler turn.AuthHandler, standalone return true } + relayAddrGen = newRejectTCPRelayAddressGenerator(relayAddrGen) + if standalone { + relayAddrGen = telemetry.NewRelayAddressGenerator(relayAddrGen) + } else { + relayAddrGen = newTURNRelayPolicy(relayAddrGen, localIPs, mediaPorts) + } + if turnConf.TLSPort > 0 { listener, err := newTURNTCPListener(turnConf, net.JoinHostPort(addr, strconv.Itoa(turnConf.TLSPort))) if err != nil { diff --git a/pkg/service/turnrelay.go b/pkg/service/turnrelay.go new file mode 100644 index 000000000..618e66db0 --- /dev/null +++ b/pkg/service/turnrelay.go @@ -0,0 +1,189 @@ +// 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 service + +import ( + "net" + "net/netip" + + "github.com/pion/turn/v5" + "github.com/pkg/errors" + + "github.com/livekit/livekit-server/pkg/config" +) + +var ErrTURNTCPRelayDisabled = errors.New("TURN TCP relay allocations are disabled") + +// restricted ranges not covered by the net.IP predicates +var extraRestrictedPeerPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), // "this" network + netip.MustParsePrefix("100.64.0.0/10"), // carrier-grade NAT + netip.MustParsePrefix("198.18.0.0/15"), // benchmarking + netip.MustParsePrefix("240.0.0.0/4"), // reserved, includes broadcast + netip.MustParsePrefix("64:ff9b::/96"), // NAT64, can map to restricted IPv4 + netip.MustParsePrefix("64:ff9b:1::/48"), // local-use NAT64 +} + +func isRestrictedPeerIP(ip net.IP) bool { + if ip.IsLoopback() || + ip.IsLinkLocalUnicast() || + ip.IsLinkLocalMulticast() || + ip.IsMulticast() || + ip.IsPrivate() || + ip.IsUnspecified() { + return true + } + + addr, ok := netip.AddrFromSlice(ip) + if !ok { + return true + } + addr = addr.Unmap() + for _, prefix := range extraRestrictedPeerPrefixes { + if prefix.Contains(addr) { + return true + } + } + return false +} + +type portRange struct { + start uint16 + end uint16 +} + +// rejectTCPRelayAddressGenerator refuses RFC 6062 TCP relays, which would let +// a client open TCP connections to any permitted address. +type rejectTCPRelayAddressGenerator struct { + turn.RelayAddressGenerator +} + +func newRejectTCPRelayAddressGenerator(g turn.RelayAddressGenerator) *rejectTCPRelayAddressGenerator { + return &rejectTCPRelayAddressGenerator{RelayAddressGenerator: g} +} + +func (g *rejectTCPRelayAddressGenerator) AllocateListener(turn.AllocateListenerConfig) (net.Listener, net.Addr, error) { + return nil, nil, ErrTURNTCPRelayDisabled +} + +func (g *rejectTCPRelayAddressGenerator) AllocateConn(turn.AllocateConnConfig) (net.Conn, error) { + return nil, ErrTURNTCPRelayDisabled +} + +// turnRelayPolicy enforces what TURN permissions cannot, since they are per IP +// only: relays to this host's own addresses may reach only the RTC media +// ports, because such packets are delivered locally and bypass any firewall +// in front of the host. Used for embedded TURN, which shares the host with +// the SFU. +type turnRelayPolicy struct { + turn.RelayAddressGenerator + + localIPs map[netip.Addr]struct{} + mediaPorts []portRange +} + +func newTURNRelayPolicy(g turn.RelayAddressGenerator, localIPs map[netip.Addr]struct{}, mediaPorts []portRange) *turnRelayPolicy { + return &turnRelayPolicy{ + RelayAddressGenerator: g, + localIPs: localIPs, + mediaPorts: mediaPorts, + } +} + +func (g *turnRelayPolicy) allows(addr net.Addr) bool { + udpAddr, ok := addr.(*net.UDPAddr) + if !ok { + return false + } + ap := udpAddr.AddrPort() + if _, ok := g.localIPs[ap.Addr().Unmap().WithZone("")]; !ok { + return true + } + for _, r := range g.mediaPorts { + if ap.Port() >= r.start && ap.Port() <= r.end { + return true + } + } + return false +} + +func (g *turnRelayPolicy) AllocatePacketConn(c turn.AllocateListenerConfig) (net.PacketConn, net.Addr, error) { + conn, addr, err := g.RelayAddressGenerator.AllocatePacketConn(c) + if err != nil { + return conn, addr, err + } + return &turnRelayPacketConn{PacketConn: conn, policy: g}, addr, nil +} + +// turnRelayPacketConn silently drops relayed packets to peers the policy does +// not allow. +type turnRelayPacketConn struct { + net.PacketConn + policy *turnRelayPolicy +} + +func (c *turnRelayPacketConn) WriteTo(p []byte, addr net.Addr) (int, error) { + if !c.policy.allows(addr) { + return len(p), nil + } + return c.PacketConn.WriteTo(p, addr) +} + +// getTURNLocalIPs returns the addresses that are delivered to this host: its +// interface addresses and the node IPs, which may be 1:1 NAT addresses. +func getTURNLocalIPs(rtcConf *config.RTCConfig) (map[netip.Addr]struct{}, error) { + ips := make(map[netip.Addr]struct{}) + + ifAddrs, err := net.InterfaceAddrs() + if err != nil { + return nil, err + } + for _, ifAddr := range ifAddrs { + ipNet, ok := ifAddr.(*net.IPNet) + if !ok { + continue + } + if addr, ok := netip.AddrFromSlice(ipNet.IP); ok { + ips[addr.Unmap()] = struct{}{} + } + } + + for _, ip := range rtcConf.NodeIP.ToStringSlice() { + addr, err := netip.ParseAddr(ip) + if err != nil { + return nil, err + } + ips[addr.Unmap()] = struct{}{} + } + return ips, nil +} + +func getTURNMediaPorts(rtcConf *config.RTCConfig) []portRange { + if rtcConf.ForceTCP { + return nil + } + + var ports []portRange + if rtcConf.ICEPortRangeStart != 0 && rtcConf.ICEPortRangeEnd != 0 { + ports = append(ports, portRange{start: uint16(rtcConf.ICEPortRangeStart), end: uint16(rtcConf.ICEPortRangeEnd)}) + } else if rtcConf.UDPPort.Valid() { + end := max(rtcConf.UDPPort.End, rtcConf.UDPPort.Start) + ports = append(ports, portRange{start: uint16(rtcConf.UDPPort.Start), end: uint16(end)}) + if rtcConf.UseStunPortAsICE { + ports = append(ports, portRange{start: 3478, end: 3478}) + } + } + return ports +} diff --git a/pkg/service/turnrelay_test.go b/pkg/service/turnrelay_test.go new file mode 100644 index 000000000..ca28890af --- /dev/null +++ b/pkg/service/turnrelay_test.go @@ -0,0 +1,311 @@ +// 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 service + +import ( + "fmt" + "net" + "net/netip" + "testing" + "time" + + "github.com/pion/turn/v5" + "github.com/stretchr/testify/require" + + "github.com/livekit/mediatransportutil/pkg/rtcconfig" + + "github.com/livekit/livekit-server/pkg/config" +) + +func TestGetTURNMediaPorts(t *testing.T) { + ports := getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882, End: 7885}, + UseStunPortAsICE: true, + }}) + require.Equal(t, []portRange{{7882, 7885}, {3478, 3478}}, ports) + + ports = getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882}, + ICEPortRangeStart: 50000, + ICEPortRangeEnd: 60000, + }}) + require.Equal(t, []portRange{{50000, 60000}}, ports) + + require.Empty(t, getTURNMediaPorts(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + UDPPort: rtcconfig.PortRange{Start: 7882}, + ForceTCP: true, + }})) +} + +func TestGetTURNLocalIPs(t *testing.T) { + ips, err := getTURNLocalIPs(&config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "203.0.113.1", V6: "2001:db8::1"}, + }}) + require.NoError(t, err) + require.Contains(t, ips, netip.MustParseAddr("127.0.0.1")) + require.Contains(t, ips, netip.MustParseAddr("203.0.113.1")) + require.Contains(t, ips, netip.MustParseAddr("2001:db8::1")) +} + +func TestTURNRelayPolicy_Allows(t *testing.T) { + policy := newTURNRelayPolicy(nil, + map[netip.Addr]struct{}{netip.MustParseAddr("203.0.113.1"): {}}, + []portRange{{7882, 7882}, {50000, 60000}}, + ) + + for _, tc := range []struct { + addr net.Addr + allowed bool + }{ + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 7882}, true}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1").To16(), Port: 55000}, true}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 22}, false}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 443}, false}, + {&net.UDPAddr{IP: net.ParseIP("203.0.113.1"), Port: 60001}, false}, + {&net.UDPAddr{IP: net.ParseIP("198.51.100.7"), Port: 7882}, true}, + {&net.UDPAddr{IP: net.ParseIP("198.51.100.7"), Port: 443}, true}, + {&net.TCPAddr{IP: net.ParseIP("198.51.100.7"), Port: 443}, false}, + } { + require.Equal(t, tc.allowed, policy.allows(tc.addr), "%s", tc.addr) + } +} + +type fakeRelayAddressGenerator struct { + conn net.PacketConn +} + +func (g *fakeRelayAddressGenerator) Validate() error { return nil } + +func (g *fakeRelayAddressGenerator) AllocatePacketConn(turn.AllocateListenerConfig) (net.PacketConn, net.Addr, error) { + return g.conn, g.conn.LocalAddr(), nil +} + +func (g *fakeRelayAddressGenerator) AllocateListener(turn.AllocateListenerConfig) (net.Listener, net.Addr, error) { + panic("must not be called") +} + +func (g *fakeRelayAddressGenerator) AllocateConn(turn.AllocateConnConfig) (net.Conn, error) { + panic("must not be called") +} + +func listenUDP(t *testing.T) net.PacketConn { + t.Helper() + conn, err := net.ListenPacket("udp4", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} + +func TestTURNRelayPolicy_PacketConn(t *testing.T) { + relay := listenUDP(t) + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + + mediaPort := uint16(mediaPeer.LocalAddr().(*net.UDPAddr).Port) + policy := newTURNRelayPolicy( + &fakeRelayAddressGenerator{conn: relay}, + map[netip.Addr]struct{}{netip.MustParseAddr("127.0.0.1"): {}}, + []portRange{{mediaPort, mediaPort}}, + ) + + conn, _, err := policy.AllocatePacketConn(turn.AllocateListenerConfig{UserID: "PA_x"}) + require.NoError(t, err) + + buf := make([]byte, 64) + + // writes to a non-media port on this host are dropped + n, err := conn.WriteTo([]byte("denied"), otherPeer.LocalAddr()) + require.NoError(t, err) + require.Equal(t, len("denied"), n) + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(100*time.Millisecond))) + _, _, err = otherPeer.ReadFrom(buf) + require.Error(t, err) + + _, err = conn.WriteTo([]byte("allowed"), mediaPeer.LocalAddr()) + require.NoError(t, err) + require.NoError(t, mediaPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, _, err = mediaPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) +} + +func TestRejectTCPRelayAddressGenerator(t *testing.T) { + relay := listenUDP(t) + g := newRejectTCPRelayAddressGenerator(&fakeRelayAddressGenerator{conn: relay}) + + _, _, err := g.AllocateListener(turn.AllocateListenerConfig{}) + require.ErrorIs(t, err, ErrTURNTCPRelayDisabled) + _, err = g.AllocateConn(turn.AllocateConnConfig{}) + require.ErrorIs(t, err, ErrTURNTCPRelayDisabled) + + conn, _, err := g.AllocatePacketConn(turn.AllocateListenerConfig{}) + require.NoError(t, err) + require.Same(t, relay, conn) +} + +// allocateTestTurnRelay starts a TURN server on loopback, with the RTC media +// port set to mediaPort, and returns a relay allocated through it. +func allocateTestTurnRelay(t *testing.T, standalone bool, mediaPort int) net.PacketConn { + t.Helper() + + turnPort := listenUDP(t) + turnAddr := turnPort.LocalAddr().String() + require.NoError(t, turnPort.Close()) + + conf := &config.Config{ + RTC: config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "127.0.0.1"}, + UDPPort: rtcconfig.PortRange{Start: mediaPort}, + }}, + TURN: config.TURNConfig{ + Enabled: true, + UDPPort: turnPort.LocalAddr().(*net.UDPAddr).Port, + BindAddresses: []string{"127.0.0.1"}, + RelayPortRangeStart: 40000, + RelayPortRangeEnd: 40100, + AllowRestrictedPeerCIDRs: []string{"127.0.0.0/8"}, + }, + } + h := newTestTurnAuthHandler() + server, err := NewTurnServer(conf, h.HandleAuth, standalone) + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + + username, expiry := h.CreateUsername(turnTestAPIKey, "PA_relay", 300) + password, err := h.CreatePassword(turnTestAPIKey, "PA_relay", expiry) + require.NoError(t, err) + + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Username: username, + Password: password, + Realm: LivekitRealm, + Conn: listenUDP(t), + }) + require.NoError(t, err) + t.Cleanup(client.Close) + require.NoError(t, client.Listen()) + + relayConn, err := client.Allocate() + require.NoError(t, err) + t.Cleanup(func() { _ = relayConn.Close() }) + return relayConn +} + +func TestNewTurnServer_RelayPolicy(t *testing.T) { + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + relayConn := allocateTestTurnRelay(t, false, mediaPeer.LocalAddr().(*net.UDPAddr).Port) + + buf := make([]byte, 64) + + _, err := relayConn.WriteTo([]byte("denied"), otherPeer.LocalAddr()) + require.NoError(t, err) + _, err = relayConn.WriteTo([]byte("allowed"), mediaPeer.LocalAddr()) + require.NoError(t, err) + + require.NoError(t, mediaPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, relayAddr, err := mediaPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) + + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(200*time.Millisecond))) + _, _, err = otherPeer.ReadFrom(buf) + require.Error(t, err, "relay to a non-media port on this host must be dropped") + + // the media peer can reply through the relay + _, err = mediaPeer.WriteTo([]byte("reply"), relayAddr) + require.NoError(t, err) + require.NoError(t, relayConn.SetReadDeadline(time.Now().Add(2*time.Second))) + n, from, err := relayConn.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "reply", string(buf[:n])) + require.Equal(t, mediaPeer.LocalAddr().String(), from.String()) +} + +func TestNewTurnServer_StandaloneRelayPolicy(t *testing.T) { + mediaPeer := listenUDP(t) + otherPeer := listenUDP(t) + relayConn := allocateTestTurnRelay(t, true, mediaPeer.LocalAddr().(*net.UDPAddr).Port) + + // standalone TURN has no SFU on the host, so ports on this host are not limited + _, err := relayConn.WriteTo([]byte("allowed"), otherPeer.LocalAddr()) + require.NoError(t, err) + + buf := make([]byte, 64) + require.NoError(t, otherPeer.SetReadDeadline(time.Now().Add(2*time.Second))) + n, _, err := otherPeer.ReadFrom(buf) + require.NoError(t, err) + require.Equal(t, "allowed", string(buf[:n])) +} + +func TestNewTurnServer_RejectsTCPRelay(t *testing.T) { + for _, standalone := range []bool{false, true} { + t.Run(fmt.Sprintf("standalone=%v", standalone), func(t *testing.T) { + ln, err := net.Listen("tcp4", "127.0.0.1:0") + require.NoError(t, err) + turnAddr := ln.Addr().String() + require.NoError(t, ln.Close()) + + conf := &config.Config{ + RTC: config.RTCConfig{RTCConfig: rtcconfig.RTCConfig{ + NodeIP: rtcconfig.NodeIP{V4: "127.0.0.1"}, + UDPPort: rtcconfig.PortRange{Start: 7882}, + }}, + TURN: config.TURNConfig{ + Enabled: true, + Domain: "turn.example.com", + TLSPort: ln.Addr().(*net.TCPAddr).Port, + ExternalTLS: true, + BindAddresses: []string{"127.0.0.1"}, + RelayPortRangeStart: 40000, + RelayPortRangeEnd: 40100, + AllowRestrictedPeerCIDRs: []string{"127.0.0.0/8"}, + }, + } + h := newTestTurnAuthHandler() + server, err := NewTurnServer(conf, h.HandleAuth, standalone) + require.NoError(t, err) + t.Cleanup(func() { _ = server.Close() }) + + username, expiry := h.CreateUsername(turnTestAPIKey, "PA_tcp", 300) + password, err := h.CreatePassword(turnTestAPIKey, "PA_tcp", expiry) + require.NoError(t, err) + + tcpConn, err := net.Dial("tcp4", turnAddr) + require.NoError(t, err) + client, err := turn.NewClient(&turn.ClientConfig{ + STUNServerAddr: turnAddr, + TURNServerAddr: turnAddr, + Username: username, + Password: password, + Realm: LivekitRealm, + Conn: turn.NewSTUNConn(tcpConn), + }) + require.NoError(t, err) + t.Cleanup(client.Close) + require.NoError(t, client.Listen()) + + _, err = client.AllocateTCP() + require.Error(t, err, "TCP relay allocation must be refused") + + // a UDP relay over the same TCP connection still works + relayConn, err := client.Allocate() + require.NoError(t, err) + _ = relayConn.Close() + }) + } +} From 22a90f24b5694eb41aa9a3baf9e82d93a5c34c11 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:52:27 -0700 Subject: [PATCH 115/130] Update alpine Docker tag to v3.24.2 (#4984) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- cmd/test-server/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index af4b18a75..b5be2c836 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,7 @@ COPY version/ version/ RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-server ./cmd/server -FROM alpine:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # Pull the latest security patches for base packages within the pinned Alpine # release. The digest above is only refreshed by a Renovate PR, so without this diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile index ab159245a..615182ac7 100644 --- a/cmd/test-server/Dockerfile +++ b/cmd/test-server/Dockerfile @@ -37,7 +37,7 @@ 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:3.24.1@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b +FROM alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6 # Pull the latest security patches for base packages within the pinned Alpine # release; the digest above is only refreshed by a Renovate PR. See the note in From 8700b35c1e6504cb602495e0eddd3f1a83352a02 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 12 Oct 2026 09:53:08 -0700 Subject: [PATCH 116/130] Update golang Docker tag to v1.27.1 (#4985) Generated by renovateBot Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Dockerfile | 2 +- cmd/test-server/Dockerfile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index b5be2c836..4d9920a2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ # This image is also the single source of truth for the Go toolchain: CI reads the # version out of this line (see .github/scripts/go-version.sh) so tests and images # always run the same runtime. -FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder +FROM golang:1.27.1-alpine3.24@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS builder ARG TARGETPLATFORM ARG TARGETARCH diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile index 615182ac7..bbac501ec 100644 --- a/cmd/test-server/Dockerfile +++ b/cmd/test-server/Dockerfile @@ -17,7 +17,7 @@ # # Pinned by digest so the build is reproducible even if the tag is republished. # The tag is kept alongside it for readability; Renovate updates both together. -FROM golang:1.27.0-alpine3.24@sha256:4c9fe60190a2a3350ddc51de80d0224b8a6698d12bdfc999fee45ea9d6c46dbc AS builder +FROM golang:1.27.1-alpine3.24@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS builder ARG TARGETARCH From 3a7228fa745953d7748e7f332cf7ebf63783a4e8 Mon Sep 17 00:00:00 2001 From: Nicolas Buszczak Date: Mon, 12 Oct 2026 18:26:32 -0300 Subject: [PATCH 117/130] Fix nil pointer dereference in IOInfoService.UpdateIngressState (#4806) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LoadIngress reads the ingress info and its state from two separate Redis keys non-atomically, while DeleteIngress removes both keys in a single transaction. If the delete lands between the two reads, LoadIngress returns an IngressInfo with a nil State and no error, and UpdateIngressState panics dereferencing info.State.Status — taking down the whole server with every room it hosts. The race fires in practice when an ingress session is being torn down by DeleteIngress and the ingress service reports the session state change concurrently: with short-lived per-room WHIP ingresses this crashed production servers within minutes (observed on v1.13.5 and v1.13.6, SIGSEGV at ioservice_ingress.go:82, addr=0x8). Guard the nil State by treating it as an empty state, matching how the store's UpdateIngressState already tolerates a missing state key. --- pkg/service/ioservice_ingress.go | 7 ++++ pkg/service/ioservice_ingress_test.go | 46 +++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) create mode 100644 pkg/service/ioservice_ingress_test.go diff --git a/pkg/service/ioservice_ingress.go b/pkg/service/ioservice_ingress.go index c1fe59555..39e882447 100644 --- a/pkg/service/ioservice_ingress.go +++ b/pkg/service/ioservice_ingress.go @@ -73,6 +73,13 @@ func (s *IOInfoService) UpdateIngressState(ctx context.Context, req *rpc.UpdateI if err != nil { return nil, err } + if info.State == nil { + // LoadIngress reads the ingress info and its state non-atomically: a concurrent + // DeleteIngress can remove the state key between the two reads, in which case + // LoadIngress returns the info with a nil State and no error. Treat it as an + // empty state instead of panicking on the status comparison below. + info.State = &livekit.IngressState{} + } if err = s.is.UpdateIngressState(ctx, req.IngressId, req.State); err != nil { logger.Errorw("could not update ingress", err) diff --git a/pkg/service/ioservice_ingress_test.go b/pkg/service/ioservice_ingress_test.go new file mode 100644 index 000000000..acd778d68 --- /dev/null +++ b/pkg/service/ioservice_ingress_test.go @@ -0,0 +1,46 @@ +// 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 service_test + +import ( + "context" + "testing" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/rpc" + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/service" + "github.com/livekit/livekit-server/pkg/service/servicefakes" + "github.com/livekit/livekit-server/pkg/telemetry/telemetryfakes" +) + +// A concurrent DeleteIngress can remove the ingress state key between the two +// non-atomic reads LoadIngress performs, making it return an IngressInfo with a +// nil State and no error. UpdateIngressState used to panic dereferencing it, +// bringing down the whole node. +func TestUpdateIngressStateWithNilState(t *testing.T) { + is := &servicefakes.FakeIngressStore{} + is.LoadIngressReturns(&livekit.IngressInfo{IngressId: "IN_test"}, nil) + + io, err := service.NewIOInfoService(nil, nil, is, nil, &telemetryfakes.FakeTelemetryService{}) + require.NoError(t, err) + + _, err = io.UpdateIngressState(context.Background(), &rpc.UpdateIngressStateRequest{ + IngressId: "IN_test", + State: &livekit.IngressState{Status: livekit.IngressState_ENDPOINT_INACTIVE}, + }) + require.NoError(t, err) +} From 1d976b860a7e92de2e4f7afe3d745bedf1d96286 Mon Sep 17 00:00:00 2001 From: Erik Hortsch Date: Mon, 12 Oct 2026 14:33:39 -0700 Subject: [PATCH 118/130] Add OSS Scanner build image and threat model (#4995) Co-authored-by: Claude Opus 5.5 --- .oss-scanner/Dockerfile | 40 +++++++++++++++++ .oss-scanner/threat_model.md | 84 ++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 .oss-scanner/Dockerfile create mode 100644 .oss-scanner/threat_model.md diff --git a/.oss-scanner/Dockerfile b/.oss-scanner/Dockerfile new file mode 100644 index 000000000..d62476856 --- /dev/null +++ b/.oss-scanner/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. + +# Build image for Anthropic's OSS Scanner. Keep the Go version at or above go.mod's: GOTOOLCHAIN=local won't fetch one. +FROM golang:1.26-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + GOTOOLCHAIN=local + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential ca-certificates git redis-server \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /src +COPY . /src + +RUN go mod download && go mod verify +RUN go build ./... && go build -o /usr/local/bin/livekit-server ./cmd/server + +# Tests use a local redis on :6379; pkg/service also wants a docker daemon unless -docker=false. +RUN redis-server --daemonize yes \ + && go test -count=1 -timeout 20m $(go list ./... | grep -v '/pkg/service$') \ + && go test -count=1 -timeout 10m ./pkg/service -docker=false \ + || echo 'WARNING: baseline tests failed' + +ENV GOPROXY=off \ + GOSUMDB=off + +CMD ["bash"] diff --git a/.oss-scanner/threat_model.md b/.oss-scanner/threat_model.md new file mode 100644 index 000000000..0302c7337 --- /dev/null +++ b/.oss-scanner/threat_model.md @@ -0,0 +1,84 @@ +# LiveKit server threat model + +LiveKit server is a WebRTC SFU written in Go. Clients join rooms with a signed +access token, publish and subscribe to audio, video, and data tracks, and exchange +signalling over WebSocket or HTTP. Operators manage rooms, participants, egress, +ingress, SIP, and agent dispatch through a Twirp API authenticated by API +key/secret. The server also embeds a TURN server. + +## Scope and trust boundaries + +Audit the server on `master`: signalling (`pkg/service`, `pkg/rtc/signalling`), +room and participant state (`pkg/rtc`), media forwarding (`pkg/sfu`), routing +(`pkg/routing`), the agent endpoint (`pkg/agent`), WHIP, WebTransport, and TURN. + +Untrusted parties: + +- Unauthenticated network clients, against every HTTP, WebSocket, WebTransport, + TURN, and UDP/TCP media port. +- Participants holding a valid token for one room. Their signalling messages, + SDP, ICE candidates, RTP/RTCP, data-channel packets, metadata, and attributes + are untrusted. +- Holders of tokens with restricted grants (subscribe-only, no data publish, + hidden, limited `canPublishSources`, and similar). + +Trusted: the operator, the server config file, the API secret, and redis. +Treat a compromised redis or a leaked API secret as out of scope. + +`pion/dtls` is enrolled separately. Report DTLS bugs there, unless LiveKit +misuses the library. + +## Security properties + +- **Token and grant enforcement:** a token for one room must not reach another + room. Grants must be enforced on every signalling path, including permission + updates pushed after join. A participant must not impersonate another identity + or act on another participant's tracks. +- **Media and data isolation:** a participant must not receive tracks or data + packets they are not subscribed to or permitted to see. Data packets with + destination identities must reach only those identities. +- **TURN:** relay allocations must not reach arbitrary hosts or ports beyond the + configured relay policy. +- **Parsing:** SDP, RTP header extensions, RTCP, codec payload munging, and + protobuf signalling must not panic, corrupt state, or read out of bounds on + malformed input. +- **Resource use:** one participant or unauthenticated client must not exhaust + memory, goroutines, or CPU for the whole process. + +## Severity guidance + +- **Critical:** unauthenticated remote code execution, or an authentication or + room-isolation bypass that needs no valid token. +- **High:** cross-room or cross-participant access to media or data, privilege + escalation beyond the token's grants, or a remote crash of the whole process + that needs at most an ordinary participant token. +- **Medium:** denial of service that takes significant traffic, or affects only + the attacker's own room. Information leaks of metadata. +- **Low / not a finding:** a participant degrading their own session; behavior + needing the API secret, operator config, or redis access; bandwidth estimation + or quality tuning; packet loss and reordering handled as designed. + +## Testing + +The checkout is at `/src`. The server binary is at `/usr/local/bin/livekit-server`. +Go dependencies are cached. `GOPROXY=off` and `GOSUMDB=off` prevent downloads. +Tests expect redis on `localhost:6379`. Start it first. No docker daemon is +available, so pass `-docker=false` to `pkg/service`. + +```sh +cd /src +redis-server --daemonize yes +go test -count=1 $(go list ./... | grep -v '/pkg/service$') +go test -count=1 ./pkg/service -docker=false +go test -race -count=1 ./pkg/rtc/... ./pkg/sfu/... +``` + +`test/` holds end-to-end tests that run real server instances against local +clients. Use them for signalling or permission reproducers. Use unit tests in +the affected package for parsing bugs. + +## Reports + +State the attacker's required access (none, any valid token, or a specific +grant), the entry point, and the demonstrated impact. Provide a minimal +reproducer, preferably a Go test. From eafd1056ca9840215bae0538e087f1b267ae555f Mon Sep 17 00:00:00 2001 From: Paul Wells Date: Mon, 12 Oct 2026 14:58:36 -0700 Subject: [PATCH 119/130] agent endpoints: address an unnamed deployment as "_" (#4997) An unnamed deployment was addressed as "default" and that spelling leaked into scope keys, so embedders keyed endpoints differently from job dispatch. Map "_" back to "" at the URL boundary, as the agent name already does, and drop NormalizeDeployment. --- pkg/agent/endpoint/front.go | 7 +++++-- pkg/agent/endpoint/front_test.go | 4 ++++ pkg/agent/endpoint/registry.go | 22 ++++------------------ pkg/service/agentendpointscope.go | 2 +- 4 files changed, 14 insertions(+), 21 deletions(-) diff --git a/pkg/agent/endpoint/front.go b/pkg/agent/endpoint/front.go index ca0dc51a8..7a2741dfa 100644 --- a/pkg/agent/endpoint/front.go +++ b/pkg/agent/endpoint/front.go @@ -415,10 +415,13 @@ func splitEndpointPath(u *url.URL) (endpointPath, error) { return endpointPath{}, errMalformedPath } // "_" and "%5F" both land here; neither is a registrable name - // (IsReservedAgentName) - if agentName == UnnamedAgentSegment { + // (IsReservedAgentName, ValidateDeployment) + if agentName == UnnamedSegment { agentName = "" } + if deployment == UnnamedSegment { + deployment = "" + } return endpointPath{agentName: agentName, deployment: deployment, path: path, escPath: escPath}, nil } diff --git a/pkg/agent/endpoint/front_test.go b/pkg/agent/endpoint/front_test.go index 089c692b5..0a98e65fb 100644 --- a/pkg/agent/endpoint/front_test.go +++ b/pkg/agent/endpoint/front_test.go @@ -287,6 +287,10 @@ func TestSplitEndpointPath(t *testing.T) { {name: "bare underscore is unnamed", target: "/agents/_/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, {name: "encoded underscore is unnamed", target: "/agents/%5F/d/x", agentName: "", deployment: "d", path: "/x", escPath: "/x"}, {name: "double encoded underscore is a name", target: "/agents/%255F/d/x", agentName: "%5F", deployment: "d", path: "/x", escPath: "/x"}, + {name: "bare underscore deployment is unnamed", target: "/agents/a/_/x", agentName: "a", deployment: "", path: "/x", escPath: "/x"}, + {name: "encoded underscore deployment is unnamed", target: "/agents/a/%5F/x", agentName: "a", deployment: "", path: "/x", escPath: "/x"}, + {name: "unnamed agent and deployment", target: "/agents/_/_/x", agentName: "", deployment: "", path: "/x", escPath: "/x"}, + {name: "default is a deployment name", target: "/agents/a/default/x", agentName: "a", deployment: "default", path: "/x", escPath: "/x"}, {name: "not an endpoint path", target: "/other/x", err: errNotEndpointPath}, {name: "no deployment segment", target: "/agents/a", err: errNotEndpointPath}, diff --git a/pkg/agent/endpoint/registry.go b/pkg/agent/endpoint/registry.go index 7fbc036ae..7b61d680d 100644 --- a/pkg/agent/endpoint/registry.go +++ b/pkg/agent/endpoint/registry.go @@ -21,29 +21,15 @@ import ( "sync/atomic" ) -// DefaultDeployment is the URL segment that addresses workers registered with an -// empty deployment name (self-hosted workers typically set none). -const DefaultDeployment = "default" - -// UnnamedAgentSegment is the URL segment that addresses workers registered with -// an empty agent name. -const UnnamedAgentSegment = "_" +// UnnamedSegment is the URL segment that addresses workers registered with an +// empty agent name or deployment. +const UnnamedSegment = "_" // IsReservedAgentName reports whether a name cannot address a worker. These are // unreserved characters (RFC 3986 §2.3), so percent-encoding them yields no // distinct form to address them by. func IsReservedAgentName(agentName string) bool { - return agentName == UnnamedAgentSegment || agentName == "." || agentName == ".." -} - -// NormalizeDeployment maps an empty deployment to the segment that addresses it -// in a URL. Every embedder must key its scopes through this, or a worker that -// registered without a deployment is unreachable from any node but its own. -func NormalizeDeployment(d string) string { - if d == "" { - return DefaultDeployment - } - return d + return agentName == UnnamedSegment || agentName == "." || agentName == ".." } // DefaultMaxStreams is the soft per-session concurrency cap used only for diff --git a/pkg/service/agentendpointscope.go b/pkg/service/agentendpointscope.go index ee4dac12e..1a6ab251f 100644 --- a/pkg/service/agentendpointscope.go +++ b/pkg/service/agentendpointscope.go @@ -32,7 +32,7 @@ type endpointScopeKey struct { } func newEndpointScopeKey(apiKey, agentName, deployment string) endpointScopeKey { - return endpointScopeKey{apiKey, agentName, endpoint.NormalizeDeployment(deployment)} + return endpointScopeKey{apiKey, agentName, deployment} } // EndpointScopes maps that tenancy onto the endpoint package's scopes, one per From 308554daf1d52c397f318d680bc9d8954b4b97d1 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 13 Oct 2026 16:40:46 +0530 Subject: [PATCH 120/130] rtc: release timed out pending tracks at the pending track limit (#4998) * rtc: reclaim abandoned pending tracks A pending track is freed only when its first media arrives or the participant leaves. A client that calls AddTrack and then gives up before any media flows leaves the slot held for the whole session. After 20 such slots, every later AddTrack is rejected. Reclaim these slots when the cap is hit, if no media arrived and the slot is older than the publish timeout. Keep the supervisor monitor in step for queued tracks that share a track id. Add a publish_timeout config value, default 30s. Fixes: #4970 Co-Authored-By: Claude Opus 4.8 * rtc: fix publish_timeout comment to not name an internal constant Co-Authored-By: Claude Opus 4.8 * rtc: release supervisor-timed-out pending tracks at the limit Replace the createdAt sweep with the supervisor's publish timeout. The supervisor counts time only while the publisher is connected and the track is unmuted, and never reports a live track, so a successor queued behind a live track is not released. Restart the publish timer when a queued publish reaches the front of the queue, so it does not inherit the clock of the track ahead of it. Write the publication error under the write lock, since it is now read from another goroutine. Drop the publish_timeout config value. Co-Authored-By: Claude Opus 5.5 * rtc: skip stale supervisor timeouts and keep live monitors TimedOutPublications re-checks the monitor, because the cached error is cleared only on the next tick. A track that published since is not reported, so its queued successor is not released. RemovePublication keeps a monitor that still holds a published track. Co-Authored-By: Claude Opus 5.5 * rtc: drop late media for released pending tracks Media or an offer with no matching pending track falls back to the first pending track of the same type. After a release, late media for the released track could take another pending track's identity. Record the released track's signal and SDP client ids, the 32 most recent, and drop media or offers for them. An AddTrack that reuses the client id clears it. Co-Authored-By: Claude Opus 5.5 * rtc: clear all ids of a released track when it is re-added A re-add cleared only the signal client id, so a Firefox republish that keeps the old SDP id had its offer and media dropped. Keep the ids of each released track together, and clear the whole track when a new AddTrack reuses its signal or simulcast codec client id. The limit now counts released tracks, and a track ages out with all of its ids. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 4.8 --- pkg/rtc/participant.go | 118 +++++++++++++++ pkg/rtc/participant_internal_test.go | 116 ++++++++++++++ pkg/rtc/participant_sdp.go | 5 + pkg/rtc/supervisor/participant_supervisor.go | 55 ++++++- .../supervisor/participant_supervisor_test.go | 112 ++++++++++++++ pkg/rtc/supervisor/publication_monitor.go | 17 +++ .../supervisor/publication_monitor_test.go | 142 ++++++++++++++++++ 7 files changed, 561 insertions(+), 4 deletions(-) create mode 100644 pkg/rtc/supervisor/participant_supervisor_test.go create mode 100644 pkg/rtc/supervisor/publication_monitor_test.go diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index bf7420d28..b0bc076fb 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -83,6 +83,7 @@ const ( cMaxPendingTracks = 20 cMaxPendingQueuedTracks = 3 + cMaxReleasedTracks = 32 // unsequenced reliable data (server API sends) cannot be recovered from the // data message cache, so it is held here until the reliable data channel is writable @@ -296,6 +297,10 @@ type ParticipantImpl struct { pendingTracks map[string]*pendingTrackInfo pendingPublishingTracks map[livekit.TrackID]*pendingTrackInfo pendingRemoteTracks []*pendingRemoteTrack + // client ids (signal, codec and SDP) of each pending track released without media, so that + // media or an offer for them is dropped instead of being matched by type to another pending + // track, bounded to cMaxReleasedTracks, oldest out first + releasedTrackCids [][]string // supported codecs enabledPublishCodecs []*livekit.Codec @@ -2976,6 +2981,94 @@ func (p *ParticipantImpl) onSubscribedAudioCodecChange( return p.sendSubscribedAudioCodecUpdate(subscribedAudioCodecUpdate) } +// releasePendingTrackLocked drops every pending entry of a track whose media never arrived, +// so it stops counting against cMaxPendingTracks. Must be called with pendingTracksLock held. +func (p *ParticipantImpl) releasePendingTrackLocked(trackID livekit.TrackID) { + var ( + released *livekit.TrackInfo + releasedCids []string + ) + for cid, pti := range p.pendingTracks { + remaining := pti.trackInfos[:0] + for _, ti := range pti.trackInfos { + if livekit.TrackID(ti.Sid) == trackID { + released = ti + releasedCids = append(releasedCids, cid) + for _, c := range ti.Codecs { + releasedCids = append(releasedCids, c.Cid, c.SdpCid) + } + continue + } + remaining = append(remaining, ti) + } + pti.trackInfos = remaining + if len(pti.trackInfos) == 0 { + delete(p.pendingTracks, cid) + } + } + if released == nil { + return + } + p.addReleasedTrackCidsLocked(releasedCids) + + p.pubLogger.Infow( + "releasing pending track that never received media", + "trackID", trackID, + "trackInfo", logger.Proto(released), + "numPendingTracks", len(p.pendingTracks), + ) + if p.supervisor != nil { + p.supervisor.RemovePublication(trackID) + } + if p.ProtocolVersion().SupportsUnpublish() { + p.sendTrackUnpublished(trackID) + } else { + p.sendTrackMuted(trackID, true) + } +} + +// must be called with pendingTracksLock held +func (p *ParticipantImpl) addReleasedTrackCidsLocked(cids []string) { + cids = slices.DeleteFunc(cids, func(cid string) bool { return cid == "" }) + slices.Sort(cids) + cids = slices.Compact(cids) + if len(cids) == 0 { + return + } + p.releasedTrackCids = append(p.releasedTrackCids, cids) + if len(p.releasedTrackCids) > cMaxReleasedTracks { + p.releasedTrackCids = p.releasedTrackCids[1:] + } +} + +// must be called with pendingTracksLock held +func (p *ParticipantImpl) isReleasedTrackCidLocked(cid string) bool { + for _, cids := range p.releasedTrackCids { + if slices.Contains(cids, cid) { + return true + } + } + return false +} + +// clearReleasedTrackCidsLocked drops every released track that shares a client id with +// this request. A client re-adding a released track intends to publish on it again, so all +// of its ids are cleared, including an SDP id a reused transceiver keeps. +// Must be called with pendingTracksLock held. +func (p *ParticipantImpl) clearReleasedTrackCidsLocked(req *livekit.AddTrackRequest) { + p.releasedTrackCids = slices.DeleteFunc(p.releasedTrackCids, func(cids []string) bool { + if slices.Contains(cids, req.Cid) { + return true + } + for _, sc := range req.SimulcastCodecs { + if sc.Cid != "" && slices.Contains(cids, sc.Cid) { + return true + } + } + return false + }) +} + func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit.TrackInfo { p.pendingTracksLock.Lock() if req.Sid != "" { @@ -2998,6 +3091,14 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit return ti } + if len(p.pendingTracks) >= cMaxPendingTracks && p.supervisor != nil { + // a client can abandon a publish without any signal the server acts on, + // so before rejecting, release pending tracks the supervisor reports as timed out + for _, trackID := range p.supervisor.TimedOutPublications() { + p.releasePendingTrackLocked(trackID) + } + } + if len(p.pendingTracks) >= cMaxPendingTracks { p.pubLogger.Infow("too many pending tracks, rejecting new track", "numPendingTracks", len(p.pendingTracks)) p.sendRequestResponse(&livekit.RequestResponse{ @@ -3011,6 +3112,8 @@ func (p *ParticipantImpl) addPendingTrack(req *livekit.AddTrackRequest) *livekit return nil } + p.clearReleasedTrackCidsLocked(req) + backupCodecPolicy := req.BackupCodecPolicy // enable simulcast codec for audio by default @@ -3368,6 +3471,16 @@ func (p *ParticipantImpl) mediaTrackReceived( ) mt, ok := p.getPublishedTrackBySdpCid(track.ID()).(*MediaTrack) if !ok { + if p.isReleasedTrackCidLocked(track.ID()) { + p.pendingTracksLock.Unlock() + p.pubLogger.Infow( + "dropping media for a released pending track", + "trackID", track.ID(), + "mid", mid, + ) + return nil, false, false, buffer.VideoLayersRid{} + } + var ( signalCid string ti *livekit.TrackInfo @@ -3715,6 +3828,11 @@ func (p *ParticipantImpl) onUpTrackManagerClose() { func (p *ParticipantImpl) getPendingTrack(clientId string, kind livekit.TrackType, skipQueued bool) (string, *livekit.TrackInfo, buffer.VideoLayersRid, bool, time.Time) { signalCid := clientId + if p.isReleasedTrackCidLocked(clientId) { + // do not fall back to matching by type, it would hand this to another pending track + return signalCid, nil, buffer.VideoLayersRid{}, false, time.Time{} + } + pendingInfo := p.pendingTracks[clientId] if pendingInfo == nil { track_loop: diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index f3271eea4..c32b8a643 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -256,6 +256,122 @@ func TestTrackPublishing(t *testing.T) { require.Equal(t, p.pendingTracks["cid"].trackInfos[0].Sid, p.pendingTracks["cid"].trackInfos[1].Sid) }) + t.Run("should release timed out pending tracks when at the pending track limit", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7}) + sink := p.params.Sink.(*routingfakes.FakeMessageSink) + + // one slot holds two queued entries that share a track id, both before any media + queued := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q0", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, queued) + p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidq", Name: "q1", Type: livekit.TrackType_VIDEO}) // queued, returns nil + require.Len(t, p.pendingTracks["cidq"].trackInfos, 2) + + var sids []livekit.TrackID + for i := 0; i < cMaxPendingTracks-1; i++ { + ti := p.addPendingTrack(&livekit.AddTrackRequest{ + Cid: fmt.Sprintf("cid%d", i), + Name: fmt.Sprintf("track%d", i), + Type: livekit.TrackType_VIDEO, + }) + require.NotNil(t, ti) + sids = append(sids, livekit.TrackID(ti.Sid)) + } + require.Len(t, p.pendingTracks, cMaxPendingTracks) + + // nothing timed out, so a new publish is rejected + require.Nil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO})) + + // one lone track times out, a new publish takes its slot + p.supervisor.SetPublicationTimedOutForTest(sids[0]) + numMessages := sink.WriteMessageCallCount() + require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh", Name: "fresh", Type: livekit.TrackType_VIDEO})) + require.Nil(t, p.pendingTracks["cid0"]) + require.NotNil(t, p.pendingTracks["fresh"]) + require.Len(t, p.pendingTracks, cMaxPendingTracks) + require.Empty(t, p.supervisor.TimedOutPublications()) + + require.Equal(t, numMessages+1, sink.WriteMessageCallCount()) + res := sink.WriteMessageArgsForCall(numMessages).(*livekit.SignalResponse) + require.IsType(t, &livekit.SignalResponse_TrackUnpublished{}, res.Message) + require.Equal(t, string(sids[0]), res.Message.(*livekit.SignalResponse_TrackUnpublished).TrackUnpublished.TrackSid) + + // the queued track id times out, both entries are released together + p.supervisor.SetPublicationTimedOutForTest(livekit.TrackID(queued.Sid)) + require.NotNil(t, p.addPendingTrack(&livekit.AddTrackRequest{Cid: "fresh2", Name: "fresh2", Type: livekit.TrackType_VIDEO})) + require.Nil(t, p.pendingTracks["cidq"]) + require.NotNil(t, p.pendingTracks["fresh2"]) + require.Len(t, p.pendingTracks, cMaxPendingTracks) + }) + + t.Run("should not match late media of a released pending track to another pending track", func(t *testing.T) { + p := newParticipantForTestWithOpts("test", &participantOpts{protocolVersion: 7}) + + released := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, released) + // SDP client id that an offer would set, as Firefox does + p.pendingTracks["cidA"].trackInfos[0].Codecs[0].SdpCid = "sdpA" + + p.pendingTracksLock.Lock() + p.releasePendingTrackLocked(livekit.TrackID(released.Sid)) + p.pendingTracksLock.Unlock() + + other := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidB", Name: "b", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, other) + + // media for the released track, by signal or SDP client id, does not fall back to the other track by type + for _, cid := range []string{"cidA", "sdpA"} { + _, ti, _, _, _ := p.getPendingTrack(cid, livekit.TrackType_VIDEO, true) + require.Nil(t, ti, cid) + } + // an unknown client id still falls back by type, as before + _, ti, _, _, _ := p.getPendingTrack("unknown", livekit.TrackType_VIDEO, true) + require.NotNil(t, ti) + require.Equal(t, other.Sid, ti.Sid) + + // reusing the released client id publishes on it again + reused := p.addPendingTrack(&livekit.AddTrackRequest{Cid: "cidA", Name: "a2", Type: livekit.TrackType_VIDEO}) + require.NotNil(t, reused) + _, ti, _, _, _ = p.getPendingTrack("cidA", livekit.TrackType_VIDEO, true) + require.NotNil(t, ti) + require.Equal(t, reused.Sid, ti.Sid) + // the old SDP id is cleared too, a reused transceiver keeps it + p.pendingTracksLock.Lock() + require.False(t, p.isReleasedTrackCidLocked("sdpA")) + p.pendingTracksLock.Unlock() + }) + + t.Run("should clear a released track when a new request reuses one of its codec client ids", func(t *testing.T) { + p := newParticipantForTest("test") + + p.pendingTracksLock.Lock() + p.addReleasedTrackCidsLocked([]string{"cidA", "codecA", "sdpA"}) + p.addReleasedTrackCidsLocked([]string{"cidB"}) + p.clearReleasedTrackCidsLocked(&livekit.AddTrackRequest{ + Cid: "cidC", + SimulcastCodecs: []*livekit.SimulcastCodec{{Codec: "vp8", Cid: "codecA"}}, + }) + for _, cid := range []string{"cidA", "codecA", "sdpA"} { + require.False(t, p.isReleasedTrackCidLocked(cid), cid) + } + require.True(t, p.isReleasedTrackCidLocked("cidB")) + p.pendingTracksLock.Unlock() + }) + + t.Run("should bound released track client ids", func(t *testing.T) { + p := newParticipantForTest("test") + + p.pendingTracksLock.Lock() + for i := 0; i <= cMaxReleasedTracks; i++ { + p.addReleasedTrackCidsLocked([]string{fmt.Sprintf("cid%d", i), fmt.Sprintf("sdp%d", i)}) + } + require.Len(t, p.releasedTrackCids, cMaxReleasedTracks) + // the oldest track ages out with all of its ids + require.False(t, p.isReleasedTrackCidLocked("cid0")) + require.False(t, p.isReleasedTrackCidLocked("sdp0")) + require.True(t, p.isReleasedTrackCidLocked(fmt.Sprintf("sdp%d", cMaxReleasedTracks))) + p.pendingTracksLock.Unlock() + }) + t.Run("should not allow adding disallowed sources", func(t *testing.T) { p := newParticipantForTest("test") p.SetPermission(&livekit.ParticipantPermission{ diff --git a/pkg/rtc/participant_sdp.go b/pkg/rtc/participant_sdp.go index 5896e99df..26b338777 100644 --- a/pkg/rtc/participant_sdp.go +++ b/pkg/rtc/participant_sdp.go @@ -50,6 +50,11 @@ func (p *ParticipantImpl) populateSdpCid(parsedOffer *sdp.SessionDescription) ([ } p.pendingTracksLock.Lock() + if p.isReleasedTrackCidLocked(streamID) { + p.pendingTracksLock.Unlock() + continue + } + signalCid, info, _, migrated, _ := p.getPendingTrack(streamID, trackType, false) if migrated { p.pendingTracksLock.Unlock() diff --git a/pkg/rtc/supervisor/participant_supervisor.go b/pkg/rtc/supervisor/participant_supervisor.go index 1dc6b9d32..cb960aadf 100644 --- a/pkg/rtc/supervisor/participant_supervisor.go +++ b/pkg/rtc/supervisor/participant_supervisor.go @@ -108,6 +108,56 @@ func (p *ParticipantSupervisor) AddPublication(trackID livekit.TrackID) { p.lock.Unlock() } +// RemovePublication drops the monitor of a pending publication. A monitor that still +// holds a published track is kept, so a live track never loses its monitor. +func (p *ParticipantSupervisor) RemovePublication(trackID livekit.TrackID) { + p.lock.Lock() + defer p.lock.Unlock() + + pm, ok := p.publications[trackID] + if !ok { + return + } + if pub, ok := pm.opMon.(*PublicationMonitor); ok && pub.hasPublishedTrack() { + p.params.Logger.Warnw( + "supervisor: not removing publication with a published track", nil, + "trackID", trackID, + ) + return + } + delete(p.publications, trackID) +} + +// TimedOutPublications returns the track IDs whose pending publish timed out. +func (p *ParticipantSupervisor) TimedOutPublications() []livekit.TrackID { + p.lock.RLock() + defer p.lock.RUnlock() + + var trackIDs []livekit.TrackID + for trackID, pm := range p.publications { + // pm.err is refreshed once per tick, check again so a track that published since is skipped + if pm.err != nil && pm.opMon.Check() != nil { + trackIDs = append(trackIDs, trackID) + } + } + return trackIDs +} + +// SetPublicationTimedOutForTest puts a publication in the state checkPublications reports +// once the publish wait passes. Only for tests outside this package. +func (p *ParticipantSupervisor) SetPublicationTimedOutForTest(trackID livekit.TrackID) { + p.lock.Lock() + if pm, ok := p.publications[trackID]; ok { + if pub, ok := pm.opMon.(*PublicationMonitor); ok { + pub.lock.Lock() + pub.unmutedAt = time.Now().Add(-2 * publishWaitDuration) + pub.lock.Unlock() + } + pm.err = errPublishTimeout + } + p.lock.Unlock() +} + func (p *ParticipantSupervisor) SetPublicationMute(trackID livekit.TrackID, isMuted bool) { p.lock.Lock() pm, ok := p.publications[trackID] @@ -149,7 +199,7 @@ func (p *ParticipantSupervisor) checkState() { func (p *ParticipantSupervisor) checkPublications() { var erroredPublications []livekit.TrackID var removablePublications []livekit.TrackID - p.lock.RLock() + p.lock.Lock() for trackID, pm := range p.publications { if err := pm.opMon.Check(); err != nil { if pm.err == nil { @@ -167,9 +217,6 @@ func (p *ParticipantSupervisor) checkPublications() { } } } - p.lock.RUnlock() - - p.lock.Lock() for _, trackID := range removablePublications { delete(p.publications, trackID) } diff --git a/pkg/rtc/supervisor/participant_supervisor_test.go b/pkg/rtc/supervisor/participant_supervisor_test.go new file mode 100644 index 000000000..c07cbda41 --- /dev/null +++ b/pkg/rtc/supervisor/participant_supervisor_test.go @@ -0,0 +1,112 @@ +// 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 supervisor + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/logger" +) + +// newSupervisorForTest skips the ticker goroutine, tests call checkPublications directly +func newSupervisorForTest() *ParticipantSupervisor { + s := &ParticipantSupervisor{ + params: ParticipantSupervisorParams{Logger: logger.GetLogger()}, + publications: make(map[livekit.TrackID]*trackMonitor), + } + s.SetPublisherPeerConnectionConnected(true) + return s +} + +func addUnmutedPublication(s *ParticipantSupervisor, trackID livekit.TrackID) { + s.AddPublication(trackID) + s.SetPublicationMute(trackID, false) +} + +// agePublication moves the publish window start back past the publish wait +func agePublication(s *ParticipantSupervisor, trackID livekit.TrackID) { + pm := s.publications[trackID].opMon.(*PublicationMonitor) + pm.lock.Lock() + pm.unmutedAt = pm.unmutedAt.Add(-2 * publishWaitDuration) + pm.lock.Unlock() +} + +func TestTimedOutPublications(t *testing.T) { + const trackID = livekit.TrackID("TR_test") + + t.Run("abandoned publication is reported and can be removed", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + + agePublication(s, trackID) + s.checkPublications() + require.Equal(t, []livekit.TrackID{trackID}, s.TimedOutPublications()) + + s.RemovePublication(trackID) + require.Empty(t, s.TimedOutPublications()) + require.Empty(t, s.publications) + }) + + t.Run("muted publication is not reported", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + s.SetPublicationMute(trackID, true) + + agePublication(s, trackID) + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + }) + + t.Run("stale timeout is not reported once the track publishes", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + addUnmutedPublication(s, trackID) + + agePublication(s, trackID) + s.checkPublications() + require.Equal(t, []livekit.TrackID{trackID}, s.TimedOutPublications()) + + // media arrives late, before the next tick clears the cached error + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + require.Empty(t, s.TimedOutPublications()) + }) + + t.Run("publication with a published track is not removed", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + + s.RemovePublication(trackID) + require.Contains(t, s.publications, trackID) + }) + + t.Run("live publication with a queued successor is not reported", func(t *testing.T) { + s := newSupervisorForTest() + addUnmutedPublication(s, trackID) + addUnmutedPublication(s, trackID) // queued successor shares the track id + s.SetPublishedTrack(trackID, &typesfakes.FakeLocalMediaTrack{}) + + agePublication(s, trackID) + s.checkPublications() + require.Empty(t, s.TimedOutPublications()) + }) +} diff --git a/pkg/rtc/supervisor/publication_monitor.go b/pkg/rtc/supervisor/publication_monitor.go index 199d2188f..572c2e2a7 100644 --- a/pkg/rtc/supervisor/publication_monitor.go +++ b/pkg/rtc/supervisor/publication_monitor.go @@ -163,6 +163,13 @@ func (p *PublicationMonitor) Check() error { return nil } +func (p *PublicationMonitor) hasPublishedTrack() bool { + p.lock.RLock() + defer p.lock.RUnlock() + + return p.publishedTrack != nil +} + func (p *PublicationMonitor) IsIdle() bool { p.lock.RLock() defer p.lock.RUnlock() @@ -170,7 +177,10 @@ func (p *PublicationMonitor) IsIdle() bool { return p.desiredPublishes.Len() == 0 && p.publishedTrack == nil } +// update is the only place the front of desiredPublishes moves. +// Any other pop must also re-arm the publish timer. func (p *PublicationMonitor) update() { + popped := false for { var pub *publish if p.desiredPublishes.Len() > 0 { @@ -184,7 +194,14 @@ func (p *PublicationMonitor) update() { if (pub.isStart && p.publishedTrack == nil) || (!pub.isStart && p.publishedTrack != nil) { // put it back as the condition is not satisfied p.desiredPublishes.PushFront(pub) + + // a queued publish reached the front, start its publish window from now + if popped && pub.isStart { + p.unmutedAt = time.Time{} + p.maybeStartMonitor() + } return } + popped = true } } diff --git a/pkg/rtc/supervisor/publication_monitor_test.go b/pkg/rtc/supervisor/publication_monitor_test.go new file mode 100644 index 000000000..721c2da69 --- /dev/null +++ b/pkg/rtc/supervisor/publication_monitor_test.go @@ -0,0 +1,142 @@ +// 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 supervisor + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/livekit/livekit-server/pkg/rtc/types" + "github.com/livekit/livekit-server/pkg/rtc/types/typesfakes" + "github.com/livekit/protocol/logger" +) + +func newMonitorForTest() *PublicationMonitor { + return NewPublicationMonitor(PublicationMonitorParams{ + TrackID: "TR_test", + IsPeerConnectionConnected: true, + Logger: logger.GetLogger(), + }) +} + +// addUnmuted adds a pending publish the way the participant does: add, then set mute +func addUnmuted(m *PublicationMonitor) { + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + m.PostEvent(types.OperationMonitorEventSetPublicationMute, false) +} + +// age moves the publish window start back past the publish wait and returns it +func age(m *PublicationMonitor) time.Time { + m.lock.Lock() + defer m.lock.Unlock() + m.unmutedAt = m.unmutedAt.Add(-2 * publishWaitDuration) + return m.unmutedAt +} + +func getUnmutedAt(m *PublicationMonitor) time.Time { + m.lock.RLock() + defer m.lock.RUnlock() + return m.unmutedAt +} + +// Each subtest is one row of the re-arm case table: the event, and whether the +// publish timer restarts because a queued start reached the front. +func TestPublicationMonitorRearm(t *testing.T) { + t.Run("first add: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.True(t, getUnmutedAt(m).IsZero()) + }) + + t.Run("successor queued before the track publishes: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.Equal(t, aged, getUnmutedAt(m)) + require.ErrorIs(t, m.Check(), errPublishTimeout) + }) + + t.Run("track publishes: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, &typesfakes.FakeLocalMediaTrack{}) + require.Equal(t, aged, getUnmutedAt(m)) + require.NoError(t, m.Check()) // front is the live track's end + }) + + t.Run("successor queued while the track is live: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, &typesfakes.FakeLocalMediaTrack{}) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventAddPendingPublication, nil) + require.Equal(t, aged, getUnmutedAt(m)) + require.NoError(t, m.Check()) + }) + + t.Run("track closes with a successor queued: re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.Less(t, time.Since(getUnmutedAt(m)), publishWaitDuration) + require.NoError(t, m.Check()) + + // the successor still times out once its own window passes without media + age(m) + require.ErrorIs(t, m.Check(), errPublishTimeout) + }) + + t.Run("track closes with a muted successor queued: clock cleared", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + m.PostEvent(types.OperationMonitorEventSetPublicationMute, true) + age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.True(t, getUnmutedAt(m).IsZero()) + + // the clock starts on unmute + m.PostEvent(types.OperationMonitorEventSetPublicationMute, false) + require.Less(t, time.Since(getUnmutedAt(m)), publishWaitDuration) + }) + + t.Run("track closes with nothing queued: no re-arm", func(t *testing.T) { + m := newMonitorForTest() + addUnmuted(m) + track := &typesfakes.FakeLocalMediaTrack{} + m.PostEvent(types.OperationMonitorEventSetPublishedTrack, track) + aged := age(m) + + m.PostEvent(types.OperationMonitorEventClearPublishedTrack, track) + require.Equal(t, aged, getUnmutedAt(m)) + require.True(t, m.IsIdle()) + }) +} From d0fd37eaa0a5a64e844d4e0e62a509262efee7d7 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 11:07:35 +0530 Subject: [PATCH 121/130] Send NACKs when processing RTX packets. (#5000) * Send NACKs when processing RTX packets. Applying the fix proposed in https://github.com/livekit/livekit/issues/4999 * Add test for NACKs sent while processing RTX packets. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/sfu/buffer/buffer.go | 19 +++++++++---- pkg/sfu/buffer/buffer_test.go | 53 +++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/pkg/sfu/buffer/buffer.go b/pkg/sfu/buffer/buffer.go index 551a60b5e..b55275508 100644 --- a/pkg/sfu/buffer/buffer.go +++ b/pkg/sfu/buffer/buffer.go @@ -327,15 +327,24 @@ func (b *Buffer) NotifyRTX(ssrc uint32, repairSSRC uint32, rsid string) { } func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { + rtcpPackets := b.writeRTXLocked(rtxPkt, arrivalTime) + if len(rtcpPackets) != 0 { + if cb := b.getOnRtcpFeedback(); cb != nil { + cb(rtcpPackets) + } + } +} + +func (b *Buffer) writeRTXLocked(rtxPkt *rtp.Packet, arrivalTime int64) []rtcp.Packet { b.Lock() defer b.Unlock() if !b.isBound { - return + return nil } if rtxPkt.PayloadType != b.rtxPayloadType { b.logger.Debugw("unexpected rtx payload type", "expected", b.rtxPayloadType, "actual", rtxPkt.PayloadType) - return + return nil } if b.rtxPktBuf == nil { @@ -344,7 +353,7 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { if len(rtxPkt.Payload) < 2 { b.logger.Warnw("rtx payload too short", nil, "size", len(rtxPkt.Payload)) - return + return nil } repairedPkt := *rtxPkt @@ -355,10 +364,10 @@ func (b *Buffer) writeRTX(rtxPkt *rtp.Packet, arrivalTime int64) { n, err := repairedPkt.MarshalTo(b.rtxPktBuf) if err != nil { b.logger.Errorw("could not marshal repaired packet", err, "ssrc", b.BufferBase.SSRC(), "sn", repairedPkt.SequenceNumber) - return + return nil } - b.calc(b.rtxPktBuf[:n], &repairedPkt, arrivalTime, false, true) + return b.calc(b.rtxPktBuf[:n], &repairedPkt, arrivalTime, false, true) } func (b *Buffer) Read(buff []byte) (n int, err error) { diff --git a/pkg/sfu/buffer/buffer_test.go b/pkg/sfu/buffer/buffer_test.go index 39267dade..032596dfd 100644 --- a/pkg/sfu/buffer/buffer_test.go +++ b/pkg/sfu/buffer/buffer_test.go @@ -182,6 +182,59 @@ func TestNack(t *testing.T) { }) } +func TestNackOnRTX(t *testing.T) { + buff := NewBuffer(123, 1, 1) + buff.codecType = webrtc.RTPCodecTypeVideo + var mu sync.Mutex + var nacked []uint16 + buff.OnRtcpFeedback(func(fb []rtcp.Packet) { + mu.Lock() + defer mu.Unlock() + for _, pkt := range fb { + if p, ok := pkt.(*rtcp.TransportLayerNack); ok && p.MediaSSRC == 123 { + for _, pair := range p.Nacks { + nacked = append(nacked, pair.PacketList()...) + } + } + } + }) + require.NoError(t, buff.Bind(webrtc.RTPParameters{ + Codecs: []webrtc.RTPCodecParameters{vp8Codec}, + }, vp8Codec.RTPCodecCapability, 0)) + buff.rtxPayloadType = 97 + rtt := uint32(20) + buff.nacker.SetRTT(rtt) + + payload := []byte{0xff, 0xff, 0xff, 0xfd, 0xb4, 0x9f, 0x94, 0x1} + // sequence numbers 1 and 2 are lost + for _, sn := range []uint16{0, 3} { + pkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 96, SequenceNumber: sn, Timestamp: uint32(sn), SSRC: 123}, + Payload: payload, + } + b, err := pkt.Marshal() + require.NoError(t, err) + _, err = buff.Write(b) + require.NoError(t, err) + } + + time.Sleep(time.Duration(2*rtt) * time.Millisecond) + mu.Lock() + nacked = nil + mu.Unlock() + + // RTX repairs sequence number 2, the NACK for 1 is due and must be sent + rtxPkt := rtp.Packet{ + Header: rtp.Header{Version: 2, PayloadType: 97, SequenceNumber: 500, Timestamp: 2, SSRC: 456}, + Payload: append([]byte{0x00, 0x02}, payload...), + } + buff.writeRTX(&rtxPkt, time.Now().UnixNano()) + + mu.Lock() + defer mu.Unlock() + require.Equal(t, []uint16{1}, nacked) +} + func TestNewBuffer(t *testing.T) { tests := []struct { name string From e5655ac20267fa5005dccaf3f0e3bfc3ea688c07 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 12:47:27 +0530 Subject: [PATCH 122/130] Release v1.13.10. (#5001) Co-authored-by: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 30 ++++++++++++++++++++++++++++++ version/version.go | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e147510..b0d90cf18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.13.10] - 2026-10-14 + +### Changed + +- sfu: forward the dependency descriptor for VP8 simulcast (#4987) +- sfu: keep VP8 descriptor frame numbers across migration (#4989) +- sfu: filter VP9 and AV1 simulcast temporal layers with the dependency descriptor (#4991) +- sfu: filter VP8 temporal layers by frame when the dependency descriptor is sent (#4976) +- sfu: switch VP8 temporal layers up with the dependency descriptor (#4974) +- sfu: pad the slot of a filtered packet that follows a sequence number gap (#4983) +- sfu: send padding in the slot of a dropped out-of-order packet (#4979) +- sfu: parse VP8 and VP9 payload descriptors without an allocation (#4981) +- DownTrack: arm key frame requester timer only while a layer lock is pending (#4980) +- turn: refuse TCP relays and limit relays to this host to media ports (#4992) +- agent endpoints: address an unnamed deployment as "_" (#4997) +- Add OSS Scanner build image and threat model (#4995) +- Update golang Docker tag to v1.27.1 (#4985) +- Update alpine Docker tag to v3.24.2 (#4984) +- Update module github.com/maxbrunsfeld/counterfeiter/v6 to v6.13.0 (#4915) + +### Fixed + +- Send NACKs when processing RTX packets (#5000) +- sfu: send padding-only packets with no payload (#4993) +- rtc: release timed out pending tracks at the pending track limit (#4998) +- Fix nil pointer dereference in IOInfoService.UpdateIngressState (#4806) +- rtc: keep update cache eviction order when listing recent disconnects (#4975) +- fix(cli): return non-zero exit status on command errors (#4888) +- Disable AV1 for Firefox clients before 157 (#4962) + ## [1.13.9] - 2026-10-07 ### Changed diff --git a/version/version.go b/version/version.go index 27cf088c7..1790b5fa6 100644 --- a/version/version.go +++ b/version/version.go @@ -14,4 +14,4 @@ package version -const Version = "1.13.9" +const Version = "1.13.10" From 36ba8ad338424d3854e07167c8143d17cea466a9 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 13:27:55 +0530 Subject: [PATCH 123/130] agent: fix flaky TestAgent job dispatch (#5002) * agent: fix flaky TestAgent job dispatch Retry JobRequest until the worker subscribes the job topic, because registration is async and the topic is subscribed after the register response. Co-Authored-By: Claude Opus 5.5 * agent: report last JobRequest error on dispatch timeout Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/agent/agent_test.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/pkg/agent/agent_test.go b/pkg/agent/agent_test.go index 4ec83bc8f..fab7656df 100644 --- a/pkg/agent/agent_test.go +++ b/pkg/agent/agent_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/atomic" @@ -41,8 +42,11 @@ func TestAgent(t *testing.T) { Room: &livekit.Room{}, AgentName: testAgentName, } - _, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job) - require.NoError(t, err) + // registration is async and the job topic is subscribed after the register response, so retry until a worker is there + require.EventuallyWithT(t, func(c *assert.CollectT) { + _, err := client.JobRequest(context.Background(), testAgentName, agent.RoomAgentTopic, job) + assert.NoError(c, err) + }, 5*time.Second, 50*time.Millisecond) select { case a := <-jobAssignments.Events(): From d8aaa45dd1d545226727c331a3cee7a2dc1a1142 Mon Sep 17 00:00:00 2001 From: "Jibon L. Costa" Date: Wed, 14 Oct 2026 12:18:36 +0200 Subject: [PATCH 124/130] fix: compute RTCP receiver-report DLSR from monotonic clock (#5005) --- pkg/sfu/rtpstats/rtpstats_receiver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/sfu/rtpstats/rtpstats_receiver.go b/pkg/sfu/rtpstats/rtpstats_receiver.go index 90f7eb2b2..bb49c13a5 100644 --- a/pkg/sfu/rtpstats/rtpstats_receiver.go +++ b/pkg/sfu/rtpstats/rtpstats_receiver.go @@ -868,7 +868,7 @@ func (r *RTPStatsReceiver) GetRtcpReceptionReport(ssrc uint32, proxyFracLost uin if r.srNewest != nil { lastSR = uint32(r.srNewest.NtpTimestamp >> 16) if r.srNewest.At != 0 { - delayUS := time.Since(time.Unix(0, r.srNewest.At)).Microseconds() + delayUS := (mono.UnixNano() - r.srNewest.At) / 1e3 dlsr = uint32(delayUS * 65536 / 1e6) } } From 29d70791641eed2db6d269a9cb4c7ae3bf5e0d66 Mon Sep 17 00:00:00 2001 From: Ramlal <132562384+ram-lal0234@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:30:22 +0530 Subject: [PATCH 125/130] rtc: apply simulcast rids from SDP for WHIP (one-shot) publishers (#5004) * rtc: apply simulcast rids from SDP for WHIP (one-shot) publishers In one-shot signalling mode HandleRemoteDescription set the remote offer directly and never called OnSetRemoteDescriptionOffer, so updateRidsFromSDP did not run for WHIP participants. Every simulcast layer then mapped to spatial layer 0 and all but the first were rejected with 'duplicate layer'. * rtc: test that a one-shot simulcast offer applies rids to the pending track * rtc: use h/q rids in one-shot simulcast test, cover single-layer offer Name the simulcast test layers medium/h and low/q per convention, and add TestOneShotSingleLayerOfferUnchanged to check a one-shot offer without rids still yields a single layer with rid "" at spatial layer 0. --- pkg/rtc/participant_internal_test.go | 123 ++++++++++++++++++++++----- pkg/rtc/transport.go | 3 + 2 files changed, 107 insertions(+), 19 deletions(-) diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index c32b8a643..68cbffa2a 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -824,6 +824,89 @@ func TestPreferMediaCodecForPublisher(t *testing.T) { } } +func TestOneShotSimulcastOfferAppliesRids(t *testing.T) { + participant := newParticipantForTestWithOpts("whip", &participantOpts{ + publisher: true, + oneShot: true, + }) + participant.SetMigrateState(types.MigrateStateComplete) + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + + codec := webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()} + medium, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("h")) + require.NoError(t, err) + low, err := webrtc.NewTrackLocalStaticRTP(codec, "video", "stream", webrtc.WithRTPStreamID("q")) + require.NoError(t, err) + sender, err := pc.AddTrack(medium) + require.NoError(t, err) + require.NoError(t, sender.AddEncoding(low)) + + offer, err := pc.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, pc.SetLocalDescription(offer)) + require.Contains(t, offer.SDP, "a=simulcast:send h;q") + + require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{ + Type: webrtc.SDPTypeOffer.String(), + Sdp: offer.SDP, + })) + + participant.pendingTracksLock.RLock() + pti := participant.pendingTracks["video"] + participant.pendingTracksLock.RUnlock() + require.NotNil(t, pti) + + layers := pti.trackInfos[0].Codecs[0].Layers + require.Len(t, layers, 2) + require.Equal(t, livekit.VideoQuality_LOW, layers[0].Quality) + require.Equal(t, "q", layers[0].Rid) + require.EqualValues(t, 0, layers[0].SpatialLayer) + require.Equal(t, livekit.VideoQuality_MEDIUM, layers[1].Quality) + require.Equal(t, "h", layers[1].Rid) + require.EqualValues(t, 1, layers[1].SpatialLayer) +} + +func TestOneShotSingleLayerOfferUnchanged(t *testing.T) { + participant := newParticipantForTestWithOpts("whip", &participantOpts{ + publisher: true, + oneShot: true, + }) + participant.SetMigrateState(types.MigrateStateComplete) + + pc, err := webrtc.NewPeerConnection(webrtc.Configuration{}) + require.NoError(t, err) + defer pc.Close() + + track, err := webrtc.NewTrackLocalStaticRTP(webrtc.RTPCodecCapability{MimeType: mime.MimeTypeVP8.String()}, "video", "stream") + require.NoError(t, err) + _, err = pc.AddTransceiverFromTrack(track, webrtc.RTPTransceiverInit{Direction: webrtc.RTPTransceiverDirectionSendonly}) + require.NoError(t, err) + + offer, err := pc.CreateOffer(nil) + require.NoError(t, err) + require.NoError(t, pc.SetLocalDescription(offer)) + require.NotContains(t, offer.SDP, "a=simulcast") + require.NotContains(t, offer.SDP, "a=rid") + + require.NoError(t, participant.HandleOffer(&livekit.SessionDescription{ + Type: webrtc.SDPTypeOffer.String(), + Sdp: offer.SDP, + })) + + participant.pendingTracksLock.RLock() + pti := participant.pendingTracks["video"] + participant.pendingTracksLock.RUnlock() + require.NotNil(t, pti) + + layers := pti.trackInfos[0].Codecs[0].Layers + require.Len(t, layers, 1) + require.Equal(t, "", layers[0].Rid) + require.EqualValues(t, 0, layers[0].SpatialLayer) +} + func TestPreferAudioCodecForRed(t *testing.T) { participant := newParticipantForTestWithOpts("123", &participantOpts{ publisher: true, @@ -969,6 +1052,7 @@ type participantOpts struct { clientConf *livekit.ClientConfiguration clientInfo *livekit.ClientInfo migration bool + oneShot bool } func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *participantOpts) *ParticipantImpl { @@ -1005,25 +1089,26 @@ func newParticipantForTestWithOpts(identity livekit.ParticipantIdentity, opts *p } sid := livekit.ParticipantID(guid.New(utils.ParticipantPrefix)) p, _ := NewParticipant(ParticipantParams{ - SID: sid, - Identity: identity, - Config: rtcConf, - Sink: &routingfakes.FakeMessageSink{}, - ProtocolVersion: opts.protocolVersion, - SessionStartTime: time.Now(), - PLIThrottleConfig: conf.RTC.PLIThrottle, - Grants: grants, - PublishEnabledCodecs: enabledCodecs, - SubscribeEnabledCodecs: enabledCodecs, - ClientConf: opts.clientConf, - ClientInfo: ClientInfo{ClientInfo: opts.clientInfo}, - Logger: LoggerWithParticipant(logger.GetLogger(), identity, sid, false), - Reporter: roomobs.NewNoopParticipantSessionReporter(), - TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{}, - VersionGenerator: utils.NewDefaultTimedVersionGenerator(), - ParticipantListener: &typesfakes.FakeLocalParticipantListener{}, - ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{}, - Migration: opts.migration, + SID: sid, + Identity: identity, + Config: rtcConf, + Sink: &routingfakes.FakeMessageSink{}, + ProtocolVersion: opts.protocolVersion, + SessionStartTime: time.Now(), + PLIThrottleConfig: conf.RTC.PLIThrottle, + Grants: grants, + PublishEnabledCodecs: enabledCodecs, + SubscribeEnabledCodecs: enabledCodecs, + ClientConf: opts.clientConf, + ClientInfo: ClientInfo{ClientInfo: opts.clientInfo}, + Logger: LoggerWithParticipant(logger.GetLogger(), identity, sid, false), + Reporter: roomobs.NewNoopParticipantSessionReporter(), + TelemetryListener: &typesfakes.FakeParticipantTelemetryListener{}, + VersionGenerator: utils.NewDefaultTimedVersionGenerator(), + ParticipantListener: &typesfakes.FakeLocalParticipantListener{}, + ParticipantHelper: &typesfakes.FakeLocalParticipantHelper{}, + Migration: opts.migration, + UseOneShotSignallingMode: opts.oneShot, }) p.isPublisher.Store(opts.publisher) p.updateState(livekit.ParticipantInfo_ACTIVE) diff --git a/pkg/rtc/transport.go b/pkg/rtc/transport.go index 350fffe43..c2db48f92 100644 --- a/pkg/rtc/transport.go +++ b/pkg/rtc/transport.go @@ -1709,6 +1709,9 @@ func (t *PCTransport) HandleRemoteDescription(sd webrtc.SessionDescription, remo return err } t.setRemoteOfferParsed(sd.Type, parsed) + if sd.Type == webrtc.SDPTypeOffer { + t.params.Handler.OnSetRemoteDescriptionOffer() + } rtxRepairs := nonSimulcastRTXRepairsFromSDP(parsed, t.params.Logger) if len(rtxRepairs) > 0 { From 43291596c4845b05f7782ac9905f993e86559d37 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 17:00:59 +0530 Subject: [PATCH 126/130] rtpstats: return sender report time in wall clock domain (#5006) LastSenderReportTime() returned time.Unix(0, srNewest.At). But At is a mono timestamp, so the result is not a real wall time. Connection quality compares it to time.Now() to decide if RTCP is stale. If the wall clock drifts from mono, for example after an NTP step, streams can look stale when they are not, or the reverse. - Convert At to wall time: now minus the mono time since the report. - Return zero time when At is 0, so the scorer skips the stale check (before, it got the 1970 epoch and saw the stream as stale). Follow-up to #5005, which fixed the same mix-up in DLSR. Tests: rtpstats and connectionquality pass. No new test, because a test process cannot make the mono and wall clocks drift. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/rtpstats/rtpstats_receiver.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/sfu/rtpstats/rtpstats_receiver.go b/pkg/sfu/rtpstats/rtpstats_receiver.go index bb49c13a5..193e44363 100644 --- a/pkg/sfu/rtpstats/rtpstats_receiver.go +++ b/pkg/sfu/rtpstats/rtpstats_receiver.go @@ -824,8 +824,9 @@ func (r *RTPStatsReceiver) LastSenderReportTime() time.Time { r.lock.RLock() defer r.lock.RUnlock() - if r.srNewest != nil { - return time.Unix(0, r.srNewest.At) + if r.srNewest != nil && r.srNewest.At != 0 { + // srNewest.At is a mono timestamp, convert to wall time for comparison with time.Now() + return time.Now().Add(-time.Duration(mono.UnixNano() - r.srNewest.At)) } return time.Time{} From e357fdffb77a084a308e0284c31004e531fa4046 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 17:44:12 +0530 Subject: [PATCH 127/130] rtpstats: take mono nanoseconds in GetExpectedRTPTimestamp (#5008) Callers passed mono.Now() as a time.Time, which made it easy to pass time.Now() by mistake and mix clocks. Take mono nanoseconds instead. Co-authored-by: Claude Opus 5.5 --- pkg/sfu/forwarder.go | 4 ++-- pkg/sfu/rtpstats/rtpstats_sender.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/sfu/forwarder.go b/pkg/sfu/forwarder.go index 7e378ad20..8e2786c08 100644 --- a/pkg/sfu/forwarder.go +++ b/pkg/sfu/forwarder.go @@ -2014,7 +2014,7 @@ func (f *Forwarder) processSourceSwitch(extPkt *buffer.ExtPacket, layer int32) ( } if f.rtpStats != nil { - tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(switchingAt) + tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(switchingAt.UnixNano()) if err == nil { extExpectedTS = tsExt if f.lastReferencePayloadType == -1 { @@ -2453,7 +2453,7 @@ func (f *Forwarder) GetSnTsForBlankFrames(frameRate uint32, numPackets int) ([]S extLastTS := f.rtpMunger.GetExtLastTimestamp() extExpectedTS := extLastTS if f.rtpStats != nil { - tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(mono.Now()) + tsExt, err := f.rtpStats.GetExpectedRTPTimestamp(mono.UnixNano()) if err == nil { extExpectedTS = tsExt } diff --git a/pkg/sfu/rtpstats/rtpstats_sender.go b/pkg/sfu/rtpstats/rtpstats_sender.go index eafa7bc7b..578a4a067 100644 --- a/pkg/sfu/rtpstats/rtpstats_sender.go +++ b/pkg/sfu/rtpstats/rtpstats_sender.go @@ -901,7 +901,7 @@ func (r *RTPStatsSender) MaybeAdjustFirstPacketTime(publisherSRData *livekit.RTC } } -func (r *RTPStatsSender) GetExpectedRTPTimestamp(at time.Time) (expectedTSExt uint64, err error) { +func (r *RTPStatsSender) GetExpectedRTPTimestamp(atNano int64) (expectedTSExt uint64, err error) { r.lock.RLock() defer r.lock.RUnlock() @@ -910,7 +910,7 @@ func (r *RTPStatsSender) GetExpectedRTPTimestamp(at time.Time) (expectedTSExt ui return } - timeDiff := at.Sub(time.Unix(0, r.firstTime)) + timeDiff := time.Duration(atNano - r.firstTime) expectedTSExt = r.extStartTS + r.rtpConverter.ToRTPExt(timeDiff) return } From 7dc6483fdc464d1b023a14d71862f5a50c6c344d Mon Sep 17 00:00:00 2001 From: Martin Njalale Date: Wed, 14 Oct 2026 17:31:44 +0300 Subject: [PATCH 128/130] rtc: remove data track subscriptions when canSubscribe is revoked (#5011) --- pkg/rtc/participant.go | 3 ++ pkg/rtc/participant_internal_test.go | 44 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/pkg/rtc/participant.go b/pkg/rtc/participant.go index b0bc076fb..0ded2aba1 100644 --- a/pkg/rtc/participant.go +++ b/pkg/rtc/participant.go @@ -901,6 +901,9 @@ func (p *ParticipantImpl) SetPermission(permission *livekit.ParticipantPermissio for _, st := range p.SubscriptionManager.GetSubscribedTracks() { st.MediaTrack().RemoveSubscriber(p.ID(), false) } + for _, ddt := range p.SubscriptionManager.GetSubscribedDataTracks() { + ddt.PublishDataTrack().RemoveSubscriber(p.ID()) + } } if !grants.Video.GetCanPublishData() { diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index 68cbffa2a..c3ac21065 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -400,6 +400,50 @@ func TestTrackPublishing(t *testing.T) { }) } +func TestSetPermissionDataTrackSubscriptions(t *testing.T) { + p := newParticipantForTestWithOpts("sub", &participantOpts{ + permissions: &livekit.ParticipantPermission{ + CanSubscribe: true, + }, + }) + defer p.Close(false, types.ParticipantCloseReasonNone, false) + + pubID := livekit.ParticipantID("PA_pub") + dt := NewDataTrack( + DataTrackParams{ + Logger: logger.GetLogger(), + ParticipantID: func() livekit.ParticipantID { return pubID }, + ParticipantIdentity: "pub", + }, + &livekit.DataTrackInfo{PubHandle: 1, Sid: "DTR_test", Name: "test"}, + ) + defer dt.Close() + + helper := p.params.ParticipantHelper.(*typesfakes.FakeLocalParticipantHelper) + helper.ResolveDataTrackReturns(types.DataResolverResult{ + DataTrack: dt, + HasPermission: true, + PublisherID: pubID, + PublisherIdentity: "pub", + }) + + p.SubscribeToDataTrack(dt.ID()) + require.Eventually(t, func() bool { + return dt.IsSubscriber(p.ID()) + }, 5*time.Second, 10*time.Millisecond, "data track subscription should be established") + + // revoking canSubscribe should drop data track subscriptions, like media track subscriptions + require.True(t, p.SetPermission(&livekit.ParticipantPermission{CanSubscribe: false})) + require.False(t, dt.IsSubscriber(p.ID())) + require.Empty(t, p.GetSubscribedDataTracks()) + + // granting canSubscribe again should restore the subscription + require.True(t, p.SetPermission(&livekit.ParticipantPermission{CanSubscribe: true})) + require.Eventually(t, func() bool { + return dt.IsSubscriber(p.ID()) + }, 5*time.Second, 10*time.Millisecond, "data track subscription should be restored") +} + func TestOutOfOrderUpdates(t *testing.T) { p := newParticipantForTest("test") p.updateState(livekit.ParticipantInfo_JOINED) From 18df6ad4b6942c74a6bd95832735addc0d2c6d87 Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Wed, 14 Oct 2026 23:11:33 +0530 Subject: [PATCH 129/130] sfu: probe with padding when recent packets are gone (#5009) (#5013) Co-authored-by: Claude Opus 5.5 --- pkg/sfu/downtrack.go | 6 + .../downtrack_downstream_integration_test.go | 277 +++++++++++++++++- 2 files changed, 282 insertions(+), 1 deletion(-) diff --git a/pkg/sfu/downtrack.go b/pkg/sfu/downtrack.go index 822387313..29e35747b 100644 --- a/pkg/sfu/downtrack.go +++ b/pkg/sfu/downtrack.go @@ -2590,6 +2590,12 @@ func (d *DownTrack) WriteProbePackets(bytesToSend int, usePadding bool) int { break } } + + // recent packets may be gone from the publisher buffer, for example while + // paused, so probe with padding to keep the probe cluster progressing + if bytesSent == 0 { + return d.WriteProbePackets(bytesToSend, true) + } } return bytesSent diff --git a/pkg/sfu/downtrack_downstream_integration_test.go b/pkg/sfu/downtrack_downstream_integration_test.go index 73f2c4c43..07fd81880 100644 --- a/pkg/sfu/downtrack_downstream_integration_test.go +++ b/pkg/sfu/downtrack_downstream_integration_test.go @@ -32,6 +32,9 @@ package sfu_test // WritePaddingRTP - padding-only packets // WriteProbePackets - RTX probe packets // +// It also checks that a track paused by congestion resumes once probing finds +// the bandwidth again. +// // pion's rtp.Packet.Unmarshal rejects a packet whose padding bit is set but whose // trailing padding-size byte is invalid/zero (errInvalidRTPPadding), so a // malformed packet is dropped by the receiver and never arrives. Arrival on the @@ -41,7 +44,9 @@ import ( "bytes" "encoding/binary" "io" + "math" "sync" + "sync/atomic" "testing" "time" @@ -50,6 +55,7 @@ import ( "github.com/pion/webrtc/v4" "github.com/stretchr/testify/require" + "github.com/livekit/mediatransportutil/pkg/bucket" "github.com/livekit/mediatransportutil/pkg/codec" "github.com/livekit/protocol/codecs/mime" "github.com/livekit/protocol/livekit" @@ -176,7 +182,7 @@ type downTrackHarness struct { // newBoundDownTrack builds a real DownTrack, attaches it to a real sender PC, // negotiates with a real subscriber PC over vnet, and makes it writable. -func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg vnettest.MediaEngineConfig) *downTrackHarness { +func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, codecParams webrtc.RTPCodecParameters, p pacer.Pacer, meCfg vnettest.MediaEngineConfig, rcvOpts ...func(*sfufakes.FakeTrackReceiver)) *downTrackHarness { t.Helper() sender := vnettest.NewPeerConnection(t, vnettest.PCConfig{Net: h.OfferNet, MediaEngine: meCfg, BufferFactory: factory.GetOrNew}) @@ -184,6 +190,9 @@ func newBoundDownTrack(t *testing.T, h *vnettest.Hosts, factory *buffer.Factory, capture := captureTrack(sub) rcv := newFakeTrackReceiver(codecParams) + for _, opt := range rcvOpts { + opt(rcv) + } dt, err := NewDownTrack(DownTrackParams{ Codecs: []webrtc.RTPCodecParameters{codecParams}, Receiver: rcv, @@ -993,3 +1002,269 @@ func TestDownTrackKeyFrameRequesterArmsOnlyWhilePending(t *testing.T) { return dh.receiver.SendPLICallCount() > before }, 5*time.Second, 20*time.Millisecond, "expected PLI after resync") } + +// ----------------------------------------------------------------------------- +// pause/resume: probing resumes a track paused by congestion +// ----------------------------------------------------------------------------- + +// countingPacer counts the bytes handed to the pacer (media, padding and probes), +// which is what a receiver sees arrive on an unconstrained link. +type countingPacer struct { + pacer.Pacer + + lock sync.Mutex + samples []rateSample + + // forwarded media packets, counted here as the far side buffer is unbound and + // stops surfacing packets after it fills up; the pass-through pacer writes + // inside Enqueue, so a counted packet is already on the wire + mediaPackets atomic.Int64 +} + +type rateSample struct { + at time.Time + bytes int +} + +func (c *countingPacer) Enqueue(p *pacer.Packet) { + size := p.HeaderSize + len(p.Payload) + if p.Header != nil { + size += int(p.Header.PaddingSize) + } + if !p.IsProbe && !p.IsRTX && len(p.Payload) != 0 { + c.mediaPackets.Add(1) + } + c.lock.Lock() + c.samples = append(c.samples, rateSample{at: time.Now(), bytes: size}) + c.lock.Unlock() + c.Pacer.Enqueue(p) +} + +// bitrate over the trailing window. +func (c *countingPacer) bitrate(window time.Duration) int64 { + c.lock.Lock() + defer c.lock.Unlock() + cutoff := time.Now().Add(-window) + total := 0 + keep := c.samples[:0] + for _, s := range c.samples { + if s.at.After(cutoff) { + total += s.bytes + keep = append(keep, s) + } + } + c.samples = keep + return int64(float64(total*8) / window.Seconds()) +} + +// TestDownTrackResumesAfterBandwidthPause reproduces livekit/livekit#5009. +// +// Default server congestion control (remote BWE / REMB, pass-through pacer, padding +// probes) drives a real DownTrack over real pion on vnet. A two layer simulcast +// stream is forwarded, the receiver estimate is capped below the lowest layer for +// 20s, then uncapped. The receiver estimate is a libwebrtc-like REMB model: under the +// cap it reports the cap; after it, the estimate only grows (8%/s) while it is below +// 1.5x the incoming rate + 10 kbps, like AimdRateControl does. +// +// With allow pause the video must come back within a bound after the cap is lifted, +// as it does without allow pause. +func TestDownTrackResumesAfterBandwidthPause(t *testing.T) { + if testing.Short() { + t.Skip("takes about 60s") + } + + for _, allowPause := range []bool{false, true} { + name := "allowPause=false" + if allowPause { + name = "allowPause=true" + } + t.Run(name, func(t *testing.T) { + testResumeAfterBandwidthPause(t, allowPause) + }) + } +} + +func testResumeAfterBandwidthPause(t *testing.T, allowPause bool) { + logger.InitFromConfig(&logger.Config{Level: "debug"}, t.Name()) + const ( + layer0Bps = 80_000 + layer1Bps = 170_000 + cappedBps = 60_000 // below the lowest layer, so allow pause pauses + fps = 30 // ~one packet per frame, so a layer is ~30 packets/s like a 360p camera + ) + + h := vnettest.NewHosts(t) + factory := buffer.NewFactoryOfBufferFactory(500, 500).CreateBufferFactory() + + b := remotebwe.NewRemoteBWE(remotebwe.RemoteBWEParams{ + Config: remotebwe.DefaultRemoteBWEConfig, + Logger: logger.GetLogger(), + }) + cp := &countingPacer{Pacer: pacer.NewPassThrough(logger.GetLogger(), nil)} + t.Cleanup(cp.Stop) + + dh := newBoundDownTrack(t, h, factory, vp8CodecParams, cp, vnettest.MediaEngineConfig{ + Video: true, + HeaderExtensions: true, + }, func(r *sfufakes.FakeTrackReceiver) { + // layer switches without sender reports from the fake publisher + r.VideoLayerModeReturns(livekit.VideoLayer_ONE_SPATIAL_LAYER_PER_STREAM_INCOMPLETE_RTCP_SR) + }) + dt := dh.dt + + var brs Bitrates + brs[0][0] = layer0Bps + brs[1][0] = layer1Bps + dh.receiver.GetLayeredBitrateReturns([]int32{0, 1}, brs) + + // publisher side packet buffers, the real bucket at the max video size, so + // retransmissions and RTX probes read the same history a real receiver has + var buckets [2]*bucket.Bucket[uint64, uint16] + var bucketsMu sync.Mutex + for i := range buckets { + buckets[i] = bucket.NewBucket[uint64, uint16](500, bucket.RTPMaxPktSize, bucket.RTPSeqNumOffset) + } + dh.receiver.ReadRTPCalls(func(buf []byte, layer uint8, esn uint64) (int, error) { + bucketsMu.Lock() + defer bucketsMu.Unlock() + return buckets[layer].GetPacket(buf, esn) + }) + + sa := streamallocator.NewStreamAllocator(streamallocator.StreamAllocatorParams{ + Config: streamallocator.DefaultStreamAllocatorConfig, + BWE: b, + Pacer: cp, + RTTGetter: func() (float64, bool) { return 0.05, true }, + Logger: logger.GetLogger(), + }, true, allowPause) + sa.OnStreamStateChange(func(update *streamallocator.StreamStateUpdate) error { + logger.Infow("TEST: stream state update", "update", update) + return nil + }) + sa.AddTrack(dt, streamallocator.AddTrackParams{ + Source: livekit.TrackSource_CAMERA, + PublisherID: "fake-pub", + }) + dt.SetRTPHeaderExtensionsForTest() + abs, _ := dt.ExtIDsForTest() + require.NotZero(t, abs, "abs-send-time must be negotiated for remote BWE") + sa.Start() + t.Cleanup(sa.Stop) + + dt.UpTrackMaxPublishedLayerChange(1) + dt.UpTrackMaxTemporalLayerSeenChange(0) + dt.SetMaxSpatialLayer(1) + dt.SetMaxTemporalLayer(0) + dt.UpTrackLayersChange() + dt.UpTrackBitrateAvailabilityChange() + + // publisher: both simulcast layers, one packet per frame, key frame every second + stop := make(chan struct{}) + var wg sync.WaitGroup + t.Cleanup(func() { close(stop); wg.Wait() }) + wg.Add(1) + go func() { + defer wg.Done() + sizes := []int{layer0Bps / 8 / fps, layer1Bps / 8 / fps} + var sn [2]uint16 + var ts [2]uint32 + var pid [2]uint16 + sn[1] = 30000 + ticker := time.NewTicker(time.Second / fps) + defer ticker.Stop() + frame := 0 + for { + select { + case <-stop: + return + case <-ticker.C: + } + for layer := int32(0); layer < 2; layer++ { + sn[layer]++ + ts[layer] += 90000 / fps + pid[layer]++ + isKey := frame%fps == 0 + ep, err := testutils.GetTestExtPacketVP8(&testutils.TestExtPacketParams{ + SequenceNumber: sn[layer], + Timestamp: ts[layer], + SSRC: 0x1000 + uint32(layer), + PayloadType: 96, + PayloadSize: sizes[layer], + IsKeyFrame: isKey, + Marker: true, + ArrivalTime: time.Now(), + VideoLayer: buffer.VideoLayer{Spatial: layer, Temporal: 0}, + }, &codec.VP8{FirstByte: 0x10, S: true, PictureID: pid[layer], IsKeyFrame: isKey}) + if err != nil { + continue + } + bucketsMu.Lock() + _, _ = buckets[layer].AddPacketWithSequenceNumber(ep.RawPacket, ep.ExtSequenceNumber) + bucketsMu.Unlock() + dt.WriteRTP(ep, layer) + } + frame++ + } + }() + + // receiver: libwebrtc-like REMB + var capped atomic.Bool + wg.Add(1) + go func() { + defer wg.Done() + est := float64(2_000_000) + const interval = 200 * time.Millisecond + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + } + incoming := float64(cp.bitrate(time.Second)) + if capped.Load() { + est = math.Min(est, cappedBps) + } else { + limit := 1.5*incoming + 10_000 + if est < limit { + est = math.Min(est*(1+0.08*interval.Seconds()), limit) + } + } + remb := &rtcp.ReceiverEstimatedMaximumBitrate{SenderSSRC: 1, Bitrate: float32(est), SSRCs: []uint32{dt.SSRC()}} + if raw, err := remb.Marshal(); err == nil { + dt.HandleRTCPForTest(raw) + } + } + }() + + // RR precondition for probes + require.Eventually(t, func() bool { return dh.capture.count() > 0 }, 10*time.Second, 20*time.Millisecond, "no media forwarded") + feedReceiverReport(t, dt, dh.capture.all()[0].SequenceNumber) + + mediaRate := func(window time.Duration) int { + before := cp.mediaPackets.Load() + time.Sleep(window) + return int(cp.mediaPackets.Load() - before) + } + + time.Sleep(3 * time.Second) + require.Greater(t, mediaRate(time.Second), 5, "media should flow before the cap") + + logger.Infow("TEST: capping") + capped.Store(true) + time.Sleep(20 * time.Second) + cappedPackets := mediaRate(2 * time.Second) + logger.Infow("TEST: capped media", "packetsIn2s", cappedPackets, "deficient", dt.IsDeficient()) + if allowPause { + require.Zero(t, cappedPackets, "video should be paused under the cap") + } + + logger.Infow("TEST: uncapping") + capped.Store(false) + uncappedAt := time.Now() + require.Eventually(t, func() bool { + return mediaRate(time.Second) >= fps/2 + }, 20*time.Second, 10*time.Millisecond, "video did not resume within 20s of lifting the cap") + logger.Infow("TEST: resumed", "after", time.Since(uncappedAt)) +} From 708b8ea6baa7a41e786a9c74a28a1203cef63b4e Mon Sep 17 00:00:00 2001 From: David Colburn Date: Wed, 14 Oct 2026 15:57:01 -0500 Subject: [PATCH 130/130] sfu: use mediatransportutil/pkg/red in RedPrimaryReceiver (#5014) RED depacketizing now lives in mediatransportutil so the SFU and replay export share one implementation. Drop the local copies; the encoder round-trip tests now exercise the shared package. --- go.mod | 2 +- go.sum | 4 +- pkg/sfu/redprimaryreceiver.go | 191 +--------------------------------- pkg/sfu/redreceiver_test.go | 15 +-- 4 files changed, 15 insertions(+), 197 deletions(-) diff --git a/go.mod b/go.mod index 806263a4a..5e5d3d78c 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,7 @@ require ( github.com/jellydator/ttlcache/v3 v3.4.1 github.com/jxskiss/base62 v1.1.0 github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 - github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 + github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0 github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 github.com/livekit/psrpc v0.8.2 github.com/mackerelio/go-osstat v0.2.8 diff --git a/go.sum b/go.sum index f7599ec1d..33f0341ad 100644 --- a/go.sum +++ b/go.sum @@ -164,8 +164,8 @@ github.com/livekit/ice/v4 v4.4.0-warp.3 h1:iSAtEiLeCi/ZKEKOsRrDlBg/BRcyUKpV/NuAX github.com/livekit/ice/v4 v4.4.0-warp.3/go.mod h1:obAyD+J+Hzs7QA7Y8YXHp5uIn6gb7z87pKedXZkrcFU= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc= github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731/go.mod h1:Rs3MhFwutWhGwmY1VQsygw28z5bWcnEYmS1OG9OxjOQ= -github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095 h1:BcliKAXoMhl/nWmzQweQ5kmh4Qqagxl4s3Z5pvM/7AY= -github.com/livekit/mediatransportutil v0.0.0-20260821083140-f234b534b095/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= +github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0 h1:BSLyHr3Lsl71bHGHsfiYNmbohugR2yoaf9HjAZeFJnc= +github.com/livekit/mediatransportutil v0.0.0-20261014192331-dd8e31a0adc0/go.mod h1:o8CFmAdrVwzJNOCsQCLUzXRjokkufNshnQHOe4fRaqU= github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819 h1:oZ/dI5RG22t0GalRDU1BJWCMtNAEQVPU+odrRgycKDE= github.com/livekit/protocol v1.52.2-0.20261012065025-9bb708cdb819/go.mod h1:k7v95/DNhMkslMeBxdQ+lQ6gKc8hMNGYB7t0+6sEGU4= github.com/livekit/psrpc v0.8.2 h1:7tbkhBJoYlK5Csfkdv1XYbZjyAQeaEZgupvIXNCv7Dc= diff --git a/pkg/sfu/redprimaryreceiver.go b/pkg/sfu/redprimaryreceiver.go index fdef27c81..fce6ece40 100644 --- a/pkg/sfu/redprimaryreceiver.go +++ b/pkg/sfu/redprimaryreceiver.go @@ -15,9 +15,6 @@ package sfu import ( - "encoding/binary" - "errors" - "go.uber.org/atomic" "github.com/pion/rtp" @@ -25,17 +22,13 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/livekit-server/pkg/sfu/utils" + "github.com/livekit/mediatransportutil/pkg/red" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" ) var _ REDTransformer = (*RedPrimaryReceiver)(nil) -var ( - ErrIncompleteRedHeader = errors.New("incomplete red block header") - ErrIncompleteRedBlock = errors.New("incomplete red block payload") -) - type RedPrimaryReceiver struct { TrackReceiver downTrackSpreader *utils.DownTrackSpreader[TrackSender] @@ -43,14 +36,10 @@ type RedPrimaryReceiver struct { closed atomic.Bool redPT uint8 - firstPktReceived bool - lastSeq uint16 + depacketizer red.Depacketizer highestForwardedExtSequenceNumber uint64 highestForwardedExtTimestamp uint64 - // bitset for upstream packet receive history [lastSeq-8, lastSeq-1], bit 1 represents packet received - pktHistory byte - // forwarded packet, reused since ForwardRTP runs on one goroutine and // down tracks do not keep the packet past WriteRTP sendExtPkt buffer.ExtPacket @@ -80,7 +69,7 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 return utils.BroadcastRTP(r.downTrackSpreader, pkt, spatialLayer) } - pkts, err := r.getSendPktsFromRed(pkt.Packet) + pkts, err := r.depacketizer.Depacketize(pkt.Packet) if err != nil { r.logger.Errorw("get encoding for red failed", err, "payloadtype", pkt.Packet.PayloadType) return 0 @@ -115,10 +104,8 @@ func (r *RedPrimaryReceiver) ForwardRTP(pkt *buffer.ExtPacket, spatialLayer int3 "packetESN", pPkt.ExtSequenceNumber, "packetExtractedTS", pkts[i].Timestamp, "packetETS", pPkt.ExtTimestamp, - "pktHistory", r.pktHistory, "redHeader", pkt.Packet.Payload[:min(len(pkt.Packet.Payload), 10)], "payloadSize", len(pkt.Packet.Payload), - "lastSeq", r.lastSeq, "highestFowardedExtSequenceNumber", r.highestForwardedExtSequenceNumber, "highestFowardedExtTimestamp", r.highestForwardedExtTimestamp, ) @@ -207,7 +194,7 @@ func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, var pkt rtp.Packet pkt.Unmarshal(buf[:n]) - payload, err := extractPrimaryEncodingForRED(pkt.Payload) + payload, err := red.ExtractPrimaryEncoding(pkt.Payload) if err != nil { return 0, err } @@ -215,173 +202,3 @@ func (r *RedPrimaryReceiver) ReadRTP(buf []byte, layer uint8, esn uint64) (int, return pkt.MarshalTo(buf) } - -func (r *RedPrimaryReceiver) getSendPktsFromRed(rtp *rtp.Packet) ([]*rtp.Packet, error) { - var needRecover bool - if !r.firstPktReceived { - r.lastSeq = rtp.SequenceNumber - r.pktHistory = 0 - r.firstPktReceived = true - } else { - diff := rtp.SequenceNumber - r.lastSeq - switch { - case diff == 0: // duplicate - break - - case diff > 0x8000: // unorder - // in history - if 65535-diff < 8 { - r.pktHistory |= 1 << (65535 - diff) - needRecover = true - } - - case diff > 8: // long jump - r.lastSeq = rtp.SequenceNumber - r.pktHistory = 0 - needRecover = true - - default: - r.lastSeq = rtp.SequenceNumber - r.pktHistory = (r.pktHistory << byte(diff)) | 1<<(diff-1) - needRecover = true - } - } - - var recoverBits byte - if needRecover { - bitIndex := r.lastSeq - rtp.SequenceNumber - for i := range maxRedCount { - if bitIndex > 7 { - break - } - if r.pktHistory&byte(1<>= 10 - tsOffset := blockHead & 0x3FFF - blockHead >>= 14 - pt := uint8(blockHead & 0x7F) - - blocks = append(blocks, block{pt: pt, length: length, tsOffset: tsOffset}) - - blockLength += length - payload = payload[4:] - } - } - - if len(payload) < blockLength { - return nil, ErrIncompleteRedBlock - } - - pkts := make([]*rtp.Packet, 0, len(blocks)) - for i, b := range blocks { - if b.primary { - header := redPkt.Header - header.PayloadType = b.pt - pkts = append(pkts, &rtp.Packet{Header: header, Payload: payload}) - break - } - - recoverIndex := len(blocks) - i - 1 - if recoverIndex < 1 || recoverBits&(1<<(recoverIndex-1)) == 0 { - // skip past packet/block that does not need recovery - payload = payload[b.length:] - continue - } - - // recover missing packet - header := redPkt.Header - header.SequenceNumber -= uint16(recoverIndex) - header.Timestamp -= b.tsOffset - header.PayloadType = b.pt - pkts = append(pkts, &rtp.Packet{Header: header, Payload: payload[:b.length]}) - - payload = payload[b.length:] - } - - return pkts, nil -} - -func extractPrimaryEncodingForRED(payload []byte) ([]byte, error) { - - /* RED payload https://datatracker.ietf.org/doc/html/rfc2198#section-3 - 0 1 2 3 - 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - |F| block PT | timestamp offset | block length | - +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - F: 1 bit First bit in header indicates whether another header block - follows. If 1 further header blocks follow, if 0 this is the - last header block. - */ - - var blockLength int - for { - if len(payload) < 1 { - // illegal data, need at least one byte for primary encoding - return nil, ErrIncompleteRedHeader - } - - if payload[0]&0x80 == 0 { - // last block is primary encoding data - payload = payload[1:] - break - } else { - if len(payload) < 4 { - // illegal data - return nil, ErrIncompleteRedHeader - } - - blockLength += int(binary.BigEndian.Uint16(payload[2:]) & 0x03FF) - payload = payload[4:] - } - } - - if len(payload) < blockLength { - return nil, ErrIncompleteRedBlock - } - - return payload[blockLength:], nil -} diff --git a/pkg/sfu/redreceiver_test.go b/pkg/sfu/redreceiver_test.go index f98824d0a..8082b0b17 100644 --- a/pkg/sfu/redreceiver_test.go +++ b/pkg/sfu/redreceiver_test.go @@ -24,6 +24,7 @@ import ( "github.com/livekit/livekit-server/pkg/sfu/buffer" "github.com/livekit/mediatransportutil/pkg/bucket" + "github.com/livekit/mediatransportutil/pkg/red" "github.com/livekit/protocol/logger" ) @@ -253,9 +254,9 @@ func TestRedReceiverReusedReadBuffer(t *testing.T) { }, }, } - red := w.GetRedReceiver().(*RedReceiver) + rr := w.GetRedReceiver().(*RedReceiver) dt := &dummyDowntrack{TrackSender: &DownTrack{}} - require.NoError(t, red.AddDownTrack(dt)) + require.NoError(t, rr.AddDownTrack(dt)) var payloads [][]byte pktBuf := make([]byte, bucket.RTPMaxPktSize) @@ -272,12 +273,12 @@ func TestRedReceiverReusedReadBuffer(t *testing.T) { require.NoError(t, err) extPkt, err := buff.ReadExtended(pktBuf) require.NoError(t, err) - red.ForwardRTP(extPkt, 0) + rr.ForwardRTP(extPkt, 0) buffer.ReleaseExtPacket(extPkt) } // the last RED packet carries the two packets before it as redundant blocks - blocks, err := extractPktsFromRed(dt.lastReceivedPkt, 0xFF) + blocks, err := red.ExtractPackets(dt.lastReceivedPkt, 0xFF) require.NoError(t, err) require.Len(t, blocks, 3) for i, block := range blocks { @@ -285,14 +286,14 @@ func TestRedReceiverReusedReadBuffer(t *testing.T) { } } -func verifyRedEncodings(t *testing.T, red *rtp.Packet, redPkts []*rtp.Packet) { +func verifyRedEncodings(t *testing.T, redPkt *rtp.Packet, redPkts []*rtp.Packet) { solidPkts := make([]*rtp.Packet, 0, len(redPkts)) for _, pkt := range redPkts { if pkt != nil { solidPkts = append(solidPkts, pkt) } } - pktsFromRed, err := extractPktsFromRed(red, 0xFF) + pktsFromRed, err := red.ExtractPackets(redPkt, 0xFF) require.NoError(t, err) require.Len(t, pktsFromRed, len(solidPkts)) for i, pkt := range pktsFromRed { @@ -493,7 +494,7 @@ func TestExtractPrimaryEncodingForRED(t *testing.T) { primaryPkts := make([]*rtp.Packet, 0, len(redPkts)) for _, redPkt := range redPkts { - payload, err := extractPrimaryEncodingForRED(redPkt.Payload) + payload, err := red.ExtractPrimaryEncoding(redPkt.Payload) require.NoError(t, err) primaryHeader := redPkt.Header primaryHeader.PayloadType = 111