Skip to content

Data tracks - #975

Merged
pblazej merged 95 commits into
mainfrom
blaze/datatracks-integration
Aug 17, 2026
Merged

Data tracks#975
pblazej merged 95 commits into
mainfrom
blaze/datatracks-integration

Conversation

@pblazej

@pblazej pblazej commented Apr 24, 2026

Copy link
Copy Markdown
Contributor

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 + push
let track = try await room.localParticipant.publishDataTrack(name: "telemetry")
try track.tryPush(frame: DataTrackFrame(payload: data))          // non-blocking
try await track.send(contentsOf: frames)                          // AsyncSequence, drop-on-full by default

// Optionally declare what the frames are, and make the schema resolvable
let schema = DataTrackSchemaId(name: "telemetry.v1", encoding: .protobuf)
try await room.localParticipant.defineSchema(schema, definition: protoSource)
let typed = try await room.localParticipant.publishDataTrack(
    name: "typed",
    options: DataTrackPublishOptions(schema: schema, frameEncoding: .protobuf)
)

// Subscribe + receive
let stream = try await remoteTrack.subscribe(bufferSize: 16)
for await frame in stream { ... }                                 // DataTrackStream is an AsyncSequence

// A subscriber can resolve the schema its publisher declared
if let declared = remoteTrack.info.schema {
    let definition = try await room.localParticipant.getSchema(declared, publishedBy: remoteTrack.publisherIdentity)
}

Remote publish/unpublish surface on RoomDelegate and ParticipantDelegate; subscribed tracks are on RemoteParticipant.dataTracks, keyed by track name. Everything is Objective-C compatible (DataTrackObjCTests covers 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-scoped DataTracks coordinator (one per Room, Room+DataTrack.swift) feeds SFU signal messages into the managers, forwards their outbound requests through SignalClient (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 — NSObject wrappers 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 LocalDataTrack is the publication: releasing the last reference unpublishes it, as does calling unpublish(). 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 ManagerDelegate object references the Room and coordinator weakly, making it the designated weak link so manager → delegate → room → manager cycles can't leak (Room+DataTrack.swift documents the shape).

Reconnect semantics. Quick reconnect preserves publications via SyncState.publishDataTracks (the managers replay their publish responses); full reconnect republishes and re-asserts subscriptions. The DataTracks subsystem 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 a publishDataTrack issued 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 RemoteDataTrack survives with its SID rewritten in place and active subscriptions transparently re-requested — no unpublish/republish events fire. This is why RemoteParticipant.dataTracks is keyed by name, not SID (Rust documents SIDs as unstable across reconnects; client-sdk-js keys by name for the same reason).

Backpressure. tryPush is non-blocking and throws queueFull, handing the rejected frame back so it can be retried (parity with Rust's PushFrameError::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, DataTrackFrameSender meters 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 mirrors DataChannelSender in 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). DataTrackFrameSenderTests pins 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/DecryptionProvider shims; DataTrackInfo.usesE2ee reflects 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 a setE2EEEnabled(true) issued after connecting to apply. Toggling between publishes does not, and Room.setE2EEEnabled documents that. The cryptor resolves Room.e2eeManager on 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 protocol submodule moves v1.45.8 → v1.50.4 (regenerated with make proto). Two reasons:

  • DataTrackInfo gained frame_encoding and schema after v1.45.8. Because handleParticipantUpdate re-serializes the parsed ParticipantInfo to 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.)
  • It brings DataBlob/StoreDataBlobRequest/GetDataBlobRequest, which defineSchema/getSchema are built on.

The general hazard remains and is worth a follow-up: any ParticipantInfo field 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 .app delta before the protocol update; the budget moves 16 → 16.5 MB to leave room for it. That figure includes the nanopb protocol migration from main, which cut 1.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.tryPush cannot surface the FFI's own error: PushFrameErrorReason is declared in the livekit_datatrack UniFFI component while try_push lives in livekit_uniffi, and the cross-component error lift fails, throwing UniffiInternalError.bufferOverflow instead. Left unhandled this leaked an FFI-internal type through the public API and broke send(contentsOf:)'s drop-on-full policy, which inspects the reason. The wrapper recovers the applicable reason from isPublished; the typed catch remains first and takes over once the bindings are fixed. Worth filing against livekit-uniffi.

Not in this PR

  • tryPush hands the rejected frame back, but the FFI still drops Rust's own PushFrameError payload.
  • Remote pipeline options (max_partial_frames) aren't exported by UniFFI yet.
  • Follow-ups filed from review: sharing the buffering state machine with 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: DataTrackFrameSenderTests covers the outbound drain, and DataTrackSendTests covers send(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 --dev across 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, withDataTrack scoping 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. DataTrackObjCTests covers the Objective-C surface including NSError bridging and value semantics.

DataTrackStream only ends when its track is unpublished, so every read goes through bounded next(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 a withPublishedDataTrack fixture; tests that control when the publish happens keep driving withRooms directly, 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 in AGENTS.md for local runs.

pblazej and others added 5 commits June 25, 2026 08:33
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>
@pblazej
pblazej force-pushed the blaze/datatracks-integration branch from cb7f186 to 6c21122 Compare June 25, 2026 11:32
@github-actions

Copy link
Copy Markdown

⚠️ This PR does not contain any files in the .changes directory.

pblazej and others added 23 commits June 26, 2026 12:54
- 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>
pblazej and others added 5 commits August 14, 2026 09:29
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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 4 commits August 14, 2026 10:28
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>
devin-ai-integration[bot]

This comment was marked as resolved.

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>
@pblazej
pblazej requested a review from ladvoc August 14, 2026 12:51
@ladvoc

ladvoc commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

LocalDataTrack.tryPush cannot surface the FFI's own error

Let's discuss this more, would like to better understand what is going on.

@ladvoc ladvoc left a comment

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.

LGTM, amazing work @pblazej! Left a few comments/suggestions, but nothing blocking.

Comment thread Sources/LiveKit/Core/Room+DataTrack.swift Outdated
Comment thread Sources/LiveKit/DataTrack/DataTrackError.swift
Comment thread Sources/LiveKit/DataTrack/DataTrackFrame.swift Outdated
Comment thread Sources/LiveKit/DataTrack/LocalDataTrack.swift
Comment thread Sources/LiveKit/DataTrack/LocalDataTrack.swift
Comment thread Sources/LiveKit/DataTrack/RemoteDataTrack.swift
Comment thread Sources/LiveKit/Extensions/TimeInterval.swift
Comment on lines +59 to +73
/// 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()
}
}

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.

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?

pblazej and others added 2 commits August 17, 2026 09:02
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
devin-ai-integration[bot]

This comment was marked as resolved.

pblazej and others added 2 commits August 17, 2026 09:46
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>
devin-ai-integration[bot]

This comment was marked as resolved.

@pblazej

pblazej commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ladvoc for having an in-depth look! 2 highlights:

  • RAII/memory management - I don't think this pattern is wrong per se, it's more about consistency with other APIs, surprising behavior/side effects; we can treat it as "low-level" difference between SDKs/langs, simialarly to cancellation semantics, etc.
  • (Re)encoding - yes I think it can be improved like this (da72781) which was my initial idea, probably got lost after multiple iterations
    • it's just a separate notification path, nothing is enforced here (to handle raw messages for all paths etc.)

pblazej and others added 4 commits August 17, 2026 10:28
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>
@pblazej
pblazej merged commit 768f7ec into main Aug 17, 2026
72 of 76 checks passed
@pblazej
pblazej deleted the blaze/datatracks-integration branch August 17, 2026 09:57
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.

3 participants