Data tracks - #975
Conversation
Replace remote livekit-uniffi-xcframework dependency with a local path to the Rust SDK's UniFFI package output, enabling iteration on data track bindings without publishing releases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Wire livekit-datatrack Rust managers into the Swift SDK's Room, SignalClient, and transport infrastructure. Data tracks provide frame-oriented, real-time data delivery with built-in DTP packetization and optional E2EE. Scaffolding (Phase 1): - Forward raw WebSocket bytes to Rust managers for signal routing - Create _data_track publisher/subscriber WebRTC data channels - Delegate bridges for signal requests, DTP packets, and track events - Manager lifecycle tied to Room connect/disconnect/reconnect - DataTrackDelegate protocol for track published/unpublished events Public API (Phase 2): - LocalParticipant.publishDataTrack(name:) and withDataTrack(name:body:) - LocalDataTrack.send(contentsOf:) for piping AsyncSequence to a track - AsyncPolling protocol with .values for DataTrackStream iteration - DataTrackFrame convenience extensions (.now, .latency) E2E Tests (Phase 3): - 8 tests mirroring Rust data_track_test.rs (publish/receive, large frames, duplicate name, unauthorized, state, timestamp, resubscribe, many tracks) - Test helper Room.waitForDataTrack(name:) for async track discovery Tests require livekit-server with enable_data_tracks and a tokio runtime fix in livekit-uniffi (pending). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Forward serialized protobuf bytes after parsing (not raw WebSocket bytes) so JSON-encoded messages from the server are also forwarded to Rust data track managers - Register DataTrackWatcher before publishing to avoid missing the initial ParticipantUpdate event - Use AsyncStream-based watcher for reliable async track discovery - Simplify resubscribe test to 2 iterations with delay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Upstream livekit-uniffi replaced the generic `handleSignalResponse` with specific per-message handlers: - handleSfuRequestResponse (for RequestResponse) - handleSfuPublishResponse (for PublishDataTrackResponse) - handleSfuParticipantUpdate (for ParticipantUpdate) - handleSubscriberHandles (for DataTrackSubscriberHandles) Each handler returns UnsupportedType for messages it doesn't handle, so the simplest integration is to call all four with the raw bytes and let them filter internally. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The 0.31.2 bindings give PushFrameErrorReason cases an associated `message: String` and conform it to Swift.Error, so `catch PushFrameErrorReason.QueueFull` no longer matches. Catch the error and pattern-match the case, rethrowing other variants. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
cb7f186 to
6c21122
Compare
|
|
- Collapse the two delegate bridges into a single DataTrackBridge that conforms to both manager delegate protocols (shared onSignalRequest, one weak-room instance for both). - Fold the data track channel/manager teardown into a single cleanUpDataTrack(). - Move the reconnect republish/resubscribe calls into the quick/full reconnect sequences so each sequence is self-contained and the retry loop stays clean. - Nest FrameDropPolicy under LocalDataTrack and drop the redundant AsyncPolling Element typealias (inferred from next()). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dd send backpressure - E2EE: bridge the UniFFI encryption/decryption providers to the existing E2EEManager via internal DataTrackEncryptionProvider/DataTrackDecryptionProvider adapters (the public E2EEManager can't adopt an internally-imported protocol directly). They reuse E2EEManager's AES-GCM data path (LKRTCDataPacketCryptor over the shared BaseKeyProvider); providers are passed only when E2EE is configured so plaintext tracks stay unmarked. Exercised end-to-end by the existing DataTrackTests, which run with E2EE on by default. - Attach remote data tracks to their RemoteParticipant (keyed by SID) so they can be enumerated, mirroring media tracks and the JS SDK. - Drop a whole frame when the publisher data track channel is congested instead of sending unconditionally, bounding the channel buffer (parity with the lossy data channel threshold). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…op latency - Merge the separate encryption/decryption providers into one DataTrackCryptor that conforms to both UniFFI protocols (mirrors the JS DataCryptor); one instance serves both managers. - Encapsulate reconnect restoration on the managers via handleReconnect(fullReconnect:) so the reconnect sequences just notify each manager with the mode. - Drop the test-only DataTrackFrame.latency helper (not exposed by Rust or JS); inline the recency check in the test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sState Group the data track managers and publisher/subscriber channels into a DataTracksState struct behind a StateSync. They were plain vars on the @unchecked Sendable Room, read from the Rust callback threads (packet send/receive) while connect/cleanup mutated them — a data race. The StateSync synchronizes access and lets cleanUpDataTrack reset everything atomically. Read sites keep working through get-only computed accessors; only the writes move to mutate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wrap the UniFFI data track types in native, documented, Objective-C-capable LiveKit types (LocalDataTrack, RemoteDataTrack, DataTrackStream, DataTrackFrame, DataTrackInfo, and public error enums), mirroring the JS SDK's public-class-over-internal-core design. This keeps LiveKitUniFFI internal (no token/access-token symbols leak) while giving the data track API a clean public surface. - LocalParticipant.publishDataTrack/withDataTrack/queryDataTracks and the track/stream/frame operations are now public, with doc comments kept close to the JS/Rust wording. - Fold the data track callbacks into the public RoomDelegate as @objc optional methods (the wrappers are NSObject-based, so this is now possible) and drop the separate internal DataTrackDelegate and dataTrackDelegates multicast. - RemoteParticipant.dataTracks is public; the bridge wraps UniFFI tracks before delivering. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add DataTrackObjCTests exercising the public API end-to-end from Objective-C: publish, push frames, query, and unpublish on the local side, plus subscribe and receive frames via the callback-based reader on the remote side. Proves the wrappers bridge to Objective-C through the auto-generated completionHandler entry points. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an internal FFIBridged marker protocol (associated FFIType + init(_:)) in Support and conform each data track wrapper to it in its own file. Documents the FFI bridge boundary; not part of the public API. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ck thread - Route outbound signal requests through an AsyncSerialDelegate so they reach the SFU in the order the manager emits them, instead of a bare Task per callback that could reorder a publish/unpublish pair (matches how SignalClient delivers its own callbacks). - Send data track packets with DispatchQueue.liveKitWebRTC.async instead of sync, so the Rust callback thread isn't blocked on the WebRTC queue. The queue is serial, so frame order is preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…eError Match the JS SDK's error name. Also leave a TODO on RemoteDataTrack.subscribe() to expose subscription options once the UniFFI layer supports subscribe_with_options. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…he SID Match the media track delegate convention: room(_:participant:didPublishDataTrack:) and room(_:participant:didUnpublishDataTrack:) now carry the RemoteParticipant, and the SID is a typed DataTrack.Sid (a namespaced String alias, mirroring Track.Sid). RemoteParticipant.dataTracks is keyed by DataTrack.Sid. Feeding the remote data track manager moves from didReceiveRawResponse to didUpdateParticipants (after the participant is added), so onTrackPublished can always resolve the publisher — the raw path ran before participants existed, which would drop the event. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Merge publishAndReceive and publishLargeFrames into one parameterized test over payload size / frame count. - Replace the loose catch blocks with #expect(throws: DataTrackPublishError.self) for the duplicate-name and unauthorized cases. - Use try #require over guard + Issue.record for stream.next() results. - Assert the received track is E2EE-encrypted (withRooms enables it by default). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…npublish delegate Add tests for the public API the suite didn't exercise: LocalDataTrack.send(contentsOf:), RemoteParticipant.dataTracks attachment, and the room(_:participant:didUnpublishDataTrack:) delegate (DataTrackWatcher gains unpublish observation). queryDataTracks is left to the ObjC test — queryTracks() returns nothing in a plain publish-then-query Swift scenario (despite isPublished being true), so a reliable Swift test isn't possible without the longer round-trip the ObjC test happens to perform. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ss test Publish several tracks, then push every frame on every track at once, exercising the Rust manager's per-track send queues and the bridge's packet dispatch under contention. Parameterized over a many-small-frames and a large-multi-packet-frames scenario. Each frame is tagged with (trackIndex, sequence); the unreliable channel may drop frames, but whatever arrives must reach the right track with no misrouting, duplicates, or corruption. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Asserts queryDataTracks returns the local participant's confirmed publications. Holds the returned LocalDataTrack across the query — the caller owns the publication lifetime, so dropping it unpublishes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dinator Collapse the Room's data-track surface — four computed accessors, a DataTracksState struct, and a lazy channel delegate — to a single `DataTracks` reference. It owns the local/remote managers, the data channels, and the manager-delegate shim, and routes Room/participant calls to the right manager, keeping the subsystem off the god-object's surface. Created in configureTransports so its lifecycle brackets the transport lifecycle (cleanUpRTC tears it down) across connects and reconnects, and so it takes ownership of the publisher data track channel as that channel is created. The subscriber channel is retained too — its Swift wrapper must outlive the call for native delegate callbacks to reach us. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Previously DataTracks was created in configureTransports and torn down in cleanUpRTC, so a full reconnect rebuilt the managers from scratch; republishTracks() then ran on an empty manager and locally-published tracks were lost. The reference SDKs (Rust/JS) keep the manager for the whole session and only re-establish transports. Create DataTracks once at connect and tear it down only on a real disconnect — cleanUpDataTracks(isFullReconnect:) skips the cleanup during a full reconnect — so the managers persist and republish their publications. configureTransports now just hands the new publisher channel to the existing subsystem. Adds republishesTrackAfterFullReconnect, which forces a full reconnect and asserts the publication survives. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Integrates the finalized UniFFI methods from rust-sdks PR #1034: - handleSfuJoinResponse: fed from the join handler so a participant sees data tracks already published by others when it joins. Verified e2e — the earlier "join carries no data tracks" finding was a test discarding the returned track (which unpublishes it), not a server gap. - publishResponsesForSyncState: wired into SyncState.publishDataTracks so a quick reconnect preserves local publications without a full republish. query_tracks() is now internal in the FFI, so the public queryDataTracks() is removed along with its Swift/ObjC coverage. Tests: bake roomName/identity into RoomTestingOptions to stage a late joiner into an existing room; add receivesTrackPublishedBeforeJoin, and rework the reconnect test to verify republish end-to-end (subscriber re-sees the track) now that queryDataTracks is gone. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ndle The Rust LocalDataTrack unpublishes on drop (RAII), so discarding the returned handle silently tore the publication down. JS instead keeps publications in its manager until an explicit unpublish. Match JS: after publishing, spawn a task that awaits the track's unpublish, keeping it alive until an explicit/SFU unpublish or teardown. The wait does not fire during a reconnect's republish, so session-scoped republish still works. Adds retainsPublicationWhenHandleDropped and drops the now-unnecessary keep-alive workarounds from the join and reconnect tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the TODO with dataTrackSurvivesQuickReconnect: publish, subscribe, force a quick reconnect (nextReconnectMode: .quick, which runs sendSyncState with publishDataTracks), and assert frames keep flowing on the same stream. The earlier flakiness was an unbounded stream read that hung on any hiccup; this uses a bounded read (15s) plus a background pusher, so a broken publication fails cleanly instead of hanging. Reliable across repeated runs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire RemoteDataTrack.subscribe(bufferSize:) to the new UniFFI subscribe_with_options, letting callers tune the internal receive buffer (default 16). ObjC gets subscribeWithBufferSize:completionHandler:. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ant delegates Bring data track publish/unpublish notifications to parity with media tracks: dual-notify (participant then room) for both local and remote, on publish and unpublish. Adds the local variants to RoomDelegate and all four data-track methods to ParticipantDelegate (which had none). Local unpublish rides the existing retention task, so it fires once when the publication really ends. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add DataTrackDelegateRecorder (RoomDelegate + ParticipantDelegate) and a test asserting publish and unpublish each fire on both delegates for the local publisher and the remote subscriber. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Expose DataTrackStream.values publicly (the documented iteration API was internal via the single-use AsyncPolling protocol, now removed). - Restore E2EEManager's no-argument public cleanUp() so the exported Objective-C selector is unchanged; the reconnect flag is internal. - Wire the room-moved path into the data-track subsystem: drop the old room's remote-track state, republish local tracks into the new room, and surface its existing publications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DataTrack.Sid is now an identifier wrapper mirroring Track.Sid instead of a String alias, so unrelated text can't be passed where a track identifier is expected (changing it post-release would be breaking). RemoteParticipant.unpublishAll snapshots and clears the track list in one locked mutation, so a track attached mid-teardown can't be dropped without its unpublish notification. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI simulators lost the race between publishing and registering the watcher: the SFU announces the track within milliseconds (confirmed in server logs), so the delegate event could fire before waitForDataTrack was listening and the wait timed out. The helper now checks already-attached tracks after registering, which covers both orders. Also handle @unknown enum cases when mapping the FFI error enums (the Xcode 26.6 leg warns on non-frozen enums from other modules). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nal bytes A remote track announced before its publisher is registered was parked and only recovered on the next join; participant updates now retry the attachment. Attachment idempotency is keyed on track object identity — a SID-based check reads through the FFI twice and can straddle a concurrent SID reassignment, double-attaching the track and firing a spurious publish event. Subscribers now observe either silent continuity across a publisher's full reconnect or a coherent unpublish/publish pair when the participant is dropped and recreated. Also forward the received binary signal payload to the data-track managers as-is instead of re-encoding every message; only JSON-string messages are re-serialized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lishing Data track encryption is a track-level protocol property subscribers key their decryption on, so it can't follow the runtime toggle per frame; publishing now captures isDataChannelEncryptionEnabled (the same gate as data channel payloads) when the subsystem is created, and reception keeps decrypting unconditionally. setE2EEEnabled documents that data tracks are fixed for the session. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cy E2EE options Inbound data-track signal responses had the same ordering hazard as the outbound side: a detached task per message reaches the managers in scheduling order, not wire order. Raw payloads now drain through a single FIFO consumer — which also removes a per-signal-message detached task, easing cooperative-pool pressure on CI simulators. The path intentionally bypasses the response queue's suspend/resume: the managers own their reconnect semantics and consume wire order directly. Data-track publishing now keys on the E2EE enabled flag rather than isDataChannelEncryptionEnabled, which requires the newer options style and silently disabled encryption for rooms configured via the legacy e2eeOptions API. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
In subscriber-primary mode the publisher peer connection is only negotiated at connect when fast publish is on or media is published, so a data-track-only publisher never opened the _data_track channel and every frame was silently dropped. Publishing now runs the same ensurePublisherConnected gate as the legacy data-channel send path (extracted to a Room method) and waits for the channel to open; a full reconnect re-establishes the transport when data tracks were published, and failed channel sends are logged instead of ignored. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI legs have wedged inside these suites with no output until the 30-minute job timeout; a per-test limit turns a hang into a named failing test with logs, and lets the remaining tests run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The trait requires iOS 16/macOS 13/tvOS 16 while the SDK floor is lower, so legs building for device floors failed to compile; every CI destination runs a newer OS, so the suites still execute everywhere. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swift Testing's timeLimit trait requires a newer availability floor than the package targets, and suites cannot be availability-annotated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…onses Data-track encryption now gates on the configured encryption type as well as the runtime flag (via either options style), matching how media and data-channel traffic treat EncryptionType.none. Raw responses buffered from a torn-down connection carry a connection epoch and are discarded after cleanUp instead of being applied to the next session. The republish-transport flag is set only when a publish succeeds, so a rejected publish doesn't force publisher negotiation on every later full reconnect. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1egoman
left a comment
There was a problem hiding this comment.
Generally makes sense to me from a high level / as a non swift expert, I think it would be good to get a swift expert to go through this as well
| /// Sends frames from `source` until it ends or the track is unpublished. | ||
| /// | ||
| /// - Parameter onQueueFull: How to handle a full send queue. Defaults to ``FrameDropPolicy/drop``. | ||
| func send<S: AsyncSequence>( | ||
| contentsOf source: S, | ||
| onQueueFull: FrameDropPolicy = .drop, | ||
| ) async throws where S.Element == DataTrackFrame { | ||
| for try await frame in source { | ||
| guard isPublished else { break } | ||
| do { | ||
| try tryPush(frame: frame) | ||
| } catch let error as DataTrackPushFrameError { | ||
| guard case .queueFull = error else { throw error } | ||
| switch onQueueFull { | ||
| case .throw: throw error | ||
| case .drop: continue | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Ah this is pretty clever, so you give the user access to control whether non queueable messages drop or throw.
Two thoughts:
- Is this always something you would want to expose? Or is this transitional until we come to a decision on the max data track frame size / the performance bounds which data tracks are expected to support?
- Should the default be throw or drop? I would argue throw maybe makes more sense, or at a minimum, drop should log when it drops so a user isn't surprised when things break unexpectedly if they send a large data track frame.
cc @lukasIO for when you are back, this might be worth doing in web as well 🤔
There was a problem hiding this comment.
My initial idea was more of a "pipe" between (buffered) AsyncSequence and data track, the backpressure (default) is a follow-up, no real opinion/example here.
| @objc public func subscribe(bufferSize: UInt32 = 16) async throws -> DataTrackStream { | ||
| do { | ||
| let options = LiveKitUniFFI.DataTrackSubscribeOptions(bufferSize: bufferSize) | ||
| let stream = try await track.subscribeWithOptions(options: options) | ||
| return DataTrackStream(stream) | ||
| } catch let error as LiveKitUniFFI.DataTrackSubscribeError { | ||
| throw DataTrackSubscribeError(error) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
thought: Should this be async? What we did in the web (which worked quite well) was to immediately return the DataTrackStream equivalent object from subscribe() and lazily initialize the underlying sfu subscription on the first stream iteration .next() type call. The benefit to doing it this way is it centralized all error handling in one path (both stream read errors and stream initialization errors). Another side benefit was it abstracted away the actual "subscribe" call so it could be run initially or later after a full reconnect all transparently to a user.
There was a problem hiding this comment.
So now (without reading your code), these errors would propagate just from the stream? try await track.subscribeWithOptions?
There was a problem hiding this comment.
Yes, they would propagate from the stream. For example, here's how it works on the web currently (unrolling the for await to make it a little easier to comprehend):
const stream = track.subscribe();
while (true) {
const { value, done } = await stream.next();
if (done) {
break;
}
console.log('Received frame:', value.payload);
}The important thing is that this doesn't do await track.subscribe(), the only await happens at the stream.next() level.
There was a problem hiding this comment.
Also, to be clear - not trying to argue for / against doing this in swift, only that it turned out to work quite well in js and consistency might be nice.
There was a problem hiding this comment.
No hard feelings here, it's just not very natural to make subscribe non-throwing and move it to the stream @lukasIO any bias here?
There was a problem hiding this comment.
I agree, I think this question is somewhat based on language conventions, and as a Swift dev it would feel weird to me if the error was deferred. Same with Rust which has the subscribe method return a result.
There was a problem hiding this comment.
Yeah I'm more on the Rust side here.
| /// Publishes a data track for the duration of `body`, then unpublishes it automatically. | ||
| /// | ||
| /// - Parameters: | ||
| /// - name: Track name visible to other participants. Must be unique per publisher. | ||
| /// - body: Receives the published track; the track is unpublished when it returns or throws. | ||
| /// - Returns: The value returned by `body`. | ||
| func withDataTrack<T>(name: String, body: (LocalDataTrack) async throws -> T) async throws -> T { | ||
| let track = try await publishDataTrack(name: name) | ||
| return try await withTaskCancellationHandler { | ||
| defer { track.unpublish() } | ||
| return try await body(track) | ||
| } onCancel: { | ||
| track.unpublish() | ||
| } | ||
| } |
There was a problem hiding this comment.
This is something else I think could be good to have on the web 🤔
There was a problem hiding this comment.
Yes if you "produce"/yield the frames inside. Take these async helpers with a grain of salt tho as they've got no equivalent for other tracks.
| // JS, where the manager owns the publication. The wait ends on an explicit/SFU unpublish or | ||
| // teardown, and does NOT fire during a reconnect's republish — so session-scoped republish | ||
| // still works, and the unpublish delegate fires once when the publication really ends. | ||
| Task { [weak self, weak room] in |
There was a problem hiding this comment.
I am not sure I fully understand the reason this has to be done - I would assume that a user would need to keep a LocalDataTrack reference alive so they can call tryPush / etc on it? So if it drops, then unpublishing should always be the right thing to do?
I think I might need to see a small example to fully wrap my head around it, if that isn't too hard to put together.
Replaces the hand-rolled NSLock — the repo's sanctioned synchronization container is StateSync (AGENTS.md), and this was the only NSLock in the codebase. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Replaces the 2 MiB congestion gate: packets are fed to the channel on buffered-amount events (8 KiB low-water mark, parity with rust-sdks' DataChannelSender) instead of dumped, so a frame of any size streams out without approaching libwebrtc's send-buffer abort limit, send latency is bounded to roughly one frame, and overload drops the stale queued frame rather than the newest — whole frames only, never leaving a partial frame on the wire. The drain lives behind a small channel seam and is covered by unit tests that pin the semantics, including where they deliberately diverge from client-sdk-js (which blocks the producer; Swift's producer is a fire-and-forget FFI callback). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…send The documented contract is to send until the source ends or the track is unpublished, but an unpublish landing between the published check and the push surfaced as a thrown error. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| func dataChannelDidChangeState(_ dataChannel: LKRTCDataChannel) { | ||
| if dataChannel === publisherChannel, dataChannel.readyState == .open { | ||
| _publisherChannelOpen.resume(returning: ()) | ||
| // Drain anything queued while the channel was still opening. | ||
| DispatchQueue.liveKitWebRTC.async { [weak self] in | ||
| self?.frameSender.pump() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Publishing a data track right after a connection drop can silently fail instead of waiting for the reconnect
The readiness gate that publishing waits on stays latched open after the connection is torn down and is only re-armed when a fresh channel arrives (_publisherChannelOpen.reset() at Sources/LiveKit/Core/Room+DataTrack.swift:217), so a publish started during a reconnect proceeds as if the link were live.
Impact: A publishDataTrack(name:) call made during the reconnect window fails with a timeout (and any frames pushed in that window are discarded) instead of waiting for the connection to come back.
Why the gate is stale during the full-reconnect window
DataTracks is session-scoped and deliberately survives a full reconnect (Sources/LiveKit/Core/Room+DataTrack.swift:323-328). _publisherChannelOpen is an AsyncCompleter whose result is sticky once resumed (Sources/LiveKit/Support/Async/AsyncCompleter.swift, wait() returns the cached _result immediately). It is reset only inside setPublisherChannel, which is called from the .join branch of configureTransports (Sources/LiveKit/Core/Room+Engine.swift:191-193).
During a full reconnect the order is: cleanUp(isFullReconnect: true) → cleanUpRTC closes the transports and sets _state.transport = nil, but _publisherChannelOpen is left resolved and DataTracks is kept. Until fullConnectSequence reaches configureTransports, a concurrent publish(name:):
- calls
room?.ensurePublisherConnected(), which returns immediately becauseguard case .subscriberPrimary = _state.transportfails whentransportisnil(Sources/LiveKit/Core/Room+Engine.swift:74-77); - passes
_publisherChannelOpen.wait()instantly on the stale cached result; - calls
local.publishTrack(...), whose outbound signal request is dropped byManagerDelegate(try? await room.signalClient.sendRequest(...)atSources/LiveKit/Core/Room+DataTrack.swift:262;_sendRequestthrows whileconnectionState == .disconnected).
The same latch also never re-arms if the publisher data channel merely transitions to .closed — dataChannelDidChangeState only handles the .open transition (Sources/LiveKit/Core/Room+DataTrack.swift:294-302).
Prompt for agents
In Sources/LiveKit/Core/Room+DataTrack.swift, `_publisherChannelOpen` is a sticky AsyncCompleter that gates `publish(name:)`. It is only re-armed in `setPublisherChannel`, which runs when a brand-new publisher data channel is created in the `.join` branch of `configureTransports`. Consequently, between `Room.cleanUp(isFullReconnect: true)` (which closes transports but keeps the DataTracks subsystem) and the new channel being created, the gate still reports "open", and `ensurePublisherConnected()` also returns early because `_state.transport` is nil. A `publishDataTrack` issued in that window proceeds against a dead transport and ultimately times out instead of waiting for the reconnect. The same latch is never cleared when the publisher data channel transitions to `.closed`/`.closing` on its own, since `dataChannelDidChangeState` only handles the `.open` case.
Consider re-arming (resetting) the completer when the publisher channel leaves the `.open` state and/or when the room tears down its transports for a full reconnect, so publishing blocks until the new channel is actually open.
Was this helpful? React with 👍 or 👎 to provide feedback.
| // teardown, and does NOT fire during a reconnect's republish — so session-scoped republish | ||
| // still works, and the unpublish delegate fires once when the publication really ends. | ||
| Task { [weak self, weak room] in | ||
| await track.waitForUnpublish() |
There was a problem hiding this comment.
note: This will also resolve when the track has been unpublished due to the manager having been shutdown. If the manager is shutdown before the room has been destroyed, this will resolve, match some for self and room, and fire the delegate events.
There was a problem hiding this comment.
I think it's way cleaner to just revert this ugly task-leak and keep the raii/retain approach (without edge cases).
Adds data tracks to the Swift SDK, implemented on top of the Rust data-track engine via UniFFI (livekit-uniffi-xcframework 0.1.8).
API
Publish/unpublish events fire on
RoomDelegateandParticipantDelegatefor both local and remote tracks, mirroring the media-track conventions. Everything is Objective-C compatible (DataTrackObjCTestscovers the surface).Design decisions
Rust owns the protocol, Swift owns the session. The UniFFI managers (
LocalDataTrackManager/RemoteDataTrackManager) implement the publish/subscribe state machine, packetization, and E2EE. The SDK contributes what only it has: the signal connection, the WebRTC data channels, participant identity, and delegate fan-out. Concretely, a session-scopedDataTrackscoordinator (one perRoom,Room+DataTrack.swift) feeds SFU signal messages into the managers, forwards their outbound requests throughSignalClient(serialized, so publish/unpublish ordering is preserved), and pumps packets between the managers and the DTP data channels.Thin public wrappers over the bindings. The generated bindings are
internal imported and every public type (LocalDataTrack,RemoteDataTrack,DataTrackFrame,DataTrackStream,DataTrackInfo, error enums) is a small SDK-owned wrapper (~740 lines total). Tradeoff considered: exposing the generated types directly would save the layer, but pins the public API to regenerated code we don't control, and UniFFI emits Swift-only structs —NSObjectwrappers are what makes the API reach Objective-C. The wrappers also carry the SDK idioms (lowerCamel error enums,Track.Sid-style identifiers, delegate naming).Retain-cycle handling at the FFI boundary. UniFFI callback interfaces hold their Swift delegate strongly (no weak references over FFI). A dedicated
ManagerDelegateobject references theRoomand coordinator weakly, making it the designated weak link somanager → delegate → room → managercycles can't leak (Room+DataTrack.swiftdocuments the shape).Reconnect semantics. Quick reconnect preserves publications via
SyncState.publishDataTracks(the managers replay their publish responses); full reconnect republishes and re-asserts subscriptions. TheDataTrackssubsystem survives reconnects (channels are swapped in) and is torn down only on real disconnect, which also unpublishes remote tracks and notifies delegates. When a remote publisher full-reconnects, the Rust manager treats the republication as a SID reassignment: the subscriber's existingRemoteDataTracksurvives with its SID rewritten in place and active subscriptions transparently re-requested — no unpublish/republish events fire.RemoteParticipant.dataTracksderives its keys from live track info for this reason (stored keys would go stale on reassignment).Backpressure.
tryPushis non-blocking and throwsqueueFull;send(contentsOf:)defaults to dropping frames when the queue is full (DTP is lossy by design — unbounded buffering would trade a dropped frame for unbounded latency). The publisher channel additionally drops frames beyond a 2 MiB buffered threshold, matching the lossy data-channel behavior. Subscribe-side buffer is caller-configurable (subscribe(bufferSize:)).E2EE. When the room has E2EE configured, the same key provider drives data-track frame encryption via UniFFI
EncryptionProvider/DecryptionProvidershims;DataTrackInfo.usesE2eereflects it.Size impact
Measured cost of the 0.0.6 → 0.1.8 UniFFI package bump plus this integration: ~+190 KB compiled Swift bindings (dead-strip floor) + ~190 KB Rust dylib (ios-arm64) ≈ +0.4 MB expected on the App Size job (~2% of baseline, within tolerance). Isolating data tracks behind a Swift package trait was evaluated and rejected for now: the core SDK already depends on LiveKitUniFFI (tokens, log forwarding), both UniFFI components live in one dylib, and the bindings are one module — a trait could only gate the ~740-line wrapper layer that dead-strip already removes. Revisit if the Rust side ships a feature-split artifact.
Not in this PR
DataTrackOptions.schema/frameEncoding, added upstream in 0.1.8) is not yet surfaced in the public API.UInt64s carried verbatim (DataTrackFrame.now(payload:)stamps the current time); no derived latency helpers yet.Testing
End-to-end
DataTrackTests(publish/subscribe/roundtrip, delegate events on all four surfaces, buffer size, pre-join publications, publication retention, publisher full-reconnect SID reassignment, quick-reconnect sync state, publisher-disconnect unpublish) againstlivekit-server --dev, plusDataTrackObjCTestsfor the Objective-C surface.