Skip to content

Attach server-opened data channels before their first message - #1169

Closed
pblazej wants to merge 3 commits into
mainfrom
blaze/pc-flaky-test
Closed

pblazej wants to merge 3 commits into
mainfrom
blaze/pc-flaky-test

Conversation

@pblazej

@pblazej pblazej commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

The SDK could lose the first messages on a data channel that the server opens. It did not log the loss.

Cause

The SFU opens the subscriber data channels and writes to them right away. It also replays the reliable data that it held for a joining participant. WebRTC sends each message to channel.delegate. If that delegate is nil, WebRTC drops the message. Transport.peerConnection(_:didOpen:) forwarded the open with MulticastDelegate.notify, which is an async hop. So the SDK set channel.delegate later, and messages that arrived in that gap were lost with no log.

Fix

peerConnection(_:didOpen:) now calls the transport delegates synchronously. WebRTC runs the message callbacks on the signaling thread after the open callback returns. So the channel has its delegate before the first message.

Test

TransportDataChannelOpenTests calls the open callback from a queue that stands in for the signaling thread. It has two tests, and neither one connects to a server:

  • The transport delegate must get the channel on that queue, not on the RTC queue, before the callback returns.
  • A Room stages a real join. After the callback returns, the channel must have its DataChannelDrain delegate.

Follow-up

User packets can still be lost while a participant joins. If connectionState is not .connected, Room.engine(_:didReceiveUserPacket:) drops a user packet with no log. The SFU can send its replay before the SDK sets .connected. That fix needs a design decision, so it is not in this PR.

pblazej and others added 3 commits October 22, 2026 08:39
The SFU opens the subscriber data channels and writes to them right
away. For reliable data that arrived while the receiver was joining, it
replays the cached packets the moment both channels are open.

WebRTC's ObjC RTCDataChannel registers its native observer when the
wrapper is created, before peerConnection(_:didOpen:) runs. Each message
goes to channel.delegate, and a nil delegate drops it. Transport
forwarded the open through MulticastDelegate.notify, a DispatchQueue
hop, so DataChannelDrain.setChannel (and the data-track channel) set the
delegate later. Messages delivered in that gap were lost with no log.

Deliver the open to the delegates synchronously. Every OnMessage runs
on the signaling thread after the announcement returns, so the channel
now has its delegate before the first message.

TransportDataChannelOpenTests calls the open callback on the RTC queue
and fails unless the delegate sees the channel on that queue before the
callback returns.

This is the cause of the rare EncryptedDataChannelTests flake on main
(decryptionFailureWithSharedKey, run 39631217851): a slow receiver join
made both channels open in the same millisecond as the send.

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

Co-authored-by: omnigent <noreply@omnigent.ai>
The open test called the WebRTC callback on the RTC queue. In
production, WebRTC calls it on its signaling thread. A queue that stands
in for that thread now makes the call. The recorder must see the open on
that queue, not on the RTC queue, before the callback returns.

A second test drives the production path. Room stages a real join, and
its subscriber transport announces a reliable channel. The channel must
have its DataChannelDrain delegate when the callback returns.

Both tests close their transports when a step throws.

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

Co-authored-by: omnigent <noreply@omnigent.ai>
The tests held raw data channels in their async bodies, so the last
release of a channel could run on a cooperative-pool thread. That
release is a blocking WebRTC call. The signaling stand-in now creates
the peer connection and the channel, and parks their close and release
with RTC.park. The room test resets the data channel pairs and the data
track drain through their reset paths, which park the channels, before
it closes the transports.

A #require ran on the stand-in queue, where Swift Testing cannot tie the
issue to a test. Setup failures there now throw a LiveKitError, and the
awaiting test body records it.

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

Co-authored-by: omnigent <noreply@omnigent.ai>
@pblazej pblazej closed this Oct 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant