Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions datatracksender.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// 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 lksdk

import (
"sync"

protoLogger "github.com/livekit/protocol/logger"
"github.com/pion/webrtc/v4"
)

const dataTrackBufferedAmountLowThreshold = 8 * 1024

// dataTrackFramePackets is one frame serialized into data track packets.
type dataTrackFramePackets [][]byte

// dataTrackSender paces frames onto the data track channel, keeping only the freshest frame
// while the channel's send buffer is above the low threshold.
type dataTrackSender struct {
log protoLogger.Logger

lock sync.Mutex
dc *webrtc.DataChannel
frame dataTrackFramePackets
notify chan struct{}
done chan struct{}
}

func newDataTrackSender(log protoLogger.Logger) *dataTrackSender {
s := &dataTrackSender{
log: log,
notify: make(chan struct{}, 1),
done: make(chan struct{}),
}
go s.run()
return s
}

// setDataChannel points the sender at the channel it should write to.
func (s *dataTrackSender) setDataChannel(dc *webrtc.DataChannel) {
s.lock.Lock()
s.dc = dc
s.lock.Unlock()

s.wake()
}

func (s *dataTrackSender) setLogger(log protoLogger.Logger) {
s.log = log
}

func (s *dataTrackSender) send(frame dataTrackFramePackets) {
if dropped := s.push(frame); dropped != nil {
s.log.Debugw("dropping data track frame", "numPackets", len(dropped))
}
}

func (s *dataTrackSender) wake() {
select {
case s.notify <- struct{}{}:
default:
}
}

func (s *dataTrackSender) stop() {
close(s.done)
}

func (s *dataTrackSender) push(frame dataTrackFramePackets) (dropped dataTrackFramePackets) {
if len(frame) == 0 {
return nil
}

s.lock.Lock()
dropped, s.frame = s.frame, frame
s.lock.Unlock()

s.wake()
return dropped
}

func (s *dataTrackSender) pop() dataTrackFramePackets {
s.lock.Lock()
defer s.lock.Unlock()

frame := s.frame
s.frame = nil
return frame
}

func (s *dataTrackSender) run() {
var (
dc *webrtc.DataChannel
inFlight dataTrackFramePackets
)
for {
select {
case <-s.done:
return
case <-s.notify:
}

s.lock.Lock()
current := s.dc
s.lock.Unlock()
if current != dc {
// A partially sent frame cannot be completed on a new channel.
dc, inFlight = current, nil
}
Comment on lines +115 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Channel replacement sends stale frames

When setDataChannel replaces the channel during an active send loop, run continues writing the current frame to the old channel. It snapshots dc only before the inner loop. Packets can reach the obsolete session or be discarded.

Prompt for agents
In datatracksender.go, dataTrackSender.run snapshots s.dc once before entering the packet-send loop. setDataChannel can replace s.dc concurrently and wake the sender, but run does not consume that wake or observe the replacement until the current inner loop exits. Preserve the intended rule that a partially sent frame cannot continue on a replacement channel by rechecking the synchronized channel pointer before each packet send. When it changes, switch channels, clear inFlight, and re-evaluate readiness before sending anything else.
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


for dc != nil && dc.ReadyState() == webrtc.DataChannelStateOpen && dc.BufferedAmount() <= dataTrackBufferedAmountLowThreshold {
if len(inFlight) == 0 {
if inFlight = s.pop(); inFlight == nil {
break
}
}
if err := dc.Send(inFlight[0]); err != nil {
s.log.Debugw("could not send data track packet", "error", err)
}
inFlight = inFlight[1:]
}
}
}
55 changes: 55 additions & 0 deletions datatracksender_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// 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 lksdk

import (
"testing"

"github.com/stretchr/testify/require"
)

func testFrame(marker byte, packets int) dataTrackFramePackets {
frame := make(dataTrackFramePackets, packets)
for i := range frame {
frame[i] = []byte{marker, byte(i)}
}
return frame
}

func TestDataTrackSenderQueue(t *testing.T) {
s := newDataTrackSender(logger)
t.Cleanup(s.stop)

require.Nil(t, s.push(nil))
require.Nil(t, s.pop())

multi := testFrame(0xaa, 13)
require.Nil(t, s.push(multi))
require.Equal(t, multi, s.pop())

older, newer := testFrame(0x01, 4), testFrame(0x02, 3)
require.Nil(t, s.push(older))
require.Equal(t, older, s.push(newer))
require.Equal(t, newer, s.pop())
require.Nil(t, s.pop())
}

func TestSendDataTrackFrameKeepsFreshest(t *testing.T) {
engine := newTestEngine(t)

engine.sendDataTrackFrame(testFrame(0x01, 2))
engine.sendDataTrackFrame(testFrame(0x02, 2))
require.Equal(t, testFrame(0x02, 2), engine.dataTrackSender.pop())
}
46 changes: 43 additions & 3 deletions engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ type engineHandler interface {
OnPublishDataTrackResponse(publishDataTrackResponse *livekit.PublishDataTrackResponse)
OnUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.UnpublishDataTrackResponse)
OnDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles)
OnDataTrackPacket(data []byte)
}

