Repository navigation
Apply backpressure to native data stream sends #1266
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: data-streams-v2
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -80,9 +80,11 @@ class NativeDataStreams implements DataStreams { | |
| final Map<String, TextStreamHandler> _textStreamHandlers = {}; | ||
| final Map<String, ByteStreamHandler> _byteStreamHandlers = {}; | ||
|
|
||
| /// Serializes outbound sends so packet order survives the hop from the pump into the engine's | ||
| /// async send. | ||
| Future<void> _sendChain = Future.value(); | ||
| /// One per in-flight `send*` call, keyed by its stream id, completed by the pump once the stream's | ||
| /// last packet has gone to the engine. The core acknowledges a send as soon as its packets are | ||
| /// queued, so this is what holds `sendText`/`sendBytes`/`sendFile` until the data is actually out, | ||
| /// and lets a transport failure reach the caller. | ||
| final Map<String, Completer<void>> _pendingSends = {}; | ||
|
|
||
| bool _disposed = false; | ||
|
|
||
|
|
@@ -112,14 +114,17 @@ class NativeDataStreams implements DataStreams { | |
| // own byte stream referenced by `attachedStreamIds` in the text header. | ||
| final attachments = options?.attachments ?? const <File>[]; | ||
| final attachmentIds = [for (var i = 0; i < attachments.length; i++) const Uuid().v4()]; | ||
| final id = const Uuid().v4(); | ||
|
|
||
| final info = await mappingFfiErrors( | ||
| final info = await _sendTracked( | ||
| id, | ||
| () => _outgoing.sendText( | ||
| text: text, | ||
| options: ffi.StreamTextOptions( | ||
| topic: options?.topic ?? '', | ||
| attributes: options?.attributes ?? const {}, | ||
| destinationIdentities: options?.destinationIdentities ?? const [], | ||
| id: id, | ||
| attachedStreamIds: attachmentIds, | ||
| compress: options?.compress, | ||
| ), | ||
|
|
@@ -147,13 +152,16 @@ class NativeDataStreams implements DataStreams { | |
|
|
||
| @override | ||
| Future<ByteStreamInfo> sendBytes(List<int> bytes, SendBytesOptions? options) async { | ||
| final info = await mappingFfiErrors( | ||
| final id = const Uuid().v4(); | ||
| final info = await _sendTracked( | ||
| id, | ||
| () => _outgoing.sendBytes( | ||
| data: Uint8List.fromList(bytes), | ||
| options: ffi.StreamByteOptions( | ||
| topic: options?.topic ?? '', | ||
| attributes: options?.attributes ?? const {}, | ||
| destinationIdentities: options?.destinationIdentities ?? const [], | ||
| id: id, | ||
| name: options?.name, | ||
| mimeType: options?.mimeType, | ||
| compress: options?.compress, | ||
|
|
@@ -174,7 +182,8 @@ class NativeDataStreams implements DataStreams { | |
| } | ||
|
|
||
| Future<void> _sendFileWithId(String id, File file, SendFileOptions options) async { | ||
| await mappingFfiErrors( | ||
| await _sendTracked( | ||
| id, | ||
| () => _outgoing.sendFile( | ||
| // The core streams the file from disk rather than buffering it. | ||
| path: file.path, | ||
|
|
@@ -243,8 +252,32 @@ class NativeDataStreams implements DataStreams { | |
| ); | ||
| } | ||
|
|
||
| /// Runs a one-shot core send for stream [id] and resolves once its last packet has been handed to | ||
| /// the engine, rather than when the core has merely queued it. | ||
| /// | ||
| /// Registered before the core call: the pump can put the whole stream out before that call | ||
| /// returns. | ||
| Future<T> _sendTracked<T>(String id, Future<T> Function() send) async { | ||
| final sent = Completer<void>(); | ||
| // The pump may fail this before we get to await it. | ||
| sent.future.ignore(); | ||
| _pendingSends[id] = sent; | ||
| try { | ||
| final result = await mappingFfiErrors(send); | ||
| await sent.future; | ||
| return result; | ||
| } finally { | ||
| _pendingSends.remove(id); | ||
| } | ||
| } | ||
|
|
||
| /// Drains outbound packets from the core and puts them on the wire, in order. | ||
| /// | ||
| /// Each packet waits for the reliable data channel's buffer to drain below its low threshold, and | ||
| /// the next batch is pulled only once this one is out. The core acknowledges sends as soon as it | ||
| /// has queued them, so this is the only flow control on the path: without it a large send is | ||
| /// drained into Dart all at once and pushed at the channel faster than it can empty. | ||
| /// | ||
| /// The pump owns the queue's lifetime and is the only thing that may dispose it — freeing it | ||
| /// while a `nextPackets` is in flight is a use-after-free. [dispose] wakes us by closing the | ||
| /// queue rather than releasing it. | ||
|
|
@@ -254,32 +287,57 @@ class NativeDataStreams implements DataStreams { | |
| final batch = await _outgoingPackets.nextPackets(); | ||
| if (batch == null) break; // closed or shutting down | ||
| for (final encoded in batch) { | ||
| _enqueueSend(encoded); | ||
| await _sendOutbound(lk_models.DataPacket.fromBuffer(encoded)); | ||
| } | ||
| } | ||
| } catch (e) { | ||
| logger.warning('[DataStreams] outgoing pump failed: $e'); | ||
| } finally { | ||
| _outgoingPackets.dispose(); | ||
| for (final sent in _pendingSends.values.toList()) { | ||
| if (!sent.isCompleted) { | ||
| sent.completeError( | ||
| DataStreamError( | ||
| reason: DataStreamErrorReason.AbnormalEnd, | ||
| message: 'Data streams were shut down before the stream was sent', | ||
| ), | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| void _enqueueSend(Uint8List encoded) { | ||
| _sendChain = _sendChain.then((_) async { | ||
| Future<void> _sendOutbound(lk_models.DataPacket packet) async { | ||
| final streamId = switch (packet.whichValue()) { | ||
| lk_models.DataPacket_Value.streamHeader => packet.streamHeader.streamId, | ||
| lk_models.DataPacket_Value.streamChunk => packet.streamChunk.streamId, | ||
| lk_models.DataPacket_Value.streamTrailer => packet.streamTrailer.streamId, | ||
| _ => null, | ||
| }; | ||
| // A stream ends with its trailer, or with its only packet when the content was inlined. | ||
| final isLast = packet.hasStreamTrailer() || (packet.hasStreamHeader() && packet.streamHeader.hasInlineContent()); | ||
| try { | ||
| final room = _room.target; | ||
| if (room == null || _disposed) return; | ||
| try { | ||
| // Back through the engine rather than the data channel directly, so E2EE wrapping, | ||
| // reliable sequencing and resume-resend all still apply. | ||
| await room.engine.sendDataPacket( | ||
| lk_models.DataPacket.fromBuffer(encoded), | ||
| reliability: Reliability.reliable, | ||
| if (room == null || _disposed) { | ||
| throw StateError('the room was disposed'); | ||
| } | ||
| await room.engine.waitForBufferStatusLow(Reliability.reliable); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Backpressured streams retain engine listeners Each backpressured packet calls Learn moreThe engine's waitForBufferStatusLow registers an Example: A long file send fills the channel, drains, then fills it again 50 times. Each drain unblocks the send but leaves one EngineClosingEvent listener alive until the room closes. Recommended fix: Change Was this helpful? React with 👍 or 👎 to provide feedback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Native send behavior lacks a changeset The backpressure change affects published sends, but this PR adds no Learn moreThe repository's release rule requires a changeset for every PR affecting the published package. This PR changes when native Example: Users upgrading to the next release see new backpressure and send errors but find no corresponding entry for the change in the changesets contributed by this PR. Recommended fix: Add a new Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| // Back through the engine rather than the data channel directly, so E2EE wrapping, | ||
| // reliable sequencing and resume-resend all still apply. | ||
| await room.engine.sendDataPacket(packet, reliability: Reliability.reliable); | ||
| if (isLast) _pendingSends[streamId]?.complete(); | ||
| } catch (e) { | ||
| logger.warning('[DataStreams] failed to send outbound packet: $e'); | ||
| final sent = _pendingSends[streamId]; | ||
| if (sent != null && !sent.isCompleted) { | ||
| sent.completeError( | ||
| DataStreamError( | ||
| reason: DataStreamErrorReason.AbnormalEnd, | ||
| message: 'Failed to send data stream packet: $e', | ||
| ), | ||
| ); | ||
| } catch (e) { | ||
| // The core acknowledges sends unconditionally, so there is nobody to propagate this to. | ||
| logger.warning('[DataStreams] failed to send outbound packet: $e'); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Receive | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 Data streams stall after full reconnect
When a full reconnect replaces a backpressured channel,
waitForBufferStatusLowcan block every remaining stream packet. The engine retains the old_dcBufferStatusvalue; the fresh channel has no reason to emit a low-buffer event.Learn more
The outbound pump now waits on the engine's data-channel buffer before each packet. waitForBufferStatusLow checks the channel's current amount, then polls
_dcBufferStatusif the channel is not low. A full reconnect replaces_reliableDCPubwithout resetting_dcBufferStatus, so an oldfalsesurvives even when the replacement channel is empty. With no packets sent on the new channel, its low-buffer callback never fires and the pump cannot progress.Example: A 64 MB send fills the reliable channel above 2 MB, setting
_dcBufferStatusto false. During a full reconnect the engine replaces that channel. The new channel has zero bytes buffered, but the blocked wait continues polling false and the remaining chunks and subsequent streams cannot send.Recommended fix: Make
Engine.waitForBufferStatusLowre-evaluate the current channel's buffered amount while waiting, or reset and signal the status when replacing publisher data channels. Ensure disconnect also wakes blocked sends.Was this helpful? React with 👍 or 👎 to provide feedback.