From 8fc82046ab2c382056385dc5feded7404b950ba5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 13:33:23 -0400 Subject: [PATCH 01/25] feat: add new DataStreams ffi encapsulation class --- Sources/LiveKit/Core/Room+DataStream.swift | 8 + Sources/LiveKit/Core/Room.swift | 10 +- Sources/LiveKit/DataStream/DataStreams.swift | 271 ++++++++++++++++++ Sources/LiveKit/DataStream/StreamError.swift | 22 ++ Sources/LiveKit/DataStream/StreamInfo.swift | 42 +++ .../LiveKit/DataStream/StreamOptions.swift | 26 ++ 6 files changed, 373 insertions(+), 6 deletions(-) create mode 100644 Sources/LiveKit/DataStream/DataStreams.swift diff --git a/Sources/LiveKit/Core/Room+DataStream.swift b/Sources/LiveKit/Core/Room+DataStream.swift index 3012bdf7e..b6c4ad57d 100644 --- a/Sources/LiveKit/Core/Room+DataStream.swift +++ b/Sources/LiveKit/Core/Room+DataStream.swift @@ -74,6 +74,14 @@ public extension Room { } } +// MARK: - Handler type aliases + +/// Handler for incoming byte data streams. +public typealias ByteStreamHandler = @Sendable (ByteStreamReader, Participant.Identity) async throws -> Void + +/// Handler for incoming text data streams. +public typealias TextStreamHandler = @Sendable (TextStreamReader, Participant.Identity) async throws -> Void + extension Room { /// Reserved data-stream topic prefix for RPC v2 (`lk.rpc_request` / `lk.rpc_response`, /// and any future `lk.rpc_*` topics). The broader `lk.*` namespace is convention-only diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 7b91dc44a..24c600066 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -806,12 +806,10 @@ extension Room: DataChannelDelegate { case let .rpcResponse(response): room(didReceiveRpcResponse: response) case let .rpcAck(ack): room(didReceiveRpcAck: ack) case let .rpcRequest(request): room(didReceiveRpcRequest: request, from: dataPacket.participantIdentity) - case let .streamHeader(header): - incomingStreamManager.handle(.header(header, dataPacket.participantIdentity, dataPacket.encryptedPacket.encryptionType.toLKType())) - case let .streamChunk(chunk): - incomingStreamManager.handle(.chunk(chunk, dataPacket.encryptedPacket.encryptionType.toLKType())) - case let .streamTrailer(trailer): - incomingStreamManager.handle(.trailer(trailer, dataPacket.encryptedPacket.encryptionType.toLKType())) + case .streamHeader, .streamChunk, .streamTrailer: + // Forward the whole (already-decrypted, deduped) packet; the UniFFI incoming manager + // decodes the stream header/chunk/trailer itself. + dataStreams.handleIncoming(dataPacket) default: return } } diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift new file mode 100644 index 000000000..959a49add --- /dev/null +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -0,0 +1,271 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +internal import LiveKitUniFFI + +// MARK: - DataStreams + +/// Owns the incoming/outgoing UniFFI data stream managers and the topic→handler registry, and +/// routes Room/participant calls to the right manager. The ``Room`` holds a single reference, +/// keeping the subsystem off the Room's surface. +/// +/// Unlike ``DataTracks``, this subsystem is **Room-scoped, not session-scoped**: stream handlers +/// (registered by the app, and by internal RPC/transcription wiring) must survive reconnects and be +/// registrable before connect, so the registry lives here for the Room's lifetime. The FFI managers +/// hold no channel handles — inbound packets are pushed in via ``handleIncoming(_:)`` and outbound +/// packets are pulled out via the delegate — so they too live for the whole Room. +/// +/// `@unchecked Sendable`: the only mutable state is the StateSync-guarded registry; the managers and +/// delegates are immutable after init. Not an actor — the UniFFI delegate callbacks are synchronous +/// and can't `await`. +final class DataStreams: NSObject, @unchecked Sendable, Loggable { + private let incoming: LiveKitUniFFI.IncomingDataStreamManager + private let outgoing: LiveKitUniFFI.OutgoingDataStreamManager + + // Held weakly: the Room owns this coordinator, so the back-reference must not retain it. Used + // for the room-level encryption type stamped onto stream info, and for logging. + private weak var room: Room? + + // The Swift-side handler registry. The FFI reports every opened stream regardless of topic + // (`onByteStreamOpened`/`onTextStreamOpened`); we route by `info.topic` to these handlers. + private let byteStreamHandlers = StateSync<[String: ByteStreamHandler]>([:]) + private let textStreamHandlers = StateSync<[String: TextStreamHandler]>([:]) + // Topics we've already logged a missing-handler warning for, to avoid log spam. + private let failedTopics = StateSync>([]) + + init(room: Room) { + self.room = room + let incomingDelegate = IncomingDelegate() + let outgoingDelegate = OutgoingDelegate(room: room) + let registry = Registry(room: room) + // No reserved topics and no payload cap: topic routing (incl. the `lk.rpc` guard) is handled + // Swift-side in `Room+DataStream`, matching the previous pure-Swift implementation. + incoming = LiveKitUniFFI.IncomingDataStreamManager( + delegate: incomingDelegate, + reservedTopics: [], + maxPayloadByteLength: nil, + ) + outgoing = LiveKitUniFFI.OutgoingDataStreamManager(delegate: outgoingDelegate, registry: registry) + super.init() + // The FFI manager retains its delegate strongly, so the delegate points back here weakly. + incomingDelegate.coordinator = self + } + + // Room-level encryption type, stamped onto every stream info as it crosses the FFI boundary. + // The FFI hardcodes the info's encryption type to `.none` (E2EE-over-FFI is a follow-up); the + // actual payload crypto still happens transparently in `DataChannelPair`, so we surface the + // room's data-channel encryption type here to preserve the previous behavior. + private var currentEncryptionType: EncryptionType { + room?.e2eeManager?.dataChannelEncryptionType ?? .none + } + + // MARK: - Handler registration + + func registerByteStreamHandler(for topic: String, _ onNewStream: @escaping ByteStreamHandler) throws { + try byteStreamHandlers.mutate { + guard $0[topic] == nil else { throw StreamError.handlerAlreadyRegistered } + $0[topic] = onNewStream + } + } + + func registerTextStreamHandler(for topic: String, _ onNewStream: @escaping TextStreamHandler) throws { + try textStreamHandlers.mutate { + guard $0[topic] == nil else { throw StreamError.handlerAlreadyRegistered } + $0[topic] = onNewStream + } + } + + /// SDK-internal: register `onNewStream` for `topic` if no handler is registered yet, otherwise + /// no-op. Used by idempotent wiring paths (e.g. RPC v2 setup runs on every connect) that don't + /// want the duplicate-registration throw from the public API. + @discardableResult + func registerTextStreamHandlerIfNeeded(for topic: String, _ onNewStream: @escaping TextStreamHandler) -> Bool { + textStreamHandlers.mutate { + guard $0[topic] == nil else { return false } + $0[topic] = onNewStream + return true + } + } + + func unregisterByteStreamHandler(for topic: String) { + byteStreamHandlers.mutate { $0[topic] = nil } + } + + func unregisterTextStreamHandler(for topic: String) { + textStreamHandlers.mutate { $0[topic] = nil } + } + + // MARK: - Sending + + func sendText(_ text: String, options: StreamTextOptions) async throws -> TextStreamInfo { + try await mappingErrors { + let info = try await outgoing.sendText(text: text, options: options.ffi) + return TextStreamInfo(info, encryptionType: currentEncryptionType) + } + } + + func sendFile(_ fileURL: URL, options: StreamByteOptions) async throws -> ByteStreamInfo { + // The FFI reads the file's bytes but doesn't infer its metadata, so resolve name/MIME/size + // from disk here (matching the previous implementation) unless the caller set them. + guard let fileInfo = FileInfo(for: fileURL) else { + throw StreamError.fileInfoUnavailable + } + let ffiOptions = LiveKitUniFFI.StreamByteOptions( + topic: options.topic, + attributes: options.attributes, + destinationIdentities: options.destinationIdentities.map(\.stringValue), + id: options.id, + mimeType: options.mimeType ?? fileInfo.mimeType, + name: options.name ?? fileInfo.name, + totalLength: UInt64(options.totalSize ?? fileInfo.size), + compress: options.compress, + senderIdentity: nil, + ) + return try await mappingErrors { + let info = try await outgoing.sendFile(path: fileURL.path, options: ffiOptions) + return ByteStreamInfo(info, encryptionType: currentEncryptionType) + } + } + + func streamText(options: StreamTextOptions) async throws -> TextStreamWriter { + try await mappingErrors { + let writer = try await outgoing.streamText(options: options.ffi) + return TextStreamWriter(writer, encryptionType: currentEncryptionType) + } + } + + func streamBytes(options: StreamByteOptions) async throws -> ByteStreamWriter { + try await mappingErrors { + let writer = try await outgoing.streamBytes(options: options.ffi) + return ByteStreamWriter(writer, encryptionType: currentEncryptionType) + } + } + + // MARK: - Incoming packets + + /// Feeds a received data-stream packet (already decrypted and deduped by `DataChannelPair`) to + /// the incoming manager. The FFI re-decodes the serialized `DataPacket` itself. + func handleIncoming(_ dataPacket: Livekit_DataPacket) { + guard let data = try? dataPacket.serializedData() else { return } + incoming.handlePacketReceived(packet: data) + } + + // MARK: - Stream open dispatch (called from the incoming delegate) + + fileprivate func handleByteStreamOpened(_ ffiReader: LiveKitUniFFI.ByteStreamReader, identity: String) { + let info = ByteStreamInfo(ffiReader.info(), encryptionType: currentEncryptionType) + guard let handler = byteStreamHandlers.copy()[info.topic] else { + logMissingHandler(topic: info.topic, id: info.id, identity: identity) + return + } + let reader = ByteStreamReader(ffiReader, info: info) + let participantIdentity = Participant.Identity(from: identity) + Task.detachedDiscarding { try await handler(reader, participantIdentity) } + } + + fileprivate func handleTextStreamOpened(_ ffiReader: LiveKitUniFFI.TextStreamReader, identity: String) { + let info = TextStreamInfo(ffiReader.info(), encryptionType: currentEncryptionType) + guard let handler = textStreamHandlers.copy()[info.topic] else { + logMissingHandler(topic: info.topic, id: info.id, identity: identity) + return + } + let reader = TextStreamReader(ffiReader, info: info) + let participantIdentity = Participant.Identity(from: identity) + Task.detachedDiscarding { try await handler(reader, participantIdentity) } + } + + private func logMissingHandler(topic: String, id: String, identity: String) { + let shouldLog = failedTopics.mutate { $0.insert(topic).inserted } + guard shouldLog else { return } + log("Unable to find handler for incoming stream: \(id), topic: \(topic), opened by: \(identity)", .warning) + } + + private func mappingErrors(_ body: () async throws -> T) async throws -> T { + do { + return try await body() + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } + } + + // MARK: - Incoming delegate + + /// Receives the incoming manager's stream-open callbacks and forwards them to the coordinator. + /// A separate object because the FFI manager retains its delegate strongly; holding the + /// coordinator weakly keeps this the weak link so teardown doesn't leak. + private final class IncomingDelegate: LiveKitUniFFI.IncomingDataStreamManagerDelegate, @unchecked Sendable { + weak var coordinator: DataStreams? + + func onByteStreamOpened(reader: LiveKitUniFFI.ByteStreamReader, identity: String) { + coordinator?.handleByteStreamOpened(reader, identity: identity) + } + + func onTextStreamOpened(reader: LiveKitUniFFI.TextStreamReader, identity: String) { + coordinator?.handleTextStreamOpened(reader, identity: identity) + } + } + + // MARK: - Outgoing delegate + + /// Receives the outgoing manager's encoded `DataPacket`s and sends them over the reliable data + /// channel via `Room.send(dataPacket:)` — preserving E2EE, reliable sequencing, and identity + /// stamping. Serialized so packets reach the SFU in the order the manager emits them; the room + /// is held weakly to avoid retaining it through the FFI manager. + private final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate, @unchecked Sendable { + private let sender = AsyncSerialDelegate() + + init(room: Room) { + sender.set(delegate: room) + } + + func onPacketsAvailable(packets: [Data]) { + sender.notifyDetached { room in + for data in packets { + guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { + room.log("Failed to decode outgoing data stream packet", .warning) + continue + } + try? await room.send(dataPacket: packet) + } + } + } + } + + // MARK: - Remote participant registry + + /// Read access to the room's remote participants, used by the outgoing manager to resolve + /// broadcast recipients and decide compression eligibility. Client protocol/capabilities aren't + /// currently exposed on `RemoteParticipant`, so they default to none — compression stays off + /// until they're wired, which is a safe default (a non-compressed send always works). + private final class Registry: LiveKitUniFFI.RemoteParticipantRegistryDelegate, @unchecked Sendable { + private weak var room: Room? + + init(room: Room) { + self.room = room + } + + func remoteClientProtocol(identity _: String) -> Int32 { 0 } + + func remoteCapabilities(identity _: String) -> [LiveKitUniFFI.ClientCapability] { [] } + + func remoteIdentities() -> [String] { + guard let room else { return [] } + return room.remoteParticipants.keys.map(\.stringValue) + } + } +} diff --git a/Sources/LiveKit/DataStream/StreamError.swift b/Sources/LiveKit/DataStream/StreamError.swift index 64b10fdc3..168f3b0c0 100644 --- a/Sources/LiveKit/DataStream/StreamError.swift +++ b/Sources/LiveKit/DataStream/StreamError.swift @@ -14,6 +14,8 @@ * limitations under the License. */ +internal import LiveKitUniFFI + public enum StreamError: Error, Equatable { /// Unable to open a stream with the same ID more than once. case alreadyOpened @@ -48,3 +50,23 @@ public enum StreamError: Error, Equatable { /// Encryption type mismatch between stream header and chunk/trailer. case encryptionTypeMismatch(expected: EncryptionType, received: EncryptionType) } + +// MARK: - FFI bridging + +extension StreamError { + /// Best-effort mapping from the UniFFI error onto the public case set. The public enum predates + /// the FFI core and doesn't cover every Rust case, so several map onto the closest public case + /// (message-carrying cases preserve the underlying message via `abnormalEnd`). + init(_ ffi: LiveKitUniFFI.DataStreamError) { + switch ffi { + case let .AbnormalEnd(message): self = .abnormalEnd(reason: message) + case let .Io(message): self = .abnormalEnd(reason: message) + case .Utf8, .Decompression: self = .decodeFailed + case .LengthExceeded, .HeaderTooLarge, .PayloadTooLarge: self = .lengthExceeded + case .Incomplete: self = .incomplete + case .EncryptionTypeMismatch: self = .encryptionTypeMismatch(expected: .none, received: .none) + case .AlreadyClosed, .InvalidHeader, .MissedChunk, .SendFailed, .Internal, .InvalidFileName: + self = .terminated + } + } +} diff --git a/Sources/LiveKit/DataStream/StreamInfo.swift b/Sources/LiveKit/DataStream/StreamInfo.swift index 331ab3b9a..6e68cd6db 100644 --- a/Sources/LiveKit/DataStream/StreamInfo.swift +++ b/Sources/LiveKit/DataStream/StreamInfo.swift @@ -16,6 +16,8 @@ import Foundation +internal import LiveKitUniFFI + /// Information about a data stream. public protocol StreamInfo: Sendable { /// Unique identifier of the stream. @@ -86,6 +88,33 @@ public final class TextStreamInfo: NSObject, StreamInfo { self.attachedStreamIDs = attachedStreamIDs self.generated = generated } + + convenience init(_ ffi: LiveKitUniFFI.TextStreamInfo, encryptionType: EncryptionType) { + self.init( + id: ffi.id, + topic: ffi.topic, + timestamp: Date(timeIntervalSince1970: TimeInterval(ffi.timestampMs) / 1000), + totalLength: ffi.totalLength.map { Int($0) }, + attributes: ffi.attributes, + encryptionType: encryptionType, + operationType: OperationType(ffi.operationType), + version: Int(ffi.version), + replyToStreamID: ffi.replyToStreamId, + attachedStreamIDs: ffi.attachedStreamIds, + generated: ffi.generated, + ) + } +} + +extension TextStreamInfo.OperationType { + init(_ ffi: LiveKitUniFFI.OperationType) { + switch ffi { + case .create: self = .create + case .update: self = .update + case .delete: self = .delete + case .reaction: self = .reaction + } + } } /// Information about a byte data stream. @@ -123,4 +152,17 @@ public final class ByteStreamInfo: NSObject, StreamInfo { self.encryptionType = encryptionType self.name = name } + + convenience init(_ ffi: LiveKitUniFFI.ByteStreamInfo, encryptionType: EncryptionType) { + self.init( + id: ffi.id, + topic: ffi.topic, + timestamp: Date(timeIntervalSince1970: TimeInterval(ffi.timestampMs) / 1000), + totalLength: ffi.totalLength.map { Int($0) }, + attributes: ffi.attributes, + encryptionType: encryptionType, + mimeType: ffi.mimeType, + name: ffi.name.isEmpty ? nil : ffi.name, + ) + } } diff --git a/Sources/LiveKit/DataStream/StreamOptions.swift b/Sources/LiveKit/DataStream/StreamOptions.swift index f22c1c952..183a45e2d 100644 --- a/Sources/LiveKit/DataStream/StreamOptions.swift +++ b/Sources/LiveKit/DataStream/StreamOptions.swift @@ -16,6 +16,8 @@ import Foundation +internal import LiveKitUniFFI + /// Options used when opening an outgoing data stream. public protocol StreamOptions: Sendable { /// Topic name used to route the stream to the appropriate handler. @@ -62,6 +64,30 @@ public final class StreamTextOptions: NSObject, StreamOptions { self.attachedStreamIDs = attachedStreamIDs self.replyToStreamID = replyToStreamID } + + /// Creates options with the default compression behavior. Preserved as the Objective-C entry + /// point and for source compatibility with callers that predate `compress`. + public convenience init( + topic: String, + attributes: [String: String] = [:], + destinationIdentities: [Participant.Identity] = [], + id: String? = nil, + version: Int = 0, + attachedStreamIDs: [String] = [], + replyToStreamID: String? = nil, + ) { + self.init( + topic: topic, + attributes: attributes, + destinationIdentities: destinationIdentities, + id: id, + version: version, + attachedStreamIDs: attachedStreamIDs, + replyToStreamID: replyToStreamID, + compress: nil, + ) + } + } } /// Options used when opening an outgoing byte data stream. From de50f911900a277ff7c5615fffdb713fb5fcba6f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 13:43:25 -0400 Subject: [PATCH 02/25] feat: cut over from swift data streams v1 implementation to rust based data streams v2 --- .../Receive/TranscriptionStreamReceiver.swift | 12 +- Sources/LiveKit/Core/Room+DataStream.swift | 8 +- Sources/LiveKit/Core/Room.swift | 18 +- Sources/LiveKit/DataStream/DataStreams.swift | 52 +- .../Incoming/ByteStreamReader.swift | 24 +- .../Incoming/IncomingStreamManager.swift | 424 ------------- .../Incoming/TextStreamReader.swift | 69 ++- .../Outgoing/ByteStreamWriter.swift | 46 +- .../Outgoing/OutgoingStreamManager.swift | 321 ---------- .../DataStream/Outgoing/StreamData.swift | 59 -- .../Outgoing/StreamWriterDestination.swift | 23 - .../Outgoing/TextStreamWriter.swift | 31 +- .../LiveKit/DataStream/StreamOptions.swift | 43 ++ .../LocalParticipant+DataStream.swift | 8 +- .../DataStream/ByteStreamInfoTests.swift | 57 -- .../DataStream/ByteStreamReaderTests.swift | 164 ----- .../IncomingStreamManagerTests.swift | 568 ------------------ .../OutgoingStreamManagerTests.swift | 176 ------ .../DataStream/StreamDataTests.swift | 86 --- .../DataStream/TextStreamInfoTests.swift | 66 -- .../DataStream/TextStreamReaderTests.swift | 121 ---- Tests/LiveKitCoreTests/Room/RoomTests.swift | 9 +- Tests/LiveKitCoreTests/RpcTests.swift | 2 +- Tests/LiveKitTestSupport/Room.swift | 2 +- 24 files changed, 239 insertions(+), 2150 deletions(-) delete mode 100644 Sources/LiveKit/DataStream/Incoming/IncomingStreamManager.swift delete mode 100644 Sources/LiveKit/DataStream/Outgoing/OutgoingStreamManager.swift delete mode 100644 Sources/LiveKit/DataStream/Outgoing/StreamData.swift delete mode 100644 Sources/LiveKit/DataStream/Outgoing/StreamWriterDestination.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/ByteStreamInfoTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/StreamDataTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/TextStreamInfoTests.swift delete mode 100644 Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift diff --git a/Sources/LiveKit/Agent/Chat/Receive/TranscriptionStreamReceiver.swift b/Sources/LiveKit/Agent/Chat/Receive/TranscriptionStreamReceiver.swift index 97179bb21..c576d624f 100644 --- a/Sources/LiveKit/Agent/Chat/Receive/TranscriptionStreamReceiver.swift +++ b/Sources/LiveKit/Agent/Chat/Receive/TranscriptionStreamReceiver.swift @@ -91,10 +91,10 @@ actor TranscriptionStreamReceiver: MessageReceiver, Loggable { let topic = topic - // SDK-internal receiver — register via `incomingStreamManager` directly - // so the receiver works even if the `Room.reservedTopicPrefix` guard is - // later widened to cover non-RPC `lk.*` topics like this one. - try await room.incomingStreamManager.registerTextStreamHandler(for: topic, ordered: true) { [weak self] reader, participantIdentity in + // SDK-internal receiver — register via `dataStreams` directly so the + // receiver works even if the `Room.reservedTopicPrefix` guard is later + // widened to cover non-RPC `lk.*` topics like this one. + try room.dataStreams.registerTextStreamHandler(for: topic, ordered: true) { [weak self] reader, participantIdentity in var lastMessage: ReceivedMessage? for try await message in reader where !message.isEmpty { guard let self else { return } @@ -114,9 +114,7 @@ actor TranscriptionStreamReceiver: MessageReceiver, Loggable { } continuation.onTermination = { [weak self] _ in - Task { [weak self] in - await self?.room.incomingStreamManager.unregisterTextStreamHandler(for: topic) - } + self?.room.dataStreams.unregisterTextStreamHandler(for: topic) } return stream diff --git a/Sources/LiveKit/Core/Room+DataStream.swift b/Sources/LiveKit/Core/Room+DataStream.swift index b6c4ad57d..862648af8 100644 --- a/Sources/LiveKit/Core/Room+DataStream.swift +++ b/Sources/LiveKit/Core/Room+DataStream.swift @@ -32,7 +32,7 @@ public extension Room { throw LiveKitError(.invalidParameter, message: "Stream topic prefix '\(Room.reservedTopicPrefix)' is reserved for internal SDK use") } - try await incomingStreamManager.registerByteStreamHandler(for: topic, onNewStream) + try dataStreams.registerByteStreamHandler(for: topic, onNewStream) } /// Registers a handler for incoming text streams matching the given topic. @@ -50,7 +50,7 @@ public extension Room { throw LiveKitError(.invalidParameter, message: "Stream topic prefix '\(Room.reservedTopicPrefix)' is reserved for internal SDK use") } - try await incomingStreamManager.registerTextStreamHandler(for: topic, onNewStream) + try dataStreams.registerTextStreamHandler(for: topic, onNewStream) } /// Unregisters a byte stream handler that was previously registered for the given topic. @@ -60,7 +60,7 @@ public extension Room { @objc func unregisterByteStreamHandler(for topic: String) async { guard !topic.hasPrefix(Room.reservedTopicPrefix) else { return } - await incomingStreamManager.unregisterByteStreamHandler(for: topic) + dataStreams.unregisterByteStreamHandler(for: topic) } /// Unregisters a text stream handler that was previously registered for the given topic. @@ -70,7 +70,7 @@ public extension Room { @objc func unregisterTextStreamHandler(for topic: String) async { guard !topic.hasPrefix(Room.reservedTopicPrefix) else { return } - await incomingStreamManager.unregisterTextStreamHandler(for: topic) + dataStreams.unregisterTextStreamHandler(for: topic) } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 24c600066..0f318a14f 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -130,12 +130,10 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { lazy var subscriberDataChannel = DataChannelPair(delegate: self) lazy var publisherDataChannel = DataChannelPair(delegate: self) - let incomingStreamManager = IncomingStreamManager() - lazy var outgoingStreamManager = OutgoingStreamManager { [weak self] packet in - try await self?.send(dataPacket: packet) - } encryptionProvider: { [weak self] in - self?.e2eeManager?.dataChannelEncryptionType ?? .none - } + // The data stream subsystem (incoming/outgoing UniFFI managers, the topic→handler registry, and + // packet routing) behind one reference. Kept for the Room's lifetime — not session-scoped like + // ``DataTracks`` — so stream handlers survive reconnects and can be registered before connect. + lazy var dataStreams = DataStreams(room: self) // MARK: - Data Tracks @@ -610,7 +608,7 @@ extension Room { await activeParticipantCompleters.reset(throwing: disconnectError) // Fail open data streams so their handlers return; a handler blocked on a // reader that will never finish would stall its topic's ordered queue. - await incomingStreamManager.reset() + dataStreams.reset() await signalClient.cleanUp(withError: disconnectError) // Cancel all track stats timers before closing transports to prevent @@ -659,10 +657,10 @@ extension Room { private func setupRpc() async { await rpcClient.attach(to: self) await rpcServer.attach(to: self) - await incomingStreamManager.registerTextStreamHandlerIfNeeded(for: RpcStreamTopic.request) { [weak rpcServer] reader, identity in + dataStreams.registerTextStreamHandlerIfNeeded(for: RpcStreamTopic.request) { [weak rpcServer] reader, identity in await rpcServer?.handleIncomingRequestStream(reader: reader, callerIdentity: identity) } - await incomingStreamManager.registerTextStreamHandlerIfNeeded(for: RpcStreamTopic.response) { [weak rpcClient] reader, identity in + dataStreams.registerTextStreamHandlerIfNeeded(for: RpcStreamTopic.response) { [weak rpcClient] reader, identity in await rpcClient?.handleIncomingResponseStream(reader: reader, senderIdentity: identity) } } @@ -701,7 +699,7 @@ extension Room { // so the caller sees `recipientDisconnected` (1503) immediately instead of // hanging until the user-supplied `responseTimeout`. await rpcClient.handleParticipantDisconnected(identity) - await incomingStreamManager.closeStreams(from: identity) + dataStreams.closeStreams(from: identity) guard let participant = _state.mutate({ $0.remoteParticipants.removeValue(forKey: identity) }) else { throw LiveKitError(.invalidState, message: "Participant not found for \(identity)") diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 959a49add..37bd26333 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -47,17 +47,21 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { private let textStreamHandlers = StateSync<[String: TextStreamHandler]>([:]) // Topics we've already logged a missing-handler warning for, to avoid log spam. private let failedTopics = StateSync>([]) + // Topics whose text handlers run in wire order. Successive (non-overlapping) streams on such a + // topic have their handlers serialized so they process in arrival order. Used by internal + // consumers like transcription; off by default so concurrent consumers (e.g. RPC) aren't slowed. + private let orderedTopics = StateSync>([]) + private let orderedTails = StateSync<[String: Task]>([:]) init(room: Room) { self.room = room let incomingDelegate = IncomingDelegate() let outgoingDelegate = OutgoingDelegate(room: room) let registry = Registry(room: room) - // No reserved topics and no payload cap: topic routing (incl. the `lk.rpc` guard) is handled - // Swift-side in `Room+DataStream`, matching the previous pure-Swift implementation. + // No payload cap; topic routing (incl. the `lk.rpc` guard) is handled Swift-side in + // `Room+DataStream`, matching the previous pure-Swift implementation. incoming = LiveKitUniFFI.IncomingDataStreamManager( delegate: incomingDelegate, - reservedTopics: [], maxPayloadByteLength: nil, ) outgoing = LiveKitUniFFI.OutgoingDataStreamManager(delegate: outgoingDelegate, registry: registry) @@ -83,11 +87,15 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { } } - func registerTextStreamHandler(for topic: String, _ onNewStream: @escaping TextStreamHandler) throws { + /// When `ordered` is true, successive streams on `topic` have their handlers run in wire order: + /// a handler for a stream that opened after another finishes only once the earlier handler + /// returns. Off by default — it would serialize consumers that want strict concurrency (e.g. RPC). + func registerTextStreamHandler(for topic: String, ordered: Bool = false, _ onNewStream: @escaping TextStreamHandler) throws { try textStreamHandlers.mutate { guard $0[topic] == nil else { throw StreamError.handlerAlreadyRegistered } $0[topic] = onNewStream } + if ordered { orderedTopics.mutate { $0.insert(topic) } } } /// SDK-internal: register `onNewStream` for `topic` if no handler is registered yet, otherwise @@ -108,6 +116,8 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { func unregisterTextStreamHandler(for topic: String) { textStreamHandlers.mutate { $0[topic] = nil } + orderedTopics.mutate { $0.remove(topic) } + orderedTails.mutate { $0[topic] = nil } } // MARK: - Sending @@ -165,6 +175,21 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { incoming.handlePacketReceived(packet: data) } + // MARK: - Stream lifecycle + + /// Fails all open incoming streams so their handlers return (e.g. on cleanup). A handler blocked + /// on a reader that will never finish would otherwise stall its topic's ordered queue. Handler + /// registrations survive, so streams arriving after a reconnect are still handled. + func reset() { + incoming.abortAllStreams() + } + + /// Fails open incoming streams sent by `identity` (they disconnected mid-send), so their readers + /// throw and their handlers return instead of hanging. + func closeStreams(from identity: Participant.Identity) { + incoming.abortStreamsFrom(identity: identity.stringValue) + } + // MARK: - Stream open dispatch (called from the incoming delegate) fileprivate func handleByteStreamOpened(_ ffiReader: LiveKitUniFFI.ByteStreamReader, identity: String) { @@ -186,7 +211,24 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { } let reader = TextStreamReader(ffiReader, info: info) let participantIdentity = Participant.Identity(from: identity) - Task.detachedDiscarding { try await handler(reader, participantIdentity) } + guard orderedTopics.copy().contains(info.topic) else { + Task.detachedDiscarding { try await handler(reader, participantIdentity) } + return + } + // Ordered topic: chain this handler after the previous one on the same topic so successive + // streams process in arrival order. + let topic = info.topic + orderedTails.mutate { tails in + let predecessor = tails[topic] + tails[topic] = Task.detached { [weak self] in + await predecessor?.value + do { + try await handler(reader, participantIdentity) + } catch { + self?.log("Ordered text stream handler for topic '\(topic)' threw: \(error)", .warning) + } + } + } } private func logMissingHandler(topic: String, id: String, identity: String) { diff --git a/Sources/LiveKit/DataStream/Incoming/ByteStreamReader.swift b/Sources/LiveKit/DataStream/Incoming/ByteStreamReader.swift index 595c45b79..1fb1a3875 100644 --- a/Sources/LiveKit/DataStream/Incoming/ByteStreamReader.swift +++ b/Sources/LiveKit/DataStream/Incoming/ByteStreamReader.swift @@ -16,17 +16,19 @@ import Foundation +internal import LiveKitUniFFI + /// An asynchronous sequence of chunks read from a byte data stream. @objcMembers public final class ByteStreamReader: NSObject, AsyncSequence, Sendable { /// Information about the incoming byte stream. public let info: ByteStreamInfo - let source: StreamReaderSource + private let reader: LiveKitUniFFI.ByteStreamReader - init(info: ByteStreamInfo, source: StreamReaderSource) { + init(_ reader: LiveKitUniFFI.ByteStreamReader, info: ByteStreamInfo) { + self.reader = reader self.info = info - self.source = source } /// Reads incoming chunks from the byte stream, concatenating them into a single data object which is returned @@ -36,20 +38,28 @@ public final class ByteStreamReader: NSObject, AsyncSequence, Sendable { /// - Throws: ``StreamError`` if an error occurs while reading the stream. /// public func readAll() async throws -> Data { - try await source.collect() + do { + return try await reader.readAll() + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } /// An asynchronous iterator of incoming chunks. public struct AsyncChunks: AsyncIteratorProtocol { - fileprivate var source: StreamReaderSource.Iterator + fileprivate let reader: LiveKitUniFFI.ByteStreamReader public mutating func next() async throws -> Data? { - try await source.next() + do { + return try await reader.next() + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } } public func makeAsyncIterator() -> AsyncChunks { - AsyncChunks(source: source.makeAsyncIterator()) + AsyncChunks(reader: reader) } } diff --git a/Sources/LiveKit/DataStream/Incoming/IncomingStreamManager.swift b/Sources/LiveKit/DataStream/Incoming/IncomingStreamManager.swift deleted file mode 100644 index cd9406d72..000000000 --- a/Sources/LiveKit/DataStream/Incoming/IncomingStreamManager.swift +++ /dev/null @@ -1,424 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -// swiftlint:disable file_length - -/// Manages state of incoming data streams. -actor IncomingStreamManager: Loggable { - /// Information about an open data stream. - private struct Descriptor { - /// Distinguishes this descriptor from others that reuse the same stream - /// ID, so a stale cleanup can't remove a successor. - let generation = UUID() - let info: StreamInfo - let identity: Participant.Identity - let continuation: StreamReaderSource.Continuation - var readLength = 0 - } - - /// Mapping between stream ID and descriptor for open streams. - private var openStreams: [String: Descriptor] = [:] - - var openStreamCount: Int { openStreams.count } - /// Stream topics without a registered handler. - private var failedToOpenStreams: Set = [] - - private var byteStreamHandlers: [String: ByteStreamHandler] = [:] - private var textStreamHandlers: [String: TextStreamHandler] = [:] - - /// Topics whose handlers preserve wire order (see `registerTextStreamHandler`). - private var orderedTopics: Set = [] - /// Handlers of streams that are still open on the wire, keyed by topic and - /// descriptor generation. Open streams gate nothing. - private var runningHandlers: [String: [UUID: Task]] = [:] - /// Handlers of streams already closed on the wire but still executing (e.g. - /// draining buffered chunks or emitting a finalization). A new stream on the - /// topic opened after these closed, so its handler must wait for them. - private var finishingHandlers: [String: [UUID: Task]] = [:] - - /// Events are processed in a serial (FIFO) order - enum StreamEvent { - case header(Livekit_DataStream.Header, String, EncryptionType) - case chunk(Livekit_DataStream.Chunk, EncryptionType) - case trailer(Livekit_DataStream.Trailer, EncryptionType) - } - - private let eventContinuation: AsyncStream.Continuation - private var eventLoopTask: AnyTaskCancellable? - - init() { - let (stream, continuation) = AsyncStream.makeStream(of: StreamEvent.self) - eventContinuation = continuation - - Task { - await observe(events: stream) - } - } - - private func observe(events stream: AsyncStream) { - eventLoopTask = stream.subscribe(self) { observer, event in - await observer.process(event) - } - } - - nonisolated func handle(_ event: StreamEvent) { - eventContinuation.yield(event) - } - - private func process(_ event: StreamEvent) { - switch event { - case let .header(header, identityString, encryptionType): - handle(header: header, from: identityString, encryptionType: encryptionType) - case let .chunk(chunk, encryptionType): - handle(chunk: chunk, encryptionType: encryptionType) - case let .trailer(trailer, encryptionType): - handle(trailer: trailer, encryptionType: encryptionType) - } - } - - // MARK: - Handler registration - - func registerByteStreamHandler(for topic: String, _ onNewStream: @escaping ByteStreamHandler) throws { - guard byteStreamHandlers[topic] == nil else { - throw StreamError.handlerAlreadyRegistered - } - byteStreamHandlers[topic] = onNewStream - } - - /// When `ordered` is true, handlers for streams that do not overlap on the - /// wire run in wire order: a stream opened after another closed waits for the - /// earlier handler to finish. Streams that are open concurrently are handled - /// concurrently, so a still-open stream never delays later ones. Off by - /// default: it would serialize consumers that want strict concurrency (e.g. - /// RPC request handling). - /// - /// Contract: an ordered handler should return promptly once its reader ends — - /// work it keeps doing after its stream closed delays every later - /// non-overlapping stream on the topic. - func registerTextStreamHandler(for topic: String, ordered: Bool = false, _ onNewStream: @escaping TextStreamHandler) throws { - guard textStreamHandlers[topic] == nil else { - throw StreamError.handlerAlreadyRegistered - } - textStreamHandlers[topic] = onNewStream - if ordered { orderedTopics.insert(topic) } - } - - /// SDK-internal: register `onNewStream` for `topic` if no handler is registered yet, - /// otherwise no-op. Used by idempotent wiring paths (e.g. RPC v2 setup runs on every - /// connect) that don't want the duplicate-registration throw from the public API. - @discardableResult - func registerTextStreamHandlerIfNeeded(for topic: String, _ onNewStream: @escaping TextStreamHandler) -> Bool { - guard textStreamHandlers[topic] == nil else { return false } - textStreamHandlers[topic] = onNewStream - return true - } - - func unregisterByteStreamHandler(for topic: String) { - byteStreamHandlers[topic] = nil - } - - func unregisterTextStreamHandler(for topic: String) { - textStreamHandlers[topic] = nil - orderedTopics.remove(topic) - } - - // MARK: - Packet processing - - /// Handles a data stream header. - private func handle(header: Livekit_DataStream.Header, from identityString: String, encryptionType: EncryptionType) { - let identity = Participant.Identity(from: identityString) - - guard let streamInfo = Self.streamInfo(from: header, encryptionType: encryptionType) else { - return - } - openStream(with: streamInfo, from: identity) - } - - private func openStream(with info: StreamInfo, from identity: Participant.Identity) { - guard openStreams[info.id] == nil else { - log("Ignoring stream \(info.id) from \(identity): a stream with this ID is already open", .warning) - return - } - guard let handler = handler(for: info) else { - let topic = info.topic - if !failedToOpenStreams.contains(topic) { - log("Unable to find handler for incoming stream: \(info.id), topic: \(topic), opened by: \(identity)", .warning) - failedToOpenStreams.insert(topic) - } - return - } - - var continuation: StreamReaderSource.Continuation! - let source = StreamReaderSource { - continuation = $0 - } - - let descriptor = Descriptor( - info: info, - identity: identity, - continuation: continuation, - ) - openStreams[info.id] = descriptor - - // Set after the descriptor is stored: this task runs at an arbitrary - // later point, and a sender may have reused the stream ID by then, so - // it must only remove its own generation. - continuation.onTermination = { @Sendable [weak self, generation = descriptor.generation] _ in - guard let self else { return } - Task { await self.closeStream(with: info.id, generation: generation) } - } - - // Detached: handler lifetime is not tied to the descriptor — abnormal stream - // conditions are signalled through `source` throwing instead. - if orderedTopics.contains(info.topic) { - // Wire happens-before: this stream opened after `predecessors` closed, - // so their handlers must finish first. Same-segment streams never - // overlap (senders close one before opening the next), which is what - // makes finalizations and stream-ID reuse race-free. - let predecessors = Array((finishingHandlers[info.topic] ?? [:]).values) - let topic = info.topic - let generation = descriptor.generation - let task = Task.detached { [weak self] in - for predecessor in predecessors { - await predecessor.value - } - do { - try await handler(source, identity) - } catch { - self?.log("Text stream handler for topic '\(topic)' threw: \(error)", .warning) - } - await self?.handlerCompleted(topic: topic, generation: generation) - } - runningHandlers[topic, default: [:]][generation] = task - } else { - Task.detachedDiscarding { - try await handler(source, identity) - } - } - } - - /// Marks the stream's handler as gating later non-overlapping streams on the - /// same ordered topic. Called wherever a stream is closed on the wire. - private func streamDidClose(_ descriptor: Descriptor) { - let topic = descriptor.info.topic - if let task = runningHandlers[topic]?.removeValue(forKey: descriptor.generation) { - finishingHandlers[topic, default: [:]][descriptor.generation] = task - } - } - - private func handlerCompleted(topic: String, generation: UUID) { - runningHandlers[topic]?[generation] = nil - finishingHandlers[topic]?[generation] = nil - } - - /// Close the stream with the given id, unless it has been superseded by a - /// newer stream reusing the same id. - private func closeStream(with id: String, generation: UUID) { - guard openStreams[id]?.generation == generation else { return } - openStreams[id] = nil - } - - /// Fails all open streams from the given participant, whose trailers can no - /// longer arrive; their readers throw and their handlers return. - func closeStreams(from identity: Participant.Identity) { - for (id, descriptor) in openStreams where descriptor.identity == identity { - openStreams[id] = nil - streamDidClose(descriptor) - descriptor.continuation.finish(throwing: StreamError.terminated) - } - } - - /// Fails all open streams. Handler registrations survive so streams arriving - /// after a reconnect are still handled. - func reset() { - for descriptor in openStreams.values { - streamDidClose(descriptor) - descriptor.continuation.finish(throwing: StreamError.terminated) - } - openStreams.removeAll() - } - - /// Handles a data stream chunk. - private func handle(chunk: Livekit_DataStream.Chunk, encryptionType: EncryptionType) { - guard !chunk.content.isEmpty, let descriptor = openStreams[chunk.streamID] else { return } - - // Error paths remove the descriptor synchronously for the same reason as - // the trailer path: a header reusing this stream ID may be the next event. - if descriptor.info.encryptionType != encryptionType { - let error = StreamError.encryptionTypeMismatch( - expected: descriptor.info.encryptionType, - received: encryptionType, - ) - openStreams[chunk.streamID] = nil - streamDidClose(descriptor) - descriptor.continuation.finish(throwing: error) - return - } - - let readLength = descriptor.readLength + chunk.content.count - - if let totalLength = descriptor.info.totalLength { - guard readLength <= totalLength else { - openStreams[chunk.streamID] = nil - streamDidClose(descriptor) - descriptor.continuation.finish(throwing: StreamError.lengthExceeded) - return - } - } - openStreams[chunk.streamID]!.readLength = readLength - descriptor.continuation.yield(chunk.content) - } - - /// Handles a data stream trailer. - private func handle(trailer: Livekit_DataStream.Trailer, encryptionType: EncryptionType) { - guard let descriptor = openStreams[trailer.streamID] else { - return - } - - // Remove synchronously: senders may reuse a stream ID, and the reopening - // header is processed by this same event loop right after the trailer. - // The reader's `onTermination` cleanup runs in its own task and can lose - // that race, making `openStream` silently drop the new stream. - openStreams[trailer.streamID] = nil - streamDidClose(descriptor) - - if descriptor.info.encryptionType != encryptionType { - let error = StreamError.encryptionTypeMismatch( - expected: descriptor.info.encryptionType, - received: encryptionType, - ) - descriptor.continuation.finish(throwing: error) - return - } - - if let totalLength = descriptor.info.totalLength { - guard descriptor.readLength == totalLength else { - descriptor.continuation.finish(throwing: StreamError.incomplete) - return - } - } - guard trailer.reason.isEmpty else { - // According to protocol documentation, a non-empty reason string indicates an error - let error = StreamError.abnormalEnd(reason: trailer.reason) - descriptor.continuation.finish(throwing: error) - return - } - descriptor.continuation.finish() - } - - // MARK: - Handler resolution - - /// Type-erased stream handler. - private typealias AnyStreamHandler = @Sendable (StreamReaderSource, Participant.Identity) async throws -> Void - - /// Finds a registered handler suitable for handling the stream with the given info. - private func handler(for info: StreamInfo) -> AnyStreamHandler? { - if let info = info as? ByteStreamInfo, - let registerdHandler = byteStreamHandlers[info.topic] - { - return { try await registerdHandler(ByteStreamReader(info: info, source: $0), $1) } - } - if let info = info as? TextStreamInfo, - let registerdHandler = textStreamHandlers[info.topic] - { - return { try await registerdHandler(TextStreamReader(info: info, source: $0), $1) } - } - return nil - } - - // MARK: - Clean up - - deinit { - eventContinuation.finish() - guard !openStreams.isEmpty else { return } - for descriptor in openStreams.values { - descriptor.continuation.finish(throwing: StreamError.terminated) - } - } -} - -// MARK: - Type aliases - -/// Handler for incoming byte data streams. -public typealias ByteStreamHandler = @Sendable (ByteStreamReader, Participant.Identity) async throws -> Void - -/// Handler for incoming text data streams. -public typealias TextStreamHandler = @Sendable (TextStreamReader, Participant.Identity) async throws -> Void - -// MARK: - From protocol types - -extension IncomingStreamManager { - static func streamInfo(from header: Livekit_DataStream.Header, encryptionType: EncryptionType) -> StreamInfo? { - switch header.contentHeader { - case let .byteHeader(byteHeader): ByteStreamInfo(header, byteHeader, encryptionType) - case let .textHeader(textHeader): TextStreamInfo(header, textHeader, encryptionType) - default: nil - } - } -} - -extension ByteStreamInfo { - convenience init( - _ header: Livekit_DataStream.Header, - _ byteHeader: Livekit_DataStream.ByteHeader, - _ encryptionType: EncryptionType, - ) { - self.init( - id: header.streamID, - topic: header.topic, - timestamp: header.timestampDate, - totalLength: header.hasTotalLength ? Int(header.totalLength) : nil, - attributes: header.attributes, - encryptionType: encryptionType, - // --- - mimeType: header.mimeType, - name: byteHeader.name, - ) - } -} - -extension TextStreamInfo { - convenience init( - _ header: Livekit_DataStream.Header, - _ textHeader: Livekit_DataStream.TextHeader, - _ encryptionType: EncryptionType, - ) { - self.init( - id: header.streamID, - topic: header.topic, - timestamp: header.timestampDate, - totalLength: header.hasTotalLength ? Int(header.totalLength) : nil, - attributes: header.attributes, - encryptionType: encryptionType, - // --- - operationType: TextStreamInfo.OperationType(textHeader.operationType), - version: Int(textHeader.version), - replyToStreamID: !textHeader.replyToStreamID.isEmpty ? textHeader.replyToStreamID : nil, - attachedStreamIDs: textHeader.attachedStreamIds, - generated: textHeader.generated, - ) - } -} - -extension TextStreamInfo.OperationType { - init(_ operationType: Livekit_DataStream.OperationType) { - self = Self(rawValue: operationType.rawValue) ?? .create - } -} - -// swiftlint:enable file_length diff --git a/Sources/LiveKit/DataStream/Incoming/TextStreamReader.swift b/Sources/LiveKit/DataStream/Incoming/TextStreamReader.swift index c81982dfc..25d94f238 100644 --- a/Sources/LiveKit/DataStream/Incoming/TextStreamReader.swift +++ b/Sources/LiveKit/DataStream/Incoming/TextStreamReader.swift @@ -16,17 +16,31 @@ import Foundation +internal import LiveKitUniFFI + /// An asynchronous sequence of chunks read from a text data stream. @objcMembers public final class TextStreamReader: NSObject, AsyncSequence, Sendable { /// Information about the incoming text stream. public let info: TextStreamInfo - let source: StreamReaderSource + // A reader is backed either by the UniFFI core (production) or an in-memory source (internal + // producers/tests that inject content directly). The FFI path stays pull-based for backpressure. + private enum Backing: Sendable { + case ffi(LiveKitUniFFI.TextStreamReader) + case source(StreamReaderSource) + } + + private let backing: Backing + + init(_ reader: LiveKitUniFFI.TextStreamReader, info: TextStreamInfo) { + backing = .ffi(reader) + self.info = info + } init(info: TextStreamInfo, source: StreamReaderSource) { + backing = .source(source) self.info = info - self.source = source } /// Reads incoming chunks from the text stream, concatenating them into a single string which is returned @@ -36,26 +50,59 @@ public final class TextStreamReader: NSObject, AsyncSequence, Sendable { /// - Throws: ``StreamError`` if an error occurs while reading the stream. /// public func readAll() async throws -> String { - try await collect() + switch backing { + case let .ffi(reader): + do { + return try await reader.readAll() + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } + case let .source(source): + var result = "" + for try await chunk in source { + guard let string = String(data: chunk, encoding: .utf8) else { + throw StreamError.decodeFailed + } + result += string + } + return result + } } /// An asynchronous iterator of incoming chunks. public struct AsyncChunks: AsyncIteratorProtocol { - fileprivate var source: StreamReaderSource.Iterator + enum Backing { + case ffi(LiveKitUniFFI.TextStreamReader) + case source(StreamReaderSource.Iterator) + } + + var backing: Backing public mutating func next() async throws -> String? { - guard let data = try await source.next() else { - return nil - } - guard let string = String(data: data, encoding: .utf8) else { - throw StreamError.decodeFailed + switch backing { + case let .ffi(reader): + do { + return try await reader.next() + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } + case var .source(iterator): + let data = try await iterator.next() + backing = .source(iterator) + guard let data else { return nil } + guard let string = String(data: data, encoding: .utf8) else { + throw StreamError.decodeFailed + } + return string } - return string } } public func makeAsyncIterator() -> AsyncChunks { - AsyncChunks(source: source.makeAsyncIterator()) + switch backing { + case let .ffi(reader): AsyncChunks(backing: .ffi(reader)) + case let .source(source): AsyncChunks(backing: .source(source.makeAsyncIterator())) + } } } diff --git a/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift b/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift index d338a2b7b..ed3755bac 100644 --- a/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift +++ b/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift @@ -16,17 +16,21 @@ import Foundation +internal import LiveKitUniFFI + /// Asynchronously write to an open byte stream. @objcMembers public final class ByteStreamWriter: NSObject, Sendable { /// Information about the outgoing byte stream. public let info: ByteStreamInfo - private let destination: StreamWriterDestination + private let writer: LiveKitUniFFI.ByteStreamWriter + // The FFI writer exposes no open state, so track local closure here. + private let _isOpen = StateSync(true) /// Whether or not the stream is still open. public var isOpen: Bool { - get async { await destination.isOpen } + get async { _isOpen.copy() } } /// Write data to the stream. @@ -36,7 +40,11 @@ public final class ByteStreamWriter: NSObject, Sendable { /// cannot be sent to remote participants. /// public func write(_ data: Data) async throws { - try await destination.write(data) + do { + try await writer.write(data: data) + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } /// Close the stream. @@ -47,26 +55,20 @@ public final class ByteStreamWriter: NSObject, Sendable { /// cannot be communicated to remote participants. /// public func close(reason: String? = nil) async throws { - try await destination.close(reason: reason) - } - - init(info: ByteStreamInfo, destination: StreamWriterDestination) { - self.info = info - self.destination = destination - } -} - -extension ByteStreamWriter { - /// Write the contents of the file located at the given URL to the stream. - func write(contentsOf fileURL: URL) async throws { - try await Task { [weak self] in - guard let self else { return } - let reader = try AsyncFileStream(readingFrom: fileURL) - for try await chunk in reader.chunks() { - try await write(chunk) + _isOpen.mutate { $0 = false } + do { + if let reason { + try await writer.closeWithReason(reason: reason) + } else { + try await writer.close() } - }.value + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } - private static let fileReadChunkSize = 4096 + init(_ writer: LiveKitUniFFI.ByteStreamWriter, encryptionType: EncryptionType) { + self.writer = writer + info = ByteStreamInfo(writer.info(), encryptionType: encryptionType) + } } diff --git a/Sources/LiveKit/DataStream/Outgoing/OutgoingStreamManager.swift b/Sources/LiveKit/DataStream/Outgoing/OutgoingStreamManager.swift deleted file mode 100644 index 7c48487ac..000000000 --- a/Sources/LiveKit/DataStream/Outgoing/OutgoingStreamManager.swift +++ /dev/null @@ -1,321 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -// Extending the generic builder needs the runtime module by name; the facades -// themselves are re-exported through LiveKit. - -/// Manages state of outgoing data streams. -actor OutgoingStreamManager: Loggable { - typealias PacketHandler = @Sendable (Livekit_DataPacket) async throws -> Void - typealias EncryptionProvider = @Sendable () -> EncryptionType - - private nonisolated let packetHandler: PacketHandler - private nonisolated let encryptionProvider: EncryptionProvider - - init(packetHandler: @escaping PacketHandler, encryptionProvider: @escaping EncryptionProvider) { - self.packetHandler = packetHandler - self.encryptionProvider = encryptionProvider - } - - // MARK: - Opening streams - - func sendText(_ text: String, options: StreamTextOptions) async throws -> TextStreamInfo { - let info = TextStreamInfo( - id: options.id ?? Self.uniqueID(), - topic: options.topic, - timestamp: Date(), - totalLength: text.utf8.count, // Number of bytes in UTF-8 representation - attributes: options.attributes, - encryptionType: encryptionProvider(), - operationType: .create, - version: options.version, - replyToStreamID: options.replyToStreamID, - attachedStreamIDs: options.attachedStreamIDs, - generated: false, - ) - let writer = try await openTextStream( - with: info, - sendingTo: options.destinationIdentities, - ) - try await writer.write(text) - try await writer.close() - - return writer.info - } - - func sendFile(_ fileURL: URL, options: StreamByteOptions) async throws -> ByteStreamInfo { - guard let fileInfo = FileInfo(for: fileURL) else { - throw StreamError.fileInfoUnavailable - } - let info = ByteStreamInfo( - id: options.id ?? Self.uniqueID(), - topic: options.topic, - timestamp: Date(), - totalLength: fileInfo.size, // Not overridable - attributes: options.attributes, - encryptionType: encryptionProvider(), - mimeType: options.mimeType ?? fileInfo.mimeType ?? Self.byteMimeType, - name: options.name ?? fileInfo.name, - ) - let writer = try await openByteStream( - with: info, - sendingTo: options.destinationIdentities, - ) - try await writer.write(contentsOf: fileURL) - try await writer.close() - - return writer.info - } - - func streamText(options: StreamTextOptions) async throws -> TextStreamWriter { - let info = TextStreamInfo( - id: options.id ?? Self.uniqueID(), - topic: options.topic, - timestamp: Date(), - totalLength: nil, - attributes: options.attributes, - encryptionType: encryptionProvider(), - operationType: .create, - version: options.version, - replyToStreamID: options.replyToStreamID, - attachedStreamIDs: options.attachedStreamIDs, - generated: false, - ) - return try await openTextStream( - with: info, - sendingTo: options.destinationIdentities, - ) - } - - func streamBytes(options: StreamByteOptions) async throws -> ByteStreamWriter { - let info = ByteStreamInfo( - id: options.id ?? Self.uniqueID(), - topic: options.topic, - timestamp: Date(), - totalLength: options.totalSize, - attributes: options.attributes, - encryptionType: encryptionProvider(), - mimeType: options.mimeType ?? Self.byteMimeType, - name: options.name, - ) - return try await openByteStream( - with: info, - sendingTo: options.destinationIdentities, - ) - } - - private func openTextStream( - with info: TextStreamInfo, - sendingTo recipients: [Participant.Identity], - ) async throws -> TextStreamWriter { - try await openStream(with: info, sendingTo: recipients) - return TextStreamWriter( - info: info, - destination: Destination(streamID: info.id, manager: self), - ) - } - - private func openByteStream( - with info: ByteStreamInfo, - sendingTo recipients: [Participant.Identity], - ) async throws -> ByteStreamWriter { - try await openStream(with: info, sendingTo: recipients) - return ByteStreamWriter( - info: info, - destination: Destination(streamID: info.id, manager: self), - ) - } - - // MARK: - State - - /// Information about an open data stream. - private struct Descriptor { - let info: StreamInfo - var writtenLength: Int = 0 - var chunkIndex: UInt64 = 0 - } - - /// Mapping between stream ID and descriptor for open streams. - private var openStreams: [String: Descriptor] = [:] - - private func hasOpenStream(for streamID: String) -> Bool { - openStreams[streamID] != nil - } - - // MARK: - Packet sending - - private func openStream( - with info: StreamInfo, - sendingTo recipients: [Participant.Identity], - ) async throws { - guard openStreams[info.id] == nil else { - throw StreamError.alreadyOpened - } - - let header = Livekit_DataStream.Header(info) - let packet = Livekit_DataPacket.with { - $0.value = .streamHeader(header) - $0.destinationIdentities = recipients.map(\.stringValue) - } - - try await packetHandler(packet) - - let descriptor = Descriptor(info: info) - openStreams[info.id] = descriptor - } - - private func send(_ data: some StreamData, to id: String) async throws { - for chunk in data.chunks(of: Self.chunkSize) { - try await sendChunk(chunk, to: id) - } - } - - private func sendChunk(_ data: Data, to id: String) async throws { - guard let descriptor = openStreams[id] else { - throw StreamError.unknownStream - } - let chunk = Livekit_DataStream.Chunk.with { - $0.streamID = id - $0.chunkIndex = descriptor.chunkIndex - $0.content = data - } - let packet = Livekit_DataPacket.with { - $0.value = .streamChunk(chunk) - } - try await packetHandler(packet) - - openStreams[id]!.writtenLength += data.count - openStreams[id]!.chunkIndex += 1 - } - - private func closeStream(with id: String, reason: String?) async throws { - guard openStreams[id] != nil else { - throw StreamError.unknownStream - } - - let trailer = Livekit_DataStream.Trailer.with { - $0.streamID = id - $0.reason = reason ?? "" - } - let packet = Livekit_DataPacket.with { - $0.value = .streamTrailer(trailer) - } - - try await packetHandler(packet) - openStreams[id] = nil - } - - // MARK: - Destination - - fileprivate struct Destination: StreamWriterDestination { - let streamID: String - weak var manager: OutgoingStreamManager? - - var isOpen: Bool { - get async { - guard let manager else { return false } - return await manager.hasOpenStream(for: streamID) - } - } - - func write(_ data: some StreamData) async throws { - guard let manager else { throw StreamError.terminated } - try await manager.send(data, to: streamID) - } - - func close(reason: String?) async throws { - guard let manager else { throw StreamError.terminated } - try? await manager.closeStream(with: streamID, reason: reason) - } - } - - // MARK: - Constants & helpers - - /// Generates a unqiue ID for a new stream. - private static func uniqueID() -> String { - UUID().uuidString - } - - /// Maximum number of bytes to send in a single chunk. - private static let chunkSize = 15 * 1024 - - /// Default MIME type to use for text streams. - fileprivate static let textMimeType = "text/plain" - - /// Default MIME type to use for byte streams. - private static let byteMimeType = "application/octet-stream" -} - -// MARK: - To protocol types - -extension Livekit_DataStream.Header { - init(_ streamInfo: StreamInfo) { - self = Livekit_DataStream.Header.with { - $0.streamID = streamInfo.id - $0.mimeType = (streamInfo as? ByteStreamInfo)?.mimeType ?? OutgoingStreamManager.textMimeType - $0.topic = streamInfo.topic - $0.timestampDate = streamInfo.timestamp - if let totalLength = streamInfo.totalLength { - $0.totalLength = UInt64(totalLength) - } - $0.attributes = streamInfo.attributes - $0.encryptionType = streamInfo.encryptionType.toPBType() - $0.contentHeader = Livekit_DataStream_Header_OneOf_ContentHeader(streamInfo) - } - } - - // Stream timestamps are in ms (13 digits) - var timestampDate: Date { - Date(timeIntervalSince1970: TimeInterval(timestamp) / TimeInterval(1000)) - } -} - -extension Livekit_DataStream_Header.Builder { - // Mirrors `Livekit_DataStream.Header.timestampDate`; setters live on the builder. - var timestampDate: Date { - get { Date(timeIntervalSince1970: TimeInterval(timestamp) / TimeInterval(1000)) } - nonmutating set { timestamp = Int64(newValue.timeIntervalSince1970 * TimeInterval(1000)) } - } -} - -extension Livekit_DataStream_Header_OneOf_ContentHeader { - init?(_ streamInfo: StreamInfo) { - if let textStreamInfo = streamInfo as? TextStreamInfo { - self = .textHeader(Livekit_DataStream.TextHeader.with { - $0.operationType = Livekit_DataStream.OperationType(textStreamInfo.operationType) - $0.version = Int32(textStreamInfo.version) - $0.replyToStreamID = textStreamInfo.replyToStreamID ?? "" - $0.attachedStreamIds = textStreamInfo.attachedStreamIDs - $0.generated = textStreamInfo.generated - }) - return - } else if let byteStreamInfo = streamInfo as? ByteStreamInfo { - self = .byteHeader(Livekit_DataStream.ByteHeader.with { - if let name = byteStreamInfo.name { $0.name = name } - }) - return - } - return nil - } -} - -extension Livekit_DataStream.OperationType { - init(_ operationType: TextStreamInfo.OperationType) { - self = Livekit_DataStream.OperationType(rawValue: operationType.rawValue) ?? .create - } -} diff --git a/Sources/LiveKit/DataStream/Outgoing/StreamData.swift b/Sources/LiveKit/DataStream/Outgoing/StreamData.swift deleted file mode 100644 index 8bf69e3e7..000000000 --- a/Sources/LiveKit/DataStream/Outgoing/StreamData.swift +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -protocol StreamData: Sendable { - func chunks(of size: Int) -> [Data] -} - -extension Data: StreamData { - func chunks(of size: Int) -> [Data] { - guard size > 0, !isEmpty else { return [] } - return stride(from: startIndex, to: endIndex, by: size).map { - let end = index($0, offsetBy: size, limitedBy: endIndex) ?? endIndex - return self[$0 ..< end] - } - } -} - -extension String: StreamData { - /// Chunk along valid UTF-8 bounderies. - /// - /// Uses the same algorithm as in the LiveKit JS SDK. - /// - func chunks(of size: Int) -> [Data] { - guard size > 0, !isEmpty else { return [] } - - var chunks: [Data] = [] - var encoded = Data(utf8)[...] - - while encoded.count > size { - var k = size - while k > 0 { - guard encoded.indices.contains(k), - encoded[k] & 0xC0 == 0x80 else { break } - k -= 1 - } - chunks.append(encoded.subdata(in: 0 ..< k)) - encoded = encoded.subdata(in: k ..< encoded.count) - } - if !encoded.isEmpty { - chunks.append(encoded) - } - return chunks - } -} diff --git a/Sources/LiveKit/DataStream/Outgoing/StreamWriterDestination.swift b/Sources/LiveKit/DataStream/Outgoing/StreamWriterDestination.swift deleted file mode 100644 index d95192d64..000000000 --- a/Sources/LiveKit/DataStream/Outgoing/StreamWriterDestination.swift +++ /dev/null @@ -1,23 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation - -protocol StreamWriterDestination: Sendable { - var isOpen: Bool { get async } - func write(_ data: some StreamData) async throws - func close(reason: String?) async throws -} diff --git a/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift b/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift index cd09dd7aa..d238d93a4 100644 --- a/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift +++ b/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift @@ -16,17 +16,21 @@ import Foundation +internal import LiveKitUniFFI + /// Asynchronously write to an open text stream. @objcMembers public final class TextStreamWriter: NSObject, Sendable { /// Information about the outgoing text stream. public let info: TextStreamInfo - private let destination: StreamWriterDestination + private let writer: LiveKitUniFFI.TextStreamWriter + // The FFI writer exposes no open state, so track local closure here. + private let _isOpen = StateSync(true) /// Whether or not the stream is still open. public var isOpen: Bool { - get async { await destination.isOpen } + get async { _isOpen.copy() } } /// Write text to the stream. @@ -36,7 +40,11 @@ public final class TextStreamWriter: NSObject, Sendable { /// cannot be sent to remote participants. /// public func write(_ text: String) async throws { - try await destination.write(text) + do { + try await writer.write(text: text) + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } /// Close the stream. @@ -47,11 +55,20 @@ public final class TextStreamWriter: NSObject, Sendable { /// cannot be communicated to remote participants. /// public func close(reason: String? = nil) async throws { - try await destination.close(reason: reason) + _isOpen.mutate { $0 = false } + do { + if let reason { + try await writer.closeWithReason(reason: reason) + } else { + try await writer.close() + } + } catch let error as LiveKitUniFFI.DataStreamError { + throw StreamError(error) + } } - init(info: TextStreamInfo, destination: StreamWriterDestination) { - self.info = info - self.destination = destination + init(_ writer: LiveKitUniFFI.TextStreamWriter, encryptionType: EncryptionType) { + self.writer = writer + info = TextStreamInfo(writer.info(), encryptionType: encryptionType) } } diff --git a/Sources/LiveKit/DataStream/StreamOptions.swift b/Sources/LiveKit/DataStream/StreamOptions.swift index 183a45e2d..3ff8d70ed 100644 --- a/Sources/LiveKit/DataStream/StreamOptions.swift +++ b/Sources/LiveKit/DataStream/StreamOptions.swift @@ -45,8 +45,14 @@ public final class StreamTextOptions: NSObject, StreamOptions { public let attachedStreamIDs: [String] public let replyToStreamID: String? + /// Whether to compress the payload when every recipient supports it. `nil` (the default) leaves + /// the decision to the SDK, which compresses when able; `false` disables compression. + public let compress: Bool? + // TODO: Expose additional protocol level fields + /// - Note: `compress` is required here to disambiguate from the compatibility initializer below; + /// omit it (use the other initializer) to accept the default behavior. public init( topic: String, attributes: [String: String] = [:], @@ -55,6 +61,7 @@ public final class StreamTextOptions: NSObject, StreamOptions { version: Int = 0, attachedStreamIDs: [String] = [], replyToStreamID: String? = nil, + compress: Bool?, ) { self.topic = topic self.attributes = attributes @@ -63,6 +70,7 @@ public final class StreamTextOptions: NSObject, StreamOptions { self.version = version self.attachedStreamIDs = attachedStreamIDs self.replyToStreamID = replyToStreamID + self.compress = compress } /// Creates options with the default compression behavior. Preserved as the Objective-C entry @@ -87,6 +95,21 @@ public final class StreamTextOptions: NSObject, StreamOptions { compress: nil, ) } + + var ffi: LiveKitUniFFI.StreamTextOptions { + LiveKitUniFFI.StreamTextOptions( + topic: topic, + attributes: attributes, + destinationIdentities: destinationIdentities.map(\.stringValue), + id: id, + operationType: nil, + version: Int32(truncatingIfNeeded: version), + replyToStreamId: replyToStreamID, + attachedStreamIds: attachedStreamIDs, + generated: nil, + compress: compress, + senderIdentity: nil, + ) } } @@ -108,6 +131,10 @@ public final class StreamByteOptions: NSObject, StreamOptions { /// Total expected size in bytes, if known. public let totalSize: Int? + /// Whether to compress the payload when every recipient supports it. `nil` (the default) leaves + /// the decision to the SDK, which compresses when able; `false` disables compression. + public let compress: Bool? + public init( topic: String, attributes: [String: String] = [:], @@ -116,6 +143,7 @@ public final class StreamByteOptions: NSObject, StreamOptions { mimeType: String? = nil, name: String? = nil, totalSize: Int? = nil, + compress: Bool? = nil, ) { self.topic = topic self.attributes = attributes @@ -124,6 +152,21 @@ public final class StreamByteOptions: NSObject, StreamOptions { self.mimeType = mimeType self.name = name self.totalSize = totalSize + self.compress = compress + } + + var ffi: LiveKitUniFFI.StreamByteOptions { + LiveKitUniFFI.StreamByteOptions( + topic: topic, + attributes: attributes, + destinationIdentities: destinationIdentities.map(\.stringValue), + id: id, + mimeType: mimeType, + name: name, + totalLength: totalSize.map { UInt64($0) }, + compress: compress, + senderIdentity: nil, + ) } /// ObjC-compatible initializer that accepts `NSNumber?` for `totalSize`. diff --git a/Sources/LiveKit/Participant/LocalParticipant+DataStream.swift b/Sources/LiveKit/Participant/LocalParticipant+DataStream.swift index 6b8c9456d..f6fd56154 100644 --- a/Sources/LiveKit/Participant/LocalParticipant+DataStream.swift +++ b/Sources/LiveKit/Participant/LocalParticipant+DataStream.swift @@ -39,7 +39,7 @@ public extension LocalParticipant { @discardableResult func sendText(_ text: String, options: StreamTextOptions) async throws -> TextStreamInfo { let room = try requireRoom() - return try await room.outgoingStreamManager.sendText(text, options: options) + return try await room.dataStreams.sendText(text, options: options) } /// Send a file on disk to participants in the room. @@ -62,7 +62,7 @@ public extension LocalParticipant { @discardableResult func sendFile(_ fileURL: URL, options: StreamByteOptions) async throws -> ByteStreamInfo { let room = try requireRoom() - return try await room.outgoingStreamManager.sendFile(fileURL, options: options) + return try await room.dataStreams.sendFile(fileURL, options: options) } // MARK: - Stream @@ -86,7 +86,7 @@ public extension LocalParticipant { @discardableResult func streamText(options: StreamTextOptions) async throws -> TextStreamWriter { let room = try requireRoom() - return try await room.outgoingStreamManager.streamText(options: options) + return try await room.dataStreams.streamText(options: options) } /// Stream bytes incrementally to participants in the room. @@ -109,6 +109,6 @@ public extension LocalParticipant { /// func streamBytes(options: StreamByteOptions) async throws -> ByteStreamWriter { let room = try requireRoom() - return try await room.outgoingStreamManager.streamBytes(options: options) + return try await room.dataStreams.streamBytes(options: options) } } diff --git a/Tests/LiveKitCoreTests/DataStream/ByteStreamInfoTests.swift b/Tests/LiveKitCoreTests/DataStream/ByteStreamInfoTests.swift deleted file mode 100644 index f8e7051f1..000000000 --- a/Tests/LiveKitCoreTests/DataStream/ByteStreamInfoTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -struct ByteStreamInfoTests { - @Test func protocolTypeConversion() { - let info = ByteStreamInfo( - id: "id", - topic: "topic", - timestamp: Date(timeIntervalSince1970: 100), - totalLength: 128, - attributes: ["key": "value"], - encryptionType: .gcm, - mimeType: "image/jpeg", - name: "filename.bin", - ) - let header = Livekit_DataStream.Header(info) - #expect(header.streamID == info.id) - #expect(header.mimeType == info.mimeType) - #expect(header.topic == info.topic) - #expect(header.timestamp == Int64(info.timestamp.timeIntervalSince1970 * TimeInterval(1000))) - #expect(header.totalLength == UInt64(info.totalLength ?? -1)) - #expect(header.attributes == info.attributes) - #expect(header.encryptionType.rawValue == info.encryptionType.rawValue) - #expect(header.byteHeader.name == info.name) - - let newInfo = ByteStreamInfo(header, header.byteHeader, .gcm) - #expect(newInfo.id == info.id) - #expect(newInfo.mimeType == info.mimeType) - #expect(newInfo.topic == info.topic) - #expect(newInfo.timestamp == info.timestamp) - #expect(newInfo.totalLength == info.totalLength) - #expect(newInfo.attributes == info.attributes) - #expect(newInfo.encryptionType == info.encryptionType) - #expect(newInfo.name == info.name) - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift deleted file mode 100644 index 9f0d36af0..000000000 --- a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift +++ /dev/null @@ -1,164 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -final class ByteStreamReaderTests: @unchecked Sendable { - private var continuation: StreamReaderSource.Continuation! - private var reader: ByteStreamReader! - - private let testInfo = ByteStreamInfo( - id: UUID().uuidString, - topic: "someTopic", - timestamp: Date(), - totalLength: nil, - attributes: [:], - encryptionType: .none, - mimeType: "application/octet-stream", - name: "filename.bin", - ) - - let testChunks = [ - Data(repeating: 0xAB, count: 128), - Data(repeating: 0xCD, count: 128), - Data(repeating: 0xEF, count: 256), - Data(repeating: 0x12, count: 32), - ] - - /// All chunks combined. - private var testPayload: Data { - testChunks.reduce(Data()) { $0 + $1 } - } - - private func sendPayload(closingError: Error? = nil) { - for chunk in testChunks { - continuation.yield(chunk) - } - continuation.finish(throwing: closingError) - } - - init() { - let source = StreamReaderSource { - self.continuation = $0 - } - reader = ByteStreamReader(info: testInfo, source: source) - } - - @Test func chunkRead() async { - await confirmation("Receive all chunks") { receiveConfirm in - await confirmation("Normal closure") { closureConfirm in - let processingTask = Task { - var chunkIndex = 0 - for try await chunk in reader { - #expect(chunk == testChunks[chunkIndex]) - if chunkIndex == testChunks.count - 1 { - receiveConfirm() - } - chunkIndex += 1 - } - closureConfirm() - } - - sendPayload() - - _ = await processingTask.result - } - } - } - - @Test func chunkReadError() async { - await confirmation("Read throws error") { confirm in - let testError = StreamError.abnormalEnd(reason: "test") - - let processingTask = Task { - do { - for try await _ in reader {} - } catch { - #expect(error as? StreamError == testError) - confirm() - } - } - sendPayload(closingError: testError) - - _ = await processingTask.result - } - } - - @Test func readAll() async { - await confirmation("Read full payload") { confirm in - let processingTask = Task { - let fullPayload = try await reader.readAll() - #expect(fullPayload == testPayload) - confirm() - } - sendPayload() - - _ = await processingTask.result - } - } - - @Test func readToFile() async { - await confirmation("File properly written") { confirm in - let processingTask = Task { - do { - let fileURL = try await reader.writeToFile() - #expect(fileURL.lastPathComponent == reader.info.name) - - let fileContents = try Data(contentsOf: fileURL) - #expect(fileContents == testPayload) - - confirm() - } catch { - print(error) - } - } - sendPayload() - - _ = await processingTask.result - } - } - - struct FileNameCase: CustomTestStringConvertible { - let preferred: String? - let fallback: String - let mimeType: String - let expected: String - var testDescription: String { "preferred=\(preferred ?? "nil"), mime=\(mimeType) → \(expected)" } - } - - @Test(arguments: [ - FileNameCase(preferred: nil, fallback: "[fallback]", mimeType: "text/plain", expected: "[fallback].txt"), - FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/plain", expected: "name.txt"), - FileNameCase(preferred: "name.jpeg", fallback: "[fallback]", mimeType: "text/plain", expected: "name.jpeg"), - FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "image/jpeg", expected: "name.jpeg"), - FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/invalid", expected: "name.bin"), - ]) - func resolveFileName(_ c: FileNameCase) { - #expect( - ByteStreamReader.resolveFileName( - preferredName: c.preferred, - fallbackName: c.fallback, - mimeType: c.mimeType, - ) == c.expected, - ) - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift deleted file mode 100644 index 0bcaa5f9e..000000000 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ /dev/null @@ -1,568 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// swiftlint:disable file_length - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -struct IncomingStreamManagerTests: @unchecked Sendable { - private var manager: IncomingStreamManager - - private let topicName = "someTopic" - private let participant = Participant.Identity(from: "someName") - - init() { - manager = IncomingStreamManager() - } - - @Test func registerByteHandler() async throws { - try await manager.registerByteStreamHandler(for: topicName) { _, _ in } - - await confirmation("Throws on duplicate registration") { confirm in - do { - try await manager.registerByteStreamHandler(for: topicName) { _, _ in } - } catch { - #expect(error as? StreamError == .handlerAlreadyRegistered) - confirm() - } - } - - await manager.unregisterByteStreamHandler(for: topicName) - } - - @Test func registerTextHandler() async throws { - try await manager.registerTextStreamHandler(for: topicName) { _, _ in } - - await confirmation("Throws on duplicate registration") { confirm in - do { - try await manager.registerTextStreamHandler(for: topicName) { _, _ in } - } catch { - #expect(error as? StreamError == .handlerAlreadyRegistered) - confirm() - } - } - - await manager.unregisterTextStreamHandler(for: topicName) - } - - @Test func byteStream() async throws { - try await confirmation("Receives payload") { confirm in - let testChunks = [ - Data(repeating: 0xAB, count: 128), - Data(repeating: 0xCD, count: 128), - Data(repeating: 0xEF, count: 256), - Data(repeating: 0x12, count: 32), - ] - let testPayload = testChunks.reduce(Data()) { $0 + $1 } - - try await manager.registerByteStreamHandler(for: topicName) { reader, participant in - #expect(participant == self.participant) - let payload = try await reader.readAll() - #expect(payload == testPayload) - confirm() - } - - await sendByteStream(chunks: testChunks) - } - } - - @Test func textStream() async throws { - try await confirmation("Receives payload") { confirm in - let testChunks = [ - String(repeating: "A", count: 128), - String(repeating: "B", count: 128), - String(repeating: "C", count: 256), - String(repeating: "D", count: 32), - ] - let testPayload = testChunks.reduce("") { $0 + $1 } - - try await manager.registerTextStreamHandler(for: topicName) { reader, participant in - #expect(participant == self.participant) - let payload = try await reader.readAll() - #expect(payload == testPayload) - confirm() - } - - await sendTextStream(chunks: testChunks) - } - } - - @Test func nonTextData() async throws { - try await confirmation("Throws error on non-text data") { confirm in - let testPayload = Data(repeating: 0xAB, count: 128) - - try await manager.registerTextStreamHandler(for: topicName) { reader, _ in - do { - _ = try await reader.readAll() - } catch { - #expect(error as? StreamError == .decodeFailed) - confirm() - } - } - - await sendTextStream(rawPayload: testPayload, totalLength: UInt64(testPayload.count)) - } - } - - @Test func abnormalClosure() async throws { - try await confirmation("Throws error on abnormal closure") { confirm in - let closureReason = "test" - - try await manager.registerByteStreamHandler(for: topicName) { reader, _ in - do { - _ = try await reader.readAll() - } catch { - #expect(error as? StreamError == .abnormalEnd(reason: closureReason)) - confirm() - } - } - - let streamID = UUID().uuidString - - let header = Livekit_DataStream.Header.with { header in - header.streamID = streamID - header.topic = topicName - header.contentHeader = .byteHeader(Livekit_DataStream.ByteHeader()) - } - manager.handle(.header(header, participant.stringValue, .none)) - - let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID; $0.reason = closureReason } - manager.handle(.trailer(trailer, .none)) - - // Handler processes asynchronously — give it time to complete - await withCheckedContinuation { (c: CheckedContinuation) in - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - c.resume() - } - } - } - } - - @Test func incomplete() async throws { - try await confirmation("Throws error on incomplete stream") { confirm in - let testPayload = Data(repeating: 0xAB, count: 128) - - try await manager.registerByteStreamHandler(for: topicName) { reader, _ in - do { - _ = try await reader.readAll() - } catch { - #expect(error as? StreamError == .incomplete) - confirm() - } - } - - let streamID = UUID().uuidString - - let header = Livekit_DataStream.Header.with { header in - header.streamID = streamID - header.topic = topicName - header.contentHeader = .byteHeader(Livekit_DataStream.ByteHeader()) - header.totalLength = UInt64(testPayload.count + 10) // expect more bytes - } - manager.handle(.header(header, participant.stringValue, .none)) - - let chunk = Livekit_DataStream.Chunk.with { chunk in - chunk.streamID = streamID - chunk.chunkIndex = 0 - chunk.content = Data(testPayload) - } - manager.handle(.chunk(chunk, .none)) - - let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID; $0.reason = "" } - manager.handle(.trailer(trailer, .none)) - - await withCheckedContinuation { (c: CheckedContinuation) in - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - c.resume() - } - } - } - } - - @Test func encryptionTypeMismatch() async throws { - let manager = IncomingStreamManager() - let topic = "test-encryption-mismatch" - - try await confirmation("Stream should receive error") { confirm in - try await manager.registerByteStreamHandler(for: topic) { reader, _ in - do { - _ = try await reader.readAll() - } catch let error as StreamError { - if case let .encryptionTypeMismatch(expected, received) = error { - #expect(expected == .gcm) - #expect(received == .none) - confirm() - } else { - Issue.record("Expected encryptionTypeMismatch error, got \(error)") - } - } - } - - let header = Livekit_DataStream.Header.with { header in - header.streamID = "test-stream-id" - header.topic = topic - header.mimeType = "application/octet-stream" - header.timestamp = Int64(Date().timeIntervalSince1970 * 1000) - header.contentHeader = .byteHeader(.with { $0.name = "test-file.bin" }) - } - manager.handle(.header(header, "test-participant", .gcm)) - - let chunk = Livekit_DataStream.Chunk.with { chunk in - chunk.streamID = "test-stream-id" - chunk.chunkIndex = 0 - chunk.content = Data("test data".utf8) - } - manager.handle(.chunk(chunk, .none)) - - await withCheckedContinuation { (c: CheckedContinuation) in - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - c.resume() - } - } - } - } - - // MARK: - Helpers - - private func sendByteStream(chunks: [Data]) async { - let streamID = UUID().uuidString - - let header = Livekit_DataStream.Header.with { header in - header.streamID = streamID - header.topic = topicName - header.contentHeader = .byteHeader(Livekit_DataStream.ByteHeader()) - } - manager.handle(.header(header, participant.stringValue, .none)) - - for (index, chunkData) in chunks.enumerated() { - let chunk = Livekit_DataStream.Chunk.with { chunk in - chunk.streamID = streamID - chunk.chunkIndex = UInt64(index) - chunk.content = chunkData - } - manager.handle(.chunk(chunk, .none)) - } - - let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID; $0.reason = "" } - manager.handle(.trailer(trailer, .none)) - - // Handler processes asynchronously — give it time to complete - await withCheckedContinuation { (c: CheckedContinuation) in - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - c.resume() - } - } - } - - private func sendTextStream(chunks: [String]? = nil, rawPayload: Data? = nil, totalLength: UInt64? = nil, streamID: String = UUID().uuidString, settle: Bool = true) async { - let header = Livekit_DataStream.Header.with { header in - header.streamID = streamID - header.topic = topicName - header.contentHeader = .textHeader(Livekit_DataStream.TextHeader()) - if let totalLength { header.totalLength = totalLength } - } - manager.handle(.header(header, participant.stringValue, .none)) - - if let chunks { - for (index, chunkData) in chunks.enumerated() { - let chunk = Livekit_DataStream.Chunk.with { chunk in - chunk.streamID = streamID - chunk.chunkIndex = UInt64(index) - chunk.content = Data(chunkData.utf8) - } - manager.handle(.chunk(chunk, .none)) - } - } else if let rawPayload { - let chunk = Livekit_DataStream.Chunk.with { chunk in - chunk.streamID = streamID - chunk.chunkIndex = 0 - chunk.content = rawPayload - } - manager.handle(.chunk(chunk, .none)) - } - - let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID; $0.reason = "" } - manager.handle(.trailer(trailer, .none)) - - guard settle else { return } - // Handler processes asynchronously — give it time to complete - await withCheckedContinuation { (c: CheckedContinuation) in - Task { - try? await Task.sleep(nanoseconds: 100_000_000) - c.resume() - } - } - } -} - -/// One-shot latch: `wait()` suspends until `open()`; waiters after `open()` pass through. -private actor TestGate { - private var isOpen = false - private var waiters: [CheckedContinuation] = [] - - func wait() async { - guard !isOpen else { return } - await withCheckedContinuation { waiters.append($0) } - } - - func open() { - isOpen = true - let continuations = waiters - waiters = [] - for continuation in continuations { - continuation.resume() - } - } -} - -extension IncomingStreamManagerTests { - /// `handle(_:)` only enqueues onto the manager's event loop, so tests that - /// call cleanup APIs directly must first wait for the events to be processed. - private func waitForOpenStreams(_ count: Int) async { - let deadline = Date().addingTimeInterval(10) - while await manager.openStreamCount < count, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - } - - private func sendTextHeader(streamID: String) async { - let header = Livekit_DataStream.Header.with { header in - header.streamID = streamID - header.topic = topicName - header.contentHeader = .textHeader(Livekit_DataStream.TextHeader()) - } - manager.handle(.header(header, participant.stringValue, .none)) - } - - private func sendTextChunk(streamID: String, content: String) async { - let chunk = Livekit_DataStream.Chunk.with { $0.streamID = streamID; $0.content = Data(content.utf8) } - manager.handle(.chunk(chunk, .none)) - } - - private func sendTextTrailer(streamID: String) async { - let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID } - manager.handle(.trailer(trailer, .none)) - } - - /// Senders may reuse one stream ID for consecutive streams (each `sendText` - /// in a transcription segment does). Descriptor cleanup used to run in the - /// reader's `onTermination` task, which raced the reopening header and made - /// `openStream` silently drop the new stream. - @Test func reusedStreamIDDeliversEveryStream() async throws { - let payloads = ["one", "two", "three"] - let received = StateSync<[String]>([]) - - try await manager.registerTextStreamHandler(for: topicName) { reader, _ in - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } - - // Back-to-back, no settling between streams: the reopening header must - // hit the event loop while the previous stream's cleanup could still be - // pending. - let streamID = UUID().uuidString - for payload in payloads { - await sendTextStream(chunks: [payload], streamID: streamID, settle: false) - } - - let deadline = Date().addingTimeInterval(10) - while received.copy().count < payloads.count, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy().sorted() == payloads.sorted()) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// A stream failing mid-flight (here: exceeding its declared length) must not - /// block a new stream that immediately reuses the same stream ID. - @Test func reusedStreamIDAfterChunkErrorDeliversNextStream() async throws { - let received = StateSync<[String]>([]) - - try await manager.registerTextStreamHandler(for: topicName) { reader, _ in - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } - - let streamID = UUID().uuidString - // 8-byte chunk against a declared total of 4 → lengthExceeded. - await sendTextStream(rawPayload: Data("ABCDEFGH".utf8), totalLength: 4, streamID: streamID, settle: false) - await sendTextStream(chunks: ["ok"], streamID: streamID, settle: false) - - let deadline = Date().addingTimeInterval(10) - while received.copy().isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy() == ["ok"]) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// Ordering must compose transitively: C waits on B even while B is itself - /// still waiting on A. If the chain breaks, B and C complete while A's - /// handler is gated and the order comes out wrong. - @Test func orderedTopicChainsAcrossFinishingHandlers() async throws { - let received = StateSync<[String]>([]) - let gate = TestGate() - - try await manager.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - let payload = try await reader.readAll() - // First handler stalls after its stream closed, becoming a - // still-finishing predecessor for the streams sent after it. - if payload == "a" { await gate.wait() } - received.mutate { $0.append(payload) } - } - - for payload in ["a", "b", "c"] { - await sendTextStream(chunks: [payload], settle: false) - } - await gate.open() - - let deadline = Date().addingTimeInterval(10) - while received.copy().count < 3, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy() == ["a", "b", "c"]) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// A still-open stream must not delay streams that overlap with it on the - /// wire (e.g. a user's live transcript arriving while an agent's message - /// stream is still open). Ordering applies only to non-overlapping streams. - @Test func orderedTopicDoesNotDelayOverlappingStreams() async throws { - let received = StateSync<[String]>([]) - - try await manager.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } - - // Stream A opens and stays open; stream B opens, delivers, and closes - // while A is still open — B's handler must complete without waiting. - await sendTextHeader(streamID: "open-a") - await sendTextChunk(streamID: "open-a", content: "a") - await sendTextStream(chunks: ["b"], streamID: "b", settle: false) - - var deadline = Date().addingTimeInterval(10) - while received.copy().isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - #expect(received.copy() == ["b"]) - - // A still completes normally once its trailer arrives. - await sendTextTrailer(streamID: "open-a") - deadline = Date().addingTimeInterval(10) - while received.copy().count < 2, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - #expect(received.copy() == ["b", "a"]) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// A sender disconnecting before its trailer leaves the stream open forever; - /// `closeStreams(from:)` must fail it so an ordered topic's queue drains. - @Test func closeStreamsUnblocksOrderedTopic() async throws { - let received = StateSync<[String]>([]) - let errors = StateSync<[StreamError]>([]) - - try await manager.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - do { - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } catch let error as StreamError { - errors.mutate { $0.append(error) } - throw error - } - } - - // Header only — no trailer ever arrives, so the handler blocks in readAll - // and, at the head of the ordered queue, would block every later stream. - await sendTextHeader(streamID: "orphan") - await waitForOpenStreams(1) - - await manager.closeStreams(from: participant) - await sendTextStream(chunks: ["after"], settle: false) - - let deadline = Date().addingTimeInterval(10) - while received.copy().isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy() == ["after"]) - #expect(errors.copy() == [.terminated]) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// Same shape as above via the room-lifecycle path: `reset()` fails all open - /// streams but keeps handlers registered for after a reconnect. - @Test func resetUnblocksOrderedTopicAndKeepsHandler() async throws { - let received = StateSync<[String]>([]) - - try await manager.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } - - await sendTextHeader(streamID: "orphan") - await waitForOpenStreams(1) - - await manager.reset() - await sendTextStream(chunks: ["after-reset"], settle: false) - - let deadline = Date().addingTimeInterval(10) - while received.copy().isEmpty, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy() == ["after-reset"]) - await manager.unregisterTextStreamHandler(for: topicName) - } - - /// Handlers for an `ordered` topic must observe streams in wire order, not - /// the scheduling order of independently spawned handler tasks. - @Test func orderedTopicDeliversStreamsInOrder() async throws { - let count = 16 - let received = StateSync<[String]>([]) - - try await manager.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - let payload = try await reader.readAll() - received.mutate { $0.append(payload) } - } - - for index in 0 ..< count { - await sendTextStream(chunks: ["payload-\(index)"], settle: false) - } - - let deadline = Date().addingTimeInterval(10) - while received.copy().count < count, Date() < deadline { - try? await Task.sleep(nanoseconds: 10_000_000) - } - - #expect(received.copy() == (0 ..< count).map { "payload-\($0)" }) - await manager.unregisterTextStreamHandler(for: topicName) - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift deleted file mode 100644 index 9d7a9e99f..000000000 --- a/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift +++ /dev/null @@ -1,176 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -struct OutgoingStreamManagerTests { - @Test func streamBytes() async throws { - let testChunks = [ - Data(repeating: 0xAB, count: 128), - Data(repeating: 0xCD, count: 128), - Data(repeating: 0xEF, count: 256), - Data(repeating: 0x12, count: 32), - ] - let streamID = UUID().uuidString - let topic = "some-topic" - - let counter = ConcurrentCounter() - - try await confirmation("Produces header packet") { headerConfirm in - try await confirmation("Produces chunk packets") { chunkConfirm in - try await confirmation("Produces trailer packet") { trailerConfirm in - let manager = OutgoingStreamManager { packet in - // Simulate data channel send - try await Task.sleep(nanoseconds: 10_000_000) - - switch packet.value { - case let .streamHeader(header): - #expect(header.streamID == streamID) - #expect(header.topic == topic) - #expect(header.mimeType == "application/octet-stream") - - headerConfirm() - - case let .streamChunk(chunk): - let currentChunk = await counter.increment() - #expect(chunk.streamID == streamID) - #expect(chunk.chunkIndex == UInt64(currentChunk)) - #expect(chunk.content == testChunks[currentChunk]) - - if await counter.getCount() == testChunks.count { - chunkConfirm() - } - - case let .streamTrailer(trailer): - #expect(trailer.streamID == streamID) - #expect(trailer.reason == "") - - trailerConfirm() - - default: Issue.record("Produced unexpected packet type") - } - } encryptionProvider: { - .none - } - - let writer = try await manager.streamBytes( - options: StreamByteOptions(topic: topic, id: streamID), - ) - - for chunk in testChunks { - try await writer.write(chunk) - } - try await writer.close() - } - } - } - } - - @Test func streamText() async throws { - let testChunks = [ - String(repeating: "A", count: 128), - String(repeating: "B", count: 128), - String(repeating: "C", count: 256), - String(repeating: "D", count: 32), - ] - let streamID = UUID().uuidString - let topic = "some-topic" - - let counter = ConcurrentCounter() - - try await confirmation("Produces header packet") { headerConfirm in - try await confirmation("Produces chunk packets") { chunkConfirm in - try await confirmation("Produces trailer packet") { trailerConfirm in - let manager = OutgoingStreamManager { packet in - // Simulate data channel send - try await Task.sleep(nanoseconds: 10_000_000) - - switch packet.value { - case let .streamHeader(header): - #expect(header.streamID == streamID) - #expect(header.topic == topic) - #expect(header.mimeType == "text/plain") - - headerConfirm() - - case let .streamChunk(chunk): - let currentChunk = await counter.increment() - #expect(chunk.streamID == streamID) - #expect(chunk.chunkIndex == UInt64(currentChunk)) - #expect(chunk.content == Data(testChunks[currentChunk].utf8)) - - if await counter.getCount() == testChunks.count { - chunkConfirm() - } - - case let .streamTrailer(trailer): - #expect(trailer.streamID == streamID) - #expect(trailer.reason == "") - - trailerConfirm() - - default: Issue.record("Produced unexpected packet type") - } - } encryptionProvider: { - .none - } - - let writer = try await manager.streamText( - options: StreamTextOptions(topic: topic, id: streamID), - ) - - for chunk in testChunks { - try await writer.write(chunk) - } - try await writer.close() - } - } - } - } - - @Test func errorPropagation() async throws { - let testError = LiveKitError(.cancelled, message: "Test error") - - try await confirmation("Error propagates to caller") { confirm in - let manager = OutgoingStreamManager { packet in - switch packet.value { - case .streamChunk: - // Wait until first chunk to produce error - throw testError - default: break - } - } encryptionProvider: { - .none - } - - let writer = try await manager.streamText( - options: StreamTextOptions(topic: "some-topic"), - ) - do { - try await writer.write("Hello, world!") - } catch { - #expect(error as? LiveKitError == testError) - confirm() - } - } - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/StreamDataTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamDataTests.swift deleted file mode 100644 index e6cc857d1..000000000 --- a/Tests/LiveKitCoreTests/DataStream/StreamDataTests.swift +++ /dev/null @@ -1,86 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -struct StreamDataTests { - // MARK: - Data chunking - - @Test func dataChunking() { - let testData = Data([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) - - let chunks = testData.chunks(of: 3) - #expect(chunks.count == 4) - #expect(chunks[0] == Data([1, 2, 3])) - #expect(chunks[1] == Data([4, 5, 6])) - #expect(chunks[2] == Data([7, 8, 9])) - #expect(chunks[3] == Data([10])) - - let fullChunk = testData.chunks(of: 10) - #expect(fullChunk.count == 1) - #expect(fullChunk[0] == testData) - - let largeChunk = testData.chunks(of: 20) - #expect(largeChunk.count == 1) - #expect(largeChunk[0] == testData) - } - - @Test func emptyDataChunking() { - #expect(Data().chunks(of: 5).isEmpty) - } - - @Test func singleByteDataChunking() { - let singleByteData = Data([42]) - let chunks = singleByteData.chunks(of: 1) - #expect(chunks == [singleByteData]) - } - - @Test func dataInvalidChunkSize() { - let testData = Data([1, 2, 3, 4, 5]) - #expect(testData.chunks(of: 0).isEmpty) - #expect(testData.chunks(of: -1).isEmpty) - } - - // MARK: - String chunking - - @Test func stringChunking() { - let testString = "Hello, World!" - let chunks = testString.chunks(of: 4) - .map { [UInt8]($0) } - #expect(chunks == [[72, 101, 108, 108], [111, 44, 32, 87], [111, 114, 108, 100], [33]]) - } - - @Test func emptyStringChunking() { - #expect("".chunks(of: 5).isEmpty) - } - - @Test func singleCharacterStringChunking() { - #expect("X".chunks(of: 5).map { [UInt8]($0) } == [[88]]) - } - - @Test func mixedStringChunking() { - let mixedString = "Hello \u{1F44B}" - let chunks = mixedString.chunks(of: 4) - .map { [UInt8]($0) } - #expect(chunks == [[0x48, 0x65, 0x6C, 0x6C], [0x6F, 0x20], [0xF0, 0x9F, 0x91, 0x8B]]) - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/TextStreamInfoTests.swift b/Tests/LiveKitCoreTests/DataStream/TextStreamInfoTests.swift deleted file mode 100644 index 3e9278304..000000000 --- a/Tests/LiveKitCoreTests/DataStream/TextStreamInfoTests.swift +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -struct TextStreamInfoTests { - @Test func protocolTypeConversion() { - let info = TextStreamInfo( - id: "id", - topic: "topic", - timestamp: Date(timeIntervalSince1970: 100), - totalLength: 128, - attributes: ["key": "value"], - encryptionType: .gcm, - operationType: .reaction, - version: 10, - replyToStreamID: "replyID", - attachedStreamIDs: ["attachedID"], - generated: true, - ) - let header = Livekit_DataStream.Header(info) - #expect(header.streamID == info.id) - #expect(header.topic == info.topic) - #expect(header.timestamp == Int64(info.timestamp.timeIntervalSince1970 * TimeInterval(1000))) - #expect(header.totalLength == UInt64(info.totalLength ?? -1)) - #expect(header.attributes == info.attributes) - #expect(header.encryptionType.rawValue == info.encryptionType.rawValue) - #expect(header.textHeader.operationType.rawValue == info.operationType.rawValue) - #expect(header.textHeader.version == Int32(info.version)) - #expect(header.textHeader.replyToStreamID == info.replyToStreamID) - #expect(header.textHeader.attachedStreamIds == info.attachedStreamIDs) - #expect(header.textHeader.generated == info.generated) - - let newInfo = TextStreamInfo(header, header.textHeader, .gcm) - #expect(newInfo.id == info.id) - #expect(newInfo.topic == info.topic) - #expect(newInfo.timestamp == info.timestamp) - #expect(newInfo.totalLength == info.totalLength) - #expect(newInfo.attributes == info.attributes) - #expect(newInfo.encryptionType == info.encryptionType) - #expect(newInfo.operationType == info.operationType) - #expect(newInfo.version == info.version) - #expect(newInfo.replyToStreamID == info.replyToStreamID) - #expect(newInfo.attachedStreamIDs == info.attachedStreamIDs) - #expect(newInfo.generated == info.generated) - } -} diff --git a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift deleted file mode 100644 index 7874bc67e..000000000 --- a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import Foundation -@testable import LiveKit -import Testing -#if canImport(LiveKitTestSupport) -import LiveKitTestSupport -#endif - -@Suite(.tags(.dataStream)) -final class TextStreamReaderTests: @unchecked Sendable { - private var continuation: StreamReaderSource.Continuation! - private var reader: TextStreamReader! - - private let testInfo = TextStreamInfo( - id: UUID().uuidString, - topic: "someTopic", - timestamp: Date(), - totalLength: nil, - attributes: [:], - encryptionType: .none, - operationType: .create, - version: 1, - replyToStreamID: nil, - attachedStreamIDs: [], - generated: false, - ) - - let testChunks = [ - String(repeating: "A", count: 128), - String(repeating: "B", count: 128), - String(repeating: "C", count: 256), - String(repeating: "D", count: 32), - ] - - /// All chunks combined. - private var testPayload: String { - testChunks.reduce("") { $0 + $1 } - } - - private func sendPayload(closingError: Error? = nil) { - for chunk in testChunks { - continuation.yield(Data(chunk.utf8)) - } - continuation.finish(throwing: closingError) - } - - init() { - let source = StreamReaderSource { - self.continuation = $0 - } - reader = TextStreamReader(info: testInfo, source: source) - } - - @Test func chunkRead() async { - await confirmation("Receive all chunks") { receiveConfirm in - await confirmation("Normal closure") { closureConfirm in - let processingTask = Task { - var chunkIndex = 0 - for try await chunk in reader { - #expect(chunk == testChunks[chunkIndex]) - if chunkIndex == testChunks.count - 1 { - receiveConfirm() - } - chunkIndex += 1 - } - closureConfirm() - } - - sendPayload() - - _ = await processingTask.result - } - } - } - - @Test func chunkReadError() async { - await confirmation("Read throws error") { confirm in - let testError = StreamError.abnormalEnd(reason: "test") - - let processingTask = Task { - do { - for try await _ in reader {} - } catch { - #expect(error as? StreamError == testError) - confirm() - } - } - sendPayload(closingError: testError) - - _ = await processingTask.result - } - } - - @Test func readAll() async { - await confirmation("Read full payload") { confirm in - let processingTask = Task { - let fullPayload = try await reader.readAll() - #expect(fullPayload == testPayload) - confirm() - } - sendPayload() - - _ = await processingTask.result - } - } -} diff --git a/Tests/LiveKitCoreTests/Room/RoomTests.swift b/Tests/LiveKitCoreTests/Room/RoomTests.swift index 239ad5429..1d8f91bad 100644 --- a/Tests/LiveKitCoreTests/Room/RoomTests.swift +++ b/Tests/LiveKitCoreTests/Room/RoomTests.swift @@ -134,8 +134,7 @@ private struct WeakRoomRefs: @unchecked Sendable { weak var subscriber: Transport? weak var publisherDataChannel: DataChannelPair? weak var subscriberDataChannel: DataChannelPair? - weak var incomingStreamManager: IncomingStreamManager? - weak var outgoingStreamManager: OutgoingStreamManager? + weak var dataStreams: DataStreams? weak var e2eeManager: E2EEManager? weak var preConnectBuffer: PreConnectAudioBuffer? weak var rpcClient: RpcClientManager? @@ -161,8 +160,7 @@ private struct WeakRoomRefs: @unchecked Sendable { publisherDataChannel = room.publisherDataChannel subscriberDataChannel = room.subscriberDataChannel - incomingStreamManager = room.incomingStreamManager - outgoingStreamManager = room.outgoingStreamManager + dataStreams = room.dataStreams if let mgr = room.e2eeManager { e2eeManager = mgr } preConnectBuffer = room.preConnectBuffer rpcClient = room.rpcClient @@ -189,8 +187,7 @@ private struct WeakRoomRefs: @unchecked Sendable { #expect(subscriber == nil, "Leaked object: Subscriber Transport") #expect(publisherDataChannel == nil, "Leaked object: Publisher DataChannel") #expect(subscriberDataChannel == nil, "Leaked object: Subscriber DataChannel") - #expect(incomingStreamManager == nil, "Leaked object: IncomingStreamManager") - #expect(outgoingStreamManager == nil, "Leaked object: OutgoingStreamManager") + #expect(dataStreams == nil, "Leaked object: DataStreams") #expect(e2eeManager == nil, "Leaked object: E2EEManager") #expect(preConnectBuffer == nil, "Leaked object: PreConnectBuffer") #expect(rpcClient == nil, "Leaked object: RpcClientManager") diff --git a/Tests/LiveKitCoreTests/RpcTests.swift b/Tests/LiveKitCoreTests/RpcTests.swift index 80eb748ff..ef72373cd 100644 --- a/Tests/LiveKitCoreTests/RpcTests.swift +++ b/Tests/LiveKitCoreTests/RpcTests.swift @@ -170,7 +170,7 @@ struct RpcTests { /// After a caller disconnects and reconnects, v2 RPC must still route correctly. /// Exercises `setupRpc` idempotency: the second `connect()` re-runs `setupRpc`, and - /// `IncomingStreamManager.registerTextStreamHandlerIfNeeded` no-ops when the v2 RPC + /// `DataStreams.registerTextStreamHandlerIfNeeded` no-ops when the v2 RPC /// stream handlers are still registered, so routing stays intact across reconnects. /// The responder is kept connected throughout so its `rpcServer.handlers` remain /// intact too. diff --git a/Tests/LiveKitTestSupport/Room.swift b/Tests/LiveKitTestSupport/Room.swift index b8d2f84aa..7cc66eb3c 100644 --- a/Tests/LiveKitTestSupport/Room.swift +++ b/Tests/LiveKitTestSupport/Room.swift @@ -105,7 +105,7 @@ public final class RoomWatcher: RoomDelegate, Sendable // MARK: - Delegates - public func room(_: Room, participant _: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType _: EncryptionType) { + public func room(_: Room, participant _: RemoteParticipant?, didReceiveData data: Data, forTopic topic: String, encryptionType _: LiveKit.EncryptionType) { // print("didReceiveData: \(data) for topic: \(topic)") Task { do { From b1082b2e895ebcd0e025cf5abe810732379467ff Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 13:46:13 -0400 Subject: [PATCH 03/25] feat: add in tests ported to use livekit-uniffi provided data tracks v2 version --- Package.swift | 1 + Package@swift-6.2.swift | 1 + .../DataStream/ByteStreamReaderTests.swift | 176 +++++++++++++ .../IncomingStreamManagerTests.swift | 241 ++++++++++++++++++ .../OutgoingStreamManagerTests.swift | 171 +++++++++++++ .../DataStream/StreamOptionsTests.swift | 56 ++++ .../DataStream/TextStreamReaderTests.swift | 137 ++++++++++ 7 files changed, 783 insertions(+) create mode 100644 Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift create mode 100644 Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift create mode 100644 Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift create mode 100644 Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift create mode 100644 Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift diff --git a/Package.swift b/Package.swift index 52d76c15b..b153b3f48 100644 --- a/Package.swift +++ b/Package.swift @@ -83,6 +83,7 @@ let package = Package( dependencies: [ "LiveKit", "LiveKitTestSupport", + .product(name: "LiveKitUniFFI", package: "livekit-uniffi-xcframework"), ], ), .testTarget( diff --git a/Package@swift-6.2.swift b/Package@swift-6.2.swift index 3b2e72e11..ecfbce28b 100644 --- a/Package@swift-6.2.swift +++ b/Package@swift-6.2.swift @@ -84,6 +84,7 @@ let package = Package( dependencies: [ "LiveKit", "LiveKitTestSupport", + .product(name: "LiveKitUniFFI", package: "livekit-uniffi-xcframework"), ], ), .testTarget( diff --git a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift new file mode 100644 index 000000000..6e6e41819 --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift @@ -0,0 +1,176 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import LiveKitUniFFI +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// Exercises every ``ByteStreamReader`` interface (iterating, `readAll()`, `writeToFile()`) against +/// a reader minted by the UniFFI incoming manager: a real `IncomingDataStreamManager` is fed data +/// stream packets, the opened reader is captured, and its contents are read back through each API. +@Suite(.tags(.dataStream)) +struct ByteStreamReaderTests { + private let topic = "someTopic" + private let name = "filename.bin" + private let mimeType = "application/octet-stream" + + private let testChunks = [ + Data(repeating: 0xAB, count: 128), + Data(repeating: 0xCD, count: 128), + Data(repeating: 0xEF, count: 256), + Data(repeating: 0x12, count: 32), + ] + + /// All chunks combined. + private var testPayload: Data { + testChunks.reduce(Data()) { $0 + $1 } + } + + @Test func chunkRead() async throws { + let (reader, manager) = await openReader() + // The reader may deliver chunks with different boundaries than they were sent, so validate + // the reassembled payload rather than a one-to-one chunk correspondence. + var received = Data() + for try await chunk in reader { + received += chunk + } + #expect(received == testPayload) + _ = manager + } + + @Test func chunkReadError() async throws { + let (reader, manager) = await openReader(trailerReason: "test") + await #expect(throws: StreamError.abnormalEnd(reason: "test")) { + for try await _ in reader {} + } + _ = manager + } + + @Test func readAll() async throws { + let (reader, manager) = await openReader() + let fullPayload = try await reader.readAll() + #expect(fullPayload == testPayload) + _ = manager + } + + @Test func readToFile() async throws { + let (reader, manager) = await openReader() + let fileURL = try await reader.writeToFile() + #expect(fileURL.lastPathComponent == reader.info.name) + #expect(try Data(contentsOf: fileURL) == testPayload) + _ = manager + } + + @Test func info() async throws { + let (reader, manager) = await openReader() + #expect(reader.info.topic == topic) + #expect(reader.info.name == name) + #expect(reader.info.mimeType == mimeType) + _ = manager + } + + // MARK: - Static filename resolution (no FFI) + + struct FileNameCase: CustomTestStringConvertible { + let preferred: String? + let fallback: String + let mimeType: String + let expected: String + var testDescription: String { "preferred=\(preferred ?? "nil"), mime=\(mimeType) → \(expected)" } + } + + @Test(arguments: [ + FileNameCase(preferred: nil, fallback: "[fallback]", mimeType: "text/plain", expected: "[fallback].txt"), + FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/plain", expected: "name.txt"), + FileNameCase(preferred: "name.jpeg", fallback: "[fallback]", mimeType: "text/plain", expected: "name.jpeg"), + FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "image/jpeg", expected: "name.jpeg"), + FileNameCase(preferred: "name", fallback: "[fallback]", mimeType: "text/invalid", expected: "name.bin"), + ]) + func resolveFileName(_ c: FileNameCase) { + #expect( + LiveKit.ByteStreamReader.resolveFileName( + preferredName: c.preferred, + fallbackName: c.fallback, + mimeType: c.mimeType, + ) == c.expected, + ) + } + + // MARK: - Helpers + + /// Delegate that wraps the FFI reader into the public ``ByteStreamReader`` and hands it back + /// through a continuation. + private final class Capture: IncomingDataStreamManagerDelegate, @unchecked Sendable { + let pending = StateSync?>(nil) + + func onByteStreamOpened(reader: LiveKitUniFFI.ByteStreamReader, identity _: String) { + let info = LiveKit.ByteStreamInfo(reader.info(), encryptionType: .none) + let publicReader = LiveKit.ByteStreamReader(reader, info: info) + let continuation = pending.mutate { current -> CheckedContinuation? in + defer { current = nil } + return current + } + continuation?.resume(returning: publicReader) + } + + func onTextStreamOpened(reader _: LiveKitUniFFI.TextStreamReader, identity _: String) {} + } + + /// Opens a byte stream through the FFI incoming manager and returns the public reader plus the + /// manager, which the caller must keep alive while reading. + private func openReader(trailerReason: String = "") async -> (LiveKit.ByteStreamReader, IncomingDataStreamManager) { + let capture = Capture() + let manager = IncomingDataStreamManager(delegate: capture, maxPayloadByteLength: nil) + let streamID = UUID().uuidString + + let reader = await withCheckedContinuation { (continuation: CheckedContinuation) in + capture.pending.mutate { $0 = continuation } + + var header = Livekit_DataStream.Header() + header.streamID = streamID + header.topic = topic + header.mimeType = mimeType + header.contentHeader = .byteHeader(.with { $0.name = name }) + feed(manager) { $0.streamHeader = header } + + for (index, chunk) in testChunks.enumerated() { + var streamChunk = Livekit_DataStream.Chunk() + streamChunk.streamID = streamID + streamChunk.chunkIndex = UInt64(index) + streamChunk.content = chunk + feed(manager) { $0.streamChunk = streamChunk } + } + + var trailer = Livekit_DataStream.Trailer() + trailer.streamID = streamID + trailer.reason = trailerReason + feed(manager) { $0.streamTrailer = trailer } + } + return (reader, manager) + } + + private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket) -> Void) { + var packet = Livekit_DataPacket() + packet.participantIdentity = "someName" + configure(&packet) + guard let data = try? packet.serializedData() else { return } + manager.handlePacketReceived(packet: data) + } +} diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift new file mode 100644 index 000000000..2701f58c0 --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -0,0 +1,241 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// Exercises the incoming data-stream path end-to-end through the ``DataStreams`` coordinator (which +/// is backed by the UniFFI Rust core), validating that the pre-existing v1 behaviors — handler +/// registration, chunk assembly, and error surfacing — still hold. Packets are fed straight into the +/// coordinator via ``DataStreams/handleIncoming(_:)``, so no network or connected room is needed. +@Suite(.tags(.dataStream)) +struct IncomingStreamManagerTests: @unchecked Sendable { + private let room: Room + private let coordinator: DataStreams + + private let topicName = "someTopic" + private let participant = Participant.Identity(from: "someName") + + init() { + room = Room() + coordinator = DataStreams(room: room) + } + + @Test func registerByteHandler() throws { + try coordinator.registerByteStreamHandler(for: topicName) { _, _ in } + + #expect(throws: StreamError.handlerAlreadyRegistered) { + try coordinator.registerByteStreamHandler(for: topicName) { _, _ in } + } + + coordinator.unregisterByteStreamHandler(for: topicName) + // Re-registration succeeds once unregistered. + try coordinator.registerByteStreamHandler(for: topicName) { _, _ in } + } + + @Test func registerTextHandler() throws { + try coordinator.registerTextStreamHandler(for: topicName) { _, _ in } + + #expect(throws: StreamError.handlerAlreadyRegistered) { + try coordinator.registerTextStreamHandler(for: topicName) { _, _ in } + } + + coordinator.unregisterTextStreamHandler(for: topicName) + try coordinator.registerTextStreamHandler(for: topicName) { _, _ in } + } + + @Test func byteStream() async throws { + let testChunks = [ + Data(repeating: 0xAB, count: 128), + Data(repeating: 0xCD, count: 128), + Data(repeating: 0xEF, count: 256), + Data(repeating: 0x12, count: 32), + ] + let testPayload = testChunks.reduce(Data()) { $0 + $1 } + + let payload: Data = try await withCheckedThrowingContinuation { continuation in + do { + try coordinator.registerByteStreamHandler(for: topicName) { reader, participant in + #expect(participant == self.participant) + do { continuation.resume(returning: try await reader.readAll()) } + catch { continuation.resume(throwing: error) } + } + } catch { + continuation.resume(throwing: error) + return + } + Task { await self.feedByteStream(chunks: testChunks) } + } + + #expect(payload == testPayload) + } + + @Test func textStream() async throws { + let testChunks = [ + String(repeating: "A", count: 128), + String(repeating: "B", count: 128), + String(repeating: "C", count: 256), + String(repeating: "D", count: 32), + ] + let testPayload = testChunks.reduce("") { $0 + $1 } + + let payload: String = try await withCheckedThrowingContinuation { continuation in + do { + try coordinator.registerTextStreamHandler(for: topicName) { reader, participant in + #expect(participant == self.participant) + do { continuation.resume(returning: try await reader.readAll()) } + catch { continuation.resume(throwing: error) } + } + } catch { + continuation.resume(throwing: error) + return + } + Task { await self.feedTextStream(chunks: testChunks) } + } + + #expect(payload == testPayload) + } + + @Test func nonTextData() async throws { + // A text stream carrying non-UTF-8 bytes surfaces `.decodeFailed` when read. + let rawPayload = Data(repeating: 0xAB, count: 128) + + await #expect(throws: StreamError.decodeFailed) { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try coordinator.registerTextStreamHandler(for: topicName) { reader, _ in + do { continuation.resume(returning: try await reader.readAll()) } + catch { continuation.resume(throwing: error) } + } + } catch { + continuation.resume(throwing: error) + return + } + Task { await self.feedTextStream(rawPayload: rawPayload) } + } + } + } + + @Test func abnormalClosure() async { + let closureReason = "test" + + let error = await byteReaderError { streamID in + self.feedHeader(streamID: streamID, byte: true) + self.feedTrailer(streamID: streamID, reason: closureReason) + } + + #expect(error as? StreamError == .abnormalEnd(reason: closureReason)) + } + + @Test func incomplete() async { + let testPayload = Data(repeating: 0xAB, count: 128) + + let error = await byteReaderError { streamID in + self.feedHeader(streamID: streamID, byte: true, totalLength: UInt64(testPayload.count + 10)) + self.feedChunk(streamID: streamID, index: 0, content: testPayload) + self.feedTrailer(streamID: streamID, reason: "") + } + + #expect(error as? StreamError == .incomplete) + } + + // Note: the v1 `encryptionTypeMismatch` behavior is intentionally not ported. Data-stream + // encryption is now applied transparently at the `DataChannelPair` layer and the UniFFI boundary + // normalizes the per-packet encryption type, so a header/chunk mismatch can no longer occur here. + + // MARK: - Helpers + + /// Registers a byte handler, feeds a stream via `feed(streamID:)`, and returns the error the + /// reader's `readAll()` throws (or `nil` on success). The continuation makes the wait + /// deterministic without relying on sleeps. + private func byteReaderError(feeding feed: @escaping @Sendable (String) -> Void) async -> Error? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + do { + try coordinator.registerByteStreamHandler(for: topicName) { reader, _ in + do { + _ = try await reader.readAll() + continuation.resume(returning: nil) + } catch { + continuation.resume(returning: error) + } + } + } catch { + continuation.resume(returning: error) + return + } + Task { feed(UUID().uuidString) } + } + } + + private func feedByteStream(chunks: [Data]) async { + let streamID = UUID().uuidString + feedHeader(streamID: streamID, byte: true) + for (index, chunk) in chunks.enumerated() { + feedChunk(streamID: streamID, index: UInt64(index), content: chunk) + } + feedTrailer(streamID: streamID, reason: "") + } + + private func feedTextStream(chunks: [String]? = nil, rawPayload: Data? = nil) async { + let streamID = UUID().uuidString + feedHeader(streamID: streamID, byte: false) + if let chunks { + for (index, chunk) in chunks.enumerated() { + feedChunk(streamID: streamID, index: UInt64(index), content: Data(chunk.utf8)) + } + } else if let rawPayload { + feedChunk(streamID: streamID, index: 0, content: rawPayload) + } + feedTrailer(streamID: streamID, reason: "") + } + + private func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil) { + var header = Livekit_DataStream.Header() + header.streamID = streamID + header.topic = topicName + header.contentHeader = byte + ? .byteHeader(Livekit_DataStream.ByteHeader()) + : .textHeader(Livekit_DataStream.TextHeader()) + if let totalLength { header.totalLength = totalLength } + feed { $0.streamHeader = header } + } + + private func feedChunk(streamID: String, index: UInt64, content: Data) { + var chunk = Livekit_DataStream.Chunk() + chunk.streamID = streamID + chunk.chunkIndex = index + chunk.content = content + feed { $0.streamChunk = chunk } + } + + private func feedTrailer(streamID: String, reason: String) { + var trailer = Livekit_DataStream.Trailer() + trailer.streamID = streamID + trailer.reason = reason + feed { $0.streamTrailer = trailer } + } + + private func feed(_ configure: (inout Livekit_DataPacket) -> Void) { + var packet = Livekit_DataPacket() + packet.participantIdentity = participant.stringValue + configure(&packet) + coordinator.handleIncoming(packet) + } +} diff --git a/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift new file mode 100644 index 000000000..faeccd21d --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift @@ -0,0 +1,171 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import LiveKitUniFFI +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// Exercises the outgoing data-stream path against the UniFFI `OutgoingDataStreamManager` directly, +/// with a capturing delegate standing in for the data channel (the same seam the old pure-Swift +/// `OutgoingStreamManager` offered via its `packetHandler`). Validates that the pre-existing v1 +/// wire behavior — a header, ordered chunks carrying the full payload, and an empty-reason trailer — +/// is produced by the new Rust core. No network or connected room is needed. +@Suite(.tags(.dataStream)) +struct OutgoingStreamManagerTests { + /// Captures the encoded `DataPacket`s the manager emits. + private final class CapturingDelegate: OutgoingDataStreamManagerDelegate, @unchecked Sendable { + let packets = StateSync<[Livekit_DataPacket]>([]) + + func onPacketsAvailable(packets emitted: [Data]) { + for data in emitted { + guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { continue } + packets.mutate { $0.append(packet) } + } + } + } + + /// Minimal registry — broadcast with no known remote capabilities. + private final class StubRegistry: RemoteParticipantRegistryDelegate, @unchecked Sendable { + func remoteClientProtocol(identity _: String) -> Int32 { 0 } + func remoteCapabilities(identity _: String) -> [LiveKitUniFFI.ClientCapability] { [] } + func remoteIdentities() -> [String] { [] } + } + + @Test func streamBytes() async throws { + let testChunks = [ + Data(repeating: 0xAB, count: 128), + Data(repeating: 0xCD, count: 128), + Data(repeating: 0xEF, count: 256), + Data(repeating: 0x12, count: 32), + ] + let streamID = UUID().uuidString + let topic = "some-topic" + + let delegate = CapturingDelegate() + let manager = OutgoingDataStreamManager(delegate: delegate, registry: StubRegistry()) + + let writer = try await manager.streamBytes( + options: LiveKitUniFFI.StreamByteOptions(topic: topic, attributes: [:], id: streamID), + ) + for chunk in testChunks { + try await writer.write(data: chunk) + } + try await writer.close() + try await settle() + + assertStream( + delegate.packets.copy(), + streamID: streamID, + topic: topic, + mimeType: "application/octet-stream", + expectedPayload: testChunks.reduce(Data()) { $0 + $1 }, + ) + } + + @Test func streamText() async throws { + let testChunks = [ + String(repeating: "A", count: 128), + String(repeating: "B", count: 128), + String(repeating: "C", count: 256), + String(repeating: "D", count: 32), + ] + let streamID = UUID().uuidString + let topic = "some-topic" + + let delegate = CapturingDelegate() + let manager = OutgoingDataStreamManager(delegate: delegate, registry: StubRegistry()) + + let writer = try await manager.streamText( + options: LiveKitUniFFI.StreamTextOptions(topic: topic, attributes: [:], id: streamID), + ) + for chunk in testChunks { + try await writer.write(text: chunk) + } + try await writer.close() + try await settle() + + assertStream( + delegate.packets.copy(), + streamID: streamID, + topic: topic, + mimeType: "text/plain", + expectedPayload: Data(testChunks.reduce("") { $0 + $1 }.utf8), + ) + } + + @Test func compressMapsToFFIOptions() { + #expect(LiveKit.StreamTextOptions(topic: "t", compress: true).ffi.compress == true) + #expect(LiveKit.StreamTextOptions(topic: "t", compress: false).ffi.compress == false) + #expect(LiveKit.StreamTextOptions(topic: "t").ffi.compress == nil) + #expect(LiveKit.StreamByteOptions(topic: "t", compress: true).ffi.compress == true) + #expect(LiveKit.StreamByteOptions(topic: "t").ffi.compress == nil) + } + + // Note: the v1 `errorPropagation` behavior (a data-channel send failure surfacing back to the + // caller of `write`) is intentionally not ported. The UniFFI outgoing manager decouples the + // send from transport delivery and does not propagate transport-level send failures. + + // MARK: - Helpers + + private func settle() async throws { + // Allow any final trailer delivery to reach the capturing delegate. + try await Task.sleep(nanoseconds: 100_000_000) + } + + private func assertStream( + _ packets: [Livekit_DataPacket], + streamID: String, + topic: String, + mimeType: String, + expectedPayload: Data, + ) { + let headers = packets.compactMap { packet -> Livekit_DataStream.Header? in + if case let .streamHeader(header) = packet.value { return header } + return nil + } + #expect(headers.count == 1) + if let header = headers.first { + #expect(header.streamID == streamID) + #expect(header.topic == topic) + #expect(header.mimeType == mimeType) + } + + let chunks = packets.compactMap { packet -> Livekit_DataStream.Chunk? in + if case let .streamChunk(chunk) = packet.value { return chunk } + return nil + } + // Robust to any re-chunking: assert the reassembled payload and sequential indices rather + // than a one-write-per-chunk correspondence. + let assembled = chunks.reduce(Data()) { $0 + $1.content } + #expect(assembled == expectedPayload) + for (index, chunk) in chunks.enumerated() { + #expect(chunk.chunkIndex == UInt64(index)) + } + #expect(chunks.allSatisfy { $0.streamID == streamID }) + + let trailers = packets.compactMap { packet -> Livekit_DataStream.Trailer? in + if case let .streamTrailer(trailer) = packet.value { return trailer } + return nil + } + #expect(trailers.count == 1) + #expect(trailers.first?.reason == "") + #expect(trailers.first?.streamID == streamID) + } +} diff --git a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift new file mode 100644 index 000000000..4caeb46be --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift @@ -0,0 +1,56 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import Testing + +/// Covers the public `compress` option added for data streams v2, including the two `StreamTextOptions` +/// initializers (the compress-bearing designated initializer and the compatibility initializer that +/// preserves the pre-`compress` — and Objective-C — call site). +@Suite(.tags(.dataStream)) +struct StreamOptionsTests { + @Test func textCompressDefaultsToNilViaCompatInit() { + #expect(StreamTextOptions(topic: "t").compress == nil) + #expect(StreamTextOptions(topic: "t", version: 2).compress == nil) + } + + @Test func textCompressExplicit() { + #expect(StreamTextOptions(topic: "t", compress: true).compress == true) + #expect(StreamTextOptions(topic: "t", compress: false).compress == false) + #expect(StreamTextOptions(topic: "t", compress: nil).compress == nil) + } + + @Test func byteCompressDefaultsToNil() { + #expect(StreamByteOptions(topic: "t").compress == nil) + } + + @Test func byteCompressExplicit() { + #expect(StreamByteOptions(topic: "t", compress: true).compress == true) + #expect(StreamByteOptions(topic: "t", compress: false).compress == false) + } + + @Test func otherFieldsUnaffected() { + let text = StreamTextOptions(topic: "t", version: 3, compress: true) + #expect(text.topic == "t") + #expect(text.version == 3) + + let byte = StreamByteOptions(topic: "b", mimeType: "image/png", totalSize: 42, compress: false) + #expect(byte.topic == "b") + #expect(byte.mimeType == "image/png") + #expect(byte.totalSize == 42) + } +} diff --git a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift new file mode 100644 index 000000000..7e3c583cb --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift @@ -0,0 +1,137 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import LiveKitUniFFI +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// Exercises every ``TextStreamReader`` interface (iterating, `readAll()`) against a reader minted by +/// the UniFFI incoming manager: a real `IncomingDataStreamManager` is fed data stream packets, the +/// opened reader is captured, and its contents are read back through each API. +@Suite(.tags(.dataStream)) +struct TextStreamReaderTests { + private let topic = "someTopic" + + private let testChunks = [ + String(repeating: "A", count: 128), + String(repeating: "B", count: 128), + String(repeating: "C", count: 256), + String(repeating: "D", count: 32), + ] + + /// All chunks combined. + private var testPayload: String { + testChunks.reduce("") { $0 + $1 } + } + + @Test func chunkRead() async throws { + let (reader, manager) = await openReader() + // The reader may deliver chunks with different boundaries than they were sent, so validate + // the reassembled payload rather than a one-to-one chunk correspondence. + var received = "" + for try await chunk in reader { + received += chunk + } + #expect(received == testPayload) + _ = manager + } + + @Test func chunkReadError() async throws { + let (reader, manager) = await openReader(trailerReason: "test") + await #expect(throws: StreamError.abnormalEnd(reason: "test")) { + for try await _ in reader {} + } + _ = manager + } + + @Test func readAll() async throws { + let (reader, manager) = await openReader() + let fullPayload = try await reader.readAll() + #expect(fullPayload == testPayload) + _ = manager + } + + @Test func info() async throws { + let (reader, manager) = await openReader() + #expect(reader.info.topic == topic) + #expect(reader.info.operationType == .create) + _ = manager + } + + // MARK: - Helpers + + /// Delegate that wraps the FFI reader into the public ``TextStreamReader`` and hands it back + /// through a continuation. + private final class Capture: IncomingDataStreamManagerDelegate, @unchecked Sendable { + let pending = StateSync?>(nil) + + func onByteStreamOpened(reader _: LiveKitUniFFI.ByteStreamReader, identity _: String) {} + + func onTextStreamOpened(reader: LiveKitUniFFI.TextStreamReader, identity _: String) { + let info = LiveKit.TextStreamInfo(reader.info(), encryptionType: .none) + let publicReader = LiveKit.TextStreamReader(reader, info: info) + let continuation = pending.mutate { current -> CheckedContinuation? in + defer { current = nil } + return current + } + continuation?.resume(returning: publicReader) + } + } + + /// Opens a text stream through the FFI incoming manager and returns the public reader plus the + /// manager, which the caller must keep alive while reading. + private func openReader(trailerReason: String = "") async -> (LiveKit.TextStreamReader, IncomingDataStreamManager) { + let capture = Capture() + let manager = IncomingDataStreamManager(delegate: capture, maxPayloadByteLength: nil) + let streamID = UUID().uuidString + + let reader = await withCheckedContinuation { (continuation: CheckedContinuation) in + capture.pending.mutate { $0 = continuation } + + var header = Livekit_DataStream.Header() + header.streamID = streamID + header.topic = topic + header.contentHeader = .textHeader(Livekit_DataStream.TextHeader()) + feed(manager) { $0.streamHeader = header } + + for (index, chunk) in testChunks.enumerated() { + var streamChunk = Livekit_DataStream.Chunk() + streamChunk.streamID = streamID + streamChunk.chunkIndex = UInt64(index) + streamChunk.content = Data(chunk.utf8) + feed(manager) { $0.streamChunk = streamChunk } + } + + var trailer = Livekit_DataStream.Trailer() + trailer.streamID = streamID + trailer.reason = trailerReason + feed(manager) { $0.streamTrailer = trailer } + } + return (reader, manager) + } + + private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket) -> Void) { + var packet = Livekit_DataPacket() + packet.participantIdentity = "someName" + configure(&packet) + guard let data = try? packet.serializedData() else { return } + manager.handlePacketReceived(packet: data) + } +} From 25c717d89a97c4e896265969154cfaab8aab9335 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 4 Aug 2026 11:44:13 -0400 Subject: [PATCH 04/25] fix: advertise client protocol of v2 by default --- Sources/LiveKit/Types/ClientProtocol.swift | 4 ++++ Sources/LiveKit/Types/Options/ConnectOptions.swift | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/LiveKit/Types/ClientProtocol.swift b/Sources/LiveKit/Types/ClientProtocol.swift index e00fe233f..6eff28946 100644 --- a/Sources/LiveKit/Types/ClientProtocol.swift +++ b/Sources/LiveKit/Types/ClientProtocol.swift @@ -28,6 +28,9 @@ public enum ClientProtocol: Int, Sendable { /// Adds RPC v2: request and response payloads transported over data streams, /// lifting the v1 15 KB payload size limit. case v1 = 1 + /// Adds Data streams v2: inline data track payloads and DEFLATE compression via the livekit-uniffi + /// exposed livekit-data-stream rust crate. + case v2 = 2 } // MARK: - Comparable @@ -45,6 +48,7 @@ extension ClientProtocol: CustomStringConvertible { switch rawValue { case 0: "v0" case 1: "v1" + case 2: "v2" default: "unknown" } } diff --git a/Sources/LiveKit/Types/Options/ConnectOptions.swift b/Sources/LiveKit/Types/Options/ConnectOptions.swift index 020b9daa3..1d6092c97 100644 --- a/Sources/LiveKit/Types/Options/ConnectOptions.swift +++ b/Sources/LiveKit/Types/Options/ConnectOptions.swift @@ -76,8 +76,8 @@ public final class ConnectOptions: NSObject, Sendable { /// Client-to-client protocol version advertised to other participants. /// - /// Defaults to ``ClientProtocol/v1``, which enables RPC v2 (data-stream-based payloads - /// with no 15 KB size limit). Generally, it's not recommended to change this. + /// Defaults to ``ClientProtocol/v2``, which enables data streams v2. + /// Generally, it's not recommended to change this. public let clientProtocol: ClientProtocol override public init() { @@ -93,7 +93,7 @@ public final class ConnectOptions: NSObject, Sendable { isDscpEnabled = false enableMicrophone = false protocolVersion = .v16 - clientProtocol = .v1 + clientProtocol = .v2 } public init(autoSubscribe: Bool = true, @@ -108,7 +108,7 @@ public final class ConnectOptions: NSObject, Sendable { isDscpEnabled: Bool = false, enableMicrophone: Bool = false, protocolVersion: ProtocolVersion = .v16, - clientProtocol: ClientProtocol = .v1) + clientProtocol: ClientProtocol = .v2) { self.autoSubscribe = autoSubscribe self.reconnectAttempts = reconnectAttempts From 0ad61e18963fae5e2e219b438ac869dae08660ea Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 4 Aug 2026 11:52:46 -0400 Subject: [PATCH 05/25] fix: wire up Registry.remoteClientProtocol --- Sources/LiveKit/DataStream/DataStreams.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 37bd26333..b4029df38 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -301,7 +301,11 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { self.room = room } - func remoteClientProtocol(identity _: String) -> Int32 { 0 } + func remoteClientProtocol(identity: String) -> Int32 { + guard let room else { return 0 } + let participant = room.remoteParticipants.first(where: { $0.key.stringValue == identity }) + return Int32(participant?.value.clientProtocol.rawValue ?? 0) + } func remoteCapabilities(identity _: String) -> [LiveKitUniFFI.ClientCapability] { [] } From 07939e95446d85247eedec00aee6fe44b72c1a9f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 4 Aug 2026 12:51:32 -0400 Subject: [PATCH 06/25] feat: add new ClientCapability struct and add list under Participant.capabilities --- Sources/LiveKit/Participant/Participant.swift | 9 +++++ Sources/LiveKit/Types/ClientCapability.swift | 35 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 Sources/LiveKit/Types/ClientCapability.swift diff --git a/Sources/LiveKit/Participant/Participant.swift b/Sources/LiveKit/Participant/Participant.swift index 170ccfe8f..1e2ec7327 100644 --- a/Sources/LiveKit/Participant/Participant.swift +++ b/Sources/LiveKit/Participant/Participant.swift @@ -64,6 +64,15 @@ public class Participant: NSObject, @unchecked Sendable, ObservableObject, Logga ClientProtocol(rawValue: Int(info?.clientProtocol ?? 0)) ?? .v0 } + /// The optional feature capabilities advertised by this participant. + /// + /// Mirrored by the server from the participant's `ClientInfo`. The protocol's + /// `CAP_UNUSED` placeholder and any value this SDK does not recognize are omitted, + /// so an empty array means the participant advertised no usable capabilities. + public var capabilities: [ClientCapability] { + (info?.capabilities ?? []).compactMap { ClientCapability(rawValue: $0.rawValue) } + } + public var trackPublications: [Track.Sid: TrackPublication] { _state.trackPublications } public var audioTracks: [TrackPublication] { diff --git a/Sources/LiveKit/Types/ClientCapability.swift b/Sources/LiveKit/Types/ClientCapability.swift new file mode 100644 index 000000000..b59ac8303 --- /dev/null +++ b/Sources/LiveKit/Types/ClientCapability.swift @@ -0,0 +1,35 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// An optional feature a client advertises support for at connect time. +/// +/// Populated automatically by each SDK; not a user-configurable setting. Peers use +/// these flags to decide whether to enable features that require support on both +/// ends. This is distinct from ``ClientProtocol``, which is a single monotonic +/// version number rather than a set of independent feature flags. +/// +/// Raw values match `livekit.ClientInfo.Capability` on the wire. The protocol's +/// `CAP_UNUSED` placeholder has no case here, and unrecognized values are dropped +/// rather than surfaced. +public enum ClientCapability: Int, Sendable, CaseIterable { + /// The client can accept RTP packet trailers passed through by the SFU + /// instead of having them stripped. + case packetTrailer = 1 + /// The client can decode `deflate-raw` compressed payloads. + case compressionDeflateRaw = 2 +} From 1931c8f79715e9c0488ec511f62dc6c192704a11 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 4 Aug 2026 12:52:20 -0400 Subject: [PATCH 07/25] feat: consume capabilities field in data streams remote registry --- Sources/LiveKit/DataStream/DataStreams.swift | 23 ++++++++++++++++---- Sources/LiveKit/Support/Utils.swift | 3 +++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index b4029df38..538e1ad47 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -301,13 +301,17 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { self.room = room } + private func participant(for identity: String) -> RemoteParticipant? { + room?.remoteParticipants.first { $0.key.stringValue == identity }?.value + } + func remoteClientProtocol(identity: String) -> Int32 { - guard let room else { return 0 } - let participant = room.remoteParticipants.first(where: { $0.key.stringValue == identity }) - return Int32(participant?.value.clientProtocol.rawValue ?? 0) + Int32(participant(for: identity)?.clientProtocol.rawValue ?? 0) } - func remoteCapabilities(identity _: String) -> [LiveKitUniFFI.ClientCapability] { [] } + func remoteCapabilities(identity: String) -> [LiveKitUniFFI.ClientCapability] { + participant(for: identity)?.capabilities.map(\.ffiValue) ?? [] + } func remoteIdentities() -> [String] { guard let room else { return [] } @@ -315,3 +319,14 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { } } } + +private extension ClientCapability { + /// Bridges to the FFI enum. Kept here so `ClientCapability` itself stays free of any + /// `LiveKitUniFFI` import, matching how the rest of the public API is layered. + var ffiValue: LiveKitUniFFI.ClientCapability { + switch self { + case .packetTrailer: .packetTrailer + case .compressionDeflateRaw: .compressionDeflateRaw + } + } +} diff --git a/Sources/LiveKit/Support/Utils.swift b/Sources/LiveKit/Support/Utils.swift index eae060bd0..717df1729 100644 --- a/Sources/LiveKit/Support/Utils.swift +++ b/Sources/LiveKit/Support/Utils.swift @@ -279,6 +279,9 @@ class Utils: Loggable { $0.version = LiveKitSDK.version $0.protocol = Int32(connectOptions.protocolVersion.rawValue) $0.clientProtocol = Int32(connectOptions.clientProtocol.rawValue) + // Advertised unconditionally: deflate-raw payloads are decompressed by the + // Rust data stream layer, so support does not vary by platform or options. + $0.capabilities = [.capCompressionDeflateRaw] $0.os = String(describing: os()) $0.osVersion = osVersionString() if let model = modelIdentifier() { $0.deviceModel = model } From 0928935bbcee333dbc698b5e403f5b06efa79d09 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 12:48:41 -0400 Subject: [PATCH 08/25] Update Sources/LiveKit/DataStream/DataStreams.swift Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- Sources/LiveKit/DataStream/DataStreams.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 538e1ad47..678ae9b9e 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -142,7 +142,7 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { id: options.id, mimeType: options.mimeType ?? fileInfo.mimeType, name: options.name ?? fileInfo.name, - totalLength: UInt64(options.totalSize ?? fileInfo.size), + totalLength: UInt64(fileInfo.size), compress: options.compress, senderIdentity: nil, ) From 730e8bc8b5e935ea3850623f7e62d1ef8e72df69 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 12:52:15 -0400 Subject: [PATCH 09/25] fix(data-stream): create the subsystem eagerly to avoid a first-access race `lazy var dataStreams` isn't atomic: two threads racing the first access could each construct a DataStreams (and its FFI managers). Assign it once in `init` after `super.init()` instead, like the eager data-stream managers it replaced. --- Sources/LiveKit/Core/Room.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 0f318a14f..9f9980639 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -133,7 +133,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { // The data stream subsystem (incoming/outgoing UniFFI managers, the topic→handler registry, and // packet routing) behind one reference. Kept for the Room's lifetime — not session-scoped like // ``DataTracks`` — so stream handlers survive reconnects and can be registered before connect. - lazy var dataStreams = DataStreams(room: self) + private(set) var dataStreams: DataStreams! // MARK: - Data Tracks @@ -280,6 +280,9 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { roomOptions: roomOptions ?? RoomOptions())) super.init() + + dataStreams = DataStreams(room: self) + // log sdk & os versions log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") From 4f2b1c6cad06cca12c3720bb0cb35afc9b943a1f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 13:26:38 -0400 Subject: [PATCH 10/25] fix(data-stream): report writer open state from the FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the local isOpen flag on Byte/TextStreamWriter and query the UniFFI writer's is_open() instead, so a writer reflects the stream actually closing — including when a send fails because the room disconnected — rather than only an explicit local close(). --- .../DataStream/Outgoing/ByteStreamWriter.swift | 8 +++----- .../DataStream/Outgoing/TextStreamWriter.swift | 8 +++----- .../DataStream/OutgoingStreamManagerTests.swift | 15 +++++++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift b/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift index ed3755bac..eb4e6e510 100644 --- a/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift +++ b/Sources/LiveKit/DataStream/Outgoing/ByteStreamWriter.swift @@ -25,12 +25,11 @@ public final class ByteStreamWriter: NSObject, Sendable { public let info: ByteStreamInfo private let writer: LiveKitUniFFI.ByteStreamWriter - // The FFI writer exposes no open state, so track local closure here. - private let _isOpen = StateSync(true) - /// Whether or not the stream is still open. + /// Whether or not the stream is still open. Reflects the FFI writer's state, so it becomes + /// `false` once the stream is closed locally or a send fails (e.g. the room disconnected). public var isOpen: Bool { - get async { _isOpen.copy() } + get async { await writer.isOpen() } } /// Write data to the stream. @@ -55,7 +54,6 @@ public final class ByteStreamWriter: NSObject, Sendable { /// cannot be communicated to remote participants. /// public func close(reason: String? = nil) async throws { - _isOpen.mutate { $0 = false } do { if let reason { try await writer.closeWithReason(reason: reason) diff --git a/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift b/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift index d238d93a4..bab4c0d65 100644 --- a/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift +++ b/Sources/LiveKit/DataStream/Outgoing/TextStreamWriter.swift @@ -25,12 +25,11 @@ public final class TextStreamWriter: NSObject, Sendable { public let info: TextStreamInfo private let writer: LiveKitUniFFI.TextStreamWriter - // The FFI writer exposes no open state, so track local closure here. - private let _isOpen = StateSync(true) - /// Whether or not the stream is still open. + /// Whether or not the stream is still open. Reflects the FFI writer's state, so it becomes + /// `false` once the stream is closed locally or a send fails (e.g. the room disconnected). public var isOpen: Bool { - get async { _isOpen.copy() } + get async { await writer.isOpen() } } /// Write text to the stream. @@ -55,7 +54,6 @@ public final class TextStreamWriter: NSObject, Sendable { /// cannot be communicated to remote participants. /// public func close(reason: String? = nil) async throws { - _isOpen.mutate { $0 = false } do { if let reason { try await writer.closeWithReason(reason: reason) diff --git a/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift index faeccd21d..674ff93e3 100644 --- a/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/OutgoingStreamManagerTests.swift @@ -118,6 +118,21 @@ struct OutgoingStreamManagerTests { #expect(LiveKit.StreamByteOptions(topic: "t").ffi.compress == nil) } + @Test func writerIsOpenReflectsClose() async throws { + let manager = OutgoingDataStreamManager(delegate: CapturingDelegate(), registry: StubRegistry()) + let ffiWriter = try await manager.streamBytes( + options: LiveKitUniFFI.StreamByteOptions(topic: "some-topic", attributes: [:]), + ) + let writer = LiveKit.ByteStreamWriter(ffiWriter, encryptionType: .none) + + var isOpen = await writer.isOpen + #expect(isOpen == true) + + try await writer.close() + isOpen = await writer.isOpen + #expect(isOpen == false) + } + // Note: the v1 `errorPropagation` behavior (a data-channel send failure surfacing back to the // caller of `write`) is intentionally not ported. The UniFFI outgoing manager decouples the // send from transport delivery and does not propagate transport-level send failures. From a71b3b150194ad408c1e38ed0830e1704d68cc1d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 13:26:38 -0400 Subject: [PATCH 11/25] feat(data-stream): configurable max payload size; per-sender ordered handling - Add RoomOptions.dataStreamOptions.maxPayloadSize, plumbed into the incoming manager so a receiver bounds the reassembled size of an incoming stream instead of accepting it uncapped. - Key ordered-topic handler serialization by sender identity rather than by topic, so a still-open stream from one sender no longer blocks a concurrent stream from another (e.g. an agent transcript arriving while a user transcript on the same topic is still streaming). --- Sources/LiveKit/Core/Room.swift | 2 +- Sources/LiveKit/DataStream/DataStreams.swift | 29 +++++++++++------- .../Types/Options/DataStreamOptions.swift | 30 +++++++++++++++++++ .../LiveKit/Types/Options/RoomOptions.swift | 9 ++++++ .../DataStream/StreamOptionsTests.swift | 7 +++++ 5 files changed, 65 insertions(+), 12 deletions(-) create mode 100644 Sources/LiveKit/Types/Options/DataStreamOptions.swift diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 9f9980639..29b1a3b22 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -281,7 +281,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { super.init() - dataStreams = DataStreams(room: self) + dataStreams = DataStreams(room: self, maxPayloadSize: _state.roomOptions.dataStreamOptions.maxPayloadSize) // log sdk & os versions log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 678ae9b9e..a04d6f66c 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -47,22 +47,28 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { private let textStreamHandlers = StateSync<[String: TextStreamHandler]>([:]) // Topics we've already logged a missing-handler warning for, to avoid log spam. private let failedTopics = StateSync>([]) - // Topics whose text handlers run in wire order. Successive (non-overlapping) streams on such a - // topic have their handlers serialized so they process in arrival order. Used by internal + // Topics whose text handlers run in wire order. Successive streams from the *same sender* on + // such a topic have their handlers serialized so they process in arrival order. Used by internal // consumers like transcription; off by default so concurrent consumers (e.g. RPC) aren't slowed. + // + // Keyed by sender identity within a topic — not by topic alone — so a still-open stream from one + // sender doesn't block a concurrent stream from another (e.g. an agent transcript arriving while + // a user transcript on the same topic is still streaming). A single sender's streams are + // sequential in practice, so per-sender serialization preserves ordering without stalling peers. private let orderedTopics = StateSync>([]) - private let orderedTails = StateSync<[String: Task]>([:]) + private let orderedTails = StateSync<[String: [String: Task]]>([:]) - init(room: Room) { + init(room: Room, maxPayloadSize: Int? = nil) { self.room = room let incomingDelegate = IncomingDelegate() let outgoingDelegate = OutgoingDelegate(room: room) let registry = Registry(room: room) - // No payload cap; topic routing (incl. the `lk.rpc` guard) is handled Swift-side in - // `Room+DataStream`, matching the previous pure-Swift implementation. + // `maxPayloadSize` caps the reassembled size of an incoming stream (nil → the core's default + // cap). Topic routing (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`, + // matching the previous pure-Swift implementation. incoming = LiveKitUniFFI.IncomingDataStreamManager( delegate: incomingDelegate, - maxPayloadByteLength: nil, + maxPayloadByteLength: maxPayloadSize.map { UInt64($0) }, ) outgoing = LiveKitUniFFI.OutgoingDataStreamManager(delegate: outgoingDelegate, registry: registry) super.init() @@ -215,12 +221,13 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { Task.detachedDiscarding { try await handler(reader, participantIdentity) } return } - // Ordered topic: chain this handler after the previous one on the same topic so successive - // streams process in arrival order. + // Ordered topic: chain this handler after the previous one from the *same sender* so that + // sender's successive streams process in arrival order — while streams from other senders on + // the topic run concurrently (a still-open stream from one sender never blocks another's). let topic = info.topic orderedTails.mutate { tails in - let predecessor = tails[topic] - tails[topic] = Task.detached { [weak self] in + let predecessor = tails[topic]?[identity] + tails[topic, default: [:]][identity] = Task.detached { [weak self] in await predecessor?.value do { try await handler(reader, participantIdentity) diff --git a/Sources/LiveKit/Types/Options/DataStreamOptions.swift b/Sources/LiveKit/Types/Options/DataStreamOptions.swift new file mode 100644 index 000000000..66c2b7d11 --- /dev/null +++ b/Sources/LiveKit/Types/Options/DataStreamOptions.swift @@ -0,0 +1,30 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +/// Options controlling how the ``Room`` handles incoming data streams. +public struct DataStreamOptions: Sendable, Equatable, Hashable { + /// Maximum size, in bytes, of an incoming data-stream payload the receiver will accept. + /// + /// A stream whose reassembled payload would exceed this cap fails the read rather than buffering + /// unbounded data. `nil` (the default) uses the SDK's built-in cap of 5gb. + public let maxPayloadSize: Int? + + public init(maxPayloadSize: Int? = nil) { + self.maxPayloadSize = maxPayloadSize + } +} diff --git a/Sources/LiveKit/Types/Options/RoomOptions.swift b/Sources/LiveKit/Types/Options/RoomOptions.swift index a00b7c0f9..a5d621ffd 100644 --- a/Sources/LiveKit/Types/Options/RoomOptions.swift +++ b/Sources/LiveKit/Types/Options/RoomOptions.swift @@ -32,6 +32,10 @@ public final class RoomOptions: NSObject, Sendable, Loggable { public let defaultDataPublishOptions: DataPublishOptions + /// Options controlling how incoming data streams are handled (e.g. the maximum accepted + /// payload size). + public let dataStreamOptions: DataStreamOptions + /// AdaptiveStream lets LiveKit automatically manage quality of subscribed /// video tracks to optimize for bandwidth and CPU. /// When attached video elements are visible, it'll choose an appropriate @@ -72,6 +76,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { defaultVideoPublishOptions = VideoPublishOptions() defaultAudioPublishOptions = AudioPublishOptions() defaultDataPublishOptions = DataPublishOptions() + dataStreamOptions = DataStreamOptions() adaptiveStream = false dynacast = false stopLocalTrackOnUnpublish = true @@ -88,6 +93,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { defaultVideoPublishOptions: VideoPublishOptions = VideoPublishOptions(), defaultAudioPublishOptions: AudioPublishOptions = AudioPublishOptions(), defaultDataPublishOptions: DataPublishOptions = DataPublishOptions(), + dataStreamOptions: DataStreamOptions = DataStreamOptions(), adaptiveStream: Bool = false, dynacast: Bool = false, stopLocalTrackOnUnpublish: Bool = true, @@ -103,6 +109,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { self.defaultVideoPublishOptions = defaultVideoPublishOptions self.defaultAudioPublishOptions = defaultAudioPublishOptions self.defaultDataPublishOptions = defaultDataPublishOptions + self.dataStreamOptions = dataStreamOptions self.adaptiveStream = adaptiveStream self.dynacast = dynacast self.stopLocalTrackOnUnpublish = stopLocalTrackOnUnpublish @@ -129,6 +136,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { defaultVideoPublishOptions == other.defaultVideoPublishOptions && defaultAudioPublishOptions == other.defaultAudioPublishOptions && defaultDataPublishOptions == other.defaultDataPublishOptions && + dataStreamOptions == other.dataStreamOptions && adaptiveStream == other.adaptiveStream && dynacast == other.dynacast && stopLocalTrackOnUnpublish == other.stopLocalTrackOnUnpublish && @@ -147,6 +155,7 @@ public final class RoomOptions: NSObject, Sendable, Loggable { hasher.combine(defaultVideoPublishOptions) hasher.combine(defaultAudioPublishOptions) hasher.combine(defaultDataPublishOptions) + hasher.combine(dataStreamOptions) hasher.combine(adaptiveStream) hasher.combine(dynacast) hasher.combine(stopLocalTrackOnUnpublish) diff --git a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift index 4caeb46be..3741d66c1 100644 --- a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift @@ -53,4 +53,11 @@ struct StreamOptionsTests { #expect(byte.mimeType == "image/png") #expect(byte.totalSize == 42) } + + @Test func dataStreamMaxPayloadSizeOption() { + #expect(DataStreamOptions().maxPayloadSize == nil) + #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSize == 1000) + #expect(RoomOptions().dataStreamOptions.maxPayloadSize == nil) + #expect(RoomOptions(dataStreamOptions: DataStreamOptions(maxPayloadSize: 42)).dataStreamOptions.maxPayloadSize == 42) + } } From 74cc7adf394acc8fe5747e03476ab3bf74e3716a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 14:05:04 -0400 Subject: [PATCH 12/25] fix(data-stream): advertise client capabilities on both connection paths The query-param connect path (buildUrl) sent client_protocol but not capabilities, so peers connected that way (the default, non-single-PC path) never saw CAP_COMPRESSION_DEFLATE_RAW and never compressed. Emit a `capabilities` query param there too (comma-separated enum names, as the server parses), and source both paths from a single advertisedClientCapabilities list. --- Sources/LiveKit/Support/Utils.swift | 29 ++++++++++-- .../ConnectionParamsTests.swift | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 Tests/LiveKitCoreTests/ConnectionParamsTests.swift diff --git a/Sources/LiveKit/Support/Utils.swift b/Sources/LiveKit/Support/Utils.swift index 717df1729..8b0920271 100644 --- a/Sources/LiveKit/Support/Utils.swift +++ b/Sources/LiveKit/Support/Utils.swift @@ -134,6 +134,24 @@ class Utils: Loggable { } } + /// Client capabilities this SDK advertises to peers. Defined once so both connection paths + /// (query-param and join-request) announce the same set — otherwise peers connected via the + /// path that omits a capability never enable the corresponding feature (e.g. compression). + /// + /// Advertised unconditionally: deflate-raw payloads are decompressed by the Rust data stream + /// layer, so support doesn't vary by platform or options. + static let advertisedClientCapabilities: [Livekit_ClientInfo_Capability] = [.capCompressionDeflateRaw] + + /// Wire (protobuf enum) name for a capability. The query-param connection path advertises + /// capabilities as a comma-separated list of these names, which the server maps back to the enum. + private static func capabilityWireName(_ capability: Livekit_ClientInfo_Capability) -> String? { + switch capability { + case .capPacketTrailer: "CAP_PACKET_TRAILER" + case .capCompressionDeflateRaw: "CAP_COMPRESSION_DEFLATE_RAW" + default: nil + } + } + static func buildUrl( _ url: URL, connectOptions: ConnectOptions? = nil, @@ -199,6 +217,13 @@ class Utils: Loggable { queryItems.append(URLQueryItem(name: "auto_subscribe", value: connectOptions.autoSubscribe ? "1" : "0")) queryItems.append(URLQueryItem(name: "adaptive_stream", value: adaptiveStream ? "1" : "0")) + // Advertise client capabilities on this (query-param) path too, matching the join-request + // path — otherwise peers connected this way never learn we support e.g. deflate compression. + let capabilityNames = advertisedClientCapabilities.compactMap(capabilityWireName) + if !capabilityNames.isEmpty { + queryItems.append(URLQueryItem(name: "capabilities", value: capabilityNames.joined(separator: ","))) + } + builder.queryItems = queryItems guard let result = builder.url else { @@ -279,9 +304,7 @@ class Utils: Loggable { $0.version = LiveKitSDK.version $0.protocol = Int32(connectOptions.protocolVersion.rawValue) $0.clientProtocol = Int32(connectOptions.clientProtocol.rawValue) - // Advertised unconditionally: deflate-raw payloads are decompressed by the - // Rust data stream layer, so support does not vary by platform or options. - $0.capabilities = [.capCompressionDeflateRaw] + $0.capabilities = advertisedClientCapabilities $0.os = String(describing: os()) $0.osVersion = osVersionString() if let model = modelIdentifier() { $0.deviceModel = model } diff --git a/Tests/LiveKitCoreTests/ConnectionParamsTests.swift b/Tests/LiveKitCoreTests/ConnectionParamsTests.swift new file mode 100644 index 000000000..78b057db3 --- /dev/null +++ b/Tests/LiveKitCoreTests/ConnectionParamsTests.swift @@ -0,0 +1,44 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import Testing + +/// Client capabilities must be advertised on *both* connection paths (query-param and +/// join-request); otherwise peers connected via the path that omits them never enable the +/// corresponding feature (e.g. deflate compression). +struct ConnectionParamsTests { + private let url = URL(string: "wss://example.livekit.cloud")! + + @Test func queryParamPathAdvertisesCapabilities() throws { + let built = try Utils.buildUrl(url, adaptiveStream: false) + let queryItems = URLComponents(url: built, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let capabilities = queryItems.first { $0.name == "capabilities" }?.value + #expect(capabilities?.contains("CAP_COMPRESSION_DEFLATE_RAW") == true) + } + + @Test func joinRequestPathAdvertisesCapabilities() throws { + let built = try Utils.buildJoinRequestUrl(url, adaptiveStream: false) + let queryItems = URLComponents(url: built, resolvingAgainstBaseURL: false)?.queryItems ?? [] + let encoded = try #require(queryItems.first { $0.name == "join_request" }?.value) + let wrappedData = try #require(Data(base64Encoded: encoded)) + + let wrapped = try Livekit_WrappedJoinRequest(serializedBytes: wrappedData) + let joinRequest = try Livekit_JoinRequest(serializedBytes: wrapped.joinRequest) + #expect(joinRequest.clientInfo.capabilities.contains(.capCompressionDeflateRaw)) + } +} From c2666be1cd8d0fc883db2e23554905070a996ed3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 15:44:04 -0400 Subject: [PATCH 13/25] feat: add object c compatibility for DataStreamOptions --- .../Types/Options/DataStreamOptions.swift | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/Sources/LiveKit/Types/Options/DataStreamOptions.swift b/Sources/LiveKit/Types/Options/DataStreamOptions.swift index 66c2b7d11..315812ef4 100644 --- a/Sources/LiveKit/Types/Options/DataStreamOptions.swift +++ b/Sources/LiveKit/Types/Options/DataStreamOptions.swift @@ -17,14 +17,40 @@ import Foundation /// Options controlling how the ``Room`` handles incoming data streams. -public struct DataStreamOptions: Sendable, Equatable, Hashable { +@objcMembers +public final class DataStreamOptions: NSObject, Sendable { /// Maximum size, in bytes, of an incoming data-stream payload the receiver will accept. /// /// A stream whose reassembled payload would exceed this cap fails the read rather than buffering /// unbounded data. `nil` (the default) uses the SDK's built-in cap of 5gb. public let maxPayloadSize: Int? - public init(maxPayloadSize: Int? = nil) { + public var maxPayloadSizeNumber: NSNumber? { + maxPayloadSize.map { NSNumber(value: $0) } + } + + public init(maxPayloadSize: Int?) { self.maxPayloadSize = maxPayloadSize } + + override public init() { + maxPayloadSize = nil + } + + public convenience init(maxPayloadSizeNumber: NSNumber?) { + self.init(maxPayloadSize: maxPayloadSizeNumber?.intValue) + } + + // MARK: - Equal + + override public func isEqual(_ object: Any?) -> Bool { + guard let other = object as? Self else { return false } + return maxPayloadSize == other.maxPayloadSize + } + + override public var hash: Int { + var hasher = Hasher() + hasher.combine(maxPayloadSize) + return hasher.finalize() + } } From 5fce9c564037159c7937f79adca14c5d38f5d283 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 15:55:49 -0400 Subject: [PATCH 14/25] fix(data-streams): honor connect-time maxPayloadSize The incoming manager was built at Room.init with the payload cap read from the initial room options, so a maxPayloadSize supplied at connect time was ignored. Build the incoming manager lazily on the first inbound packet (post-connect), reading the room's current options then. Guarded by StateSync so it's constructed exactly once. reset()/closeStreams(from:) no-op when no packets have arrived. --- Sources/LiveKit/Core/Room.swift | 2 +- Sources/LiveKit/DataStream/DataStreams.swift | 45 ++++++++----- .../DataStream/StreamOptionsTests.swift | 4 ++ .../DataStreamOptionsObjCTests.m | 67 +++++++++++++++++++ 4 files changed, 102 insertions(+), 16 deletions(-) create mode 100644 Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 29b1a3b22..9f9980639 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -281,7 +281,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable { super.init() - dataStreams = DataStreams(room: self, maxPayloadSize: _state.roomOptions.dataStreamOptions.maxPayloadSize) + dataStreams = DataStreams(room: self) // log sdk & os versions log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))") diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index a04d6f66c..c14ae3ab1 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -34,9 +34,14 @@ internal import LiveKitUniFFI /// delegates are immutable after init. Not an actor — the UniFFI delegate callbacks are synchronous /// and can't `await`. final class DataStreams: NSObject, @unchecked Sendable, Loggable { - private let incoming: LiveKitUniFFI.IncomingDataStreamManager private let outgoing: LiveKitUniFFI.OutgoingDataStreamManager + // Created lazily on the first inbound packet, not at init: the incoming manager's payload cap + // comes from the room's options, which aren't finalized until `connect` — after this coordinator + // is built at `Room.init`. Deferring lets it pick up a `maxPayloadSize` passed at connect time. + // StateSync-guarded so it's constructed exactly once even if packets race in. + private let _incoming = StateSync(nil) + // Held weakly: the Room owns this coordinator, so the back-reference must not retain it. Used // for the room-level encryption type stamped onto stream info, and for logging. private weak var room: Room? @@ -58,22 +63,31 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { private let orderedTopics = StateSync>([]) private let orderedTails = StateSync<[String: [String: Task]]>([:]) - init(room: Room, maxPayloadSize: Int? = nil) { + init(room: Room) { self.room = room - let incomingDelegate = IncomingDelegate() let outgoingDelegate = OutgoingDelegate(room: room) let registry = Registry(room: room) - // `maxPayloadSize` caps the reassembled size of an incoming stream (nil → the core's default - // cap). Topic routing (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`, - // matching the previous pure-Swift implementation. - incoming = LiveKitUniFFI.IncomingDataStreamManager( - delegate: incomingDelegate, - maxPayloadByteLength: maxPayloadSize.map { UInt64($0) }, - ) outgoing = LiveKitUniFFI.OutgoingDataStreamManager(delegate: outgoingDelegate, registry: registry) super.init() - // The FFI manager retains its delegate strongly, so the delegate points back here weakly. - incomingDelegate.coordinator = self + } + + /// The incoming manager, created on first use with the room's current payload cap. Topic routing + /// (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`. + private func incomingManager() -> LiveKitUniFFI.IncomingDataStreamManager { + _incoming.mutate { existing in + if let existing { return existing } + let delegate = IncomingDelegate() + delegate.coordinator = self + // `nil` → the core's default cap. Read now (first packet, i.e. post-connect) so a + // `maxPayloadSize` supplied via `connect(roomOptions:)` is honored. + let maxPayloadSize = room?._state.roomOptions.dataStreamOptions.maxPayloadSize + let manager = LiveKitUniFFI.IncomingDataStreamManager( + delegate: delegate, + maxPayloadByteLength: maxPayloadSize.map { UInt64($0) }, + ) + existing = manager + return manager + } } // Room-level encryption type, stamped onto every stream info as it crosses the FFI boundary. @@ -178,7 +192,7 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// the incoming manager. The FFI re-decodes the serialized `DataPacket` itself. func handleIncoming(_ dataPacket: Livekit_DataPacket) { guard let data = try? dataPacket.serializedData() else { return } - incoming.handlePacketReceived(packet: data) + incomingManager().handlePacketReceived(packet: data) } // MARK: - Stream lifecycle @@ -187,13 +201,14 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// on a reader that will never finish would otherwise stall its topic's ordered queue. Handler /// registrations survive, so streams arriving after a reconnect are still handled. func reset() { - incoming.abortAllStreams() + // No-op if the incoming manager was never created (no packets received): nothing is open. + _incoming.copy()?.abortAllStreams() } /// Fails open incoming streams sent by `identity` (they disconnected mid-send), so their readers /// throw and their handlers return instead of hanging. func closeStreams(from identity: Participant.Identity) { - incoming.abortStreamsFrom(identity: identity.stringValue) + _incoming.copy()?.abortStreamsFrom(identity: identity.stringValue) } // MARK: - Stream open dispatch (called from the incoming delegate) diff --git a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift index 3741d66c1..a0870d77f 100644 --- a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift @@ -59,5 +59,9 @@ struct StreamOptionsTests { #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSize == 1000) #expect(RoomOptions().dataStreamOptions.maxPayloadSize == nil) #expect(RoomOptions(dataStreamOptions: DataStreamOptions(maxPayloadSize: 42)).dataStreamOptions.maxPayloadSize == 42) + // Objective-C accessor mirrors the Swift `Int?`. + #expect(DataStreamOptions().maxPayloadSizeNumber == nil) + #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSizeNumber == 1000) + #expect(DataStreamOptions(maxPayloadSizeNumber: 1000).maxPayloadSize == 1000) } } diff --git a/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m b/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m new file mode 100644 index 000000000..417926f63 --- /dev/null +++ b/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m @@ -0,0 +1,67 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Cross-ref: Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift +// +// Guards that DataStreamOptions stays usable from Objective-C (it's a class, not a struct, so it +// bridges) and that adding it to RoomOptions did not drop RoomOptions's Objective-C initializer. + +@import XCTest; +@import LiveKit; + +@interface DataStreamOptionsObjCTests : XCTestCase +@end + +@implementation DataStreamOptionsObjCTests + +- (void)testDataStreamOptionsConstructibleFromObjC { + DataStreamOptions *defaults = [[DataStreamOptions alloc] init]; + XCTAssertNil(defaults.maxPayloadSizeNumber); + + DataStreamOptions *capped = [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024]; + XCTAssertEqualObjects(capped.maxPayloadSizeNumber, @1024); + + XCTAssertEqualObjects(capped, [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024]); + XCTAssertNotEqualObjects(capped, defaults); +} + +- (void)testRoomOptionsRetainsObjCInitializerWithDataStreamOptions { + // Adding a value type here would have dropped RoomOptions's Objective-C initializer entirely + // (Swift won't export an init that takes an ObjC-unrepresentable parameter). Because + // DataStreamOptions is a class, the designated initializer — which takes `dataStreamOptions:` — + // must remain callable from Objective-C. The sub-option parameters have their own + // ObjC-unavailable initializers (pre-existing), so we assert the selector exists rather than + // invoking it. + SEL initializer = @selector(initWithDefaultCameraCaptureOptions: + defaultScreenShareCaptureOptions: + defaultAudioCaptureOptions: + defaultVideoPublishOptions: + defaultAudioPublishOptions: + defaultDataPublishOptions: + dataStreamOptions: + adaptiveStream: + dynacast: + stopLocalTrackOnUnpublish: + suspendLocalVideoTracksInBackground: + e2eeOptions: + encryptionOptions: + reportRemoteTrackStatistics: + singlePeerConnection:); + XCTAssertTrue([RoomOptions instancesRespondToSelector:initializer], + @"RoomOptions must keep its Objective-C initializer accepting dataStreamOptions:"); +} + +@end From c8df3cb75cdd0f0e8ccd9017ed822e9737b3095d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:09:05 +0200 Subject: [PATCH 15/25] test(data-stream): port ported suites to the nanopb builder API Rebase adaptation: main moved the protocol layer to nanopb, where messages are immutable and built with `.with { }` instead of `var msg = T()`. Co-Authored-By: Claude Opus 5 (1M context) --- .../DataStream/ByteStreamReaderTests.swift | 36 +++++++++-------- .../IncomingStreamManagerTests.swift | 40 ++++++++++--------- .../DataStream/TextStreamReaderTests.swift | 34 +++++++++------- 3 files changed, 61 insertions(+), 49 deletions(-) diff --git a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift index 6e6e41819..5367d4b26 100644 --- a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift @@ -143,33 +143,37 @@ struct ByteStreamReaderTests { let reader = await withCheckedContinuation { (continuation: CheckedContinuation) in capture.pending.mutate { $0 = continuation } - var header = Livekit_DataStream.Header() - header.streamID = streamID - header.topic = topic - header.mimeType = mimeType - header.contentHeader = .byteHeader(.with { $0.name = name }) + let header = Livekit_DataStream.Header.with { + $0.streamID = streamID + $0.topic = topic + $0.mimeType = mimeType + $0.contentHeader = .byteHeader(.with { $0.name = name }) + } feed(manager) { $0.streamHeader = header } for (index, chunk) in testChunks.enumerated() { - var streamChunk = Livekit_DataStream.Chunk() - streamChunk.streamID = streamID - streamChunk.chunkIndex = UInt64(index) - streamChunk.content = chunk + let streamChunk = Livekit_DataStream.Chunk.with { + $0.streamID = streamID + $0.chunkIndex = UInt64(index) + $0.content = chunk + } feed(manager) { $0.streamChunk = streamChunk } } - var trailer = Livekit_DataStream.Trailer() - trailer.streamID = streamID - trailer.reason = trailerReason + let trailer = Livekit_DataStream.Trailer.with { + $0.streamID = streamID + $0.reason = trailerReason + } feed(manager) { $0.streamTrailer = trailer } } return (reader, manager) } - private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket) -> Void) { - var packet = Livekit_DataPacket() - packet.participantIdentity = "someName" - configure(&packet) + private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket.Builder) -> Void) { + let packet = Livekit_DataPacket.with { + $0.participantIdentity = "someName" + configure(&$0) + } guard let data = try? packet.serializedData() else { return } manager.handlePacketReceived(packet: data) } diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift index 2701f58c0..d988f5cde 100644 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -207,35 +207,39 @@ struct IncomingStreamManagerTests: @unchecked Sendable { } private func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil) { - var header = Livekit_DataStream.Header() - header.streamID = streamID - header.topic = topicName - header.contentHeader = byte - ? .byteHeader(Livekit_DataStream.ByteHeader()) - : .textHeader(Livekit_DataStream.TextHeader()) - if let totalLength { header.totalLength = totalLength } + let header = Livekit_DataStream.Header.with { + $0.streamID = streamID + $0.topic = topicName + $0.contentHeader = byte + ? .byteHeader(Livekit_DataStream.ByteHeader()) + : .textHeader(Livekit_DataStream.TextHeader()) + if let totalLength { $0.totalLength = totalLength } + } feed { $0.streamHeader = header } } private func feedChunk(streamID: String, index: UInt64, content: Data) { - var chunk = Livekit_DataStream.Chunk() - chunk.streamID = streamID - chunk.chunkIndex = index - chunk.content = content + let chunk = Livekit_DataStream.Chunk.with { + $0.streamID = streamID + $0.chunkIndex = index + $0.content = content + } feed { $0.streamChunk = chunk } } private func feedTrailer(streamID: String, reason: String) { - var trailer = Livekit_DataStream.Trailer() - trailer.streamID = streamID - trailer.reason = reason + let trailer = Livekit_DataStream.Trailer.with { + $0.streamID = streamID + $0.reason = reason + } feed { $0.streamTrailer = trailer } } - private func feed(_ configure: (inout Livekit_DataPacket) -> Void) { - var packet = Livekit_DataPacket() - packet.participantIdentity = participant.stringValue - configure(&packet) + private func feed(_ configure: (inout Livekit_DataPacket.Builder) -> Void) { + let packet = Livekit_DataPacket.with { + $0.participantIdentity = participant.stringValue + configure(&$0) + } coordinator.handleIncoming(packet) } } diff --git a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift index 7e3c583cb..a9b1673c9 100644 --- a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift @@ -105,32 +105,36 @@ struct TextStreamReaderTests { let reader = await withCheckedContinuation { (continuation: CheckedContinuation) in capture.pending.mutate { $0 = continuation } - var header = Livekit_DataStream.Header() - header.streamID = streamID - header.topic = topic - header.contentHeader = .textHeader(Livekit_DataStream.TextHeader()) + let header = Livekit_DataStream.Header.with { + $0.streamID = streamID + $0.topic = topic + $0.contentHeader = .textHeader(Livekit_DataStream.TextHeader()) + } feed(manager) { $0.streamHeader = header } for (index, chunk) in testChunks.enumerated() { - var streamChunk = Livekit_DataStream.Chunk() - streamChunk.streamID = streamID - streamChunk.chunkIndex = UInt64(index) - streamChunk.content = Data(chunk.utf8) + let streamChunk = Livekit_DataStream.Chunk.with { + $0.streamID = streamID + $0.chunkIndex = UInt64(index) + $0.content = Data(chunk.utf8) + } feed(manager) { $0.streamChunk = streamChunk } } - var trailer = Livekit_DataStream.Trailer() - trailer.streamID = streamID - trailer.reason = trailerReason + let trailer = Livekit_DataStream.Trailer.with { + $0.streamID = streamID + $0.reason = trailerReason + } feed(manager) { $0.streamTrailer = trailer } } return (reader, manager) } - private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket) -> Void) { - var packet = Livekit_DataPacket() - packet.participantIdentity = "someName" - configure(&packet) + private func feed(_ manager: IncomingDataStreamManager, _ configure: (inout Livekit_DataPacket.Builder) -> Void) { + let packet = Livekit_DataPacket.with { + $0.participantIdentity = "someName" + configure(&$0) + } guard let data = try? packet.serializedData() else { return } manager.handlePacketReceived(packet: data) } From 1c75e5810281bd7ef1d4c4639fa7f09a898b5ffa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:45:09 +0200 Subject: [PATCH 16/25] fix(data-stream): order the outgoing pump; re-read maxPayloadSize per session The FFI emits one packet per `onPacketsAvailable` call, synchronously and in order. Answering each with `AsyncSerialDelegate.notifyDetached` spawned a task per packet that raced to the serial runner, so emission order was preserved only by timing: measured on the primitive, ordering breaks in 20/20 runs at zero inter-call spacing and 3/20 at ~50us. The receiver drops a chunk that arrives before its header and fails the stream on a non-consecutive index, so drain the callbacks through a single ordered task instead. The incoming manager's payload cap is fixed at construction, so memoizing the manager for the Room's lifetime pinned it to the first connect's value. Discard it in `reset()` and let the next session rebuild it; it holds no handler state. Also take the read fast path when it already exists, keeping the exclusive lock off the per-packet inbound path. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/LiveKit/DataStream/DataStreams.swift | 66 ++++++++++++------ .../DataStreamOptionsObjCTests.m | 67 ------------------- 2 files changed, 47 insertions(+), 86 deletions(-) delete mode 100644 Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index c14ae3ab1..6b6392885 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -36,10 +36,12 @@ internal import LiveKitUniFFI final class DataStreams: NSObject, @unchecked Sendable, Loggable { private let outgoing: LiveKitUniFFI.OutgoingDataStreamManager - // Created lazily on the first inbound packet, not at init: the incoming manager's payload cap - // comes from the room's options, which aren't finalized until `connect` — after this coordinator - // is built at `Room.init`. Deferring lets it pick up a `maxPayloadSize` passed at connect time. - // StateSync-guarded so it's constructed exactly once even if packets race in. + // Created lazily on the first inbound packet of a session, not at init: the incoming manager's + // payload cap is fixed at construction (the FFI exposes no setter) and comes from the room's + // options, which aren't finalized until `connect` — after this coordinator is built at + // `Room.init`. Deferring lets it pick up a `maxPayloadSize` passed at connect time, and `reset()` + // drops it at teardown so the *next* connect re-reads the cap rather than inheriting the first + // session's. StateSync-guarded so it's constructed exactly once even if packets race in. private let _incoming = StateSync(nil) // Held weakly: the Room owns this coordinator, so the back-reference must not retain it. Used @@ -74,7 +76,10 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// The incoming manager, created on first use with the room's current payload cap. Topic routing /// (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`. private func incomingManager() -> LiveKitUniFFI.IncomingDataStreamManager { - _incoming.mutate { existing in + // Fast path: after the first packet of a session this is a plain read, keeping the exclusive + // lock off the per-packet inbound path. `mutate` re-checks, so the race is still safe. + if let existing = _incoming.copy() { return existing } + return _incoming.mutate { existing in if let existing { return existing } let delegate = IncomingDelegate() delegate.coordinator = self @@ -200,9 +205,17 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// Fails all open incoming streams so their handlers return (e.g. on cleanup). A handler blocked /// on a reader that will never finish would otherwise stall its topic's ordered queue. Handler /// registrations survive, so streams arriving after a reconnect are still handled. + /// + /// The incoming manager itself is discarded, not just drained: its payload cap is immutable after + /// construction, so a fresh one has to be built for the next session to honor that session's + /// `maxPayloadSize`. It holds no handler state — that lives here — so this loses nothing. func reset() { // No-op if the incoming manager was never created (no packets received): nothing is open. - _incoming.copy()?.abortAllStreams() + let existing = _incoming.mutate { manager in + defer { manager = nil } + return manager + } + existing?.abortAllStreams() } /// Fails open incoming streams sent by `identity` (they disconnected mid-send), so their readers @@ -288,25 +301,40 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// Receives the outgoing manager's encoded `DataPacket`s and sends them over the reliable data /// channel via `Room.send(dataPacket:)` — preserving E2EE, reliable sequencing, and identity - /// stamping. Serialized so packets reach the SFU in the order the manager emits them; the room - /// is held weakly to avoid retaining it through the FFI manager. + /// stamping. The room is held weakly to avoid retaining it through the FFI manager. + /// + /// Order is structural, not incidental. A stream's packets must reach the SFU in emission order: + /// the receiver drops a chunk that arrives before its header and fails the stream outright on a + /// non-consecutive chunk index. The FFI calls `onPacketsAvailable` synchronously and strictly + /// sequentially, so this delegate only has to *preserve* that order — hence a single drain task + /// over an `AsyncStream`, rather than a task per callback racing to a serial executor. private final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate, @unchecked Sendable { - private let sender = AsyncSerialDelegate() + private let continuation: AsyncStream<[Data]>.Continuation + private let pump: AnyTaskCancellable init(room: Room) { - sender.set(delegate: room) + let (stream, continuation) = AsyncStream.makeStream(of: [Data].self) + self.continuation = continuation + pump = Task.detached { [weak room] in + for await packets in stream { + guard let room else { return } + for data in packets { + guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { + room.log("Failed to decode outgoing data stream packet", .warning) + continue + } + try? await room.send(dataPacket: packet) + } + } + }.cancellable() + } + + deinit { + continuation.finish() } func onPacketsAvailable(packets: [Data]) { - sender.notifyDetached { room in - for data in packets { - guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { - room.log("Failed to decode outgoing data stream packet", .warning) - continue - } - try? await room.send(dataPacket: packet) - } - } + continuation.yield(packets) } } diff --git a/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m b/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m deleted file mode 100644 index 417926f63..000000000 --- a/Tests/LiveKitObjCTests/DataStreamOptionsObjCTests.m +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2026 LiveKit - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// Cross-ref: Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift -// -// Guards that DataStreamOptions stays usable from Objective-C (it's a class, not a struct, so it -// bridges) and that adding it to RoomOptions did not drop RoomOptions's Objective-C initializer. - -@import XCTest; -@import LiveKit; - -@interface DataStreamOptionsObjCTests : XCTestCase -@end - -@implementation DataStreamOptionsObjCTests - -- (void)testDataStreamOptionsConstructibleFromObjC { - DataStreamOptions *defaults = [[DataStreamOptions alloc] init]; - XCTAssertNil(defaults.maxPayloadSizeNumber); - - DataStreamOptions *capped = [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024]; - XCTAssertEqualObjects(capped.maxPayloadSizeNumber, @1024); - - XCTAssertEqualObjects(capped, [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024]); - XCTAssertNotEqualObjects(capped, defaults); -} - -- (void)testRoomOptionsRetainsObjCInitializerWithDataStreamOptions { - // Adding a value type here would have dropped RoomOptions's Objective-C initializer entirely - // (Swift won't export an init that takes an ObjC-unrepresentable parameter). Because - // DataStreamOptions is a class, the designated initializer — which takes `dataStreamOptions:` — - // must remain callable from Objective-C. The sub-option parameters have their own - // ObjC-unavailable initializers (pre-existing), so we assert the selector exists rather than - // invoking it. - SEL initializer = @selector(initWithDefaultCameraCaptureOptions: - defaultScreenShareCaptureOptions: - defaultAudioCaptureOptions: - defaultVideoPublishOptions: - defaultAudioPublishOptions: - defaultDataPublishOptions: - dataStreamOptions: - adaptiveStream: - dynacast: - stopLocalTrackOnUnpublish: - suspendLocalVideoTracksInBackground: - e2eeOptions: - encryptionOptions: - reportRemoteTrackStatistics: - singlePeerConnection:); - XCTAssertTrue([RoomOptions instancesRespondToSelector:initializer], - @"RoomOptions must keep its Objective-C initializer accepting dataStreamOptions:"); -} - -@end From f2b4e54973043af2855327bbe5163f8a7ee04b36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:45:21 +0200 Subject: [PATCH 17/25] test(data-stream): restore ordered-topic specs; cover the FFI info/error bridging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `ordered` text-stream contract is still implemented in Swift — only chunk assembly moved to the Rust core — so its five specs are re-pointed at the `DataStreams` coordinator rather than dropped. Four pass. `orderedTopicDoesNotDelayOverlappingStreams` does not, and is kept disabled as the specification of the difference: v1 chained a newly opened stream behind handlers of streams that had already closed, while `DataStreams` chains on the order streams opened in, so a stream that stays open head-of-line-blocks later streams from the same sender. Restoring that needs a stream-closed signal the FFI does not surface. `ByteStreamInfoTests`/`TextStreamInfoTests` covered protobuf to `StreamInfo` conversions that no longer exist; their FFI replacements ran untested. Pin every field mapping, the millisecond timestamp scaling, the empty-name-to-nil rule, the operation-type cases, and the twelve-case error mapping. The ObjC options suite is dropped: adding `dataStreamOptions:` changes RoomOptions's ObjC initializer selector, and that break is accepted rather than pinned by a test. Co-Authored-By: Claude Opus 5 (1M context) --- .../IncomingStreamManagerTests.swift | 71 +++++-- .../DataStream/OrderedTextStreamTests.swift | 198 ++++++++++++++++++ .../DataStream/StreamInfoBridgeTests.swift | 168 +++++++++++++++ 3 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift create mode 100644 Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift index d988f5cde..c492939e7 100644 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -27,11 +27,11 @@ import LiveKitTestSupport /// coordinator via ``DataStreams/handleIncoming(_:)``, so no network or connected room is needed. @Suite(.tags(.dataStream)) struct IncomingStreamManagerTests: @unchecked Sendable { - private let room: Room - private let coordinator: DataStreams + let room: Room + let coordinator: DataStreams - private let topicName = "someTopic" - private let participant = Participant.Identity(from: "someName") + let topicName = "someTopic" + let participant = Participant.Identity(from: "someName") init() { room = Room() @@ -74,14 +74,17 @@ struct IncomingStreamManagerTests: @unchecked Sendable { do { try coordinator.registerByteStreamHandler(for: topicName) { reader, participant in #expect(participant == self.participant) - do { continuation.resume(returning: try await reader.readAll()) } - catch { continuation.resume(throwing: error) } + do { + try await continuation.resume(returning: reader.readAll()) + } catch { + continuation.resume(throwing: error) + } } } catch { continuation.resume(throwing: error) return } - Task { await self.feedByteStream(chunks: testChunks) } + Task { await feedByteStream(chunks: testChunks) } } #expect(payload == testPayload) @@ -100,14 +103,17 @@ struct IncomingStreamManagerTests: @unchecked Sendable { do { try coordinator.registerTextStreamHandler(for: topicName) { reader, participant in #expect(participant == self.participant) - do { continuation.resume(returning: try await reader.readAll()) } - catch { continuation.resume(throwing: error) } + do { + try await continuation.resume(returning: reader.readAll()) + } catch { + continuation.resume(throwing: error) + } } } catch { continuation.resume(throwing: error) return } - Task { await self.feedTextStream(chunks: testChunks) } + Task { await feedTextStream(chunks: testChunks) } } #expect(payload == testPayload) @@ -121,8 +127,11 @@ struct IncomingStreamManagerTests: @unchecked Sendable { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in do { try coordinator.registerTextStreamHandler(for: topicName) { reader, _ in - do { continuation.resume(returning: try await reader.readAll()) } - catch { continuation.resume(throwing: error) } + do { + try await continuation.resume(returning: reader.readAll()) + } catch { + continuation.resume(throwing: error) + } } } catch { continuation.resume(throwing: error) @@ -137,8 +146,8 @@ struct IncomingStreamManagerTests: @unchecked Sendable { let closureReason = "test" let error = await byteReaderError { streamID in - self.feedHeader(streamID: streamID, byte: true) - self.feedTrailer(streamID: streamID, reason: closureReason) + feedHeader(streamID: streamID, byte: true) + feedTrailer(streamID: streamID, reason: closureReason) } #expect(error as? StreamError == .abnormalEnd(reason: closureReason)) @@ -148,9 +157,9 @@ struct IncomingStreamManagerTests: @unchecked Sendable { let testPayload = Data(repeating: 0xAB, count: 128) let error = await byteReaderError { streamID in - self.feedHeader(streamID: streamID, byte: true, totalLength: UInt64(testPayload.count + 10)) - self.feedChunk(streamID: streamID, index: 0, content: testPayload) - self.feedTrailer(streamID: streamID, reason: "") + feedHeader(streamID: streamID, byte: true, totalLength: UInt64(testPayload.count + 10)) + feedChunk(streamID: streamID, index: 0, content: testPayload) + feedTrailer(streamID: streamID, reason: "") } #expect(error as? StreamError == .incomplete) @@ -193,9 +202,13 @@ struct IncomingStreamManagerTests: @unchecked Sendable { feedTrailer(streamID: streamID, reason: "") } - private func feedTextStream(chunks: [String]? = nil, rawPayload: Data? = nil) async { - let streamID = UUID().uuidString - feedHeader(streamID: streamID, byte: false) + func feedTextStream( + chunks: [String]? = nil, + rawPayload: Data? = nil, + totalLength: UInt64? = nil, + streamID: String = UUID().uuidString, + ) async { + feedHeader(streamID: streamID, byte: false, totalLength: totalLength) if let chunks { for (index, chunk) in chunks.enumerated() { feedChunk(streamID: streamID, index: UInt64(index), content: Data(chunk.utf8)) @@ -206,7 +219,19 @@ struct IncomingStreamManagerTests: @unchecked Sendable { feedTrailer(streamID: streamID, reason: "") } - private func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil) { + func feedTextHeader(streamID: String) { + feedHeader(streamID: streamID, byte: false) + } + + func feedTextChunk(streamID: String, content: String) { + feedChunk(streamID: streamID, index: 0, content: Data(content.utf8)) + } + + func feedTextTrailer(streamID: String) { + feedTrailer(streamID: streamID, reason: "") + } + + func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil) { let header = Livekit_DataStream.Header.with { $0.streamID = streamID $0.topic = topicName @@ -218,7 +243,7 @@ struct IncomingStreamManagerTests: @unchecked Sendable { feed { $0.streamHeader = header } } - private func feedChunk(streamID: String, index: UInt64, content: Data) { + func feedChunk(streamID: String, index: UInt64, content: Data) { let chunk = Livekit_DataStream.Chunk.with { $0.streamID = streamID $0.chunkIndex = index @@ -227,7 +252,7 @@ struct IncomingStreamManagerTests: @unchecked Sendable { feed { $0.streamChunk = chunk } } - private func feedTrailer(streamID: String, reason: String) { + func feedTrailer(streamID: String, reason: String) { let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID $0.reason = reason diff --git a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift new file mode 100644 index 000000000..508738ab7 --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift @@ -0,0 +1,198 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// One-shot latch: `wait()` suspends until `open()`; waiters after `open()` pass through. +private actor TestGate { + private var isOpen = false + private var waiters: [CheckedContinuation] = [] + + func wait() async { + guard !isOpen else { return } + await withCheckedContinuation { waiters.append($0) } + } + + func open() { + isOpen = true + let continuations = waiters + waiters = [] + for continuation in continuations { + continuation.resume() + } + } +} + +// MARK: - Ordered topics + +/// The `ordered` text-stream contract is still implemented in Swift (``DataStreams`` chains handler +/// tasks); only chunk assembly moved to the Rust core. These are the v1 behavioral specs, re-pointed +/// at the coordinator, so the rewrite from per-topic to per-sender chaining stays honest. +extension IncomingStreamManagerTests { + private func waitUntil(_ condition: @Sendable () -> Bool, timeout: TimeInterval = 10) async { + let deadline = Date().addingTimeInterval(timeout) + while !condition(), Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + /// Handlers for an `ordered` topic must observe streams in wire order, not the scheduling order + /// of independently spawned handler tasks. + @Test func orderedTopicDeliversStreamsInOrder() async throws { + let count = 16 + let received = StateSync<[String]>([]) + + try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in + let payload = try await reader.readAll() + received.mutate { $0.append(payload) } + } + + for index in 0 ..< count { + await feedTextStream(chunks: ["payload-\(index)"]) + } + + await waitUntil { received.copy().count >= count } + #expect(received.copy() == (0 ..< count).map { "payload-\($0)" }) + coordinator.unregisterTextStreamHandler(for: topicName) + } + + /// Ordering must compose transitively: C waits on B even while B is itself still waiting on A. + /// If the chain breaks, B and C complete while A's handler is gated and the order comes out wrong. + @Test func orderedTopicChainsAcrossFinishingHandlers() async throws { + let received = StateSync<[String]>([]) + let gate = TestGate() + + try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in + let payload = try await reader.readAll() + // First handler stalls after its stream closed, becoming a still-finishing predecessor + // for the streams sent after it. + if payload == "a" { await gate.wait() } + received.mutate { $0.append(payload) } + } + + for payload in ["a", "b", "c"] { + await feedTextStream(chunks: [payload]) + } + await gate.open() + + await waitUntil { received.copy().count >= 3 } + #expect(received.copy() == ["a", "b", "c"]) + coordinator.unregisterTextStreamHandler(for: topicName) + } + + /// A still-open stream must not delay streams that overlap with it on the wire (e.g. a live + /// transcript arriving while an earlier message stream is still open). Ordering is a wire + /// happens-before relation — it applies between streams that don't overlap, so it must be keyed + /// on streams that have *closed*, not merely on the order streams opened in. + /// + /// Currently failing, and kept as the specification of the v1 behavior it documents. v1 chained a + /// newly opened stream behind the handlers of streams that had already *closed* + /// (`IncomingStreamManager.finishingHandlers`); ``DataStreams`` chains on the order streams + /// *opened* in, per sender, so a stream that stays open head-of-line-blocks every later stream + /// from that sender on the topic. Restoring the v1 semantics needs a stream-closed signal, which + /// the FFI doesn't surface — Swift would have to inspect trailer packets in `handleIncoming` + /// again, which this migration deliberately stopped doing. Practical exposure is small: only + /// internal consumers set `ordered`, senders close one segment before opening the next, and the + /// orphan case self-heals because `closeStreams(from:)` fires on the disconnect that caused it. + @Test(.disabled("Ordered topics now chain on open order, not on closed predecessors — see doc comment")) + func orderedTopicDoesNotDelayOverlappingStreams() async throws { + let received = StateSync<[String]>([]) + + try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in + let payload = try await reader.readAll() + received.mutate { $0.append(payload) } + } + + // Stream A opens and stays open; stream B opens, delivers and closes while A is still open — + // B's handler must complete without waiting for A. + feedTextHeader(streamID: "open-a") + feedTextChunk(streamID: "open-a", content: "a") + await feedTextStream(chunks: ["b"], streamID: "b") + + await waitUntil { !received.copy().isEmpty } + #expect(received.copy() == ["b"]) + + // A still completes normally once its trailer arrives. + feedTextTrailer(streamID: "open-a") + await waitUntil { received.copy().count >= 2 } + #expect(received.copy() == ["b", "a"]) + coordinator.unregisterTextStreamHandler(for: topicName) + } + + /// A sender disconnecting before its trailer leaves the stream open forever; `closeStreams(from:)` + /// must fail it so an ordered topic's queue drains. + @Test func closeStreamsUnblocksOrderedTopic() async throws { + let received = StateSync<[String]>([]) + let errors = StateSync<[StreamError]>([]) + let entered = TestGate() + + try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in + await entered.open() + do { + let payload = try await reader.readAll() + received.mutate { $0.append(payload) } + } catch let error as StreamError { + errors.mutate { $0.append(error) } + throw error + } + } + + // Header only — no trailer ever arrives, so the handler blocks in `readAll` and, at the head + // of the ordered queue, would block every later stream. + feedTextHeader(streamID: "orphan") + await entered.wait() + + coordinator.closeStreams(from: participant) + await feedTextStream(chunks: ["after"]) + + await waitUntil { !received.copy().isEmpty } + #expect(received.copy() == ["after"]) + // The FFI core terminates aborted streams with an abnormal-end reason naming the sender. + #expect(errors.copy().count == 1) + if case .abnormalEnd = errors.copy().first {} else { + Issue.record("expected .abnormalEnd, got \(String(describing: errors.copy().first))") + } + coordinator.unregisterTextStreamHandler(for: topicName) + } + + /// Same shape as above via the room-lifecycle path: `reset()` fails all open streams but keeps + /// handlers registered for after a reconnect. + @Test func resetUnblocksOrderedTopicAndKeepsHandler() async throws { + let received = StateSync<[String]>([]) + let entered = TestGate() + + try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in + await entered.open() + let payload = try await reader.readAll() + received.mutate { $0.append(payload) } + } + + feedTextHeader(streamID: "orphan") + await entered.wait() + + coordinator.reset() + await feedTextStream(chunks: ["after-reset"]) + + await waitUntil { !received.copy().isEmpty } + #expect(received.copy() == ["after-reset"]) + coordinator.unregisterTextStreamHandler(for: topicName) + } +} diff --git a/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift new file mode 100644 index 000000000..762f34c1f --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift @@ -0,0 +1,168 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +@testable import LiveKit +import LiveKitUniFFI +import Testing + +/// Covers the FFI → public bridging that replaced the v1 protobuf → `StreamInfo` conversions +/// (previously `ByteStreamInfoTests` / `TextStreamInfoTests`). These conversions run on every opened +/// stream and every opened writer, so each field mapping is pinned here — including the millisecond +/// timestamp scaling, the optional/sentinel translations, and the enum mappings. +@Suite(.tags(.dataStream)) +struct StreamInfoBridgeTests { + // MARK: - TextStreamInfo + + private func ffiTextInfo( + totalLength: UInt64? = 128, + operationType: LiveKitUniFFI.OperationType = .create, + replyToStreamId: String? = "replyID", + encryptionType: LiveKitUniFFI.EncryptionType = .gcm, + ) -> LiveKitUniFFI.TextStreamInfo { + LiveKitUniFFI.TextStreamInfo( + id: "id", + topic: "topic", + timestampMs: 100_000, + totalLength: totalLength, + attributes: ["key": "value"], + mimeType: "text/plain", + operationType: operationType, + version: 10, + replyToStreamId: replyToStreamId, + attachedStreamIds: ["attachedID"], + generated: true, + encryptionType: encryptionType, + ) + } + + @Test func textInfoMapsEveryField() { + let info = LiveKit.TextStreamInfo(ffiTextInfo(), encryptionType: .gcm) + + #expect(info.id == "id") + #expect(info.topic == "topic") + // Milliseconds on the wire, `Date` in the API. + #expect(info.timestamp == Date(timeIntervalSince1970: 100)) + #expect(info.totalLength == 128) + #expect(info.attributes == ["key": "value"]) + #expect(info.operationType == .create) + #expect(info.version == 10) + #expect(info.replyToStreamID == "replyID") + #expect(info.attachedStreamIDs == ["attachedID"]) + #expect(info.generated == true) + } + + @Test func textInfoTotalLengthIsOptional() { + #expect(LiveKit.TextStreamInfo(ffiTextInfo(totalLength: nil), encryptionType: .none).totalLength == nil) + #expect(LiveKit.TextStreamInfo(ffiTextInfo(totalLength: 0), encryptionType: .none).totalLength == 0) + } + + @Test func textInfoReplyToStreamIDIsOptional() { + #expect(LiveKit.TextStreamInfo(ffiTextInfo(replyToStreamId: nil), encryptionType: .none).replyToStreamID == nil) + } + + /// `StreamInfo.encryptionType` is stamped by the SDK from the room's data-channel encryption + /// setting, *not* taken from the FFI record — the per-packet type is unrecoverable after + /// decryption, since `encrypted_packet` shares a protobuf `oneof` with the stream payload. + @Test func textInfoEncryptionTypeComesFromTheCallerNotTheFFI() { + let ffi = ffiTextInfo(encryptionType: .gcm) + #expect(LiveKit.TextStreamInfo(ffi, encryptionType: .none).encryptionType == .none) + #expect(LiveKit.TextStreamInfo(ffi, encryptionType: .gcm).encryptionType == .gcm) + } + + @Test(arguments: [ + (LiveKitUniFFI.OperationType.create, LiveKit.TextStreamInfo.OperationType.create), + (.update, .update), + (.delete, .delete), + (.reaction, .reaction), + ]) + func textInfoOperationTypeMapping(_ ffi: LiveKitUniFFI.OperationType, _ expected: LiveKit.TextStreamInfo.OperationType) { + #expect(LiveKit.TextStreamInfo(ffiTextInfo(operationType: ffi), encryptionType: .none).operationType == expected) + } + + // MARK: - ByteStreamInfo + + private func ffiByteInfo( + totalLength: UInt64? = 128, + name: String = "filename.bin", + ) -> LiveKitUniFFI.ByteStreamInfo { + LiveKitUniFFI.ByteStreamInfo( + id: "id", + topic: "topic", + timestampMs: 100_000, + totalLength: totalLength, + attributes: ["key": "value"], + mimeType: "image/jpeg", + name: name, + encryptionType: .gcm, + ) + } + + @Test func byteInfoMapsEveryField() { + let info = LiveKit.ByteStreamInfo(ffiByteInfo(), encryptionType: .gcm) + + #expect(info.id == "id") + #expect(info.topic == "topic") + #expect(info.timestamp == Date(timeIntervalSince1970: 100)) + #expect(info.totalLength == 128) + #expect(info.attributes == ["key": "value"]) + #expect(info.mimeType == "image/jpeg") + #expect(info.name == "filename.bin") + #expect(info.encryptionType == .gcm) + } + + /// The FFI carries an absent name as the empty string; the public API models it as `nil`, which + /// `ByteStreamReader.resolveFileName` relies on to fall back to the stream ID. + @Test func byteInfoEmptyNameBecomesNil() { + #expect(LiveKit.ByteStreamInfo(ffiByteInfo(name: ""), encryptionType: .none).name == nil) + #expect(LiveKit.ByteStreamInfo(ffiByteInfo(name: "a.bin"), encryptionType: .none).name == "a.bin") + } + + @Test func byteInfoTotalLengthIsOptional() { + #expect(LiveKit.ByteStreamInfo(ffiByteInfo(totalLength: nil), encryptionType: .none).totalLength == nil) + } + + // MARK: - StreamError + + /// The public error set predates the FFI core, so several Rust cases collapse onto one public + /// case. Pinned exhaustively: a new Rust variant that lands on the wrong case is otherwise silent. + @Test(arguments: [ + (LiveKitUniFFI.DataStreamError.AbnormalEnd(reason: "why"), StreamError.abnormalEnd(reason: "why")), + (.Io(reason: "disk"), .abnormalEnd(reason: "disk")), + (.Utf8(reason: "bad"), .decodeFailed), + (.Decompression, .decodeFailed), + (.LengthExceeded, .lengthExceeded), + (.HeaderTooLarge, .lengthExceeded), + (.PayloadTooLarge, .lengthExceeded), + (.Incomplete, .incomplete), + (.AlreadyClosed, .terminated), + (.InvalidHeader, .terminated), + (.MissedChunk, .terminated), + (.SendFailed, .terminated), + (.Internal, .terminated), + (.InvalidFileName, .terminated), + ]) + func streamErrorMapping(_ ffi: LiveKitUniFFI.DataStreamError, _ expected: StreamError) { + #expect(StreamError(ffi) == expected) + } + + /// `EncryptionTypeMismatch` is currently unreachable — the FFI normalizes every packet's + /// encryption type to `.none`, so header and chunk can never disagree — and the mapping has no + /// real values to carry, so it reports `.none`/`.none`. + @Test func streamErrorEncryptionTypeMismatchHasNoRealPayload() { + #expect(StreamError(.EncryptionTypeMismatch) == .encryptionTypeMismatch(expected: .none, received: .none)) + } +} From 4a39c691551f400ed4a224e9f17e66fe775b2a61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:02:19 +0200 Subject: [PATCH 18/25] refactor(data-stream): tighten Sendable and known-issue signal after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The outgoing delegate's stored properties are all immutable and Sendable, so it conforms plainly rather than `@unchecked` — no invariant left for a reviewer to take on trust. Same for the incoming test suite. Document why the pump is unstructured, and drop the `defer`-in-`mutate` trick in `reset()` for a plain take, which does not need evaluation-order reasoning to read. `orderedTopicDoesNotDelayOverlappingStreams` moves from `.disabled` to `withKnownIssue`: it now compiles, runs, records the two divergences with their actual values, and fails if the behavior is ever fixed, instead of silently rotting. Bounded to a 3s wait since the first expectation is meant to time out. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/LiveKit/DataStream/DataStreams.swift | 15 +++++--- .../IncomingStreamManagerTests.swift | 2 +- .../DataStream/OrderedTextStreamTests.swift | 36 ++++++++++++------- 3 files changed, 36 insertions(+), 17 deletions(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 6b6392885..9aca7ffa9 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -211,10 +211,13 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// `maxPayloadSize`. It holds no handler state — that lives here — so this loses nothing. func reset() { // No-op if the incoming manager was never created (no packets received): nothing is open. - let existing = _incoming.mutate { manager in - defer { manager = nil } - return manager + let existing = _incoming.mutate { manager -> LiveKitUniFFI.IncomingDataStreamManager? in + let current = manager + manager = nil + return current } + // Aborted through the reference taken above, so open readers still error out even though the + // manager is no longer reachable from `_incoming`. existing?.abortAllStreams() } @@ -308,8 +311,12 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// non-consecutive chunk index. The FFI calls `onPacketsAvailable` synchronously and strictly /// sequentially, so this delegate only has to *preserve* that order — hence a single drain task /// over an `AsyncStream`, rather than a task per callback racing to a serial executor. - private final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate, @unchecked Sendable { + /// Fully `Sendable`, not `@unchecked`: both stored properties are immutable and `Sendable`, so + /// there is no invariant here for a reviewer to have to take on trust. + private final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate { private let continuation: AsyncStream<[Data]>.Continuation + // Unstructured on purpose: the pump's lifetime is the delegate's, not any caller's. Wrapped so + // it is cancelled on deinit rather than outliving the object (SwiftLint enforces this shape). private let pump: AnyTaskCancellable init(room: Room) { diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift index c492939e7..35516892c 100644 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -26,7 +26,7 @@ import LiveKitTestSupport /// registration, chunk assembly, and error surfacing — still hold. Packets are fed straight into the /// coordinator via ``DataStreams/handleIncoming(_:)``, so no network or connected room is needed. @Suite(.tags(.dataStream)) -struct IncomingStreamManagerTests: @unchecked Sendable { +struct IncomingStreamManagerTests: Sendable { let room: Room let coordinator: DataStreams diff --git a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift index 508738ab7..3141870f1 100644 --- a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift @@ -47,6 +47,11 @@ private actor TestGate { /// tasks); only chunk assembly moved to the Rust core. These are the v1 behavioral specs, re-pointed /// at the coordinator, so the rewrite from per-topic to per-sender chaining stays honest. extension IncomingStreamManagerTests { + private func isAbnormalEnd(_ error: StreamError) -> Bool { + if case .abnormalEnd = error { return true } + return false + } + private func waitUntil(_ condition: @Sendable () -> Bool, timeout: TimeInterval = 10) async { let deadline = Date().addingTimeInterval(timeout) while !condition(), Date() < deadline { @@ -103,8 +108,11 @@ extension IncomingStreamManagerTests { /// happens-before relation — it applies between streams that don't overlap, so it must be keyed /// on streams that have *closed*, not merely on the order streams opened in. /// - /// Currently failing, and kept as the specification of the v1 behavior it documents. v1 chained a - /// newly opened stream behind the handlers of streams that had already *closed* + /// Kept running as a known issue rather than disabled, so it still compiles, still exercises the + /// path, and fails loudly if the behavior is ever fixed (at which point drop the + /// `withKnownIssue` wrappers). + /// + /// v1 chained a newly opened stream behind the handlers of streams that had already *closed* /// (`IncomingStreamManager.finishingHandlers`); ``DataStreams`` chains on the order streams /// *opened* in, per sender, so a stream that stays open head-of-line-blocks every later stream /// from that sender on the topic. Restoring the v1 semantics needs a stream-closed signal, which @@ -112,8 +120,7 @@ extension IncomingStreamManagerTests { /// again, which this migration deliberately stopped doing. Practical exposure is small: only /// internal consumers set `ordered`, senders close one segment before opening the next, and the /// orphan case self-heals because `closeStreams(from:)` fires on the disconnect that caused it. - @Test(.disabled("Ordered topics now chain on open order, not on closed predecessors — see doc comment")) - func orderedTopicDoesNotDelayOverlappingStreams() async throws { + @Test func orderedTopicDoesNotDelayOverlappingStreams() async throws { let received = StateSync<[String]>([]) try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in @@ -127,13 +134,18 @@ extension IncomingStreamManagerTests { feedTextChunk(streamID: "open-a", content: "a") await feedTextStream(chunks: ["b"], streamID: "b") - await waitUntil { !received.copy().isEmpty } - #expect(received.copy() == ["b"]) + // Short timeout: this is expected to time out, so don't spend the default deadline on it. + await withKnownIssue("B's handler is queued behind still-open A") { + await waitUntil({ !received.copy().isEmpty }, timeout: 3) + #expect(received.copy() == ["b"]) + } - // A still completes normally once its trailer arrives. + // A still completes normally once its trailer arrives — but B only lands after it. feedTextTrailer(streamID: "open-a") await waitUntil { received.copy().count >= 2 } - #expect(received.copy() == ["b", "a"]) + await withKnownIssue("A drains before B instead of after it") { + #expect(received.copy() == ["b", "a"]) + } coordinator.unregisterTextStreamHandler(for: topicName) } @@ -165,11 +177,11 @@ extension IncomingStreamManagerTests { await waitUntil { !received.copy().isEmpty } #expect(received.copy() == ["after"]) - // The FFI core terminates aborted streams with an abnormal-end reason naming the sender. + // The FFI core terminates aborted streams with an abnormal-end reason naming the sender, so + // match the case rather than the message. + let observed = try #require(errors.copy().first) #expect(errors.copy().count == 1) - if case .abnormalEnd = errors.copy().first {} else { - Issue.record("expected .abnormalEnd, got \(String(describing: errors.copy().first))") - } + #expect(isAbnormalEnd(observed), "expected .abnormalEnd, got \(observed)") coordinator.unregisterTextStreamHandler(for: topicName) } From 49ba7b268b74fb71c60939565368d69e265de88f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 17 Aug 2026 15:08:16 +0200 Subject: [PATCH 19/25] docs(data-stream): correct where the missing stream-closed signal lives The Rust core already emits `incoming::OutputEvent::TrailerReceived` with the stream id, sender and topic; the UniFFI layer drops it and forwards only `StreamOpened`. Surfacing that event is the fix for the ordered-topic divergence, not re-parsing trailer packets on the Swift side. Co-Authored-By: Claude Opus 5 (1M context) --- .../DataStream/OrderedTextStreamTests.swift | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift index 3141870f1..8cc044a00 100644 --- a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift @@ -115,11 +115,18 @@ extension IncomingStreamManagerTests { /// v1 chained a newly opened stream behind the handlers of streams that had already *closed* /// (`IncomingStreamManager.finishingHandlers`); ``DataStreams`` chains on the order streams /// *opened* in, per sender, so a stream that stays open head-of-line-blocks every later stream - /// from that sender on the topic. Restoring the v1 semantics needs a stream-closed signal, which - /// the FFI doesn't surface — Swift would have to inspect trailer packets in `handleIncoming` - /// again, which this migration deliberately stopped doing. Practical exposure is small: only - /// internal consumers set `ordered`, senders close one segment before opening the next, and the - /// orphan case self-heals because `closeStreams(from:)` fires on the disconnect that caused it. + /// from that sender on the topic. + /// + /// Restoring the v1 semantics needs a stream-closed signal. The Rust core already emits one — + /// `incoming::OutputEvent::TrailerReceived`, carrying the stream id, sender and topic — but the + /// UniFFI layer drops it, forwarding only `StreamOpened` to + /// `IncomingDataStreamManagerDelegate`. Surfacing it (or a purpose-built `onStreamClosed`) is + /// the fix; doing it Swift-side instead would mean parsing trailer packets in `handleIncoming` + /// again, which this migration deliberately stopped doing. + /// + /// Practical exposure is small: only internal consumers set `ordered`, senders close one segment + /// before opening the next, and the orphan case self-heals because `closeStreams(from:)` fires on + /// the disconnect that caused it. @Test func orderedTopicDoesNotDelayOverlappingStreams() async throws { let received = StateSync<[String]>([]) From 039ed3fc4b5ff75de09eeeaf14a0dc73632d7c31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:37:43 +0200 Subject: [PATCH 20/25] feat(data-stream): adopt the revised data-stream FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rust-sdks#1286 addressed the gaps raised in review; take them up: Stream closes are now reported (`onStreamClosed`), so ordered text topics go back to v1 semantics — a newly opened stream waits on handlers whose streams have already *closed*, not on whatever opened before it. A stream that stays open no longer head-of-line-blocks later streams from the same sender, which re-enables `orderedTopicDoesNotDelayOverlappingStreams`. Entries are dropped when a handler returns, so neither map grows without bound. `handlePacketReceived` takes the wire encryption type. Decryption consumes the packet field that carried it (`EncryptedPacket` shares a `oneof` with the stream payload), so `DataChannelPair` captures it beforehand and passes it alongside. That revives the core's header/chunk mismatch guard, which could not fire while every packet was reported as unencrypted, and lets inbound `StreamInfo` report the stream's real encryption type instead of the room's configured one. `EncryptionTypeMismatch` now carries both types, so the public error stops fabricating `.none`/`.none`. `onPacketsAvailable` is throwing. Its contract — return once the packets reach the transport — can't be met from a synchronous callback when every send path is async, so the ordered pump still acknowledges early; noted in place. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/LiveKit/Core/DataChannelPair.swift | 10 +- Sources/LiveKit/Core/Room.swift | 7 +- .../DataStream/DataStreams+FFIDelegates.swift | 141 +++++++++++++ Sources/LiveKit/DataStream/DataStreams.swift | 197 ++++++------------ Sources/LiveKit/DataStream/StreamError.swift | 3 +- Sources/LiveKit/E2EE/Options.swift | 22 ++ .../DataStream/ByteStreamReaderTests.swift | 6 +- .../IncomingStreamManagerTests.swift | 2 +- .../DataStream/OrderedTextStreamTests.swift | 40 +--- .../DataStream/StreamInfoBridgeTests.swift | 23 +- .../DataStream/TextStreamReaderTests.swift | 6 +- 11 files changed, 274 insertions(+), 183 deletions(-) create mode 100644 Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift diff --git a/Sources/LiveKit/Core/DataChannelPair.swift b/Sources/LiveKit/Core/DataChannelPair.swift index 0f34a779e..ea120703c 100644 --- a/Sources/LiveKit/Core/DataChannelPair.swift +++ b/Sources/LiveKit/Core/DataChannelPair.swift @@ -23,7 +23,10 @@ internal import LiveKitWebRTC // MARK: - Internal delegate protocol DataChannelDelegate: AnyObject, Sendable { - func dataChannel(_ dataChannelPair: DataChannelPair, didReceiveDataPacket dataPacket: Livekit_DataPacket) + /// - Parameter encryptionType: the type declared on the packet *as received*, captured before + /// decryption. `EncryptedPacket` shares a protobuf `oneof` with the stream payload, so + /// decrypting overwrites it — the value is unrecoverable from `dataPacket` afterwards. + func dataChannel(_ dataChannelPair: DataChannelPair, didReceiveDataPacket dataPacket: Livekit_DataPacket, encryptionType: EncryptionType) func dataChannel(_ dataChannelPair: DataChannelPair, didFailToDecryptDataPacket dataPacket: Livekit_DataPacket, error: LiveKitError) } @@ -675,6 +678,7 @@ extension DataChannelPair: LKRTCDataChannelDelegate { if let encryptedPacket = dataPacket.encryptedPacketOrNil, let e2eeManager = _state.e2eeManager { + let encryptionType = encryptedPacket.encryptionType.toLKType() do { let decryptedData = try e2eeManager.handle(encryptedData: encryptedPacket.toRTCEncryptedPacket(), participantIdentity: dataPacket.participantIdentity) let decryptedPayload = try Livekit_EncryptedPacketPayload(serializedBytes: decryptedData) @@ -682,7 +686,7 @@ extension DataChannelPair: LKRTCDataChannelDelegate { let dataPacket = dataPacket.modifying { decryptedPayload.applyTo(&$0) } delegates.notify { [dataPacket] in - $0.dataChannel(self, didReceiveDataPacket: dataPacket) + $0.dataChannel(self, didReceiveDataPacket: dataPacket, encryptionType: encryptionType) } } catch { log("Failed to decrypt data packet: \(error)", .error) @@ -692,7 +696,7 @@ extension DataChannelPair: LKRTCDataChannelDelegate { } } else { delegates.notify { - $0.dataChannel(self, didReceiveDataPacket: dataPacket) + $0.dataChannel(self, didReceiveDataPacket: dataPacket, encryptionType: .none) } } } diff --git a/Sources/LiveKit/Core/Room.swift b/Sources/LiveKit/Core/Room.swift index 9f9980639..daac234d0 100644 --- a/Sources/LiveKit/Core/Room.swift +++ b/Sources/LiveKit/Core/Room.swift @@ -799,7 +799,7 @@ public extension Room { // MARK: - DataChannelDelegate extension Room: DataChannelDelegate { - func dataChannel(_: DataChannelPair, didReceiveDataPacket dataPacket: Livekit_DataPacket) { + func dataChannel(_: DataChannelPair, didReceiveDataPacket dataPacket: Livekit_DataPacket, encryptionType: EncryptionType) { switch dataPacket.value { case let .speaker(update): engine(self, didUpdateSpeakers: update.speakers) case let .user(userPacket): engine(self, didReceiveUserPacket: userPacket, encryptionType: dataPacket.encryptedPacket.encryptionType.toLKType()) @@ -809,8 +809,9 @@ extension Room: DataChannelDelegate { case let .rpcRequest(request): room(didReceiveRpcRequest: request, from: dataPacket.participantIdentity) case .streamHeader, .streamChunk, .streamTrailer: // Forward the whole (already-decrypted, deduped) packet; the UniFFI incoming manager - // decodes the stream header/chunk/trailer itself. - dataStreams.handleIncoming(dataPacket) + // decodes the stream header/chunk/trailer itself. The encryption type travels beside it + // because decryption consumed the packet field that carried it. + dataStreams.handleIncoming(dataPacket, encryptionType: encryptionType) default: return } } diff --git a/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift b/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift new file mode 100644 index 000000000..428c43401 --- /dev/null +++ b/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift @@ -0,0 +1,141 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation + +internal import LiveKitUniFFI + +// The objects the Rust core calls into. Split from `DataStreams` itself so the coordinator file +// stays about coordination; each type here is a thin adapter that forwards across the boundary. +extension DataStreams { + // MARK: - Incoming delegate + + /// Receives the incoming manager's stream-open callbacks and forwards them to the coordinator. + /// A separate object because the FFI manager retains its delegate strongly; holding the + /// coordinator weakly keeps this the weak link so teardown doesn't leak. + final class IncomingDelegate: LiveKitUniFFI.IncomingDataStreamManagerDelegate, @unchecked Sendable { + weak var coordinator: DataStreams? + + func onByteStreamOpened(reader: LiveKitUniFFI.ByteStreamReader, identity: String) { + coordinator?.handleByteStreamOpened(reader, identity: identity) + } + + func onTextStreamOpened(reader: LiveKitUniFFI.TextStreamReader, identity: String) { + coordinator?.handleTextStreamOpened(reader, identity: identity) + } + + func onStreamClosed(streamId: String, identity: String) { + coordinator?.handleStreamClosed(streamID: streamId, identity: identity) + } + } + + // MARK: - Outgoing delegate + + /// Receives the outgoing manager's encoded `DataPacket`s and sends them over the reliable data + /// channel via `Room.send(dataPacket:)` — preserving E2EE, reliable sequencing, and identity + /// stamping. The room is held weakly to avoid retaining it through the FFI manager. + /// + /// Order is structural, not incidental. A stream's packets must reach the SFU in emission order: + /// the receiver drops a chunk that arrives before its header and fails the stream outright on a + /// non-consecutive chunk index. The FFI calls `onPacketsAvailable` synchronously and strictly + /// sequentially, so this delegate only has to *preserve* that order — hence a single drain task + /// over an `AsyncStream`, rather than a task per callback racing to a serial executor. + /// Fully `Sendable`, not `@unchecked`: both stored properties are immutable and `Sendable`, so + /// there is no invariant here for a reviewer to have to take on trust. + final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate { + private let continuation: AsyncStream<[Data]>.Continuation + // Unstructured on purpose: the pump's lifetime is the delegate's, not any caller's. Wrapped so + // it is cancelled on deinit rather than outliving the object (SwiftLint enforces this shape). + private let pump: AnyTaskCancellable + + init(room: Room) { + let (stream, continuation) = AsyncStream.makeStream(of: [Data].self) + self.continuation = continuation + pump = Task.detached { [weak room] in + for await packets in stream { + guard let room else { return } + for data in packets { + guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { + room.log("Failed to decode outgoing data stream packet", .warning) + continue + } + try? await room.send(dataPacket: packet) + } + } + }.cancellable() + } + + deinit { + continuation.finish() + } + + func onPacketsAvailable(packets: [Data]) throws { + // The FFI asks us to return only once the packets reached the transport, so that the + // originating `write`/`send_*` bounds how fast a producer can enqueue. We can't honor + // that here: this callback is synchronous and every send path on `Room` is `async`, so + // waiting would mean blocking the calling thread on an async result — a synchronisation + // primitive this SDK doesn't allow, and one that would stall a Rust runtime thread. + // + // Handing off to the ordered pump keeps emission order and surfaces decode failures, but + // acknowledges before the wire write, so back-pressure and transport errors are still + // not propagated. Closing that gap needs `on_packets_available` to be an `async fn` on + // the Rust trait, which uniffi supports for foreign traits; raised upstream. + continuation.yield(packets) + } + } + + // MARK: - Remote participant registry + + /// Read access to the room's remote participants, used by the outgoing manager to resolve + /// broadcast recipients and decide compression eligibility. Client protocol/capabilities aren't + /// currently exposed on `RemoteParticipant`, so they default to none — compression stays off + /// until they're wired, which is a safe default (a non-compressed send always works). + final class Registry: LiveKitUniFFI.RemoteParticipantRegistryDelegate, @unchecked Sendable { + private weak var room: Room? + + init(room: Room) { + self.room = room + } + + private func participant(for identity: String) -> RemoteParticipant? { + room?.remoteParticipants.first { $0.key.stringValue == identity }?.value + } + + func remoteClientProtocol(identity: String) -> Int32 { + Int32(participant(for: identity)?.clientProtocol.rawValue ?? 0) + } + + func remoteCapabilities(identity: String) -> [LiveKitUniFFI.ClientCapability] { + participant(for: identity)?.capabilities.map(\.ffiValue) ?? [] + } + + func remoteIdentities() -> [String] { + guard let room else { return [] } + return room.remoteParticipants.keys.map(\.stringValue) + } + } +} + +private extension ClientCapability { + /// Bridges to the FFI enum. Kept here so `ClientCapability` itself stays free of any + /// `LiveKitUniFFI` import, matching how the rest of the public API is layered. + var ffiValue: LiveKitUniFFI.ClientCapability { + switch self { + case .packetTrailer: .packetTrailer + case .compressionDeflateRaw: .compressionDeflateRaw + } + } +} diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 9aca7ffa9..c96b3dd18 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -54,16 +54,24 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { private let textStreamHandlers = StateSync<[String: TextStreamHandler]>([:]) // Topics we've already logged a missing-handler warning for, to avoid log spam. private let failedTopics = StateSync>([]) - // Topics whose text handlers run in wire order. Successive streams from the *same sender* on - // such a topic have their handlers serialized so they process in arrival order. Used by internal - // consumers like transcription; off by default so concurrent consumers (e.g. RPC) aren't slowed. - // - // Keyed by sender identity within a topic — not by topic alone — so a still-open stream from one - // sender doesn't block a concurrent stream from another (e.g. an agent transcript arriving while - // a user transcript on the same topic is still streaming). A single sender's streams are - // sequential in practice, so per-sender serialization preserves ordering without stalling peers. + // Topics whose text handlers run in wire order. Used by internal consumers like transcription; + // off by default so concurrent consumers (e.g. RPC) aren't slowed. private let orderedTopics = StateSync>([]) - private let orderedTails = StateSync<[String: [String: Task]]>([:]) + + // Ordering is a wire *happens-before* relation: a stream that opened after another one closed + // must have its handler run after that one's. Streams that overlap on the wire are concurrent + // and must not delay each other — a live transcript arriving while an earlier message stream is + // still open has to be delivered immediately. + // + // So a newly opened stream waits on `finishingHandlers` — handlers whose stream has already + // closed but which haven't returned yet — and *not* on handlers of streams that are still open. + // `runningHandlers` holds the latter until the FFI reports the close, at which point the entry + // moves across. Both are keyed by stream id within a topic, and entries are removed when the + // handler returns, so neither grows without bound. + private let runningHandlers = StateSync<[String: [String: Task]]>([:]) + private let finishingHandlers = StateSync<[String: [String: Task]]>([:]) + // Stream id -> topic, so a close event (which carries no topic) can find its queue. + private let streamTopics = StateSync<[String: String]>([:]) init(room: Room) { self.room = room @@ -95,10 +103,9 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { } } - // Room-level encryption type, stamped onto every stream info as it crosses the FFI boundary. - // The FFI hardcodes the info's encryption type to `.none` (E2EE-over-FFI is a follow-up); the - // actual payload crypto still happens transparently in `DataChannelPair`, so we surface the - // room's data-channel encryption type here to preserve the previous behavior. + // Encryption type for *outgoing* stream info. Inbound infos carry the real per-packet value + // reported by the FFI, which `handleIncoming` now supplies; there is no equivalent signal for a + // stream we're sending, so the room's data-channel setting is the accurate answer there. private var currentEncryptionType: EncryptionType { room?.e2eeManager?.dataChannelEncryptionType ?? .none } @@ -142,7 +149,8 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { func unregisterTextStreamHandler(for topic: String) { textStreamHandlers.mutate { $0[topic] = nil } orderedTopics.mutate { $0.remove(topic) } - orderedTails.mutate { $0[topic] = nil } + runningHandlers.mutate { $0[topic] = nil } + finishingHandlers.mutate { $0[topic] = nil } } // MARK: - Sending @@ -195,9 +203,13 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// Feeds a received data-stream packet (already decrypted and deduped by `DataChannelPair`) to /// the incoming manager. The FFI re-decodes the serialized `DataPacket` itself. - func handleIncoming(_ dataPacket: Livekit_DataPacket) { + /// + /// `encryptionType` is passed separately because decryption consumes the packet field that + /// carried it; the core compares it against the stream's header to reject a sender that mixes + /// encrypted and plaintext frames within one stream. + func handleIncoming(_ dataPacket: Livekit_DataPacket, encryptionType: EncryptionType) { guard let data = try? dataPacket.serializedData() else { return } - incomingManager().handlePacketReceived(packet: data) + incomingManager().handlePacketReceived(packet: data, encryptionType: encryptionType.ffiValue) } // MARK: - Stream lifecycle @@ -229,8 +241,9 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { // MARK: - Stream open dispatch (called from the incoming delegate) - fileprivate func handleByteStreamOpened(_ ffiReader: LiveKitUniFFI.ByteStreamReader, identity: String) { - let info = ByteStreamInfo(ffiReader.info(), encryptionType: currentEncryptionType) + func handleByteStreamOpened(_ ffiReader: LiveKitUniFFI.ByteStreamReader, identity: String) { + let ffiInfo = ffiReader.info() + let info = ByteStreamInfo(ffiInfo, encryptionType: EncryptionType(ffiInfo.encryptionType)) guard let handler = byteStreamHandlers.copy()[info.topic] else { logMissingHandler(topic: info.topic, id: info.id, identity: identity) return @@ -240,35 +253,57 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { Task.detachedDiscarding { try await handler(reader, participantIdentity) } } - fileprivate func handleTextStreamOpened(_ ffiReader: LiveKitUniFFI.TextStreamReader, identity: String) { - let info = TextStreamInfo(ffiReader.info(), encryptionType: currentEncryptionType) + func handleTextStreamOpened(_ ffiReader: LiveKitUniFFI.TextStreamReader, identity: String) { + let ffiInfo = ffiReader.info() + let info = TextStreamInfo(ffiInfo, encryptionType: EncryptionType(ffiInfo.encryptionType)) guard let handler = textStreamHandlers.copy()[info.topic] else { logMissingHandler(topic: info.topic, id: info.id, identity: identity) return } let reader = TextStreamReader(ffiReader, info: info) let participantIdentity = Participant.Identity(from: identity) - guard orderedTopics.copy().contains(info.topic) else { + let topic = info.topic + guard orderedTopics.copy().contains(topic) else { Task.detachedDiscarding { try await handler(reader, participantIdentity) } return } - // Ordered topic: chain this handler after the previous one from the *same sender* so that - // sender's successive streams process in arrival order — while streams from other senders on - // the topic run concurrently (a still-open stream from one sender never blocks another's). - let topic = info.topic - orderedTails.mutate { tails in - let predecessor = tails[topic]?[identity] - tails[topic, default: [:]][identity] = Task.detached { [weak self] in - await predecessor?.value + // Ordered topic. Wait only on handlers whose streams have already closed: this stream opened + // after they ended, so it comes after them on the wire. Streams still open right now overlap + // with this one and must not gate it. + let streamID = info.id + streamTopics.mutate { $0[streamID] = topic } + let predecessors = Array(finishingHandlers.copy()[topic]?.values ?? [:].values) + runningHandlers.mutate { running in + running[topic, default: [:]][streamID] = Task.detached { [weak self] in + for predecessor in predecessors { + await predecessor.value + } do { try await handler(reader, participantIdentity) } catch { self?.log("Ordered text stream handler for topic '\(topic)' threw: \(error)", .warning) } + self?.handlerCompleted(topic: topic, streamID: streamID) } } } + /// The stream closed on the wire. Its handler may still be running, and until it returns it gates + /// streams that open from now on — so move it out of `runningHandlers` and into the set later + /// streams wait for. + func handleStreamClosed(streamID: String, identity _: String) { + guard let topic = streamTopics.copy()[streamID] else { return } + guard let task = runningHandlers.mutate({ $0[topic]?.removeValue(forKey: streamID) }) else { return } + finishingHandlers.mutate { $0[topic, default: [:]][streamID] = task } + } + + /// Handler returned: it no longer gates anything, so drop it and stop tracking its stream. + private func handlerCompleted(topic: String, streamID: String) { + runningHandlers.mutate { $0[topic]?.removeValue(forKey: streamID) } + finishingHandlers.mutate { $0[topic]?.removeValue(forKey: streamID) } + streamTopics.mutate { $0[streamID] = nil } + } + private func logMissingHandler(topic: String, id: String, identity: String) { let shouldLog = failedTopics.mutate { $0.insert(topic).inserted } guard shouldLog else { return } @@ -282,108 +317,4 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { throw StreamError(error) } } - - // MARK: - Incoming delegate - - /// Receives the incoming manager's stream-open callbacks and forwards them to the coordinator. - /// A separate object because the FFI manager retains its delegate strongly; holding the - /// coordinator weakly keeps this the weak link so teardown doesn't leak. - private final class IncomingDelegate: LiveKitUniFFI.IncomingDataStreamManagerDelegate, @unchecked Sendable { - weak var coordinator: DataStreams? - - func onByteStreamOpened(reader: LiveKitUniFFI.ByteStreamReader, identity: String) { - coordinator?.handleByteStreamOpened(reader, identity: identity) - } - - func onTextStreamOpened(reader: LiveKitUniFFI.TextStreamReader, identity: String) { - coordinator?.handleTextStreamOpened(reader, identity: identity) - } - } - - // MARK: - Outgoing delegate - - /// Receives the outgoing manager's encoded `DataPacket`s and sends them over the reliable data - /// channel via `Room.send(dataPacket:)` — preserving E2EE, reliable sequencing, and identity - /// stamping. The room is held weakly to avoid retaining it through the FFI manager. - /// - /// Order is structural, not incidental. A stream's packets must reach the SFU in emission order: - /// the receiver drops a chunk that arrives before its header and fails the stream outright on a - /// non-consecutive chunk index. The FFI calls `onPacketsAvailable` synchronously and strictly - /// sequentially, so this delegate only has to *preserve* that order — hence a single drain task - /// over an `AsyncStream`, rather than a task per callback racing to a serial executor. - /// Fully `Sendable`, not `@unchecked`: both stored properties are immutable and `Sendable`, so - /// there is no invariant here for a reviewer to have to take on trust. - private final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate { - private let continuation: AsyncStream<[Data]>.Continuation - // Unstructured on purpose: the pump's lifetime is the delegate's, not any caller's. Wrapped so - // it is cancelled on deinit rather than outliving the object (SwiftLint enforces this shape). - private let pump: AnyTaskCancellable - - init(room: Room) { - let (stream, continuation) = AsyncStream.makeStream(of: [Data].self) - self.continuation = continuation - pump = Task.detached { [weak room] in - for await packets in stream { - guard let room else { return } - for data in packets { - guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { - room.log("Failed to decode outgoing data stream packet", .warning) - continue - } - try? await room.send(dataPacket: packet) - } - } - }.cancellable() - } - - deinit { - continuation.finish() - } - - func onPacketsAvailable(packets: [Data]) { - continuation.yield(packets) - } - } - - // MARK: - Remote participant registry - - /// Read access to the room's remote participants, used by the outgoing manager to resolve - /// broadcast recipients and decide compression eligibility. Client protocol/capabilities aren't - /// currently exposed on `RemoteParticipant`, so they default to none — compression stays off - /// until they're wired, which is a safe default (a non-compressed send always works). - private final class Registry: LiveKitUniFFI.RemoteParticipantRegistryDelegate, @unchecked Sendable { - private weak var room: Room? - - init(room: Room) { - self.room = room - } - - private func participant(for identity: String) -> RemoteParticipant? { - room?.remoteParticipants.first { $0.key.stringValue == identity }?.value - } - - func remoteClientProtocol(identity: String) -> Int32 { - Int32(participant(for: identity)?.clientProtocol.rawValue ?? 0) - } - - func remoteCapabilities(identity: String) -> [LiveKitUniFFI.ClientCapability] { - participant(for: identity)?.capabilities.map(\.ffiValue) ?? [] - } - - func remoteIdentities() -> [String] { - guard let room else { return [] } - return room.remoteParticipants.keys.map(\.stringValue) - } - } -} - -private extension ClientCapability { - /// Bridges to the FFI enum. Kept here so `ClientCapability` itself stays free of any - /// `LiveKitUniFFI` import, matching how the rest of the public API is layered. - var ffiValue: LiveKitUniFFI.ClientCapability { - switch self { - case .packetTrailer: .packetTrailer - case .compressionDeflateRaw: .compressionDeflateRaw - } - } } diff --git a/Sources/LiveKit/DataStream/StreamError.swift b/Sources/LiveKit/DataStream/StreamError.swift index 168f3b0c0..85810c514 100644 --- a/Sources/LiveKit/DataStream/StreamError.swift +++ b/Sources/LiveKit/DataStream/StreamError.swift @@ -64,7 +64,8 @@ extension StreamError { case .Utf8, .Decompression: self = .decodeFailed case .LengthExceeded, .HeaderTooLarge, .PayloadTooLarge: self = .lengthExceeded case .Incomplete: self = .incomplete - case .EncryptionTypeMismatch: self = .encryptionTypeMismatch(expected: .none, received: .none) + case let .EncryptionTypeMismatch(expected, received): + self = .encryptionTypeMismatch(expected: EncryptionType(expected), received: EncryptionType(received)) case .AlreadyClosed, .InvalidHeader, .MissedChunk, .SendFailed, .Internal, .InvalidFileName: self = .terminated } diff --git a/Sources/LiveKit/E2EE/Options.swift b/Sources/LiveKit/E2EE/Options.swift index cc621b588..cda2fd813 100644 --- a/Sources/LiveKit/E2EE/Options.swift +++ b/Sources/LiveKit/E2EE/Options.swift @@ -16,6 +16,8 @@ import Foundation +internal import LiveKitUniFFI + @objc public enum EncryptionType: Int, Sendable { case none @@ -34,6 +36,26 @@ extension EncryptionType { } } +extension EncryptionType { + /// Bridges to and from the FFI enum. Lives here beside the protobuf conversions so the data + /// stream types stay free of encryption-specific mapping code. + var ffiValue: LiveKitUniFFI.EncryptionType { + switch self { + case .none: .none + case .gcm: .gcm + case .custom: .custom + } + } + + init(_ ffi: LiveKitUniFFI.EncryptionType) { + switch ffi { + case .none: self = .none + case .gcm: self = .gcm + case .custom: self = .custom + } + } +} + extension Livekit_Encryption.TypeEnum { func toLKType() -> EncryptionType { switch self { diff --git a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift index 5367d4b26..2c485c94f 100644 --- a/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/ByteStreamReaderTests.swift @@ -78,7 +78,7 @@ struct ByteStreamReaderTests { _ = manager } - @Test func info() async throws { + @Test func info() async { let (reader, manager) = await openReader() #expect(reader.info.topic == topic) #expect(reader.info.name == name) @@ -131,6 +131,8 @@ struct ByteStreamReaderTests { } func onTextStreamOpened(reader _: LiveKitUniFFI.TextStreamReader, identity _: String) {} + + func onStreamClosed(streamId _: String, identity _: String) {} } /// Opens a byte stream through the FFI incoming manager and returns the public reader plus the @@ -175,6 +177,6 @@ struct ByteStreamReaderTests { configure(&$0) } guard let data = try? packet.serializedData() else { return } - manager.handlePacketReceived(packet: data) + manager.handlePacketReceived(packet: data, encryptionType: .none) } } diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift index 35516892c..6b37ad43c 100644 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -265,6 +265,6 @@ struct IncomingStreamManagerTests: Sendable { $0.participantIdentity = participant.stringValue configure(&$0) } - coordinator.handleIncoming(packet) + coordinator.handleIncoming(packet, encryptionType: .none) } } diff --git a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift index 8cc044a00..2a683c113 100644 --- a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift @@ -44,8 +44,8 @@ private actor TestGate { // MARK: - Ordered topics /// The `ordered` text-stream contract is still implemented in Swift (``DataStreams`` chains handler -/// tasks); only chunk assembly moved to the Rust core. These are the v1 behavioral specs, re-pointed -/// at the coordinator, so the rewrite from per-topic to per-sender chaining stays honest. +/// tasks off the FFI's stream-open and stream-closed callbacks); only chunk assembly moved to the +/// Rust core. These are the v1 behavioral specs, re-pointed at the coordinator. extension IncomingStreamManagerTests { private func isAbnormalEnd(_ error: StreamError) -> Bool { if case .abnormalEnd = error { return true } @@ -108,25 +108,10 @@ extension IncomingStreamManagerTests { /// happens-before relation — it applies between streams that don't overlap, so it must be keyed /// on streams that have *closed*, not merely on the order streams opened in. /// - /// Kept running as a known issue rather than disabled, so it still compiles, still exercises the - /// path, and fails loudly if the behavior is ever fixed (at which point drop the - /// `withKnownIssue` wrappers). - /// - /// v1 chained a newly opened stream behind the handlers of streams that had already *closed* - /// (`IncomingStreamManager.finishingHandlers`); ``DataStreams`` chains on the order streams - /// *opened* in, per sender, so a stream that stays open head-of-line-blocks every later stream - /// from that sender on the topic. - /// - /// Restoring the v1 semantics needs a stream-closed signal. The Rust core already emits one — - /// `incoming::OutputEvent::TrailerReceived`, carrying the stream id, sender and topic — but the - /// UniFFI layer drops it, forwarding only `StreamOpened` to - /// `IncomingDataStreamManagerDelegate`. Surfacing it (or a purpose-built `onStreamClosed`) is - /// the fix; doing it Swift-side instead would mean parsing trailer packets in `handleIncoming` - /// again, which this migration deliberately stopped doing. - /// - /// Practical exposure is small: only internal consumers set `ordered`, senders close one segment - /// before opening the next, and the orphan case self-heals because `closeStreams(from:)` fires on - /// the disconnect that caused it. + /// Restored: the FFI now reports stream closes (`onStreamClosed`), so ``DataStreams`` chains a + /// newly opened stream behind handlers whose streams have already *closed* — matching v1 — rather + /// than behind whatever opened before it. Without that signal an open stream head-of-line-blocked + /// every later stream from the same sender on the topic. @Test func orderedTopicDoesNotDelayOverlappingStreams() async throws { let received = StateSync<[String]>([]) @@ -141,18 +126,13 @@ extension IncomingStreamManagerTests { feedTextChunk(streamID: "open-a", content: "a") await feedTextStream(chunks: ["b"], streamID: "b") - // Short timeout: this is expected to time out, so don't spend the default deadline on it. - await withKnownIssue("B's handler is queued behind still-open A") { - await waitUntil({ !received.copy().isEmpty }, timeout: 3) - #expect(received.copy() == ["b"]) - } + await waitUntil { !received.copy().isEmpty } + #expect(received.copy() == ["b"]) - // A still completes normally once its trailer arrives — but B only lands after it. + // A still completes normally once its trailer arrives. feedTextTrailer(streamID: "open-a") await waitUntil { received.copy().count >= 2 } - await withKnownIssue("A drains before B instead of after it") { - #expect(received.copy() == ["b", "a"]) - } + #expect(received.copy() == ["b", "a"]) coordinator.unregisterTextStreamHandler(for: topicName) } diff --git a/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift index 762f34c1f..b6e77f2ff 100644 --- a/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/StreamInfoBridgeTests.swift @@ -74,9 +74,9 @@ struct StreamInfoBridgeTests { #expect(LiveKit.TextStreamInfo(ffiTextInfo(replyToStreamId: nil), encryptionType: .none).replyToStreamID == nil) } - /// `StreamInfo.encryptionType` is stamped by the SDK from the room's data-channel encryption - /// setting, *not* taken from the FFI record — the per-packet type is unrecoverable after - /// decryption, since `encrypted_packet` shares a protobuf `oneof` with the stream payload. + /// The initializer takes the encryption type as a parameter rather than reading the FFI record, + /// so callers control it: inbound streams pass the wire value the core reports, outgoing ones the + /// room's data-channel setting (there is no per-packet value to read for a stream being sent). @Test func textInfoEncryptionTypeComesFromTheCallerNotTheFFI() { let ffi = ffiTextInfo(encryptionType: .gcm) #expect(LiveKit.TextStreamInfo(ffi, encryptionType: .none).encryptionType == .none) @@ -159,10 +159,17 @@ struct StreamInfoBridgeTests { #expect(StreamError(ffi) == expected) } - /// `EncryptionTypeMismatch` is currently unreachable — the FFI normalizes every packet's - /// encryption type to `.none`, so header and chunk can never disagree — and the mapping has no - /// real values to carry, so it reports `.none`/`.none`. - @Test func streamErrorEncryptionTypeMismatchHasNoRealPayload() { - #expect(StreamError(.EncryptionTypeMismatch) == .encryptionTypeMismatch(expected: .none, received: .none)) + /// `EncryptionTypeMismatch` now carries both types, so the bridge reports what actually + /// disagreed rather than a sentinel. Reachable again now that the wire encryption type is passed + /// to `handlePacketReceived`. + @Test func streamErrorEncryptionTypeMismatchCarriesBothTypes() { + #expect( + StreamError(.EncryptionTypeMismatch(expected: .gcm, received: .none)) + == .encryptionTypeMismatch(expected: .gcm, received: .none), + ) + #expect( + StreamError(.EncryptionTypeMismatch(expected: .none, received: .custom)) + == .encryptionTypeMismatch(expected: .none, received: .custom), + ) } } diff --git a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift index a9b1673c9..24e651fe0 100644 --- a/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/TextStreamReaderTests.swift @@ -68,7 +68,7 @@ struct TextStreamReaderTests { _ = manager } - @Test func info() async throws { + @Test func info() async { let (reader, manager) = await openReader() #expect(reader.info.topic == topic) #expect(reader.info.operationType == .create) @@ -84,6 +84,8 @@ struct TextStreamReaderTests { func onByteStreamOpened(reader _: LiveKitUniFFI.ByteStreamReader, identity _: String) {} + func onStreamClosed(streamId _: String, identity _: String) {} + func onTextStreamOpened(reader: LiveKitUniFFI.TextStreamReader, identity _: String) { let info = LiveKit.TextStreamInfo(reader.info(), encryptionType: .none) let publicReader = LiveKit.TextStreamReader(reader, info: info) @@ -136,6 +138,6 @@ struct TextStreamReaderTests { configure(&$0) } guard let data = try? packet.serializedData() else { return } - manager.handlePacketReceived(packet: data) + manager.handlePacketReceived(packet: data, encryptionType: .none) } } From e7947c4dae4ec7d9198a17322d694cce9cfb5367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 18 Aug 2026 12:50:59 +0200 Subject: [PATCH 21/25] feat(data-stream): await the outgoing delegate, propagating send failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prototypes the async-delegate change on the Rust side (a follow-up to rust-sdks#1286): `on_packets_available` becomes an `async fn` on the foreign trait, which uniffi supports and which generates `func onPacketsAvailable(packets:) async throws` here. That makes the FFI's stated contract implementable. The core awaits the call before pumping the next packet, so emission order holds without a Swift-side pump — the AsyncStream and its drain task are gone. The originating `write`/`send_*` stays pending until the packet reaches the transport, so a producer can no longer outrun it. And a failed send throws `PacketDeliveryError`, which fails that call and closes the stream. Covered by a test that was impossible to write before: after the room disconnects, `write` throws and `isOpen` reports false, where both previously reported success on a stream that could not be written. Co-Authored-By: Claude Opus 5 (1M context) --- .../DataStream/DataStreams+FFIDelegates.swift | 73 ++++++++----------- .../DataStream/OutgoingDeliveryTests.swift | 45 ++++++++++++ 2 files changed, 77 insertions(+), 41 deletions(-) create mode 100644 Tests/LiveKitCoreTests/DataStream/OutgoingDeliveryTests.swift diff --git a/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift b/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift index 428c43401..403a72f5f 100644 --- a/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift +++ b/Sources/LiveKit/DataStream/DataStreams+FFIDelegates.swift @@ -48,52 +48,43 @@ extension DataStreams { /// channel via `Room.send(dataPacket:)` — preserving E2EE, reliable sequencing, and identity /// stamping. The room is held weakly to avoid retaining it through the FFI manager. /// - /// Order is structural, not incidental. A stream's packets must reach the SFU in emission order: - /// the receiver drops a chunk that arrives before its header and fails the stream outright on a - /// non-consecutive chunk index. The FFI calls `onPacketsAvailable` synchronously and strictly - /// sequentially, so this delegate only has to *preserve* that order — hence a single drain task - /// over an `AsyncStream`, rather than a task per callback racing to a serial executor. - /// Fully `Sendable`, not `@unchecked`: both stored properties are immutable and `Sendable`, so - /// there is no invariant here for a reviewer to have to take on trust. - final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate { - private let continuation: AsyncStream<[Data]>.Continuation - // Unstructured on purpose: the pump's lifetime is the delegate's, not any caller's. Wrapped so - // it is cancelled on deinit rather than outliving the object (SwiftLint enforces this shape). - private let pump: AnyTaskCancellable + /// The callback is `async`, which is what lets this honor the FFI's contract: it returns only + /// once the packets have actually reached the transport. Three properties follow, none of which + /// needs machinery on this side. + /// + /// - **Order.** The core awaits this call before pumping the next packet, so calls arrive — and + /// complete — strictly in emission order. That matters: the receiver drops a chunk that + /// arrives before its header and fails the stream on a non-consecutive chunk index. + /// - **Back-pressure.** The originating `write`/`send_*` stays pending until this returns, so a + /// producer can't outrun the transport and queue unboundedly. + /// - **Failures.** Throwing `PacketDeliveryError` fails that originating call with + /// `.sendFailed` and closes the stream, so `isOpen` reflects reality. + /// + /// `@unchecked Sendable` for the weak back-reference alone: it is assigned in `init` and never + /// mutated afterwards. + final class OutgoingDelegate: LiveKitUniFFI.OutgoingDataStreamManagerDelegate, @unchecked Sendable { + private weak var room: Room? init(room: Room) { - let (stream, continuation) = AsyncStream.makeStream(of: [Data].self) - self.continuation = continuation - pump = Task.detached { [weak room] in - for await packets in stream { - guard let room else { return } - for data in packets { - guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { - room.log("Failed to decode outgoing data stream packet", .warning) - continue - } - try? await room.send(dataPacket: packet) - } - } - }.cancellable() + self.room = room } - deinit { - continuation.finish() - } + func onPacketsAvailable(packets: [Data]) async throws { + // The room is gone, so there is no transport left to fail against; the manager is being + // torn down with it. + guard let room else { return } - func onPacketsAvailable(packets: [Data]) throws { - // The FFI asks us to return only once the packets reached the transport, so that the - // originating `write`/`send_*` bounds how fast a producer can enqueue. We can't honor - // that here: this callback is synchronous and every send path on `Room` is `async`, so - // waiting would mean blocking the calling thread on an async result — a synchronisation - // primitive this SDK doesn't allow, and one that would stall a Rust runtime thread. - // - // Handing off to the ordered pump keeps emission order and surfaces decode failures, but - // acknowledges before the wire write, so back-pressure and transport errors are still - // not propagated. Closing that gap needs `on_packets_available` to be an `async fn` on - // the Rust trait, which uniffi supports for foreign traits; raised upstream. - continuation.yield(packets) + for data in packets { + guard let packet = try? Livekit_DataPacket(serializedBytes: data) else { + room.log("Failed to decode outgoing data stream packet", .warning) + continue + } + do { + try await room.send(dataPacket: packet) + } catch { + throw LiveKitUniFFI.PacketDeliveryError.Failed(reason: String(describing: error)) + } + } } } diff --git a/Tests/LiveKitCoreTests/DataStream/OutgoingDeliveryTests.swift b/Tests/LiveKitCoreTests/DataStream/OutgoingDeliveryTests.swift new file mode 100644 index 000000000..85f3ee027 --- /dev/null +++ b/Tests/LiveKitCoreTests/DataStream/OutgoingDeliveryTests.swift @@ -0,0 +1,45 @@ +/* + * Copyright 2026 LiveKit + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import Foundation +import LiveKit +import Testing +#if canImport(LiveKitTestSupport) +import LiveKitTestSupport +#endif + +/// The outgoing delegate is `async`, so a transport failure propagates back to the call that caused +/// it. While it returned `Void` neither of these held: a failed send was swallowed, `write` resolved +/// as if it had succeeded, and `isOpen` stayed `true` on a stream that could no longer be written. +@Suite(.serialized, .tags(.dataStream, .e2e)) +struct OutgoingDeliveryTests { + @Test func writeFailsAndClosesStreamWhenTransportIsGone() async throws { + try await TestEnvironment.withRooms([RoomTestingOptions(canSubscribe: true), RoomTestingOptions(canPublishData: true)]) { rooms in + let sender = rooms[1] + let writer = try await sender.localParticipant.streamBytes(options: StreamByteOptions(topic: "delivery")) + + try await writer.write(Data(repeating: 0x01, count: 1024)) + await #expect(writer.isOpen) + + await sender.disconnect() + + await #expect(throws: StreamError.terminated) { + try await writer.write(Data(repeating: 0x02, count: 1024)) + } + await #expect(!writer.isOpen) + } + } +} From 0d1391af66c8ef221a5a9af7ffef0f0d79c06f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 18 Aug 2026 13:00:33 +0200 Subject: [PATCH 22/25] test(data-stream): wait on open-stream count instead of handler entry The FFI now exposes `open_stream_count`, restoring the introspection v1 had on its manager. The abort-path tests were inferring "stream is open" from their handler having been dispatched, which measures a different thing and would stop being equivalent if dispatch moved relative to registration. Co-Authored-By: Claude Opus 5 (1M context) --- Sources/LiveKit/DataStream/DataStreams.swift | 8 ++++++++ .../DataStream/OrderedTextStreamTests.swift | 17 +++++++++++------ 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index c96b3dd18..8d61c552b 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -212,6 +212,14 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { incomingManager().handlePacketReceived(packet: data, encryptionType: encryptionType.ffiValue) } + /// Number of incoming streams currently open. Restores the introspection v1 exposed on its + /// manager: it lets a caller wait for a stream's descriptor to actually register before driving + /// the abort paths, rather than inferring it from a handler having been dispatched. + func openStreamCount() async -> UInt64 { + guard let manager = _incoming.copy() else { return 0 } + return await manager.openStreamCount() + } + // MARK: - Stream lifecycle /// Fails all open incoming streams so their handlers return (e.g. on cleanup). A handler blocked diff --git a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift index 2a683c113..c659a813f 100644 --- a/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/OrderedTextStreamTests.swift @@ -52,6 +52,15 @@ extension IncomingStreamManagerTests { return false } + /// `handleIncoming` only enqueues onto the core's run loop, so a test that drives the abort + /// paths directly must first wait for the stream's descriptor to register. + private func waitForOpenStreams(_ count: UInt64) async { + let deadline = Date().addingTimeInterval(10) + while await coordinator.openStreamCount() < count, Date() < deadline { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + private func waitUntil(_ condition: @Sendable () -> Bool, timeout: TimeInterval = 10) async { let deadline = Date().addingTimeInterval(timeout) while !condition(), Date() < deadline { @@ -141,10 +150,8 @@ extension IncomingStreamManagerTests { @Test func closeStreamsUnblocksOrderedTopic() async throws { let received = StateSync<[String]>([]) let errors = StateSync<[StreamError]>([]) - let entered = TestGate() try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - await entered.open() do { let payload = try await reader.readAll() received.mutate { $0.append(payload) } @@ -157,7 +164,7 @@ extension IncomingStreamManagerTests { // Header only — no trailer ever arrives, so the handler blocks in `readAll` and, at the head // of the ordered queue, would block every later stream. feedTextHeader(streamID: "orphan") - await entered.wait() + await waitForOpenStreams(1) coordinator.closeStreams(from: participant) await feedTextStream(chunks: ["after"]) @@ -176,16 +183,14 @@ extension IncomingStreamManagerTests { /// handlers registered for after a reconnect. @Test func resetUnblocksOrderedTopicAndKeepsHandler() async throws { let received = StateSync<[String]>([]) - let entered = TestGate() try coordinator.registerTextStreamHandler(for: topicName, ordered: true) { reader, _ in - await entered.open() let payload = try await reader.readAll() received.mutate { $0.append(payload) } } feedTextHeader(streamID: "orphan") - await entered.wait() + await waitForOpenStreams(1) coordinator.reset() await feedTextStream(chunks: ["after-reset"]) From 7f82f82370287249996c6795d966ad0959295587 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:15:34 +0200 Subject: [PATCH 23/25] test(data-stream): restore the encryption-type mismatch coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core now holds trailers to their stream's encryption type as well as chunks (rust-sdks 1da01b49), and the wire type reaches it from `handleIncoming`, so the v1 behavior this PR dropped is reachable again — and now covers the trailer path the v1 Swift implementation also checked. Parameterized over which packet downgrades, since merging trailer attributes is the more interesting of the two: that is how an unencrypted peer could otherwise close someone else's encrypted stream and inject attributes on the way out. Co-Authored-By: Claude Opus 5 (1M context) --- .../IncomingStreamManagerTests.swift | 38 +++++++++++++------ 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift index 6b37ad43c..a9833fa7b 100644 --- a/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/IncomingStreamManagerTests.swift @@ -165,9 +165,25 @@ struct IncomingStreamManagerTests: Sendable { #expect(error as? StreamError == .incomplete) } - // Note: the v1 `encryptionTypeMismatch` behavior is intentionally not ported. Data-stream - // encryption is now applied transparently at the `DataChannelPair` layer and the UniFFI boundary - // normalizes the per-packet encryption type, so a header/chunk mismatch can no longer occur here. + /// A stream is opened as encrypted; a peer then tries to continue it in the clear. Both the + /// chunk and the trailer paths must reject that — the trailer especially, since merging its + /// attributes is how an unencrypted peer could otherwise close someone else's encrypted stream + /// and inject attributes on the way out. + @Test(arguments: [false, true]) + func encryptionTypeMismatch(onTrailer: Bool) async { + let error = await byteReaderError { streamID in + feedHeader(streamID: streamID, byte: true, encryptionType: .gcm) + feedChunk( + streamID: streamID, + index: 0, + content: Data(repeating: 0xAB, count: 16), + encryptionType: onTrailer ? .gcm : .none, + ) + feedTrailer(streamID: streamID, reason: "", encryptionType: onTrailer ? .none : .gcm) + } + + #expect(error as? StreamError == .encryptionTypeMismatch(expected: .gcm, received: .none)) + } // MARK: - Helpers @@ -231,7 +247,7 @@ struct IncomingStreamManagerTests: Sendable { feedTrailer(streamID: streamID, reason: "") } - func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil) { + func feedHeader(streamID: String, byte: Bool, totalLength: UInt64? = nil, encryptionType: EncryptionType = .none) { let header = Livekit_DataStream.Header.with { $0.streamID = streamID $0.topic = topicName @@ -240,31 +256,31 @@ struct IncomingStreamManagerTests: Sendable { : .textHeader(Livekit_DataStream.TextHeader()) if let totalLength { $0.totalLength = totalLength } } - feed { $0.streamHeader = header } + feed(encryptionType: encryptionType) { $0.streamHeader = header } } - func feedChunk(streamID: String, index: UInt64, content: Data) { + func feedChunk(streamID: String, index: UInt64, content: Data, encryptionType: EncryptionType = .none) { let chunk = Livekit_DataStream.Chunk.with { $0.streamID = streamID $0.chunkIndex = index $0.content = content } - feed { $0.streamChunk = chunk } + feed(encryptionType: encryptionType) { $0.streamChunk = chunk } } - func feedTrailer(streamID: String, reason: String) { + func feedTrailer(streamID: String, reason: String, encryptionType: EncryptionType = .none) { let trailer = Livekit_DataStream.Trailer.with { $0.streamID = streamID $0.reason = reason } - feed { $0.streamTrailer = trailer } + feed(encryptionType: encryptionType) { $0.streamTrailer = trailer } } - private func feed(_ configure: (inout Livekit_DataPacket.Builder) -> Void) { + private func feed(encryptionType: EncryptionType = .none, _ configure: (inout Livekit_DataPacket.Builder) -> Void) { let packet = Livekit_DataPacket.with { $0.participantIdentity = participant.stringValue configure(&$0) } - coordinator.handleIncoming(packet, encryptionType: .none) + coordinator.handleIncoming(packet, encryptionType: encryptionType) } } From 51bcce3f03172f6123f0dec19a2aa69e33c6a961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Wed, 19 Aug 2026 15:30:15 +0200 Subject: [PATCH 24/25] docs(data-stream): document the new options; reject a non-positive payload cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback on PR #1075. Every public member of `DataStreamOptions` now carries a docstring, per AGENTS.md. A negative `maxPayloadSize` reached `UInt64(_:)` at the FFI boundary and trapped the process on the first inbound packet. Non-positive values are normalized to `nil` at construction — the built-in cap — so the conversion can't trap, which also keeps the crash out of a consumer-supplied value. Adds the release changeset the PR was missing. Co-Authored-By: Claude Opus 5 (1M context) --- .changes/data-streams-v2 | 1 + Sources/LiveKit/DataStream/DataStreams.swift | 3 ++- .../LiveKit/Types/Options/DataStreamOptions.swift | 14 +++++++++++++- .../DataStream/StreamOptionsTests.swift | 8 ++++++++ 4 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .changes/data-streams-v2 diff --git a/.changes/data-streams-v2 b/.changes/data-streams-v2 new file mode 100644 index 000000000..acc7805db --- /dev/null +++ b/.changes/data-streams-v2 @@ -0,0 +1 @@ +minor type="changed" "Data streams: reimplemented on the Rust livekit-data-stream core over UniFFI, adding DEFLATE compression and single-packet inlining for one-shot sends when every recipient supports them, a configurable incoming payload cap via RoomOptions.dataStreamOptions, and Participant.capabilities" diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index 8d61c552b..c9c7f86a0 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -92,7 +92,8 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { let delegate = IncomingDelegate() delegate.coordinator = self // `nil` → the core's default cap. Read now (first packet, i.e. post-connect) so a - // `maxPayloadSize` supplied via `connect(roomOptions:)` is honored. + // `maxPayloadSize` supplied via `connect(roomOptions:)` is honored. `DataStreamOptions` + // normalizes non-positive values to `nil`, so the conversion below can't trap. let maxPayloadSize = room?._state.roomOptions.dataStreamOptions.maxPayloadSize let manager = LiveKitUniFFI.IncomingDataStreamManager( delegate: delegate, diff --git a/Sources/LiveKit/Types/Options/DataStreamOptions.swift b/Sources/LiveKit/Types/Options/DataStreamOptions.swift index 315812ef4..08fe9c2af 100644 --- a/Sources/LiveKit/Types/Options/DataStreamOptions.swift +++ b/Sources/LiveKit/Types/Options/DataStreamOptions.swift @@ -23,20 +23,32 @@ public final class DataStreamOptions: NSObject, Sendable { /// /// A stream whose reassembled payload would exceed this cap fails the read rather than buffering /// unbounded data. `nil` (the default) uses the SDK's built-in cap of 5gb. + /// + /// - Note: A value of zero or less is treated as `nil`, falling back to the built-in cap. public let maxPayloadSize: Int? + /// ``maxPayloadSize`` as an `NSNumber`, for Objective-C callers. + /// + /// `Int?` is not representable in Objective-C, so the optional is bridged rather than exposed + /// directly; `nil` here means the same as `nil` there. public var maxPayloadSizeNumber: NSNumber? { maxPayloadSize.map { NSNumber(value: $0) } } + /// Creates options with the given incoming payload cap. + /// + /// - Parameter maxPayloadSize: Cap in bytes, or `nil` for the SDK's built-in cap. Values of zero + /// or less are treated as `nil`. public init(maxPayloadSize: Int?) { - self.maxPayloadSize = maxPayloadSize + self.maxPayloadSize = maxPayloadSize.flatMap { $0 > 0 ? $0 : nil } } + /// Creates options with the default incoming payload cap. override public init() { maxPayloadSize = nil } + /// Objective-C-compatible initializer that accepts `NSNumber?` for ``maxPayloadSize``. public convenience init(maxPayloadSizeNumber: NSNumber?) { self.init(maxPayloadSize: maxPayloadSizeNumber?.intValue) } diff --git a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift index a0870d77f..c3a38a696 100644 --- a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift @@ -64,4 +64,12 @@ struct StreamOptionsTests { #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSizeNumber == 1000) #expect(DataStreamOptions(maxPayloadSizeNumber: 1000).maxPayloadSize == 1000) } + + /// A non-positive cap used to reach `UInt64(_:)` at the FFI boundary and trap the process on the + /// first inbound packet. Normalized to `nil` (the built-in cap) at construction instead. + @Test(arguments: [-1, 0, Int.min]) + func dataStreamNonPositiveMaxPayloadSizeIsIgnored(_ value: Int) { + #expect(DataStreamOptions(maxPayloadSize: value).maxPayloadSize == nil) + #expect(DataStreamOptions(maxPayloadSizeNumber: NSNumber(value: value)).maxPayloadSize == nil) + } } From 0d5dd1f310413c1feca345087409b1bccd818178 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 20 Aug 2026 13:47:36 -0400 Subject: [PATCH 25/25] fix: rename maxPayloadSize to maxPayloadByteLength to match other sdks --- Sources/LiveKit/DataStream/DataStreams.swift | 17 +++++------ .../Types/Options/DataStreamOptions.swift | 28 +++++++++---------- .../DataStream/StreamOptionsTests.swift | 22 +++++++-------- 3 files changed, 34 insertions(+), 33 deletions(-) diff --git a/Sources/LiveKit/DataStream/DataStreams.swift b/Sources/LiveKit/DataStream/DataStreams.swift index c9c7f86a0..9b274576b 100644 --- a/Sources/LiveKit/DataStream/DataStreams.swift +++ b/Sources/LiveKit/DataStream/DataStreams.swift @@ -39,9 +39,9 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { // Created lazily on the first inbound packet of a session, not at init: the incoming manager's // payload cap is fixed at construction (the FFI exposes no setter) and comes from the room's // options, which aren't finalized until `connect` — after this coordinator is built at - // `Room.init`. Deferring lets it pick up a `maxPayloadSize` passed at connect time, and `reset()` - // drops it at teardown so the *next* connect re-reads the cap rather than inheriting the first - // session's. StateSync-guarded so it's constructed exactly once even if packets race in. + // `Room.init`. Deferring lets it pick up a `maxPayloadByteLength` passed at connect time, and + // `reset()` drops it at teardown so the *next* connect re-reads the cap rather than inheriting + // the first session's. StateSync-guarded so it's constructed exactly once even if packets race in. private let _incoming = StateSync(nil) // Held weakly: the Room owns this coordinator, so the back-reference must not retain it. Used @@ -92,12 +92,13 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { let delegate = IncomingDelegate() delegate.coordinator = self // `nil` → the core's default cap. Read now (first packet, i.e. post-connect) so a - // `maxPayloadSize` supplied via `connect(roomOptions:)` is honored. `DataStreamOptions` - // normalizes non-positive values to `nil`, so the conversion below can't trap. - let maxPayloadSize = room?._state.roomOptions.dataStreamOptions.maxPayloadSize + // `maxPayloadByteLength` supplied via `connect(roomOptions:)` is honored. + // `DataStreamOptions` normalizes non-positive values to `nil`, so the conversion below + // can't trap. + let maxPayloadByteLength = room?._state.roomOptions.dataStreamOptions.maxPayloadByteLength let manager = LiveKitUniFFI.IncomingDataStreamManager( delegate: delegate, - maxPayloadByteLength: maxPayloadSize.map { UInt64($0) }, + maxPayloadByteLength: maxPayloadByteLength.map { UInt64($0) }, ) existing = manager return manager @@ -229,7 +230,7 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable { /// /// The incoming manager itself is discarded, not just drained: its payload cap is immutable after /// construction, so a fresh one has to be built for the next session to honor that session's - /// `maxPayloadSize`. It holds no handler state — that lives here — so this loses nothing. + /// `maxPayloadByteLength`. It holds no handler state — that lives here — so this loses nothing. func reset() { // No-op if the incoming manager was never created (no packets received): nothing is open. let existing = _incoming.mutate { manager -> LiveKitUniFFI.IncomingDataStreamManager? in diff --git a/Sources/LiveKit/Types/Options/DataStreamOptions.swift b/Sources/LiveKit/Types/Options/DataStreamOptions.swift index 08fe9c2af..c17a07ea6 100644 --- a/Sources/LiveKit/Types/Options/DataStreamOptions.swift +++ b/Sources/LiveKit/Types/Options/DataStreamOptions.swift @@ -25,44 +25,44 @@ public final class DataStreamOptions: NSObject, Sendable { /// unbounded data. `nil` (the default) uses the SDK's built-in cap of 5gb. /// /// - Note: A value of zero or less is treated as `nil`, falling back to the built-in cap. - public let maxPayloadSize: Int? + public let maxPayloadByteLength: Int? - /// ``maxPayloadSize`` as an `NSNumber`, for Objective-C callers. + /// ``maxPayloadByteLength`` as an `NSNumber`, for Objective-C callers. /// /// `Int?` is not representable in Objective-C, so the optional is bridged rather than exposed /// directly; `nil` here means the same as `nil` there. - public var maxPayloadSizeNumber: NSNumber? { - maxPayloadSize.map { NSNumber(value: $0) } + public var maxPayloadByteLengthNumber: NSNumber? { + maxPayloadByteLength.map { NSNumber(value: $0) } } /// Creates options with the given incoming payload cap. /// - /// - Parameter maxPayloadSize: Cap in bytes, or `nil` for the SDK's built-in cap. Values of zero - /// or less are treated as `nil`. - public init(maxPayloadSize: Int?) { - self.maxPayloadSize = maxPayloadSize.flatMap { $0 > 0 ? $0 : nil } + /// - Parameter maxPayloadByteLength: Cap in bytes, or `nil` for the SDK's built-in cap. Values of + /// zero or less are treated as `nil`. + public init(maxPayloadByteLength: Int?) { + self.maxPayloadByteLength = maxPayloadByteLength.flatMap { $0 > 0 ? $0 : nil } } /// Creates options with the default incoming payload cap. override public init() { - maxPayloadSize = nil + maxPayloadByteLength = nil } - /// Objective-C-compatible initializer that accepts `NSNumber?` for ``maxPayloadSize``. - public convenience init(maxPayloadSizeNumber: NSNumber?) { - self.init(maxPayloadSize: maxPayloadSizeNumber?.intValue) + /// Objective-C-compatible initializer that accepts `NSNumber?` for ``maxPayloadByteLength``. + public convenience init(maxPayloadByteLengthNumber: NSNumber?) { + self.init(maxPayloadByteLength: maxPayloadByteLengthNumber?.intValue) } // MARK: - Equal override public func isEqual(_ object: Any?) -> Bool { guard let other = object as? Self else { return false } - return maxPayloadSize == other.maxPayloadSize + return maxPayloadByteLength == other.maxPayloadByteLength } override public var hash: Int { var hasher = Hasher() - hasher.combine(maxPayloadSize) + hasher.combine(maxPayloadByteLength) return hasher.finalize() } } diff --git a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift index c3a38a696..5c3574940 100644 --- a/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift +++ b/Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift @@ -54,22 +54,22 @@ struct StreamOptionsTests { #expect(byte.totalSize == 42) } - @Test func dataStreamMaxPayloadSizeOption() { - #expect(DataStreamOptions().maxPayloadSize == nil) - #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSize == 1000) - #expect(RoomOptions().dataStreamOptions.maxPayloadSize == nil) - #expect(RoomOptions(dataStreamOptions: DataStreamOptions(maxPayloadSize: 42)).dataStreamOptions.maxPayloadSize == 42) + @Test func dataStreamMaxPayloadByteLengthOption() { + #expect(DataStreamOptions().maxPayloadByteLength == nil) + #expect(DataStreamOptions(maxPayloadByteLength: 1000).maxPayloadByteLength == 1000) + #expect(RoomOptions().dataStreamOptions.maxPayloadByteLength == nil) + #expect(RoomOptions(dataStreamOptions: DataStreamOptions(maxPayloadByteLength: 42)).dataStreamOptions.maxPayloadByteLength == 42) // Objective-C accessor mirrors the Swift `Int?`. - #expect(DataStreamOptions().maxPayloadSizeNumber == nil) - #expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSizeNumber == 1000) - #expect(DataStreamOptions(maxPayloadSizeNumber: 1000).maxPayloadSize == 1000) + #expect(DataStreamOptions().maxPayloadByteLengthNumber == nil) + #expect(DataStreamOptions(maxPayloadByteLength: 1000).maxPayloadByteLengthNumber == 1000) + #expect(DataStreamOptions(maxPayloadByteLengthNumber: 1000).maxPayloadByteLength == 1000) } /// A non-positive cap used to reach `UInt64(_:)` at the FFI boundary and trap the process on the /// first inbound packet. Normalized to `nil` (the built-in cap) at construction instead. @Test(arguments: [-1, 0, Int.min]) - func dataStreamNonPositiveMaxPayloadSizeIsIgnored(_ value: Int) { - #expect(DataStreamOptions(maxPayloadSize: value).maxPayloadSize == nil) - #expect(DataStreamOptions(maxPayloadSizeNumber: NSNumber(value: value)).maxPayloadSize == nil) + func dataStreamNonPositiveMaxPayloadByteLengthIsIgnored(_ value: Int) { + #expect(DataStreamOptions(maxPayloadByteLength: value).maxPayloadByteLength == nil) + #expect(DataStreamOptions(maxPayloadByteLengthNumber: NSNumber(value: value)).maxPayloadByteLength == nil) } }