From 8af97efcf8bf93f13fffe11b27114558be0793ae Mon Sep 17 00:00:00 2001 From: Raja Subramanian Date: Tue, 10 Nov 2026 11:33:49 +0530 Subject: [PATCH] rtc: do not send `lk.rtc.` attributes to clients (#5180) * rtc: do not send lk.rtc. attributes to other participants Older JS SDKs fire ParticipantAttributesChanged for each new remote participant that carries a server-set lk.rtc. attribute, and some crash on it. Drop those attributes from participant updates and the join response for everyone except the receiver. Co-Authored-By: Claude Opus 5.5 * rtc: strip lk.rtc. attributes for every receiver Also drop them from the receiver's own info and from the room moved response, so no client sees them. Co-Authored-By: Claude Opus 5.5 * test: expect the join request time attribute to stay on the server Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- pkg/rtc/participant_internal_test.go | 17 ++++++++++++++ pkg/rtc/participant_signal.go | 34 ++++++++++++++++++++++++++-- test/singlenode_test.go | 9 ++------ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/pkg/rtc/participant_internal_test.go b/pkg/rtc/participant_internal_test.go index f958170bc..2e88c1c54 100644 --- a/pkg/rtc/participant_internal_test.go +++ b/pkg/rtc/participant_internal_test.go @@ -553,6 +553,23 @@ func TestOutOfOrderUpdates(t *testing.T) { require.Equal(t, "second update", sent.GetUpdate().Participants[0].Metadata) } +func TestServerAttributesNotSent(t *testing.T) { + p := newParticipantForTest("test") + p.updateState(livekit.ParticipantInfo_JOINED) + sink := p.GetResponseSink().(*routingfakes.FakeMessageSink) + + attrs := map[string]string{"lk.rtc.joinRequestTime": "1", "app": "a"} + other := &livekit.ParticipantInfo{Sid: "PA_other", Identity: "other", Version: 1, Attributes: attrs} + self := &livekit.ParticipantInfo{Sid: string(p.ID()), Identity: "test", Version: 1, Attributes: attrs} + require.NoError(t, p.SendParticipantUpdate([]*livekit.ParticipantInfo{other, self})) + + sent := sink.WriteMessageArgsForCall(0).(*livekit.SignalResponse).GetUpdate().Participants + require.Equal(t, map[string]string{"app": "a"}, sent[0].Attributes) + require.Equal(t, map[string]string{"app": "a"}, sent[1].Attributes) + // the shared input is not changed + require.Len(t, other.Attributes, 2) +} + func TestRecentlyDisconnectedKeepsUpdateOrder(t *testing.T) { p := newParticipantForTest("test") diff --git a/pkg/rtc/participant_signal.go b/pkg/rtc/participant_signal.go index 315fbfe99..e03b670d4 100644 --- a/pkg/rtc/participant_signal.go +++ b/pkg/rtc/participant_signal.go @@ -15,9 +15,11 @@ package rtc import ( + "strings" "time" "github.com/pion/webrtc/v4" + "google.golang.org/protobuf/proto" "github.com/livekit/protocol/livekit" "github.com/livekit/protocol/logger" @@ -40,9 +42,12 @@ func (p *ParticipantImpl) CloseSignalConnection(reason types.SignallingCloseReas } func (p *ParticipantImpl) SendJoinResponse(joinResponse *livekit.JoinResponse) error { + joinResponse.Participant = withoutServerAttributes(joinResponse.Participant) + // keep track of participant updates and versions p.updateLock.Lock() - for _, op := range joinResponse.OtherParticipants { + for i, op := range joinResponse.OtherParticipants { + joinResponse.OtherParticipants[i] = withoutServerAttributes(op) p.updateCache.Add(livekit.ParticipantID(op.Sid), participantUpdateInfo{ identity: livekit.ParticipantIdentity(op.Identity), version: op.Version, @@ -118,7 +123,7 @@ func (p *ParticipantImpl) SendParticipantUpdate(participantsToUpdate []*livekit. state: pi.State, updatedAt: time.Now(), }) - validUpdates = append(validUpdates, pi) + validUpdates = append(validUpdates, withoutServerAttributes(pi)) } } p.updateLock.Unlock() @@ -126,6 +131,27 @@ func (p *ParticipantImpl) SendParticipantUpdate(participantsToUpdate []*livekit. return p.signaller.WriteMessage(p.signalling.SignalParticipantUpdate(validUpdates)) } +// serverAttributePrefix marks attributes the server sets for its own use; older clients +// treat them as attribute changes, so they are never sent to clients. +const serverAttributePrefix = "lk.rtc." + +// withoutServerAttributes returns pi, or a copy without server attributes when it has any. +// The input is shared across participants, so it is never changed. +func withoutServerAttributes(pi *livekit.ParticipantInfo) *livekit.ParticipantInfo { + for k := range pi.GetAttributes() { + if strings.HasPrefix(k, serverAttributePrefix) { + cloned := proto.CloneOf(pi) + for ck := range cloned.Attributes { + if strings.HasPrefix(ck, serverAttributePrefix) { + delete(cloned.Attributes, ck) + } + } + return cloned + } + } + return pi +} + // SendSpeakerUpdate notifies participant changes to speakers. only send members that have changed since last update func (p *ParticipantImpl) SendSpeakerUpdate(speakers []*livekit.SpeakerInfo, force bool) error { if !p.IsReady() { @@ -172,6 +198,10 @@ func (p *ParticipantImpl) sendRequestResponse(requestResponse *livekit.RequestRe } func (p *ParticipantImpl) SendRoomMovedResponse(roomMovedResponse *livekit.RoomMovedResponse) error { + roomMovedResponse.Participant = withoutServerAttributes(roomMovedResponse.Participant) + for i, op := range roomMovedResponse.OtherParticipants { + roomMovedResponse.OtherParticipants[i] = withoutServerAttributes(op) + } return p.signaller.WriteMessage(p.signalling.SignalRoomMovedResponse(roomMovedResponse)) } diff --git a/test/singlenode_test.go b/test/singlenode_test.go index 2f4e37d36..e78b5d103 100644 --- a/test/singlenode_test.go +++ b/test/singlenode_test.go @@ -19,7 +19,6 @@ import ( "encoding/binary" "errors" "fmt" - "maps" "net" "net/http" "reflect" @@ -865,12 +864,8 @@ func TestSingleNodeAttributes(t *testing.T) { if pubRemote == nil { return "could not find remote publisher" } - attrs := maps.Clone(pubRemote.Attributes) - // the server stamps when it received the join request - if attrs[livekit.AttrRTCJoinRequestTime] == "" { - return fmt.Sprintf("did not receive the join request time: %v", attrs) - } - delete(attrs, livekit.AttrRTCJoinRequestTime) + // the server-set join request time is not sent to clients + attrs := pubRemote.Attributes if !reflect.DeepEqual(attrs, map[string]string{ "a": "0", "b": "2",