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>
pump() handed off packets with Array.removeFirst, shifting the whole remaining frame each time — quadratic in packet count, ~870 packets for a 1 MiB frame. Deque is the SDK's existing structure for this, already backing DataChannelPair's send queues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oves Fills the gaps the review found: releasing the handle unpublishes (the RAII semantic the API is built on, previously unpinned), withDataTrack scoping including the cancellation path its cancellation handler exists for, both tryPush error reasons, .throw on a saturated queue and the frame it hands back, the subscribe buffer's zero clamp and drop-oldest eviction, and the room-moved path. On the Objective-C side, NSError bridging of a publish failure and value semantics for frames and info. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The publishing manager was built at connect with the E2EE toggle as it stood then, so a setE2EEEnabled(true) issued after connecting never reached data tracks: frames stayed in plaintext for the rest of the session while media and data channel payloads became encrypted, and usesE2ee reported false. Build the manager on first publish instead — nothing it does concerns a session that has published nothing. Encryption still can't be decided per frame: it is a track-level protocol property subscribers key their decryption on, and UniFFI scopes the provider to the manager, so the setting is fixed for the session once the first track is published. Documented as such. Also stops forwarding UnpublishDataTrackResponse to the side channel. No manager consumes it — local unpublishes are tracked locally and remote ones arrive through participant updates — so it crossed the filter only to be dropped, while _process claimed it was handled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Publishers could declare a DataTrackSchemaId and subscribers could read it back, but neither could store or resolve the definition it names, so the schema surface was only half wired. Adds defineSchema/getSchema on LocalParticipant, matching rust-sdks, on top of the Store/GetDataBlob signal messages the protocol update brought in. Those requests are the SFU's first that correlate by an echoed request id — data track publishes report failures through RequestResponse but match on the echoed request instead — so SignalClient gains an id counter and a completer per in-flight request, cleared on disconnect. The server keeps data blobs behind enable_participant_data_blob, off in plain --dev; enabled for CI and documented for local runs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RequestResponse is the failure half of the id-correlated requests, but its reason enum also carries `ok` and `queued`. Treating either as a rejection would fail a store or read the server had accepted (or merely queued), so guard on the reason and let the typed response resolve it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
sendContentsOfThrowsOnQueueFull tried to observe the drop policy by saturating a live pipeline, so whether it passed depended on how fast the SFU drained relative to the push loop — it flaked on CI. Introduce a DataTrackSendChannel-style seam for the send loop and assert the policy against a sink that rejects on demand: same coverage, no timing. Also covers what the e2e test couldn't reach — that unpublishing mid-send ends the send quietly under either policy, and that an already-unpublished track sends nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Room.e2eeManager is publicly settable, so one assigned after connecting left the data track cryptor nil while the publish gate still reported encryption enabled — frames went out in cleartext with nothing logged. The same capture broke reception: a manager set post-connect could not decrypt incoming frames at all. The cryptor now looks the manager up through the room on each call and throws when there isn't one, which also turns a silent plaintext send into a reported failure. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DataTrackStream only ends when the track is unpublished, so every bare next() and `for await frame in stream` waited forever if a frame was lost — on an unreliable channel that turns a failed assertion into a hung job rather than a red test. Adds bounded next(within:)/collect(_:) helpers and routes every read through them, which also replaces the two hand-rolled task-group-versus-sleep races in the lifecycle suite. DataTrackTests had become the themeless remainder; its four delegate event tests move to DataTrackDelegateTests, leaving it the receive path. Every file stays well inside the length limits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twenty-three of the twenty-eight room configurations across the suites were the same publisher/subscriber pair, each followed by the same watcher-register-publish-await preamble. withPublishedDataTrack does it once and hands back both rooms with the track already delivered. Applied only where the preamble is incidental. Tests that control when the publish happens (reconnects, pre-join, room moves) or what the room looks like first (permissions, encryption toggles, publish options) keep using withRooms directly, since that timing is the thing under test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
remoteTrackUnpublished only notified delegates for a participant it could remove the track from. A full reconnect detaches remote tracks from their participants while the subsystem keeps them, so a publisher that unpublished inside that window produced no event at all — the app had already been told the track appeared, and would never be told it went away. rust-sdks dispatches DataTrackUnpublished straight off the manager's output event with no participant lookup; the lookup exists here only because the Swift delegates carry a participant. Resolve it from the track's own publisher identity instead, which works attached or not, with the removal from _remoteTracks as the idempotency token. handleRoomMoved now drops the old room's tracks through that same path rather than clearing them outright, so a move reports whatever the participant teardown didn't. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Let's discuss this more, would like to better understand what is going on. |
| /// 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.
Here's my understanding, @pblazej correct me if I'm wrong. In GC languages, you don't want to rely on destructors for RAII since there is a non-deterministic timing gap between when the object is no longer referenced and when its destructor is run. Swift uses ARC (automatic reference counting) rather than GC, which means objects are destroyed as soon as the reference count reaches zero. The difference between this and Rust is there is not a 1:1 correlation with scope, so it couldn't be used for something like a mutex guard or accessing raw memory. For the latter, Switch uses this closure pattern to ensure you can only access a pointer within the scope of a closure. However, for the data track use case, do we really need publication to be linked exactly to scope? Maybe relying on deinit is ok in this case?
The frame timestamp is opaque — publisher and subscriber agree on what it means, and a sensor's clock is as valid as wall time. Only now(payload:) and durationSinceTimestamp impose Unix milliseconds, so the docs say so there and nowhere else, and the Objective-C surface drops the assumption from its names: userTimestamp rather than userTimestampMs, initWithPayload:userTimestamp: rather than initWithPayload:userTimestampMs:. Also ports what the Rust docs say and ours didn't: waitForUnpublish returning immediately when the track is already unpublished, tryPush failing when the room is disconnected, and a publish timeout hinting at an SFU release without data track support when self-hosting. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…gration # Conflicts: # LiveKitClient.podspec # Package.swift # Package@swift-6.2.swift
handleJoinResponse and handleParticipantUpdate re-encoded our decoded copy of the message to give the managers something to parse, which silently dropped every field the pinned protocol didn't know — nanopb discards unknown fields on decode. That is how declared schema and frame encoding went missing before the protocol bump, and the next field added upstream would have gone the same way. The response queue now carries each message alongside the bytes it arrived as, and SignalClient hands those over through a new EncodedResponse case. Delivered after the decoded form and awaited rather than detached: the managers must see a message only once the participants it announces are registered, and notifyDetached gives no ordering between two calls, since each races to the serial runner in its own task. Room moves still re-encode. The SFU sends a RoomMovedResponse while the manager consumes a ParticipantUpdate, so that one has to be built and stays lossy until Rust exposes an entry point for it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…romise The wording invited the Rust reading, where try_push consumes the frame and the error variant returns the same memory. Swift can't reproduce that — the frame is a class, the caller keeps its own reference, and the payload is copied into a RustBuffer on the way across regardless — so the docs now name it as the same instance rather than an ownership transfer, and point at the one caller it exists for. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks @ladvoc for having an in-depth look! 2 highlights:
|
Awaiting notifyAsync to order the decoded and encoded forms of a message put the room's handler on the signal response queue's critical path. That handler awaits participant cleanup, which awaits track teardown and the application's own participantDidDisconnect delegates, so a slow app callback delayed every later offer, answer and ICE candidate on the connection. It also held the connect completer behind the join handler. Ordering only needs the two notifications to be enqueued in order, not waited on, so notifyDetached(inOrder:) runs them from a single task awaiting each in turn. One notifyDetached per closure cannot do this — each races to the serial runner in a task of its own, which was an undocumented trap in AsyncSerialDelegate until now. AsyncSerialDelegateTests pins the guarantee: 50 concurrent batches with per-batch order asserted, a slow first notification that must not be overtaken, and the weak-delegate drop. The data track e2e tests cannot cover this — park-and-reattach masks a violation, verified by breaking the ordering deliberately and watching them still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Room+DataTrack.swift had been sitting on the 400-line limit, with the last few changes paid for by trimming comments. setupDataTracks and cleanUpDataTracks are Room's side of the subsystem rather than the coordinator's, so they read fine apart from it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The catch-all around the publisher-channel wait rewrote every failure as .disconnected, including the caller cancelling their own task — AsyncCompleter.wait resumes waiters with LiveKitError(.cancelled), and ensurePublisherConnected can propagate CancellationError. A deliberately cancelled publish therefore looked like a lost connection, which is observable through withDataTrack, whose whole point is cancellation. Rethrow both unchanged and document that publishing can report one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A move tears every participant down before the coordinator is told, and unpublishAll already reports each attached track. Routing the parked tracks through remoteTrackUnpublished reported them a second time, because _addNewParticipants runs first and puts a participant with the same identity back — so any publisher that moved with us produced two identical unpublishes for one track. Forget them silently instead: a track still parked was never attached, so no publish was reported for it either. The room-move test drove the coordinator without the teardown, so it asserted the event it should have forbidden. It now asserts the absence, and fails against the old shape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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
Remote publish/unpublish surface on
RoomDelegateandParticipantDelegate; subscribed tracks are onRemoteParticipant.dataTracks, keyed by track name. 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 channel.Thin public wrappers over the bindings. The generated bindings are
internal imported and every public type (LocalDataTrack,RemoteDataTrack,DataTrackFrame,DataTrackStream,DataTrackInfo, schema/encoding types, error enums) is a small SDK-owned wrapper (~900 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, value semantics).Publication lifetime follows the handle (RAII). The returned
LocalDataTrackis the publication: releasing the last reference unpublishes it, as does callingunpublish(). This matches rust-sdks, where the handle drops the publication. Consequently there are no local publish/unpublish delegate events — Rust dispatches room events only for remote data tracks, and the handle (isPublished,waitForUnpublish()) is the local observer.withDataTrack(name:body:)scopes a publication to a block for the common case.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. The publisher-channel readiness gate is re-armed — not failed — on teardown, so apublishDataTrackissued mid-reconnect waits for the new channel instead of racing a dead transport. A local full reconnect recreates participants but not their data tracks: those are detached silently and re-attached, so no spurious unpublish is reported.When a remote publisher full-reconnects, the Rust manager treats the republication as a SID reassignment: the subscriber's existing
RemoteDataTracksurvives with its SID rewritten in place and active subscriptions transparently re-requested — no unpublish/republish events fire. This is whyRemoteParticipant.dataTracksis keyed by name, not SID (Rust documents SIDs as unstable across reconnects; client-sdk-js keys by name for the same reason).Backpressure.
tryPushis non-blocking and throwsqueueFull, handing the rejected frame back so it can be retried (parity with Rust'sPushFrameError::into_frame).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). On the channel,DataTrackFrameSendermeters packets out on buffered-amount events against an 8 KiB low-water mark, keeping send latency bounded while letting a frame of any size stream out: at most one frame waits while another drains, a newer frame evicts the waiting one (drop-oldest), and frames are handled whole so a partial frame is never left on the wire. This mirrorsDataChannelSenderin rust-sdks; client-sdk-js instead blocks the producer, which isn't available here (the producer is a fire-and-forget FFI callback with no backpressure channel).DataTrackFrameSenderTestspins these semantics, including the cross-SDK divergence. 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. Unlike data-channel payloads (a per-message property), data-track encryption is a track-level protocol property subscribers key their decryption on, so it can't be consulted per frame: the publishing manager is built on the first publish and captures the toggle then, which is late enough for asetE2EEEnabled(true)issued after connecting to apply. Toggling between publishes does not, andRoom.setE2EEEnableddocuments that. The cryptor resolvesRoom.e2eeManageron each call rather than capturing it at connect, so a manager assigned late is picked up for both encryption and decryption, and a missing one throws instead of silently sending plaintext.Protocol update
The
protocolsubmodule moves v1.45.8 → v1.50.4 (regenerated withmake proto). Two reasons:DataTrackInfogainedframe_encodingandschemaafter v1.45.8. BecausehandleParticipantUpdatere-serializes the parsedParticipantInfoto hand the managers raw bytes, the older protocol silently stripped both fields before Rust saw them — declared metadata never reached subscribers. (The SFU was echoing it correctly; this was ours.)DataBlob/StoreDataBlobRequest/GetDataBlobRequest, whichdefineSchema/getSchemaare built on.The general hazard remains and is worth a follow-up: any
ParticipantInfofield newer than the pinned protocol is lost on the way to the managers. Threading the received wire bytes instead of re-serializing removes both the loss and a redundant encode.Size impact
App Size job measured 16.28 MB for the SDK's uncompressed
.appdelta before the protocol update; the budget moves 16 → 16.5 MB to leave room for it. That figure includes the nanopb protocol migration frommain, which cut1.8 MB; the data-track share is the 0.0.6 → 0.1.8 UniFFI bump plus this integration (+0.4 MB: compiled bindings at the dead-strip floor plus the Rust dylib growth).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 wrapper layer that dead-strip already removes. Revisit if the Rust side ships a feature-split artifact.
Known upstream issue
LocalDataTrack.tryPushcannot surface the FFI's own error:PushFrameErrorReasonis declared in thelivekit_datatrackUniFFI component whiletry_pushlives inlivekit_uniffi, and the cross-component error lift fails, throwingUniffiInternalError.bufferOverflowinstead. Left unhandled this leaked an FFI-internal type through the public API and brokesend(contentsOf:)'s drop-on-full policy, which inspects the reason. The wrapper recovers the applicable reason fromisPublished; the typed catch remains first and takes over once the bindings are fixed. Worth filing against livekit-uniffi.Not in this PR
tryPushhands the rejected frame back, but the FFI still drops Rust's ownPushFrameErrorpayload.max_partial_frames) aren't exported by UniFFI yet.DataChannelPair, and threading wire bytes through the signal path instead of re-serializing parsed join/participant updates.Testing
Two unit suites run without a server:
DataTrackFrameSenderTestscovers the outbound drain, andDataTrackSendTestscoverssend(contentsOf:)'s queue-full policy against a sink that rejects on demand — deliberately not by saturating a live pipeline, which made the outcome depend on how fast the SFU drained.End-to-end tests against
livekit-server --devacross six suites: publish/subscribe/roundtrip (small + multi-packet frames), publish options and schema/encoding metadata round-tripping through the SFU, schema definition storage and resolution, error cases (duplicate name, unauthorized, disconnected, push after unpublish, saturated queue), E2EE on, off, and toggled after connect, delegate events, unpublish paths, RAII handle release,withDataTrackscoping including cancellation, subscribe buffer clamping and drop-oldest eviction, concurrent multi-track push and 64-track publish, plus lifecycle: pre-join publications, publish during a full reconnect, publisher full-reconnect SID reassignment, quick-reconnect sync state, remote tracks surviving the local client's full reconnect, and room moves.DataTrackObjCTestscovers the Objective-C surface including NSError bridging and value semantics.DataTrackStreamonly ends when its track is unpublished, so every read goes through boundednext(within:)/collect(_:)helpers — a lost frame on an unreliable channel should fail a test, not hang the job. The repeated two-room publish/subscribe preamble lives in awithPublishedDataTrackfixture; tests that control when the publish happens keep drivingwithRoomsdirectly, since that timing is what they exercise.Schema definitions are stored as participant data blobs, which the server keeps behind
enable_participant_data_blob— enabled for CI, and documented inAGENTS.mdfor local runs.