Skip to content

Commit 701f538

Browse files
committed
feat: add ConnectOptions.dataStream with maxPayloadByteLength
The Rust core has always accepted a payload cap — it is the guard against a sender making a receiver allocate arbitrary memory, including via a compressed stream that inflates far past its wire size — but nothing exposed it, so `null` was hardcoded and every room ran with the core's 5 GB default. Placed on `ConnectOptions` rather than `RoomOptions`, which is where Swift puts it. It fits the lifecycle better here: the incoming manager is created lazily on the first inbound packet precisely so a connect-time value is in effect by the time it is read. Worth knowing it diverges from Swift if cross-SDK consistency matters more than that. Also enforced on web, which otherwise would have accepted the option and silently done nothing with it — the same trap as the ignored `connect(roomOptions:)` argument. The Dart path tracks accumulated content bytes per stream so an unknown-length stream is capped too, not just one that declares an oversized `totalLength`. Both paths follow the core's semantics, which are subtler than they look: the stream-opened event fires *before* the cap is applied, so the topic handler still runs and it is the reader that fails with `LengthExceeded`. A consumer is told the stream died rather than watching it never arrive. My first pass had web refusing the stream outright and the doc comment describing that; both are corrected here.
1 parent c14d0e7 commit 701f538

5 files changed

Lines changed: 190 additions & 11 deletions

File tree

.changes/data-stream-options

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
minor type="added" "ConnectOptions.dataStream with maxPayloadByteLength, bounding the payload a single incoming data stream may deliver"