// -------------------------------------------
Expand All @@ -92,8 +93,9 @@ var (
// -------------------------------------------

const (
reliableDataChannelName = "_reliable"
lossyDataChannelName = "_lossy"
reliableDataChannelName = "_reliable"
lossyDataChannelName = "_lossy"
dataTrackDataChannelName = "_data_track"

maxReconnectCount = 10
initialReconnectInterval = 300 * time.Millisecond
Expand Down Expand Up @@ -127,9 +129,13 @@ type RTCEngine struct {
lossyDC *webrtc.DataChannel
reliableDCSub *webrtc.DataChannel
lossyDCSub *webrtc.DataChannel
dataTrackDC *webrtc.DataChannel
dataTrackDCSub *webrtc.DataChannel
reliableMsgLock sync.Mutex
reliableMsgSeq uint32

dataTrackSender *dataTrackSender

trackPublishedListenersLock sync.Mutex
trackPublishedListeners map[string]chan *livekit.TrackPublishedResponse

Expand Down Expand Up @@ -170,6 +176,7 @@ func NewRTCEngine(
Logger: e.log,
Processor: e,
})
e.dataTrackSender = newDataTrackSender(e.log)
e.configureSignalling(useSinglePeerConnection)

return e
Expand Down Expand Up @@ -202,6 +209,7 @@ func (e *RTCEngine) configureSignalling(useSinglePeerConnection bool) {
// SetLogger overrides default logger.
func (e *RTCEngine) SetLogger(l protoLogger.Logger) {
e.log = l
e.dataTrackSender.setLogger(l)
e.connectionManager.setLogger(l)
e.signalling.SetLogger(l)
e.signalHandler.SetLogger(l)
Expand Down Expand Up @@ -369,6 +377,7 @@ func (e *RTCEngine) Close() {

e.connectionManager.setClosed()
e.abortPendingRequests()
e.dataTrackSender.stop()

e.pclock.Lock()
e.pendingPublisherOffer = webrtc.SessionDescription{}
Expand Down Expand Up @@ -557,6 +566,20 @@ func (e *RTCEngine) createPublisherPCLocked(configuration webrtc.Configuration)
return err
}
e.reliableDC.OnMessage(e.handleDataPacket)

e.dataTrackDC, err = e.publisher.pc.CreateDataChannel(dataTrackDataChannelName, &webrtc.DataChannelInit{
Ordered: &falseVal,
MaxRetransmits: new(uint16),
})
if err != nil {
e.dclock.Unlock()
return err
}
e.dataTrackDC.OnMessage(e.handleDataTrackPacket)
e.dataTrackDC.SetBufferedAmountLowThreshold(dataTrackBufferedAmountLowThreshold)
e.dataTrackDC.OnBufferedAmountLow(e.dataTrackSender.wake)
e.dataTrackDC.OnOpen(e.dataTrackSender.wake)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made a note about starting the data track sender, this is another place it can be started I think.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe move the data channel configuration statements (SetBufferedAmountLowThreshold, OnBufferedAmountLow, OnOpen) into the sender.setDataChannel

e.dataTrackSender.setDataChannel(e.dataTrackDC)
e.dclock.Unlock()

return nil
Expand Down Expand Up @@ -643,6 +666,10 @@ func (e *RTCEngine) createSubscriberPCLocked(configuration webrtc.Configuration)
e.reliableDCSub = c
} else if c.Label() == lossyDataChannelName {
e.lossyDCSub = c
} else if c.Label() == dataTrackDataChannelName {
e.dataTrackDCSub = c
c.OnMessage(e.handleDataTrackPacket)
return
} else {
return
}
Expand Down Expand Up @@ -750,7 +777,9 @@ func (e *RTCEngine) ensurePublisherConnected(ensureDataReady bool) error {
func (e *RTCEngine) dataPubChannelReady() bool {
e.dclock.RLock()
defer e.dclock.RUnlock()
return e.reliableDC.ReadyState() == webrtc.DataChannelStateOpen && e.lossyDC.ReadyState() == webrtc.DataChannelStateOpen
return e.reliableDC.ReadyState() == webrtc.DataChannelStateOpen &&
e.lossyDC.ReadyState() == webrtc.DataChannelStateOpen &&
e.dataTrackDC.ReadyState() == webrtc.DataChannelStateOpen
}

func (e *RTCEngine) RegisterTrackPublishedListener(cid string, c chan *livekit.TrackPublishedResponse) {
Expand Down Expand Up @@ -909,6 +938,13 @@ func (e *RTCEngine) handleDataPacket(msg webrtc.DataChannelMessage) {
}
}

func (e *RTCEngine) handleDataTrackPacket(msg webrtc.DataChannelMessage) {
if msg.IsString {
return
}
e.engineHandler.OnDataTrackPacket(msg.Data)
}

func (e *RTCEngine) readDataPacket(msg webrtc.DataChannelMessage) (*livekit.DataPacket, error) {
dataPacket := &livekit.DataPacket{}
if msg.IsString {
Expand Down Expand Up @@ -1809,3 +1845,7 @@ func waitUntilConnected(d time.Duration, test func() bool) error {
}
}
}

func (e *RTCEngine) sendDataTrackFrame(frame dataTrackFramePackets) {
e.dataTrackSender.send(frame)
}
3 changes: 3 additions & 0 deletions room.go
Original file line number Diff line number Diff line change
Expand Up @@ -1415,6 +1415,9 @@ func (r *Room) OnUnpublishDataTrackResponse(unpublishDataTrackResponse *livekit.
func (r *Room) OnDataTrackSubscriberHandles(dataTrackSubscriberHandles *livekit.DataTrackSubscriberHandles) {
}

func (r *Room) OnDataTrackPacket(data []byte) {
}

func (r *Room) OnStreamHeader(streamHeader *livekit.DataStream_Header, participantIdentity string) {
switch header := streamHeader.ContentHeader.(type) {
case *livekit.DataStream_Header_TextHeader:
Expand Down
Loading