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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* test: expect the join request time attribute to stay on the server

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Raja SubramanianandClaude Opus 5.5 authored and GitHub committed 2026-11-10 11:33:49 +05:30
1 parent 22df5618f4
commit 8af97efcf8
3 files changed
+51 -9

No files matched your search

+17
View File
@@ -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")
+32 -2
View File
@@ -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))
}
+2 -7
View File
@@ -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",