Skip to content

Commit 53ecd83

Browse files
committed
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.
1 parent 0bb2ff2 commit 53ecd83

4 files changed

Lines changed: 102 additions & 16 deletions

File tree

Sources/LiveKit/Core/Room.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ public class Room: NSObject, @unchecked Sendable, ObservableObject, Loggable {
280280

281281
super.init()
282282

283-
dataStreams = DataStreams(room: self, maxPayloadSize: _state.roomOptions.dataStreamOptions.maxPayloadSize)
283+
dataStreams = DataStreams(room: self)
284284

285285
// log sdk & os versions
286286
log("sdk: \(LiveKitSDK.version), ffi: \(LiveKitSDK.ffiVersion), os: \(String(describing: Utils.os()))(\(Utils.osVersionString())), modelId: \(String(describing: Utils.modelIdentifier() ?? "unknown"))")

Sources/LiveKit/DataStream/DataStreams.swift

Lines changed: 30 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,14 @@ internal import LiveKitUniFFI
3434
/// delegates are immutable after init. Not an actor — the UniFFI delegate callbacks are synchronous
3535
/// and can't `await`.
3636
final class DataStreams: NSObject, @unchecked Sendable, Loggable {
37-
private let incoming: LiveKitUniFFI.IncomingDataStreamManager
3837
private let outgoing: LiveKitUniFFI.OutgoingDataStreamManager
3938

39+
// Created lazily on the first inbound packet, not at init: the incoming manager's payload cap
40+
// comes from the room's options, which aren't finalized until `connect` — after this coordinator
41+
// is built at `Room.init`. Deferring lets it pick up a `maxPayloadSize` passed at connect time.
42+
// StateSync-guarded so it's constructed exactly once even if packets race in.
43+
private let _incoming = StateSync<LiveKitUniFFI.IncomingDataStreamManager?>(nil)
44+
4045
// Held weakly: the Room owns this coordinator, so the back-reference must not retain it. Used
4146
// for the room-level encryption type stamped onto stream info, and for logging.
4247
private weak var room: Room?
@@ -58,22 +63,31 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable {
5863
private let orderedTopics = StateSync<Set<String>>([])
5964
private let orderedTails = StateSync<[String: [String: Task<Void, Never>]]>([:])
6065

61-
init(room: Room, maxPayloadSize: Int? = nil) {
66+
init(room: Room) {
6267
self.room = room
63-
let incomingDelegate = IncomingDelegate()
6468
let outgoingDelegate = OutgoingDelegate(room: room)
6569
let registry = Registry(room: room)
66-
// `maxPayloadSize` caps the reassembled size of an incoming stream (nil → the core's default
67-
// cap). Topic routing (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`,
68-
// matching the previous pure-Swift implementation.
69-
incoming = LiveKitUniFFI.IncomingDataStreamManager(
70-
delegate: incomingDelegate,
71-
maxPayloadByteLength: maxPayloadSize.map { UInt64($0) },
72-
)
7370
outgoing = LiveKitUniFFI.OutgoingDataStreamManager(delegate: outgoingDelegate, registry: registry)
7471
super.init()
75-
// The FFI manager retains its delegate strongly, so the delegate points back here weakly.
76-
incomingDelegate.coordinator = self
72+
}
73+
74+
/// The incoming manager, created on first use with the room's current payload cap. Topic routing
75+
/// (incl. the `lk.rpc` guard) is handled Swift-side in `Room+DataStream`.
76+
private func incomingManager() -> LiveKitUniFFI.IncomingDataStreamManager {
77+
_incoming.mutate { existing in
78+
if let existing { return existing }
79+
let delegate = IncomingDelegate()
80+
delegate.coordinator = self
81+
// `nil` → the core's default cap. Read now (first packet, i.e. post-connect) so a
82+
// `maxPayloadSize` supplied via `connect(roomOptions:)` is honored.
83+
let maxPayloadSize = room?._state.roomOptions.dataStreamOptions.maxPayloadSize
84+
let manager = LiveKitUniFFI.IncomingDataStreamManager(
85+
delegate: delegate,
86+
maxPayloadByteLength: maxPayloadSize.map { UInt64($0) },
87+
)
88+
existing = manager
89+
return manager
90+
}
7791
}
7892

7993
// 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 {
178192
/// the incoming manager. The FFI re-decodes the serialized `DataPacket` itself.
179193
func handleIncoming(_ dataPacket: Livekit_DataPacket) {
180194
guard let data = try? dataPacket.serializedData() else { return }
181-
incoming.handlePacketReceived(packet: data)
195+
incomingManager().handlePacketReceived(packet: data)
182196
}
183197

184198
// MARK: - Stream lifecycle
@@ -187,13 +201,14 @@ final class DataStreams: NSObject, @unchecked Sendable, Loggable {
187201
/// on a reader that will never finish would otherwise stall its topic's ordered queue. Handler
188202
/// registrations survive, so streams arriving after a reconnect are still handled.
189203
func reset() {
190-
incoming.abortAllStreams()
204+
// No-op if the incoming manager was never created (no packets received): nothing is open.
205+
_incoming.copy()?.abortAllStreams()
191206
}
192207

193208
/// Fails open incoming streams sent by `identity` (they disconnected mid-send), so their readers
194209
/// throw and their handlers return instead of hanging.
195210
func closeStreams(from identity: Participant.Identity) {
196-
incoming.abortStreamsFrom(identity: identity.stringValue)
211+
_incoming.copy()?.abortStreamsFrom(identity: identity.stringValue)
197212
}
198213

199214
// MARK: - Stream open dispatch (called from the incoming delegate)

Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,5 +59,9 @@ struct StreamOptionsTests {
5959
#expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSize == 1000)
6060
#expect(RoomOptions().dataStreamOptions.maxPayloadSize == nil)
6161
#expect(RoomOptions(dataStreamOptions: DataStreamOptions(maxPayloadSize: 42)).dataStreamOptions.maxPayloadSize == 42)
62+
// Objective-C accessor mirrors the Swift `Int?`.
63+
#expect(DataStreamOptions().maxPayloadSizeNumber == nil)
64+
#expect(DataStreamOptions(maxPayloadSize: 1000).maxPayloadSizeNumber == 1000)
65+
#expect(DataStreamOptions(maxPayloadSizeNumber: 1000).maxPayloadSize == 1000)
6266
}
6367
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2026 LiveKit
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
// Cross-ref: Tests/LiveKitCoreTests/DataStream/StreamOptionsTests.swift
18+
//
19+
// Guards that DataStreamOptions stays usable from Objective-C (it's a class, not a struct, so it
20+
// bridges) and that adding it to RoomOptions did not drop RoomOptions's Objective-C initializer.
21+
22+
@import XCTest;
23+
@import LiveKit;
24+
25+
@interface DataStreamOptionsObjCTests : XCTestCase
26+
@end
27+
28+
@implementation DataStreamOptionsObjCTests
29+
30+
- (void)testDataStreamOptionsConstructibleFromObjC {
31+
DataStreamOptions *defaults = [[DataStreamOptions alloc] init];
32+
XCTAssertNil(defaults.maxPayloadSizeNumber);
33+
34+
DataStreamOptions *capped = [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024];
35+
XCTAssertEqualObjects(capped.maxPayloadSizeNumber, @1024);
36+
37+
XCTAssertEqualObjects(capped, [[DataStreamOptions alloc] initWithMaxPayloadSizeNumber:@1024]);
38+
XCTAssertNotEqualObjects(capped, defaults);
39+
}
40+
41+
- (void)testRoomOptionsRetainsObjCInitializerWithDataStreamOptions {
42+
// Adding a value type here would have dropped RoomOptions's Objective-C initializer entirely
43+
// (Swift won't export an init that takes an ObjC-unrepresentable parameter). Because
44+
// DataStreamOptions is a class, the designated initializer — which takes `dataStreamOptions:` —
45+
// must remain callable from Objective-C. The sub-option parameters have their own
46+
// ObjC-unavailable initializers (pre-existing), so we assert the selector exists rather than
47+
// invoking it.
48+
SEL initializer = @selector(initWithDefaultCameraCaptureOptions:
49+
defaultScreenShareCaptureOptions:
50+
defaultAudioCaptureOptions:
51+
defaultVideoPublishOptions:
52+
defaultAudioPublishOptions:
53+
defaultDataPublishOptions:
54+
dataStreamOptions:
55+
adaptiveStream:
56+
dynacast:
57+
stopLocalTrackOnUnpublish:
58+
suspendLocalVideoTracksInBackground:
59+
e2eeOptions:
60+
encryptionOptions:
61+
reportRemoteTrackStatistics:
62+
singlePeerConnection:);
63+
XCTAssertTrue([RoomOptions instancesRespondToSelector:initializer],
64+
@"RoomOptions must keep its Objective-C initializer accepting dataStreamOptions:");
65+
}
66+
67+
@end

0 commit comments

Comments
 (0)