Skip to content

Data tracks - #975

Open
pblazej wants to merge 51 commits into
mainfrom
blaze/datatracks-integration
Open

Data tracks#975
pblazej wants to merge 51 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

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

Publish/unpublish events fire on RoomDelegate and ParticipantDelegate for both local and remote tracks, mirroring the media-track conventions. 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 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 — 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).

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. 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. RemoteParticipant.dataTracks derives its keys from live track info for this reason (stored keys would go stale on reassignment).

Backpressure. tryPush is non-blocking and throws queueFull; 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/DecryptionProvider shims; DataTrackInfo.usesE2ee reflects 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

  • Schema/frame-encoding metadata (DataTrackOptions.schema/frameEncoding, added upstream in 0.1.8) is not yet surfaced in the public API.
  • Frame timestamps are millisecond 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) against livekit-server --dev, plus DataTrackObjCTests for the Objective-C surface.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

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

This comment was marked as resolved.

pblazej and others added 3 commits August 5, 2026 15:01
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>
devin-ai-integration[bot]

This comment was marked as resolved.

@1egoman 1egoman 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.

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

Comment thread Sources/LiveKit/Core/Room+DataTrack.swift Outdated
Comment thread Sources/LiveKit/Core/Room+DataTrack.swift Outdated
Comment on lines +72 to +92
/// 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
}
}
}
}
}

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.

Ah this is pretty clever, so you give the user access to control whether non queueable messages drop or throw.

Two thoughts:

  1. 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?
  2. 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 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment on lines +46 to +55
@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)
}
}
}

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

So now (without reading your code), these errors would propagate just from the stream? try await track.subscribeWithOptions?

@1egoman 1egoman Aug 6, 2026

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.

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.

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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?

@ladvoc ladvoc Aug 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah I'm more on the Rust side here.

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.

This is something else I think could be good to have on the web 🤔

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I 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.

pblazej and others added 2 commits August 6, 2026 08:23
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>
@pblazej

pblazej commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@1egoman @ladvoc a variant of buffering, similar to our existing DataChannel infrastructure 91e9203

Semantic similarities/diffs between JS/Rust indicated in tests.

I also see some potential extraction to FlowControlledDataChannel 👍

BTW this logic could be moved to Rust as well 🙃

devin-ai-integration[bot]

This comment was marked as resolved.

…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>

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

View 15 additional findings in Devin Review.

Open in Devin Review

Comment on lines +294 to +302
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()
}
}
}

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.

🟡 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:):

  1. calls room?.ensurePublisherConnected(), which returns immediately because guard case .subscriberPrimary = _state.transport fails when transport is nil (Sources/LiveKit/Core/Room+Engine.swift:74-77);
  2. passes _publisherChannelOpen.wait() instantly on the stale cached result;
  3. calls local.publishTrack(...), whose outbound signal request is dropped by ManagerDelegate (try? await room.signalClient.sendRequest(...) at Sources/LiveKit/Core/Room+DataTrack.swift:262; _sendRequest throws while connectionState == .disconnected).

The same latch also never re-arms if the publisher data channel merely transitions to .closeddataChannelDidChangeState 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.
Open in Devin Review

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()

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think it's way cleaner to just revert this ugly task-leak and keep the raii/retain approach (without edge cases).

@pblazej
pblazej requested a review from lukasIO August 7, 2026 11:37
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