lib/src/data_stream/data_streams_native.dart

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ class NativeDataStreams implements DataStreams {
7171
late final ffi.OutgoingDataStreamManager _outgoing;
7272
late final ffi.OutgoingPacketQueue _outgoingPackets;
7373

74-
/// Created on the first inbound packet rather than here, so a `maxPayloadSize` supplied at
75-
/// connect time is picked up.
74+
/// Created on the first inbound packet rather than here, so a
75+
/// [DataStreamOptions.maxPayloadByteLength] supplied at connect time is picked up.
7676
ffi.IncomingDataStreamManager? _incoming;
7777
ffi.IncomingStreamQueue? _incomingStreams;
7878

@@ -286,7 +286,11 @@ class NativeDataStreams implements DataStreams {
286286
ffi.IncomingDataStreamManager _incomingManager() {
287287
final existing = _incoming;
288288
if (existing != null) return existing;
289-
final incoming = ffi.polledIncomingDataStreamManager(maxPayloadByteLength: null);
289+
// Read now rather than at construction: this runs on the first inbound packet, i.e. after
290+
// connect, so a cap supplied via `connect(connectOptions:)` is in effect by this point.
291+
final incoming = ffi.polledIncomingDataStreamManager(
292+
maxPayloadByteLength: _room.target?.connectOptions.dataStream.maxPayloadByteLength,
293+
);
290294
_incoming = incoming.manager;
291295
_incomingStreams = incoming.streams;
292296
unawaited(_pumpIncoming(incoming.streams));

lib/src/data_stream/data_streams_web.dart

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import '../core/room.dart';
2626
import '../e2ee/options.dart';
2727
import '../internal/events.dart';
2828
import '../logger.dart';
29+
import '../options.dart';
2930
import '../proto/livekit_models.pb.dart' as lk_models;
3031
import '../types/data_stream.dart';
3132
import '../types/other.dart';
@@ -59,6 +60,32 @@ class WebDataStreams implements DataStreams {
5960
final Map<String, DataStreamController<lk_models.DataStream_Chunk>> _byteStreamControllers = {};
6061
final Map<String, DataStreamController<lk_models.DataStream_Chunk>> _textStreamControllers = {};
6162

63+
/// Content bytes delivered so far per stream id, checked against
64+
/// [DataStreamOptions.maxPayloadByteLength].
65+
final Map<String, int> _receivedBytes = {};
66+
67+
int get _maxPayloadByteLength => _room.connectOptions.dataStream.maxPayloadByteLength ?? kDefaultMaxPayloadByteLength;
68+
69+
/// Whether a stream declaring [totalLength] is over the payload cap. Streams of unknown length
70+
/// pass here and are capped as their chunks arrive instead.
71+
bool _declaresOverCap(int? totalLength) => totalLength != null && totalLength > _maxPayloadByteLength;
72+
73+
/// Fails an oversized stream's reader, after its handler has been given it.
74+
///
75+
/// Matches the Rust core, which emits the stream-opened event before applying the cap: the
76+
/// consumer is told the stream failed rather than never hearing about it.
77+
Future<void> _failOverCap(
78+
DataStreamController<lk_models.DataStream_Chunk> controller,
79+
String streamId,
80+
) async {
81+
logger.warning(
82+
'incoming stream $streamId exceeds the maxPayloadByteLength of $_maxPayloadByteLength',
83+
);
84+
controller.error(_payloadTooLarge());
85+
await controller.close();
86+
_forgetStream(streamId);
87+
}
88+
6289
@override
6390
void registerTextStreamHandler(String topic, TextStreamHandler callback) => textStreamHandlers[topic] = callback;
6491

@@ -124,6 +151,9 @@ class WebDataStreams implements DataStreams {
124151
_byteStreamControllers[streamHeader.streamId] = controller;
125152

126153
streamHandlerCallback(ByteStreamReader(info, controller, info.size), participantIdentity);
154+
if (_declaresOverCap(streamHeader.hasTotalLength() ? info.size : null)) {
155+
await _failOverCap(controller, streamHeader.streamId);
156+
}
127157
return;
128158
}
129159

@@ -166,6 +196,9 @@ class WebDataStreams implements DataStreams {
166196
_textStreamControllers[streamHeader.streamId] = controller;
167197

168198
streamHandlerCallback(TextStreamReader(info, controller, info.size), participantIdentity);
199+
if (_declaresOverCap(streamHeader.hasTotalLength() ? info.size : null)) {
200+
await _failOverCap(controller, streamHeader.streamId);
201+
}
169202
}
170203
}
171204

@@ -174,8 +207,14 @@ class WebDataStreams implements DataStreams {
174207
if (textController != null) {
175208
if (textController.info.encryptionType != encryptionType) {
176209
textController.error(_encryptionMismatch());
177-
_textStreamControllers.remove(chunk.streamId);
210+
_forgetStream(chunk.streamId);
178211
} else if (chunk.content.isNotEmpty) {
212+
if (_exceedsPayloadCap(chunk)) {
213+
textController.error(_payloadTooLarge());
214+
unawaited(textController.close());
215+
_forgetStream(chunk.streamId);
216+
return;
217+
}
179218
textController.write(chunk);
180219
}
181220
}
@@ -184,36 +223,61 @@ class WebDataStreams implements DataStreams {
184223
if (byteController != null) {
185224
if (byteController.info.encryptionType != encryptionType) {
186225
byteController.error(_encryptionMismatch());
187-
_byteStreamControllers.remove(chunk.streamId);
226+
_forgetStream(chunk.streamId);
188227
} else if (chunk.content.isNotEmpty) {
228+
if (_exceedsPayloadCap(chunk)) {
229+
byteController.error(_payloadTooLarge());
230+
unawaited(byteController.close());
231+
_forgetStream(chunk.streamId);
232+
return;
233+
}
189234
byteController.write(chunk);
190235
}
191236
}
192237
}
193238

239+
/// Accumulates this chunk against the stream's running total, returning true once the payload
240+
/// cap is passed.
241+
bool _exceedsPayloadCap(lk_models.DataStream_Chunk chunk) {
242+
final total = (_receivedBytes[chunk.streamId] ?? 0) + chunk.content.length;
243+
_receivedBytes[chunk.streamId] = total;
244+
return total > _maxPayloadByteLength;
245+
}
246+
247+
DataStreamError _payloadTooLarge() => DataStreamError(
248+
message: 'Stream payload exceeds the maxPayloadByteLength of $_maxPayloadByteLength',
249+
reason: DataStreamErrorReason.LengthExceeded,
250+
);
251+
252+
void _forgetStream(String streamId) {
253+
_textStreamControllers.remove(streamId);
254+
_byteStreamControllers.remove(streamId);
255+
_receivedBytes.remove(streamId);
256+
}
257+
194258
Future<void> _handleStreamTrailer(lk_models.DataStream_Trailer trailer, EncryptionType encryptionType) async {
195259
final textController = _textStreamControllers[trailer.streamId];
196260
if (textController != null) {
197261
if (textController.info.encryptionType != encryptionType) {
198262
textController.error(_encryptionMismatch());
199-
_textStreamControllers.remove(trailer.streamId);
263+
_forgetStream(trailer.streamId);
200264
return;
201265
}
202266
textController.info.attributes = {...textController.info.attributes, ...trailer.attributes};
203267
await textController.close();
204-
_textStreamControllers.remove(trailer.streamId);
268+
_forgetStream(trailer.streamId);
205269
}
206270

207271
final byteController = _byteStreamControllers[trailer.streamId];
208272
if (byteController != null) {
209273
if (byteController.info.encryptionType != encryptionType) {
210274
byteController.error(_encryptionMismatch());
211-
_byteStreamControllers.remove(trailer.streamId);
275+
_forgetStream(trailer.streamId);
212276
return;
213277
}
214278
byteController.info.attributes = {...byteController.info.attributes, ...trailer.attributes};
215279
await byteController.close();
216-
_byteStreamControllers.remove(trailer.streamId);
280+
_forgetStream(trailer.streamId);
217281
}
218282
}
219283

@@ -461,12 +525,12 @@ class WebDataStreams implements DataStreams {
461525
for (final controller in bytes) {
462526
controller.error(abnormalEndError);
463527
await controller.close();
464-
_byteStreamControllers.remove(controller.info.id);
528+
_forgetStream(controller.info.id);
465529
}
466530
for (final controller in texts) {
467531
controller.error(abnormalEndError);
468532
await controller.close();
469-
_textStreamControllers.remove(controller.info.id);
533+
_forgetStream(controller.info.id);
470534
}
471535
}
472536

@@ -477,6 +541,7 @@ class WebDataStreams implements DataStreams {
477541
}
478542
_textStreamControllers.clear();
479543
_byteStreamControllers.clear();
544+
_receivedBytes.clear();
480545
}
481546

482547
@override

lib/src/options.dart

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,15 +219,38 @@ class ConnectOptions {
219219

220220
final Timeouts timeouts;
221221

222+
/// Tuning for incoming data streams.
223+
final DataStreamOptions dataStream;
224+
222225
const ConnectOptions({
223226
this.autoSubscribe = true,
224227
this.rtcConfiguration = const RTCConfiguration(),
225228
this.protocolVersion = ProtocolVersion.v16,
226229
this.clientProtocolVersion = ClientProtocolVersion.current,
227230
this.timeouts = Timeouts.defaultTimeouts,
231+
this.dataStream = const DataStreamOptions(),
228232
});
229233
}
230234

235+
/// Options for receiving data streams.
236+
/// {@category Room}
237+
class DataStreamOptions {
238+
/// Largest payload, in bytes, that a single incoming stream may deliver.
239+
///
240+
/// Bounds the memory one sender can make a receiver allocate, including via a compressed stream
241+
/// that inflates far beyond its wire size. The topic handler is still invoked for an oversized
242+
/// stream — it is the *reader* that fails, with [DataStreamErrorReason.LengthExceeded] — so
243+
/// consumers find out rather than seeing a stream silently vanish.
244+
///
245+
/// `null` uses [kDefaultMaxPayloadByteLength].
246+
final int? maxPayloadByteLength;
247+
248+
const DataStreamOptions({this.maxPayloadByteLength});
249+
}
250+
251+
/// Default for [DataStreamOptions.maxPayloadByteLength]; matches the Rust core's own default.
252+
const int kDefaultMaxPayloadByteLength = 5000000000;
253+
231254
/// Options used to modify the behavior of the [Room].
232255
/// {@category Room}
233256
class RoomOptions {

test/core/data_stream_v2_test.dart

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,4 +282,90 @@ void main() {
282282
expect(fired, isFalse);
283283
});
284284
});
285+
286+
group('maxPayloadByteLength', () {
287+
test('a stream declaring more than the cap fails its reader', () async {
288+
// A fresh container so the cap is set at connect time, which is when the native manager
289+
// reads it.
290+
resetMockDataChannels();
291+
final capped = E2EContainer();
292+
addTearDown(capped.dispose);
293+
await capped.connectRoom(
294+
connectOptions: const ConnectOptions(
295+
dataStream: DataStreamOptions(maxPayloadByteLength: 16),
296+
),
297+
);
298+
299+
// The handler is still invoked — the core reports the stream opened before applying the
300+
// cap — and it is the read that fails.
301+
final outcome = Completer<Object?>();
302+
capped.room.registerTextStreamHandler('capped', (reader, identity) async {
303+
try {
304+
await reader.readAll();
305+
outcome.complete(null);
306+
} catch (e) {
307+
outcome.complete(e);
308+
}
309+
});
310+
311+
capped.deliverInboundDataPacket(
312+
lk_models.DataPacket(
313+
kind: lk_models.DataPacket_Kind.RELIABLE,
314+
participantIdentity: 'alice',
315+
streamHeader: lk_models.DataStream_Header(
316+
streamId: 'too-big',
317+
topic: 'capped',
318+
mimeType: 'text/plain',
319+
timestamp: Int64(DateTime.timestamp().millisecondsSinceEpoch),
320+
totalLength: Int64(1000),
321+
inlineContent: Uint8List.fromList(utf8.encode('x' * 1000)),
322+
textHeader: lk_models.DataStream_TextHeader(),
323+
),
324+
),
325+
);
326+
327+
final error = await outcome.future.timeout(const Duration(seconds: 5));
328+
expect(error, isA<DataStreamError>());
329+
expect(
330+
(error as DataStreamError).reason,
331+
DataStreamErrorReason.LengthExceeded,
332+
reason: 'the payload exceeds maxPayloadByteLength',
333+
);
334+
});
335+
336+
test('a stream within the cap is delivered', () async {
337+
resetMockDataChannels();
338+
final capped = E2EContainer();
339+
addTearDown(capped.dispose);
340+
await capped.connectRoom(
341+
connectOptions: const ConnectOptions(
342+
dataStream: DataStreamOptions(maxPayloadByteLength: 1000),
343+
),
344+
);
345+
346+
const text = 'small enough';
347+
final received = Completer<String>();
348+
capped.room.registerTextStreamHandler('capped', (reader, identity) async {
349+
received.complete(await reader.readAll());
350+
});
351+
352+
capped.deliverInboundDataPacket(
353+
lk_models.DataPacket(
354+
kind: lk_models.DataPacket_Kind.RELIABLE,
355+
participantIdentity: 'alice',
356+
streamHeader: lk_models.DataStream_Header(
357+
streamId: 'small',
358+
topic: 'capped',
359+
mimeType: 'text/plain',
360+
timestamp: Int64(DateTime.timestamp().millisecondsSinceEpoch),
361+
totalLength: Int64(utf8.encode(text).length),
362+
inlineContent: Uint8List.fromList(utf8.encode(text)),
363+
textHeader: lk_models.DataStream_TextHeader(),
364+
),
365+
),
366+
);
367+
368+
expect(await received.future, equals(text));
369+
});
370+
});
285371
}

0 commit comments

Comments
 (0)