From e88ba63732969edd93ceb432b6cd84a24c33bc06 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Jul 2026 15:21:25 -0400 Subject: [PATCH 01/24] feat: add data streams to livekit-uniffi --- Cargo.lock | 1 + livekit-uniffi/Cargo.toml | 2 + livekit-uniffi/src/data_stream/common.rs | 304 +++++++++++++++++++++ livekit-uniffi/src/data_stream/incoming.rs | 223 +++++++++++++++ livekit-uniffi/src/data_stream/mod.rs | 30 ++ livekit-uniffi/src/data_stream/outgoing.rs | 211 ++++++++++++++ livekit-uniffi/src/data_stream/tests.rs | 140 ++++++++++ livekit-uniffi/src/lib.rs | 3 + 8 files changed, 914 insertions(+) create mode 100644 livekit-uniffi/src/data_stream/common.rs create mode 100644 livekit-uniffi/src/data_stream/incoming.rs create mode 100644 livekit-uniffi/src/data_stream/mod.rs create mode 100644 livekit-uniffi/src/data_stream/outgoing.rs create mode 100644 livekit-uniffi/src/data_stream/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 01cc12045..bdcb9155e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4128,6 +4128,7 @@ dependencies = [ "camino", "futures-util", "livekit-common", + "livekit-data-stream", "livekit-datatrack", "livekit-net", "livekit-protocol", diff --git a/livekit-uniffi/Cargo.toml b/livekit-uniffi/Cargo.toml index 0eccd3698..ce5e57a0c 100644 --- a/livekit-uniffi/Cargo.toml +++ b/livekit-uniffi/Cargo.toml @@ -17,6 +17,8 @@ livekit-common = { workspace = true, features = ["uniffi"] } livekit-token = { workspace = true } livekit-datatrack = { workspace = true, features = ["uniffi"] } livekit-net = { workspace = true, features = ["uniffi"] } +livekit-data-stream = { workspace = true } +livekit-common = { workspace = true } uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns", "tokio"] } log = { workspace = true } tokio = { workspace = true, features = ["sync", "rt-multi-thread"] } diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs new file mode 100644 index 000000000..87cfcacea --- /dev/null +++ b/livekit-uniffi/src/data_stream/common.rs @@ -0,0 +1,304 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +//! Types that cross the FFI boundary for data streams: info/option records, enums, the error +//! wrapper, and the wire-packet decode helper. +//! +//! `Bytes` is already registered as a custom type by [`crate::data_track::common`]; it is reused +//! here rather than redefined (a second `custom_type!` in the same crate would conflict). +//! Participant identities cross as plain `String`. + +use std::collections::HashMap; + +use livekit_common as common; +use livekit_data_stream::{api as ds_api, backend as ds}; +use livekit_protocol as proto; +use prost::Message; + +// MARK: - Enums + +/// Encryption applied to a data stream, mirroring [`common::EncryptionType`]. +#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum EncryptionType { + None, + Gcm, + Custom, +} + +impl From for EncryptionType { + fn from(value: common::EncryptionType) -> Self { + match value { + common::EncryptionType::None => Self::None, + common::EncryptionType::Gcm => Self::Gcm, + common::EncryptionType::Custom => Self::Custom, + } + } +} + +/// Operation type for text streams, mirroring [`ds_api::OperationType`]. +#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum OperationType { + Create, + Update, + Delete, + Reaction, +} + +impl From for OperationType { + fn from(value: ds_api::OperationType) -> Self { + match value { + ds_api::OperationType::Create => Self::Create, + ds_api::OperationType::Update => Self::Update, + ds_api::OperationType::Delete => Self::Delete, + ds_api::OperationType::Reaction => Self::Reaction, + } + } +} + +impl From for ds_api::OperationType { + fn from(value: OperationType) -> Self { + match value { + OperationType::Create => Self::Create, + OperationType::Update => Self::Update, + OperationType::Delete => Self::Delete, + OperationType::Reaction => Self::Reaction, + } + } +} + +/// A capability a remote participant's client advertises, mirroring [`common::ClientCapability`]. +#[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum ClientCapability { + Unused, + PacketTrailer, + CompressionDeflateRaw, +} + +impl From for ClientCapability { + fn from(value: common::ClientCapability) -> Self { + match value { + common::ClientCapability::Unused => Self::Unused, + common::ClientCapability::PacketTrailer => Self::PacketTrailer, + common::ClientCapability::CompressionDeflateRaw => Self::CompressionDeflateRaw, + // `common::ClientCapability` is `#[non_exhaustive]`; treat anything newer as unusable. + _ => Self::Unused, + } + } +} + +impl From for common::ClientCapability { + fn from(value: ClientCapability) -> Self { + match value { + ClientCapability::Unused => Self::Unused, + ClientCapability::PacketTrailer => Self::PacketTrailer, + ClientCapability::CompressionDeflateRaw => Self::CompressionDeflateRaw, + } + } +} + +// MARK: - Info records + +/// Information about a byte data stream. FFI wrapper around [`ds_api::ByteStreamInfo`]. +#[derive(uniffi::Record, Clone, Debug)] +pub struct ByteStreamInfo { + pub id: String, + pub topic: String, + /// Unix timestamp in milliseconds. + pub timestamp_ms: i64, + pub total_length: Option, + pub attributes: HashMap, + pub mime_type: String, + pub name: String, + pub encryption_type: EncryptionType, +} + +impl From for ByteStreamInfo { + fn from(info: ds_api::ByteStreamInfo) -> Self { + let attributes = info.attributes(); + Self { + id: info.id, + topic: info.topic, + timestamp_ms: info.timestamp.timestamp_millis(), + total_length: info.total_length, + attributes, + mime_type: info.mime_type, + name: info.name, + encryption_type: info.encryption_type.into(), + } + } +} + +/// Information about a text data stream. FFI wrapper around [`ds_api::TextStreamInfo`]. +#[derive(uniffi::Record, Clone, Debug)] +pub struct TextStreamInfo { + pub id: String, + pub topic: String, + /// Unix timestamp in milliseconds. + pub timestamp_ms: i64, + pub total_length: Option, + pub attributes: HashMap, + pub mime_type: String, + pub operation_type: OperationType, + pub version: i32, + pub reply_to_stream_id: Option, + pub attached_stream_ids: Vec, + pub generated: bool, + pub encryption_type: EncryptionType, +} + +impl From for TextStreamInfo { + fn from(info: ds_api::TextStreamInfo) -> Self { + let attributes = info.attributes(); + Self { + id: info.id, + topic: info.topic, + timestamp_ms: info.timestamp.timestamp_millis(), + total_length: info.total_length, + attributes, + mime_type: info.mime_type, + operation_type: info.operation_type.into(), + version: info.version, + reply_to_stream_id: info.reply_to_stream_id, + attached_stream_ids: info.attached_stream_ids, + generated: info.generated, + encryption_type: info.encryption_type.into(), + } + } +} + +// MARK: - Option records + +/// Options for sending a byte stream. FFI wrapper around [`ds_api::StreamByteOptions`]. +#[derive(uniffi::Record, Clone, Debug, Default)] +pub struct StreamByteOptions { + pub topic: String, + pub attributes: HashMap, + #[uniffi(default = [])] + pub destination_identities: Vec, + #[uniffi(default = None)] + pub id: Option, + #[uniffi(default = None)] + pub mime_type: Option, + #[uniffi(default = None)] + pub name: Option, + #[uniffi(default = None)] + pub total_length: Option, + #[uniffi(default = None)] + pub compress: Option, + #[uniffi(default = None)] + pub sender_identity: Option, +} + +impl From for ds_api::StreamByteOptions { + fn from(options: StreamByteOptions) -> Self { + Self { + topic: options.topic, + attributes: options.attributes, + destination_identities: options + .destination_identities + .into_iter() + .map(Into::into) + .collect(), + id: options.id, + mime_type: options.mime_type, + name: options.name, + total_length: options.total_length, + compress: options.compress, + sender_identity: options.sender_identity.map(Into::into), + } + } +} + +/// Options for sending a text stream. FFI wrapper around [`ds_api::StreamTextOptions`]. +#[derive(uniffi::Record, Clone, Debug, Default)] +pub struct StreamTextOptions { + pub topic: String, + pub attributes: HashMap, + #[uniffi(default = [])] + pub destination_identities: Vec, + #[uniffi(default = None)] + pub id: Option, + #[uniffi(default = None)] + pub operation_type: Option, + #[uniffi(default = None)] + pub version: Option, + #[uniffi(default = None)] + pub reply_to_stream_id: Option, + #[uniffi(default = [])] + pub attached_stream_ids: Vec, + #[uniffi(default = None)] + pub generated: Option, + #[uniffi(default = None)] + pub compress: Option, + #[uniffi(default = None)] + pub sender_identity: Option, +} + +impl From for ds_api::StreamTextOptions { + fn from(options: StreamTextOptions) -> Self { + Self { + topic: options.topic, + attributes: options.attributes, + destination_identities: options + .destination_identities + .into_iter() + .map(Into::into) + .collect(), + id: options.id, + operation_type: options.operation_type.map(Into::into), + version: options.version, + reply_to_stream_id: options.reply_to_stream_id, + attached_stream_ids: options.attached_stream_ids, + generated: options.generated, + compress: options.compress, + sender_identity: options.sender_identity.map(Into::into), + } + } +} + +// MARK: - Error + +/// A data stream operation failed. Flat wrapper around [`ds_api::StreamError`]; the underlying +/// error's message is carried across the boundary. +#[derive(uniffi::Error, thiserror::Error, Debug)] +#[uniffi(flat_error)] +pub enum DataStreamError { + #[error(transparent)] + Stream(#[from] ds_api::StreamError), +} + +// MARK: - Wire decode + +/// Decodes a serialized [`proto::DataPacket`] carrying a data-stream header/chunk/trailer into an +/// incoming-manager input event. Returns `None` if the bytes don't decode or the packet isn't a +/// data-stream packet. +/// +/// Encryption is defaulted to `None`: end-to-end encryption for data streams over this FFI is a +/// follow-up (the foreign side is expected to hand us already-decrypted packets). +pub(crate) fn decode_data_packet(bytes: &[u8]) -> Option { + let mut packet = proto::DataPacket::decode(bytes).ok()?; + let identity: common::ParticipantIdentity = packet.participant_identity.clone().into(); + let ds_packet = match packet.value.take()? { + proto::data_packet::Value::StreamHeader(header) => ds::Packet::Header { + header: header.into(), + encryption_type: common::EncryptionType::None, + }, + proto::data_packet::Value::StreamChunk(chunk) => { + ds::Packet::Chunk { chunk: chunk.into(), encryption_type: common::EncryptionType::None } + } + proto::data_packet::Value::StreamTrailer(trailer) => ds::Packet::Trailer(trailer.into()), + _ => return None, + }; + Some(ds::incoming::PacketReceived::new(ds_packet, identity)) +} diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs new file mode 100644 index 000000000..ee89d0523 --- /dev/null +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -0,0 +1,223 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +use std::path::PathBuf; +use std::sync::Arc; + +use bytes::{Bytes, BytesMut}; +use futures_util::StreamExt; +use livekit_data_stream::{api as ds_api, backend as ds}; +use tokio::sync::mpsc::UnboundedReceiver; +use tokio::sync::Mutex; +use tokio_util::sync::{CancellationToken, DropGuard}; + +use super::common::{decode_data_packet, ByteStreamInfo, DataStreamError, TextStreamInfo}; +use ds_api::StreamReader as _; + +/// Receives inbound data-stream packets and processes them on the incoming manager's actor loop, +/// surfacing opened readers through a foreign delegate. +/// +/// Mirrors [`crate::data_track::remote::RemoteDataTrackManager`]: `handle_packet_received` is a +/// cheap synchronous enqueue (safe to call from a native data-channel callback), while +/// decompression and reassembly happen on the spawned `run` task in packet order. +#[derive(uniffi::Object)] +pub struct IncomingDataStreamManager { + input: ds::incoming::ManagerInput, + _guard: DropGuard, +} + +/// Delegate for receiving output events from [`IncomingDataStreamManager`]. +/// +/// Only stream-open events are surfaced. The manager's deprecated v1 raw chunk/trailer +/// notifications are intentionally not forwarded over the FFI boundary. +#[uniffi::export(with_foreign)] +pub trait IncomingDataStreamManagerDelegate: Send + Sync { + /// A byte stream was opened by `identity` and is ready to be read. + fn on_byte_stream_opened(&self, reader: Arc, identity: String); + + /// A text stream was opened by `identity` and is ready to be read. + fn on_text_stream_opened(&self, reader: Arc, identity: String); +} + +#[uniffi::export] +impl IncomingDataStreamManager { + #[uniffi::constructor] + pub fn new(delegate: Arc, max_payload_byte_length: Option) -> Arc { + let token = CancellationToken::new(); + // No reserved topics: RPC routing is a concern of the `livekit` crate, not this FFI layer. + let (manager, input, output) = ds::incoming::Manager::new(vec![], max_payload_byte_length); + + let rt = crate::runtime::runtime(); + rt.spawn(shutdown_forward_task(input.clone(), token.clone())); + let delegate_forward = DelegateForwardTask { output, delegate, token: token.clone() }; + rt.spawn(delegate_forward.run()); + rt.spawn(manager.run()); + + Self { input, _guard: token.drop_guard() }.into() + } + + /// Handles an encoded [`livekit_protocol::DataPacket`] received over the data channel. + /// + /// Fire-and-forget: the packet is decoded and enqueued in order; processing happens on the + /// manager's run loop. Non-data-stream or undecodable packets are ignored. + pub fn handle_packet_received(&self, packet: Bytes) { + if let Some(event) = decode_data_packet(&packet) { + let _ = self.input.send(event.into()); + } + } +} + +/// Reader for an incoming byte data stream. +#[derive(uniffi::Object)] +pub struct ByteStreamReader { + info: ByteStreamInfo, + inner: Mutex, +} + +impl ByteStreamReader { + fn new(reader: ds_api::ByteStreamReader) -> Self { + Self { info: reader.info().clone().into(), inner: Mutex::new(reader) } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl ByteStreamReader { + /// Information about the underlying stream. + pub fn info(&self) -> ByteStreamInfo { + self.info.clone() + } + + /// Returns the next chunk, or `None` once the stream has closed. + pub async fn next(&self) -> Result, DataStreamError> { + Ok(self.inner.lock().await.next().await.transpose()?) + } + + /// Reads every chunk, concatenating them into a single buffer returned once the stream closes. + pub async fn read_all(&self) -> Result { + let mut reader = self.inner.lock().await; + let mut buffer = BytesMut::new(); + while let Some(chunk) = reader.next().await { + buffer.extend_from_slice(&chunk?); + } + Ok(buffer.freeze()) + } + + /// Streams the contents to a file as chunks arrive, returning the written path. + /// + /// `directory` defaults to the system temp dir; `name_override` defaults to the stream name. + pub async fn write_to_file( + &self, + directory: Option, + name_override: Option, + ) -> Result { + use tokio::io::AsyncWriteExt as _; + let directory = directory.map(PathBuf::from).unwrap_or_else(std::env::temp_dir); + let name = name_override.unwrap_or_else(|| self.info.name.clone()); + let path = directory.join(name); + + let mut reader = self.inner.lock().await; + let mut file = tokio::fs::File::create(&path).await.map_err(ds_api::StreamError::Io)?; + while let Some(chunk) = reader.next().await { + file.write_all(&chunk?).await.map_err(ds_api::StreamError::Io)?; + } + file.flush().await.map_err(ds_api::StreamError::Io)?; + Ok(path.to_string_lossy().into_owned()) + } +} + +/// Reader for an incoming text data stream. +#[derive(uniffi::Object)] +pub struct TextStreamReader { + info: TextStreamInfo, + inner: Mutex, +} + +impl TextStreamReader { + fn new(reader: ds_api::TextStreamReader) -> Self { + Self { info: reader.info().clone().into(), inner: Mutex::new(reader) } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl TextStreamReader { + /// Information about the underlying stream. + pub fn info(&self) -> TextStreamInfo { + self.info.clone() + } + + /// Returns the next chunk, or `None` once the stream has closed. + pub async fn next(&self) -> Result, DataStreamError> { + Ok(self.inner.lock().await.next().await.transpose()?) + } + + /// Reads every chunk, concatenating them into a single string returned once the stream closes. + pub async fn read_all(&self) -> Result { + let mut reader = self.inner.lock().await; + let mut result = String::new(); + while let Some(chunk) = reader.next().await { + result.push_str(&chunk?); + } + Ok(result) + } +} + +/// Forwards manager output events to the foreign [`IncomingDataStreamManagerDelegate`]. +struct DelegateForwardTask { + output: UnboundedReceiver, + delegate: Arc, + token: CancellationToken, +} + +impl DelegateForwardTask { + async fn run(mut self) { + loop { + tokio::select! { + _ = self.token.cancelled() => break, + event = self.output.recv() => match event { + Some(event) => self.forward_event(event), + None => break, + } + } + } + } + + fn forward_event(&self, event: ds::incoming::OutputEvent) { + match event { + ds::incoming::OutputEvent::StreamOpened(ds::incoming::StreamOpened { + stream_reader, + participant_identity, + }) => { + let identity = participant_identity.to_string(); + match stream_reader { + ds_api::AnyStreamReader::Byte(reader) => { + let reader = Arc::new(ByteStreamReader::new(reader)); + self.delegate.on_byte_stream_opened(reader, identity); + } + ds_api::AnyStreamReader::Text(reader) => { + let reader = Arc::new(TextStreamReader::new(reader)); + self.delegate.on_text_stream_opened(reader, identity); + } + } + } + // Deprecated v1 raw chunk/trailer notifications are not surfaced over the FFI boundary. + ds::incoming::OutputEvent::ChunkReceived(_) + | ds::incoming::OutputEvent::TrailerReceived(_) => {} + } + } +} + +async fn shutdown_forward_task(input: ds::incoming::ManagerInput, token: CancellationToken) { + token.cancelled().await; + let _ = input.send(ds::incoming::InputEvent::Shutdown); +} diff --git a/livekit-uniffi/src/data_stream/mod.rs b/livekit-uniffi/src/data_stream/mod.rs new file mode 100644 index 000000000..72b0aa1ed --- /dev/null +++ b/livekit-uniffi/src/data_stream/mod.rs @@ -0,0 +1,30 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +//! UniFFI bindings for data streams v2 from [`livekit-data-stream`]. +//! +//! Mirrors the [`crate::data_track`] pattern: +//! - [`incoming::IncomingDataStreamManager`] wraps the incoming actor. Packets are fed in via a +//! synchronous `handle_packet_received` (safe to call from a native data-channel callback), and +//! opened readers / v1 back-compat events are pushed out through a foreign delegate. +//! - [`outgoing::OutgoingDataStreamManager`] wraps the outgoing manager as an object with async +//! `send_*`/`stream_*` methods. Outbound packets are handed to a foreign delegate, and remote +//! participant protocol/capabilities are read through a foreign registry callback. + +pub mod common; +pub mod incoming; +pub mod outgoing; + +#[cfg(test)] +mod tests; diff --git a/livekit-uniffi/src/data_stream/outgoing.rs b/livekit-uniffi/src/data_stream/outgoing.rs new file mode 100644 index 000000000..7de83c163 --- /dev/null +++ b/livekit-uniffi/src/data_stream/outgoing.rs @@ -0,0 +1,211 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +use std::sync::Arc; + +use bytes::Bytes; +use livekit_common as lk_common; +use livekit_data_stream::{api as ds_api, backend as ds}; +use prost::Message as _; +use tokio_util::sync::{CancellationToken, DropGuard}; + +use super::common::{ + ByteStreamInfo, ClientCapability, DataStreamError, StreamByteOptions, StreamTextOptions, + TextStreamInfo, +}; +use ds_api::StreamWriter as _; + +/// Sends data streams, choosing v2 single-packet/compression or legacy multi-packet framing based +/// on recipient capabilities. Outbound packets are handed to a foreign delegate for transport. +#[derive(uniffi::Object)] +pub struct OutgoingDataStreamManager { + manager: ds::outgoing::Manager, + registry: Arc, + _guard: DropGuard, +} + +/// Delegate for receiving outbound packets from [`OutgoingDataStreamManager`]. +#[uniffi::export(with_foreign)] +pub trait OutgoingDataStreamManagerDelegate: Send + Sync { + /// Encoded [`livekit_protocol::DataPacket`]s to be sent over the data channel transport. + fn on_packets_available(&self, packets: Vec); +} + +/// Read access to remote participants' advertised protocol and capabilities, implemented by the +/// foreign side. Mirrors [`lk_common::RemoteParticipantRegistry`]; used to decide inline/compression +/// eligibility per send. +#[uniffi::export(with_foreign)] +pub trait RemoteParticipantRegistryDelegate: Send + Sync { + /// A remote participant's `client_protocol`, or `0` (`CLIENT_PROTOCOL_DEFAULT`) if unknown. + fn remote_client_protocol(&self, identity: String) -> i32; + + /// A remote participant's advertised capabilities, or empty if unknown. + fn remote_capabilities(&self, identity: String) -> Vec; + + /// The identities of every remote participant, used to resolve a broadcast send. + fn remote_identities(&self) -> Vec; +} + +/// Adapts the foreign [`RemoteParticipantRegistryDelegate`] to the crate-internal +/// [`lk_common::RemoteParticipantRegistry`] the outgoing manager consumes. +struct ForeignRegistry(Arc); + +impl lk_common::RemoteParticipantRegistry for ForeignRegistry { + fn remote_client_protocol(&self, identity: &lk_common::ParticipantIdentity) -> i32 { + self.0.remote_client_protocol(identity.to_string()) + } + + fn remote_capabilities( + &self, + identity: &lk_common::ParticipantIdentity, + ) -> Vec { + self.0.remote_capabilities(identity.to_string()).into_iter().map(Into::into).collect() + } + + fn remote_identities(&self) -> Vec { + self.0.remote_identities().into_iter().map(Into::into).collect() + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl OutgoingDataStreamManager { + #[uniffi::constructor] + pub fn new( + delegate: Arc, + registry: Arc, + ) -> Arc { + let token = CancellationToken::new(); + let (manager, mut packet_rx) = ds::outgoing::Manager::new(); + + // Forward each outbound packet to the transport delegate and acknowledge the send. Wire + // send-failures are not propagated back to the originating `send_*` call for now (matches + // the data-track delegate); can be upgraded to a Result-returning delegate later. + let forward_token = token.clone(); + crate::runtime::runtime().spawn(async move { + loop { + tokio::select! { + _ = forward_token.cancelled() => break, + recv = packet_rx.recv() => match recv { + Ok((packet, responder)) => { + delegate.on_packets_available(vec![Bytes::from(packet.encode_to_vec())]); + let _ = responder.respond(Ok(())); + } + Err(_) => break, + } + } + } + }); + + let registry: Arc = + Arc::new(ForeignRegistry(registry)); + Self { manager, registry, _guard: token.drop_guard() }.into() + } + + /// Sends a complete text payload, returning info about the created stream. + pub async fn send_text( + &self, + text: String, + options: StreamTextOptions, + ) -> Result { + Ok(self.manager.send_text(&text, options.into(), &*self.registry).await?.into()) + } + + /// Sends a complete byte payload, returning info about the created stream. + pub async fn send_bytes( + &self, + data: Bytes, + options: StreamByteOptions, + ) -> Result { + Ok(self.manager.send_bytes(data, options.into(), &*self.registry).await?.into()) + } + + /// Streams a file from disk, returning info about the created stream. + pub async fn send_file( + &self, + path: String, + options: StreamByteOptions, + ) -> Result { + Ok(self.manager.send_file(path, options.into(), &*self.registry).await?.into()) + } + + /// Opens an incremental text stream writer (never compressed or inlined). + pub async fn stream_text( + &self, + options: StreamTextOptions, + ) -> Result { + Ok(TextStreamWriter(self.manager.stream_text(options.into()).await?)) + } + + /// Opens an incremental byte stream writer (never compressed or inlined). + pub async fn stream_bytes( + &self, + options: StreamByteOptions, + ) -> Result { + Ok(ByteStreamWriter(self.manager.stream_bytes(options.into()).await?)) + } +} + +/// Writer for an open text data stream. +#[derive(uniffi::Object)] +pub struct TextStreamWriter(ds_api::TextStreamWriter); + +#[uniffi::export(async_runtime = "tokio")] +impl TextStreamWriter { + /// Information about the underlying stream. + pub fn info(&self) -> TextStreamInfo { + self.0.info().clone().into() + } + + /// Appends text to the stream. + pub async fn write(&self, text: String) -> Result<(), DataStreamError> { + Ok(self.0.write(&text).await?) + } + + /// Closes the stream normally. + pub async fn close(&self) -> Result<(), DataStreamError> { + Ok(self.0.clone().close().await?) + } + + /// Closes the stream abnormally with a reason. + pub async fn close_with_reason(&self, reason: String) -> Result<(), DataStreamError> { + Ok(self.0.clone().close_with_reason(&reason).await?) + } +} + +/// Writer for an open byte data stream. +#[derive(uniffi::Object)] +pub struct ByteStreamWriter(ds_api::ByteStreamWriter); + +#[uniffi::export(async_runtime = "tokio")] +impl ByteStreamWriter { + /// Information about the underlying stream. + pub fn info(&self) -> ByteStreamInfo { + self.0.info().clone().into() + } + + /// Appends bytes to the stream. + pub async fn write(&self, data: Bytes) -> Result<(), DataStreamError> { + Ok(self.0.write(data.as_ref()).await?) + } + + /// Closes the stream normally. + pub async fn close(&self) -> Result<(), DataStreamError> { + Ok(self.0.clone().close().await?) + } + + /// Closes the stream abnormally with a reason. + pub async fn close_with_reason(&self, reason: String) -> Result<(), DataStreamError> { + Ok(self.0.clone().close_with_reason(&reason).await?) + } +} diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs new file mode 100644 index 000000000..db8ac1948 --- /dev/null +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -0,0 +1,140 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +//! Round-trip tests driving the FFI wrappers through mock foreign delegates on the global runtime. + +use std::sync::{Arc, Mutex}; + +use bytes::Bytes; +use livekit_protocol as proto; +use prost::Message as _; +use tokio::sync::oneshot; + +use super::common::{ClientCapability, StreamTextOptions}; +use super::incoming::{ + ByteStreamReader, IncomingDataStreamManager, IncomingDataStreamManagerDelegate, + TextStreamReader, +}; +use super::outgoing::{ + OutgoingDataStreamManager, OutgoingDataStreamManagerDelegate, RemoteParticipantRegistryDelegate, +}; + +/// Builds an encoded v2 inline (single-packet) text `DataPacket`. +fn inline_text_packet(identity: &str, topic: &str, text: &str) -> Bytes { + let header = proto::data_stream::Header { + stream_id: "s1".to_string(), + topic: topic.to_string(), + mime_type: "text/plain".to_string(), + timestamp: 0, + total_length: Some(text.len() as u64), + inline_content: Some(text.as_bytes().to_vec()), + content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( + proto::data_stream::TextHeader::default(), + )), + ..Default::default() + }; + let packet = proto::DataPacket { + participant_identity: identity.to_string(), + value: Some(proto::data_packet::Value::StreamHeader(header)), + ..Default::default() + }; + Bytes::from(packet.encode_to_vec()) +} + +/// Captures the first opened text reader. +struct TextCapture(Mutex, String)>>>); + +impl IncomingDataStreamManagerDelegate for TextCapture { + fn on_byte_stream_opened(&self, _reader: Arc, _identity: String) {} + + fn on_text_stream_opened(&self, reader: Arc, identity: String) { + if let Some(tx) = self.0.lock().unwrap().take() { + let _ = tx.send((reader, identity)); + } + } +} + +#[test] +fn incoming_inline_text_stream_roundtrips() { + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate); + + manager.handle_packet_received(inline_text_packet("alice", "my-topic", "hello world")); + + let (reader, identity) = rx.await.expect("a stream should open"); + assert_eq!(identity, "alice"); + assert_eq!(reader.info().topic, "my-topic"); + assert_eq!(reader.read_all().await.unwrap(), "hello world"); + }); +} + +/// Collects every outbound packet the manager emits. +struct PacketCapture(Mutex>); + +impl OutgoingDataStreamManagerDelegate for PacketCapture { + fn on_packets_available(&self, packets: Vec) { + self.0.lock().unwrap().extend(packets); + } +} + +/// A room where every recipient is v2 and advertises deflate-raw compression. +struct AllV2Registry; + +impl RemoteParticipantRegistryDelegate for AllV2Registry { + fn remote_client_protocol(&self, _identity: String) -> i32 { + livekit_common::CLIENT_PROTOCOL_DATA_STREAM_V2 + } + + fn remote_capabilities(&self, _identity: String) -> Vec { + vec![ClientCapability::CompressionDeflateRaw] + } + + fn remote_identities(&self) -> Vec { + vec!["bob".to_string()] + } +} + +#[test] +fn outgoing_all_v2_text_inlines_compressed() { + crate::runtime::runtime().block_on(async { + let delegate = Arc::new(PacketCapture(Mutex::new(Vec::new()))); + let manager = OutgoingDataStreamManager::new(delegate.clone(), Arc::new(AllV2Registry)); + + let options = StreamTextOptions { + topic: "chat".to_string(), + destination_identities: vec!["bob".to_string()], + ..Default::default() + }; + let info = manager + .send_text("hello hello compressible world".to_string(), options) + .await + .expect("send_text should succeed"); + assert_eq!(info.topic, "chat"); + + // send_text awaits the transport responder, which the forward task fulfills only after + // invoking the delegate — so the packet is already captured here. + let packets = delegate.0.lock().unwrap(); + assert_eq!(packets.len(), 1, "expected a single inline header packet"); + + let decoded = proto::DataPacket::decode(packets[0].as_ref()).unwrap(); + let Some(proto::data_packet::Value::StreamHeader(header)) = decoded.value else { + panic!("expected a stream header packet"); + }; + assert_eq!(header.compression(), proto::data_stream::CompressionType::DeflateRaw); + let inline = header.inline_content.expect("inline content should be present"); + assert_ne!(inline.as_slice(), b"hello hello compressible world", "should be compressed"); + }); +} diff --git a/livekit-uniffi/src/lib.rs b/livekit-uniffi/src/lib.rs index 0a9ad72ec..c93e40cdc 100644 --- a/livekit-uniffi/src/lib.rs +++ b/livekit-uniffi/src/lib.rs @@ -15,6 +15,9 @@ /// Data tracks core from [`livekit-datatrack`]. pub mod data_track; +/// Data streams v2 core from [`livekit-data-stream`]. +pub mod data_stream; + /// Access token generation and verification from [`livekit-api::access_token`]. pub mod access_token; From 68b312b861a3774035e97386cbd3bc9228bd6176 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 27 Jul 2026 11:28:33 -0400 Subject: [PATCH 02/24] feat: add example testing script for data streams v2 uniffi --- datastream_uniffi_test.py | 73 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 datastream_uniffi_test.py diff --git a/datastream_uniffi_test.py b/datastream_uniffi_test.py new file mode 100644 index 000000000..f5cb4cd75 --- /dev/null +++ b/datastream_uniffi_test.py @@ -0,0 +1,73 @@ +import asyncio +import livekit_uniffi + +class OutgoingDelegate(livekit_uniffi.OutgoingDataStreamManagerDelegate): + def on_packets_available(self, packets): + print('PACKETS:', packets) + +class RemoteParticipantRegistry(livekit_uniffi.RemoteParticipantRegistryDelegate): + def remote_capabilities(self, identity): + return [] # typing.List[ClientCapability] + + def remote_client_protocol(self, identity): + return 2 + + def remote_identities(self): + return ["alice", "bob", "randy"] + +class IncomingDelegate(livekit_uniffi.IncomingDataStreamManagerDelegate): + """Forwards opened readers onto the main asyncio loop. + + Delegate callbacks fire on a Rust tokio thread, so they must not block or await; + hand the reader off to the main loop and let it drive the async reads. + """ + + def __init__(self, loop: asyncio.AbstractEventLoop, opened: asyncio.Queue): + self._loop = loop + self._opened = opened + + def on_byte_stream_opened(self, reader, identity: str): + self._loop.call_soon_threadsafe(self._opened.put_nowait, ("byte", reader, identity)) + + def on_text_stream_opened(self, reader, identity: str): + self._loop.call_soon_threadsafe(self._opened.put_nowait, ("text", reader, identity)) + +# Encoded livekit.DataPacket envelopes (participant_identity = "alice") carrying a +# DataStream.Header / Chunk / Trailer for an 11-byte "hello world" text stream. +DATA_STREAM_HEADER_BYTES = b'"\x05alicej@\n\x11example-stream-id\x10\xad\xf5\xcb\xae\xf93\x1a\x08my-topic"\ntext/plain(\x0bB\n\n\x03foo\x12\x03barJ\x00' +DATA_STREAM_CHUNK_BYTES = b'"\x05alicer \n\x11example-stream-id\x1a\x0bhello world' +DATA_STREAM_TRAILER_BYTES = b'"\x05alicez\'\n\x11example-stream-id\x1a\x12\n\x06status\x12\x08complete' + +async def main(): + opened = asyncio.Queue() + + print("--- OUTGOING:") + outgoing_delegate = OutgoingDelegate() + remote_participant_registry = RemoteParticipantRegistry() + outgoing = livekit_uniffi.OutgoingDataStreamManager(outgoing_delegate, remote_participant_registry) + await outgoing.send_text('hello world', livekit_uniffi.StreamTextOptions( + topic="test", + attributes={}, + # destination_identities: 'typing.List[str]' = , + # id: 'typing.Optional[str]' = , + # operation_type: 'typing.Optional[OperationType]' = , + # version: 'typing.Optional[int]' = , + # reply_to_stream_id: 'typing.Optional[str]' = , + # attached_stream_ids: 'typing.List[str]' = , + # generated: 'typing.Optional[bool]' = , + # compress: 'typing.Optional[bool]' = , + # sender_identity: 'typing.Optional[str]' = + )) + + print("--- INCOMING:") + incoming_delegate = IncomingDelegate(asyncio.get_running_loop(), opened) + incoming = livekit_uniffi.IncomingDataStreamManager(incoming_delegate, [], None) + incoming.handle_packet_received(DATA_STREAM_HEADER_BYTES) + incoming.handle_packet_received(DATA_STREAM_CHUNK_BYTES) + incoming.handle_packet_received(DATA_STREAM_TRAILER_BYTES) + + kind, reader, identity = await asyncio.wait_for(opened.get(), timeout=5) + print(f"{kind.upper()} STREAM OPENED:", identity, "CONTENTS:", await reader.read_all()) + +if __name__ == '__main__': + asyncio.run(main()) From 675461dfa34abaf46db6bd9eecc82af261b598f2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 14:09:00 -0400 Subject: [PATCH 03/24] feat: make data stream error not a flat error Expose all the different error cases so consuming clients can get better quality errors. --- livekit-uniffi/src/data_stream/common.rs | 73 ++++++++++++++++++++++-- 1 file changed, 68 insertions(+), 5 deletions(-) diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs index 87cfcacea..e6c615017 100644 --- a/livekit-uniffi/src/data_stream/common.rs +++ b/livekit-uniffi/src/data_stream/common.rs @@ -269,13 +269,76 @@ impl From for ds_api::StreamTextOptions { // MARK: - Error -/// A data stream operation failed. Flat wrapper around [`ds_api::StreamError`]; the underlying -/// error's message is carried across the boundary. +/// A data stream operation failed. Structured mirror of [`ds_api::StreamError`] so foreign callers +/// can map each case to their own error type; variants carrying a message forward it as `message`. #[derive(uniffi::Error, thiserror::Error, Debug)] -#[uniffi(flat_error)] pub enum DataStreamError { - #[error(transparent)] - Stream(#[from] ds_api::StreamError), + #[error("stream has already been closed")] + AlreadyClosed, + + #[error("stream closed abnormally: {message}")] + AbnormalEnd { message: String }, + + #[error("UTF-8 decoding error: {message}")] + Utf8 { message: String }, + + #[error("incoming header was invalid")] + InvalidHeader, + + #[error("expected chunk index to be exactly one more than the previous")] + MissedChunk, + + #[error("read length exceeded total length specified in stream header")] + LengthExceeded, + + #[error("stream data is incomplete")] + Incomplete, + + #[error("unable to send packet")] + SendFailed, + + #[error("I/O error: {message}")] + Io { message: String }, + + #[error("internal error")] + Internal, + + #[error("encryption type mismatch")] + EncryptionTypeMismatch, + + #[error("stream header exceeds maximum size")] + HeaderTooLarge, + + #[error("stream payload exceeds maximum size")] + PayloadTooLarge, + + #[error("decompression failed")] + Decompression, + + #[error("file name must be a plain file name without path separators or '..'")] + InvalidFileName, +} + +impl From for DataStreamError { + fn from(error: ds_api::StreamError) -> Self { + match error { + ds_api::StreamError::AlreadyClosed => Self::AlreadyClosed, + ds_api::StreamError::AbnormalEnd(message) => Self::AbnormalEnd { message }, + ds_api::StreamError::Utf8(error) => Self::Utf8 { message: error.to_string() }, + ds_api::StreamError::InvalidHeader => Self::InvalidHeader, + ds_api::StreamError::MissedChunk => Self::MissedChunk, + ds_api::StreamError::LengthExceeded => Self::LengthExceeded, + ds_api::StreamError::Incomplete => Self::Incomplete, + ds_api::StreamError::SendFailed => Self::SendFailed, + ds_api::StreamError::Io(error) => Self::Io { message: error.to_string() }, + ds_api::StreamError::Internal => Self::Internal, + ds_api::StreamError::EncryptionTypeMismatch => Self::EncryptionTypeMismatch, + ds_api::StreamError::HeaderTooLarge => Self::HeaderTooLarge, + ds_api::StreamError::PayloadTooLarge => Self::PayloadTooLarge, + ds_api::StreamError::Decompression => Self::Decompression, + ds_api::StreamError::InvalidFileName => Self::InvalidFileName, + } + } } // MARK: - Wire decode From 618ed1f5fb4f262dfc8df6290fef5493487f9d5a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 15:37:06 -0400 Subject: [PATCH 04/24] fix: drop ByteStreamReader::write_to_file This uses tokio for one (so it's not portable), but also had some security flaws (allowed paths like ../../evil.txt). So, remove it. --- livekit-uniffi/src/data_stream/incoming.rs | 23 ---------------------- 1 file changed, 23 deletions(-) diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index ee89d0523..94ff433ad 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::path::PathBuf; use std::sync::Arc; use bytes::{Bytes, BytesMut}; @@ -112,28 +111,6 @@ impl ByteStreamReader { } Ok(buffer.freeze()) } - - /// Streams the contents to a file as chunks arrive, returning the written path. - /// - /// `directory` defaults to the system temp dir; `name_override` defaults to the stream name. - pub async fn write_to_file( - &self, - directory: Option, - name_override: Option, - ) -> Result { - use tokio::io::AsyncWriteExt as _; - let directory = directory.map(PathBuf::from).unwrap_or_else(std::env::temp_dir); - let name = name_override.unwrap_or_else(|| self.info.name.clone()); - let path = directory.join(name); - - let mut reader = self.inner.lock().await; - let mut file = tokio::fs::File::create(&path).await.map_err(ds_api::StreamError::Io)?; - while let Some(chunk) = reader.next().await { - file.write_all(&chunk?).await.map_err(ds_api::StreamError::Io)?; - } - file.flush().await.map_err(ds_api::StreamError::Io)?; - Ok(path.to_string_lossy().into_owned()) - } } /// Reader for an incoming text data stream. From d461e702546dbb29fabaf9623bbb5196384229e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 30 Jul 2026 16:56:45 -0400 Subject: [PATCH 05/24] fix: fix compile error --- livekit-uniffi/src/data_stream/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index db8ac1948..45fccd519 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -70,7 +70,7 @@ fn incoming_inline_text_stream_roundtrips() { crate::runtime::runtime().block_on(async { let (tx, rx) = oneshot::channel(); let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); - let manager = IncomingDataStreamManager::new(delegate); + let manager = IncomingDataStreamManager::new(delegate, None); manager.handle_packet_received(inline_text_packet("alice", "my-topic", "hello world")); From bf849adff4ecb0e2f972cc20a133539b810c9223 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 12:19:18 -0400 Subject: [PATCH 06/24] feat(data-stream): abort_all_streams / abort_streams_from on incoming FFI Add an AbortAllStreams input event to the incoming data-stream backend and expose abort_all_streams() / abort_streams_from(identity) on the UniFFI IncomingDataStreamManager, so the host can fail open readers on disconnect or participant-leave instead of letting them hang (which would also stall an ordered topic's queue). Also fix the constructor to take Option (UniFFI can't lift usize) and drop the stale reserved_topics argument to Manager::new. --- livekit-data-stream/src/incoming/events.rs | 3 +++ livekit-data-stream/src/incoming/manager.rs | 11 +++++++++++ livekit-uniffi/src/data_stream/incoming.rs | 22 ++++++++++++++++++--- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/livekit-data-stream/src/incoming/events.rs b/livekit-data-stream/src/incoming/events.rs index ca05b9832..37a9cdf16 100644 --- a/livekit-data-stream/src/incoming/events.rs +++ b/livekit-data-stream/src/incoming/events.rs @@ -39,6 +39,9 @@ pub enum InputEvent { PacketReceived(PacketReceived), /// Abort every open stream sent by this participant (they disconnected mid-send). AbortStreamsFrom(ParticipantIdentity), + /// Abort every open stream (e.g. the local connection is going away). Unlike + /// [`InputEvent::Shutdown`], the run loop keeps going so streams opened later are still handled. + AbortAllStreams, /// Stop the run loop. Shutdown, } diff --git a/livekit-data-stream/src/incoming/manager.rs b/livekit-data-stream/src/incoming/manager.rs index 72cc47f33..fb979b255 100644 --- a/livekit-data-stream/src/incoming/manager.rs +++ b/livekit-data-stream/src/incoming/manager.rs @@ -234,6 +234,7 @@ impl Manager { } } InputEvent::AbortStreamsFrom(identity) => self.handle_abort(identity), + InputEvent::AbortAllStreams => self.handle_abort_all(), InputEvent::Shutdown => break, } } @@ -518,6 +519,16 @@ impl Manager { } }); } + + /// Aborts every open stream, erroring each reader with [`StreamError::AbnormalEnd`]. Unlike + /// [`Self::handle_abort`] this isn't scoped to one participant; the host calls it when the + /// connection is torn down so no reader hangs waiting for chunks that will never arrive. + /// The run loop keeps going, so streams opened after (e.g. a reconnect) are still handled. + fn handle_abort_all(&mut self) { + self.inner.close_matching_streams_with_error(|_id, _descriptor| { + Err(StreamError::AbnormalEnd("Data stream connection closed".to_string())) + }); + } } impl ManagerInner { diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index 94ff433ad..e722dadf6 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -52,10 +52,13 @@ pub trait IncomingDataStreamManagerDelegate: Send + Sync { #[uniffi::export] impl IncomingDataStreamManager { #[uniffi::constructor] - pub fn new(delegate: Arc, max_payload_byte_length: Option) -> Arc { + pub fn new( + delegate: Arc, + max_payload_byte_length: Option, + ) -> Arc { let token = CancellationToken::new(); - // No reserved topics: RPC routing is a concern of the `livekit` crate, not this FFI layer. - let (manager, input, output) = ds::incoming::Manager::new(vec![], max_payload_byte_length); + let (manager, input, output) = + ds::incoming::Manager::new(max_payload_byte_length.map(|n| n as usize)); let rt = crate::runtime::runtime(); rt.spawn(shutdown_forward_task(input.clone(), token.clone())); @@ -75,6 +78,19 @@ impl IncomingDataStreamManager { let _ = self.input.send(event.into()); } } + + /// Aborts all open incoming streams so their readers error instead of hanging (e.g. on + /// disconnect). Handler wiring on the foreign side survives, so streams that arrive later + /// (e.g. after a reconnect) are still processed. + pub fn abort_all_streams(&self) { + let _ = self.input.send(ds::incoming::InputEvent::AbortAllStreams); + } + + /// Aborts open incoming streams sent by `identity` (e.g. when that participant disconnects + /// mid-send), so their readers error instead of hanging. + pub fn abort_streams_from(&self, identity: String) { + let _ = self.input.send(ds::incoming::InputEvent::AbortStreamsFrom(identity.into())); + } } /// Reader for an incoming byte data stream. From 6401331a87e41d280d12d756e45e442643702128 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 5 Aug 2026 13:22:23 -0400 Subject: [PATCH 07/24] feat(data-stream): expose writer is_open over the FFI Add ByteStreamWriter/TextStreamWriter.is_open() to the UniFFI outgoing writers, backed by RawStream.is_closed(). RawStream now also marks itself closed when a chunk send fails (not only on an explicit close), so a writer whose room disconnected mid-send reports closed instead of open. --- livekit-data-stream/src/outgoing/raw_stream.rs | 14 ++++++++++++-- livekit-data-stream/src/outgoing/stream_writer.rs | 10 ++++++++++ livekit-uniffi/src/data_stream/outgoing.rs | 10 ++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/livekit-data-stream/src/outgoing/raw_stream.rs b/livekit-data-stream/src/outgoing/raw_stream.rs index 8f9055fe5..6688eccf1 100644 --- a/livekit-data-stream/src/outgoing/raw_stream.rs +++ b/livekit-data-stream/src/outgoing/raw_stream.rs @@ -63,12 +63,21 @@ impl RawStream { }) } + pub(crate) fn is_closed(&self) -> bool { + self.is_closed + } + pub(crate) async fn write_chunk(&mut self, bytes: &[u8]) -> StreamResult<()> { let mut packet = Self::create_chunk_packet(&self.id, self.progress.chunk_index, bytes); if let Some(sender_identity) = self.sender_identity.as_ref() { packet.participant_identity = sender_identity.clone().into(); } - Self::send_packet(&self.packet_tx, packet).await?; + if let Err(error) = Self::send_packet(&self.packet_tx, packet).await { + // A failed send makes the stream unusable; mark it closed so readers/writers stop + // treating it as open. + self.is_closed = true; + return Err(error); + } self.progress.bytes_processed += bytes.len() as u64; self.progress.chunk_index += 1; Ok(()) @@ -155,8 +164,9 @@ impl RawStream { if let Some(sender_identity) = self.sender_identity.as_ref() { packet.participant_identity = sender_identity.clone().into(); } - Self::send_packet(&self.packet_tx, packet).await?; + // The stream is done after a close attempt regardless of whether the trailer send succeeds. self.is_closed = true; + Self::send_packet(&self.packet_tx, packet).await?; Ok(()) } diff --git a/livekit-data-stream/src/outgoing/stream_writer.rs b/livekit-data-stream/src/outgoing/stream_writer.rs index fa2165e7e..c5a823f30 100644 --- a/livekit-data-stream/src/outgoing/stream_writer.rs +++ b/livekit-data-stream/src/outgoing/stream_writer.rs @@ -68,6 +68,11 @@ impl ByteStreamWriter { pub(crate) fn new(info: Arc, stream: Arc>) -> Self { Self { info, stream } } + + /// Whether the stream has been closed — either locally (via `close`) or because a send failed. + pub async fn is_closed(&self) -> bool { + self.stream.lock().await.is_closed() + } } #[derive(Clone)] @@ -81,6 +86,11 @@ impl TextStreamWriter { pub(crate) fn new(info: Arc, stream: Arc>) -> Self { Self { info, stream } } + + /// Whether the stream has been closed — either locally (via `close`) or because a send failed. + pub async fn is_closed(&self) -> bool { + self.stream.lock().await.is_closed() + } } impl<'a> StreamWriter<'a> for ByteStreamWriter { diff --git a/livekit-uniffi/src/data_stream/outgoing.rs b/livekit-uniffi/src/data_stream/outgoing.rs index 7de83c163..cb9e2fd07 100644 --- a/livekit-uniffi/src/data_stream/outgoing.rs +++ b/livekit-uniffi/src/data_stream/outgoing.rs @@ -167,6 +167,11 @@ impl TextStreamWriter { self.0.info().clone().into() } + /// Whether the stream is still open — false once it has been closed locally or a send has failed. + pub async fn is_open(&self) -> bool { + !self.0.is_closed().await + } + /// Appends text to the stream. pub async fn write(&self, text: String) -> Result<(), DataStreamError> { Ok(self.0.write(&text).await?) @@ -194,6 +199,11 @@ impl ByteStreamWriter { self.0.info().clone().into() } + /// Whether the stream is still open — false once it has been closed locally or a send has failed. + pub async fn is_open(&self) -> bool { + !self.0.is_closed().await + } + /// Appends bytes to the stream. pub async fn write(&self, data: Bytes) -> Result<(), DataStreamError> { Ok(self.0.write(data.as_ref()).await?) From eb91f600215d2c894b6de5dd088801b66b9abd70 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 6 Aug 2026 15:35:36 -0400 Subject: [PATCH 08/24] fix(uniffi): make the data stream bindings compile for Kotlin Generating Kotlin bindings for the data stream FFI produces a file that does not compile. Nobody had done it before -- packages/kotlin has never existed in this tree -- so this went unnoticed while Swift, Python and Node worked fine. Two collisions, fixed differently: - An exported Rust method named `close` collides with the non-suspend `close()` uniffi synthesizes for AutoCloseable. They differ only by `suspend`, which Kotlin rejects as conflicting overloads. Fixed with [bindings.kotlin.rename], so Kotlin sees `closeStream()` and every other language keeps `close()`. - An error variant field named `message` collides with the `message` override uniffi emits from Throwable, and their types differ (String vs String?) so they cannot be merged. This one cannot be fixed from uniffi.toml: uniffi keys the rename table by crate name but looks up enum and record members by the item's full module path, so a rename for anything in a submodule is accepted and silently dropped. (Methods are unaffected because they key off the crate name -- which is why the fix above works.) There is no field-level #[uniffi(name)] attribute either, so the field is renamed to `reason` in the Rust source. Renaming the field changes the exposed API, so consumers reading it by name need updating. Swift is not one of them: its mapping binds these positionally. Verified by generating Kotlin bindings and compiling them into the Android AAR with no post-processing, then running that AAR's data stream tests on device. --- livekit-uniffi/src/data_stream/common.rs | 21 ++++++++++++--------- livekit-uniffi/uniffi.toml | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 9 deletions(-) diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs index e6c615017..d185f231e 100644 --- a/livekit-uniffi/src/data_stream/common.rs +++ b/livekit-uniffi/src/data_stream/common.rs @@ -276,11 +276,14 @@ pub enum DataStreamError { #[error("stream has already been closed")] AlreadyClosed, - #[error("stream closed abnormally: {message}")] - AbnormalEnd { message: String }, + // Named `reason` rather than `message`: in Kotlin a variant field called `message` collides + // with the `message` uniffi overrides from Throwable, and the collision cannot be renamed away + // from uniffi.toml (renames of enum members declared in a submodule are silently dropped). + #[error("stream closed abnormally: {reason}")] + AbnormalEnd { reason: String }, - #[error("UTF-8 decoding error: {message}")] - Utf8 { message: String }, + #[error("UTF-8 decoding error: {reason}")] + Utf8 { reason: String }, #[error("incoming header was invalid")] InvalidHeader, @@ -297,8 +300,8 @@ pub enum DataStreamError { #[error("unable to send packet")] SendFailed, - #[error("I/O error: {message}")] - Io { message: String }, + #[error("I/O error: {reason}")] + Io { reason: String }, #[error("internal error")] Internal, @@ -323,14 +326,14 @@ impl From for DataStreamError { fn from(error: ds_api::StreamError) -> Self { match error { ds_api::StreamError::AlreadyClosed => Self::AlreadyClosed, - ds_api::StreamError::AbnormalEnd(message) => Self::AbnormalEnd { message }, - ds_api::StreamError::Utf8(error) => Self::Utf8 { message: error.to_string() }, + ds_api::StreamError::AbnormalEnd(reason) => Self::AbnormalEnd { reason }, + ds_api::StreamError::Utf8(error) => Self::Utf8 { reason: error.to_string() }, ds_api::StreamError::InvalidHeader => Self::InvalidHeader, ds_api::StreamError::MissedChunk => Self::MissedChunk, ds_api::StreamError::LengthExceeded => Self::LengthExceeded, ds_api::StreamError::Incomplete => Self::Incomplete, ds_api::StreamError::SendFailed => Self::SendFailed, - ds_api::StreamError::Io(error) => Self::Io { message: error.to_string() }, + ds_api::StreamError::Io(error) => Self::Io { reason: error.to_string() }, ds_api::StreamError::Internal => Self::Internal, ds_api::StreamError::EncryptionTypeMismatch => Self::EncryptionTypeMismatch, ds_api::StreamError::HeaderTooLarge => Self::HeaderTooLarge, diff --git a/livekit-uniffi/uniffi.toml b/livekit-uniffi/uniffi.toml index 2ed2769f6..71b6d20fe 100644 --- a/livekit-uniffi/uniffi.toml +++ b/livekit-uniffi/uniffi.toml @@ -6,6 +6,21 @@ android = true package_name = "io.livekit.uniffi" cdylib_name = "livekit_uniffi" # the name of the so file to be loaded +# Two names that are perfectly fine in Rust but do not compile in Kotlin. Renamed for Kotlin only, +# so the Rust source and the Swift/Python/Node bindings keep the original names. +[bindings.kotlin.rename] +# UniFFI gives every object a non-suspend `close()` to satisfy AutoCloseable. An exported Rust +# method also called `close` differs from it only by `suspend`, which Kotlin rejects as conflicting +# overloads -- and the whole generated file then fails to compile. Renaming the stream's own close +# leaves AutoCloseable's untouched. +"ByteStreamWriter.close" = "close_stream" +"TextStreamWriter.close" = "close_stream" + +# DataStreamError's variant fields cannot be renamed from here: uniffi keys the rename table by +# crate name but looks up enum and record members by the item's full module path, so a rename for +# anything declared in a submodule is silently ignored (methods use the crate name and do work). +# Those fields are named in Rust instead -- see data_stream/common.rs. + [bindings.dart] # Dart package name; must match the published pub package so the Native Assets # asset id resolves to `package:livekit_uniffi/uniffi:livekit_uniffi`. From a73758b0c8af3ad2fc766f3e18059dacd036c759 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Aug 2026 12:55:08 -0400 Subject: [PATCH 09/24] fix: temporarily switch over to personal uniffi-dart fork Waiting for some fixes to be unstreamed before this can be swapped back over to the mainline build --- livekit-uniffi/Cargo.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/livekit-uniffi/Cargo.toml b/livekit-uniffi/Cargo.toml index ce5e57a0c..6a68f9221 100644 --- a/livekit-uniffi/Cargo.toml +++ b/livekit-uniffi/Cargo.toml @@ -31,7 +31,13 @@ thiserror = { workspace = true } # Dart binding generator. Not published to crates.io, so pinned by git rev. The # rev must target the same uniffi-rs release (0.31) as the `uniffi` dependency # above, or it cannot read this crate's compiled metadata. -uniffi-dart = { git = "https://github.com/Uniffi-Dart/uniffi-dart", rev = "90f2c6f29cbf88c8bc2cf515e6a0c2314a48844c", optional = true } +# +# Temporarily a fork: upstream cannot generate compiling bindings for this crate +# -- custom types leak into FFI signatures, object converter statics collide +# with Rust methods named `write`, and each crate re-declares the runtime +# scaffolding, so livekit-datatrack's types will not cross into livekit-uniffi. +# Point back at Uniffi-Dart/uniffi-dart once those fixes are upstreamed. +uniffi-dart = { git = "https://github.com/1egoman/uniffi-dart", rev = "4633a7d3c93186ac6a96007b5f109268b4746190", optional = true } camino = { version = "1", optional = true } [features] From 08db233c6aace08223aab195246fcadc62415ef2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Aug 2026 12:56:00 -0400 Subject: [PATCH 10/24] feat: add data streams dart polling manager adapter This "shim" adapts the data streams v2 interface given dart's c ffi limitations (a Pointer.fromFunction callback is only invocable while the calling thread is inside a native call that Dart itself initiated). --- livekit-uniffi/src/data_stream/mod.rs | 4 + livekit-uniffi/src/data_stream/polled.rs | 247 +++++++++++++++++++++++ livekit-uniffi/src/data_stream/tests.rs | 70 +++++++ 3 files changed, 321 insertions(+) create mode 100644 livekit-uniffi/src/data_stream/polled.rs diff --git a/livekit-uniffi/src/data_stream/mod.rs b/livekit-uniffi/src/data_stream/mod.rs index 72b0aa1ed..52f75f851 100644 --- a/livekit-uniffi/src/data_stream/mod.rs +++ b/livekit-uniffi/src/data_stream/mod.rs @@ -21,10 +21,14 @@ //! - [`outgoing::OutgoingDataStreamManager`] wraps the outgoing manager as an object with async //! `send_*`/`stream_*` methods. Outbound packets are handed to a foreign delegate, and remote //! participant protocol/capabilities are read through a foreign registry callback. +//! +//! [`polled`] adapts both managers for bindings that cannot accept a delegate call from an +//! arbitrary thread — see its module docs. pub mod common; pub mod incoming; pub mod outgoing; +pub mod polled; #[cfg(test)] mod tests; diff --git a/livekit-uniffi/src/data_stream/polled.rs b/livekit-uniffi/src/data_stream/polled.rs new file mode 100644 index 000000000..e090895cc --- /dev/null +++ b/livekit-uniffi/src/data_stream/polled.rs @@ -0,0 +1,247 @@ +// Copyright 2026 LiveKit, Inc. +// +// 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. + +//! Pull-based adapters over the push delegates, for bindings whose callbacks are thread-affine. +//! +//! [`incoming`](super::incoming) and [`outgoing`](super::outgoing) surface their output by calling +//! a foreign delegate from this crate's tokio runtime. Some bindings cannot accept that. Dart is +//! the motivating case: uniffi compiles a callback interface to `Pointer.fromFunction`, which is +//! only valid on the thread owning the isolate, so a delegate invoked from a tokio worker aborts +//! the VM outright with "Cannot invoke native callback outside an isolate" — not a catchable +//! error. (Dart's thread-safe callback form, `NativeCallable.listener`, is asynchronous and cannot +//! return a value, so it can't satisfy uniffi's synchronous callback ABI either.) +//! +//! The fix is to keep the delegate on the Rust side. Each type here implements the relevant +//! delegate trait, buffers what it receives into a channel, and exposes an `async fn next_*` the +//! foreign side awaits. Nothing crosses the FFI until that await resolves, and uniffi polls those +//! futures from whichever thread called `rust_future_poll` — the binding's own. Delegate +//! invocation still happens on a tokio thread, which is fine precisely because the implementation +//! is Rust. +//! +//! Note that [`RemoteParticipantRegistryDelegate`] needs no adapter: it is only ever called +//! synchronously inside a `send_*` future, so it already runs on the polling thread. +//! +//! Bindings that can take callbacks from any thread (Swift, Kotlin) should ignore this module and +//! construct the managers directly. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; + +use bytes::Bytes; +use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use super::incoming::{ + ByteStreamReader, IncomingDataStreamManager, IncomingDataStreamManagerDelegate, + TextStreamReader, +}; +use super::outgoing::{ + OutgoingDataStreamManager, OutgoingDataStreamManagerDelegate, RemoteParticipantRegistryDelegate, +}; + +/// Queue depth at which we start warning. The channels are unbounded so a stalled consumer can't +/// deadlock the manager, which means the only backstop against unbounded growth is noticing. +const QUEUE_DEPTH_WARN: usize = 1024; + +fn warn_if_deep(kind: &str, depth: usize) { + if depth == QUEUE_DEPTH_WARN { + log::warn!( + "{kind} queue has reached {depth} pending items; the foreign side is not draining it \ + fast enough" + ); + } +} + +// MARK: - Outgoing + +/// Buffers outbound packets so they can be pulled instead of pushed. +/// +/// Implements [`OutgoingDataStreamManagerDelegate`] in Rust; see the module docs. +#[derive(uniffi::Object)] +pub struct OutgoingPacketQueue { + tx: UnboundedSender, + rx: Mutex>, + depth: AtomicUsize, + shutdown: CancellationToken, +} + +impl OutgoingPacketQueue { + fn new() -> Self { + let (tx, rx) = unbounded_channel(); + Self { + tx, + rx: Mutex::new(rx), + depth: AtomicUsize::new(0), + shutdown: CancellationToken::new(), + } + } +} + +impl OutgoingDataStreamManagerDelegate for OutgoingPacketQueue { + fn on_packets_available(&self, packets: Vec) { + for packet in packets { + if self.tx.send(packet).is_ok() { + warn_if_deep("outgoing packet", self.depth.fetch_add(1, Ordering::Relaxed) + 1); + } + } + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl OutgoingPacketQueue { + /// Awaits the next batch of encoded `livekit.DataPacket`s to put on the wire. + /// + /// Returns `None` once the manager has shut down, which ends the caller's drain loop. + /// Everything already queued is returned together, so a burst costs one FFI crossing rather + /// than one per packet. + pub async fn next_packets(&self) -> Option> { + let mut rx = self.rx.lock().await; + let first = tokio::select! { + _ = self.shutdown.cancelled() => return None, + received = rx.recv() => received?, + }; + let mut batch = vec![first]; + while let Ok(next) = rx.try_recv() { + batch.push(next); + } + self.depth.fetch_sub(batch.len(), Ordering::Relaxed); + Some(batch) + } + + /// Wakes a pending [`Self::next_packets`] with `None` so the caller's drain loop can exit. + /// + /// Call this before releasing the queue: a caller blocked in `next_packets` is holding a + /// pointer to it, so freeing it first is a use-after-free. + pub fn close(&self) { + self.shutdown.cancel(); + } +} + +/// An [`OutgoingDataStreamManager`] and the queue draining it, already connected. +#[derive(uniffi::Record)] +pub struct PolledOutgoingDataStreamManager { + pub manager: Arc, + pub packets: Arc, +} + +/// Builds an outgoing manager whose packets are pulled rather than pushed. +/// +/// The two halves are wired together here rather than by the caller: passing a Rust object where +/// `Arc` is expected is awkward-to-impossible from some +/// bindings, and unnecessary — it's ordinary Rust on this side. +#[uniffi::export] +pub fn polled_outgoing_data_stream_manager( + registry: Arc, +) -> PolledOutgoingDataStreamManager { + let packets = Arc::new(OutgoingPacketQueue::new()); + let manager = OutgoingDataStreamManager::new(packets.clone(), registry); + PolledOutgoingDataStreamManager { manager, packets } +} + +// MARK: - Incoming + +/// A stream opened by a remote participant. +/// +/// Exactly one of the two readers is set; which one tells you the stream's kind. Two `Option`s +/// rather than an enum keeps the shape trivial in every binding. +#[derive(uniffi::Record)] +pub struct OpenedStream { + /// Identity of the participant that opened the stream. + pub identity: String, + /// Set when the stream carries bytes. + pub byte_reader: Option>, + /// Set when the stream carries text. + pub text_reader: Option>, +} + +/// Buffers opened streams so they can be pulled instead of pushed. +/// +/// Implements [`IncomingDataStreamManagerDelegate`] in Rust; see the module docs. +#[derive(uniffi::Object)] +pub struct IncomingStreamQueue { + tx: UnboundedSender, + rx: Mutex>, + depth: AtomicUsize, + shutdown: CancellationToken, +} + +impl IncomingStreamQueue { + fn new() -> Self { + let (tx, rx) = unbounded_channel(); + Self { + tx, + rx: Mutex::new(rx), + depth: AtomicUsize::new(0), + shutdown: CancellationToken::new(), + } + } + + fn push(&self, opened: OpenedStream) { + if self.tx.send(opened).is_ok() { + warn_if_deep("incoming stream", self.depth.fetch_add(1, Ordering::Relaxed) + 1); + } + } +} + +impl IncomingDataStreamManagerDelegate for IncomingStreamQueue { + fn on_byte_stream_opened(&self, reader: Arc, identity: String) { + self.push(OpenedStream { identity, byte_reader: Some(reader), text_reader: None }); + } + + fn on_text_stream_opened(&self, reader: Arc, identity: String) { + self.push(OpenedStream { identity, byte_reader: None, text_reader: Some(reader) }); + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl IncomingStreamQueue { + /// Awaits the next stream opened by a remote participant. + /// + /// Returns `None` once the manager has shut down, which ends the caller's drain loop. Unlike + /// [`OutgoingPacketQueue::next_packets`] this yields one at a time: each carries a reader the + /// caller has to route to a handler, so batching would only defer that work. + pub async fn next_opened_stream(&self) -> Option { + let mut rx = self.rx.lock().await; + let opened = tokio::select! { + _ = self.shutdown.cancelled() => return None, + received = rx.recv() => received?, + }; + self.depth.fetch_sub(1, Ordering::Relaxed); + Some(opened) + } + + /// Wakes a pending [`Self::next_opened_stream`] with `None`. See + /// [`OutgoingPacketQueue::close`]. + pub fn close(&self) { + self.shutdown.cancel(); + } +} + +/// An [`IncomingDataStreamManager`] and the queue draining it, already connected. +#[derive(uniffi::Record)] +pub struct PolledIncomingDataStreamManager { + pub manager: Arc, + pub streams: Arc, +} + +/// Builds an incoming manager whose opened streams are pulled rather than pushed. +#[uniffi::export] +pub fn polled_incoming_data_stream_manager( + max_payload_byte_length: Option, +) -> PolledIncomingDataStreamManager { + let streams = Arc::new(IncomingStreamQueue::new()); + let manager = IncomingDataStreamManager::new(streams.clone(), max_payload_byte_length); + PolledIncomingDataStreamManager { manager, streams } +} diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 45fccd519..1164a6a8c 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -138,3 +138,73 @@ fn outgoing_all_v2_text_inlines_compressed() { assert_ne!(inline.as_slice(), b"hello hello compressible world", "should be compressed"); }); } + +/// A room where every recipient predates v2. +struct PreV2Registry; + +impl RemoteParticipantRegistryDelegate for PreV2Registry { + fn remote_client_protocol(&self, _identity: String) -> i32 { + livekit_common::CLIENT_PROTOCOL_DEFAULT + } + + fn remote_capabilities(&self, _identity: String) -> Vec { + vec![] + } + + fn remote_identities(&self) -> Vec { + vec!["bob".to_string()] + } +} + +/// Drives both managers through the pull adapters in [`super::polled`] — the path thread-affine +/// bindings take — and checks a payload survives the round trip. +async fn polled_roundtrip( + registry: Arc, + text: &str, +) -> (usize, String) { + let outgoing = super::polled::polled_outgoing_data_stream_manager(registry); + let incoming = super::polled::polled_incoming_data_stream_manager(None); + + let options = StreamTextOptions { + topic: "chat".to_string(), + destination_identities: vec!["bob".to_string()], + ..Default::default() + }; + outgoing.manager.send_text(text.to_string(), options).await.expect("send_text should succeed"); + + // send_text only resolves once every packet has been queued, so draining terminates rather + // than blocking. + let mut packet_count = 0; + while let Ok(Some(packets)) = + tokio::time::timeout(std::time::Duration::from_millis(200), outgoing.packets.next_packets()) + .await + { + for packet in packets { + packet_count += 1; + incoming.manager.handle_packet_received(packet); + } + } + + let opened = incoming.streams.next_opened_stream().await.expect("a stream should open"); + let reader = opened.text_reader.expect("expected a text stream"); + (packet_count, reader.read_all().await.unwrap()) +} + +#[test] +fn polled_inlines_for_v2_recipients() { + crate::runtime::runtime().block_on(async { + let (packets, text) = + polled_roundtrip(Arc::new(AllV2Registry), "hello hello compressible world").await; + assert_eq!(packets, 1, "a v2 recipient should get a single inline packet"); + assert_eq!(text, "hello hello compressible world"); + }); +} + +#[test] +fn polled_falls_back_to_legacy_framing_for_pre_v2_recipients() { + crate::runtime::runtime().block_on(async { + let (packets, text) = polled_roundtrip(Arc::new(PreV2Registry), "hello world").await; + assert_eq!(packets, 3, "a pre-v2 recipient should get header + chunk + trailer"); + assert_eq!(text, "hello world"); + }); +} From 9f4616f6bc1bf9825ed6fbf7e392f23640ca1b7c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 10 Aug 2026 13:11:14 -0400 Subject: [PATCH 11/24] Create data_streams_v2_uniffi.md --- .changeset/data_streams_v2_uniffi.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/data_streams_v2_uniffi.md diff --git a/.changeset/data_streams_v2_uniffi.md b/.changeset/data_streams_v2_uniffi.md new file mode 100644 index 000000000..4bf50596a --- /dev/null +++ b/.changeset/data_streams_v2_uniffi.md @@ -0,0 +1,8 @@ +--- +livekit: patch +livekit-data-stream: patch +livekit-ffi: patch +livekit-uniffi: patch +--- + +Add data streams v2 to exposed uniffi interface - #1286 (@1egoman) From 0f8b04287926267b5455c3969986f890810e73da Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 11 Aug 2026 15:04:05 -0400 Subject: [PATCH 12/24] fix: add override for close method name for dart uniffi helper --- livekit-uniffi/uniffi.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/livekit-uniffi/uniffi.toml b/livekit-uniffi/uniffi.toml index 71b6d20fe..bed123f44 100644 --- a/livekit-uniffi/uniffi.toml +++ b/livekit-uniffi/uniffi.toml @@ -15,6 +15,8 @@ cdylib_name = "livekit_uniffi" # the name of the so file to be loaded # leaves AutoCloseable's untouched. "ByteStreamWriter.close" = "close_stream" "TextStreamWriter.close" = "close_stream" +"IncomingStreamQueue.close" = "close_stream" +"OutgoingPacketQueue.close" = "close_stream" # DataStreamError's variant fields cannot be renamed from here: uniffi keys the rename table by # crate name but looks up enum and record members by the item's full module path, so a rename for From 3cab25e2fbde8bbd86bed53a8404da5c92f02a0f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 11 Aug 2026 15:27:23 -0400 Subject: [PATCH 13/24] fix: remove kotlin checksums for now See comments in diff explaining why, it seems to be broken in our current uniffi release version. --- Cargo.toml | 9 +++++++++ livekit-datatrack/uniffi.toml | 6 ++++++ livekit-uniffi/uniffi.toml | 25 +++++++++++++++++++++++++ 3 files changed, 40 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 1bd034dba..8d4e51546 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,6 +92,15 @@ serde_json = "1.0" thiserror = "2" tokio = { version = "1", default-features = false } tokio-stream = "0.1" +# Test on a 64-bit ARM device before you change this version. +# +# The Kotlin bindings from uniffi 0.31.2 and 0.32.0 compare each checksum incorrectly on 64-bit +# ARM. Every affected method then fails. See https://github.com/mozilla/uniffi-rs/pull/2897, which +# introduced the defect. Version 0.31.1 has a related defect on 32-bit ARM, and it also does not +# build here, because uniffi-dart requires 0.31.2 or later. mozilla/uniffi-rs#2935 corrects both +# defects, but no release (as of mid august 2026) contains that change. +# +# For this reason, livekit-uniffi sets `omit_checksums` for Kotlin. See livekit-uniffi/uniffi.toml. uniffi = "0.31" # For examples diff --git a/livekit-datatrack/uniffi.toml b/livekit-datatrack/uniffi.toml index e138669b9..5148c72db 100644 --- a/livekit-datatrack/uniffi.toml +++ b/livekit-datatrack/uniffi.toml @@ -1,2 +1,8 @@ [bindings.swift] ffi_module_name = "RustLiveKitDataTrack" + +[bindings.kotlin] +# The Kotlin checksum test is off because it fails on ARM devices. uniffi 0.31.2 and 0.32.0 do not +# mask the upper 16 bits of the checksum. For more info, see the similar comment in the root +# Cargo.toml. +omit_checksums = true diff --git a/livekit-uniffi/uniffi.toml b/livekit-uniffi/uniffi.toml index bed123f44..fbc7e1adc 100644 --- a/livekit-uniffi/uniffi.toml +++ b/livekit-uniffi/uniffi.toml @@ -6,6 +6,31 @@ android = true package_name = "io.livekit.uniffi" cdylib_name = "livekit_uniffi" # the name of the so file to be loaded +# The Kotlin checksum test is off because it fails on ARM devices. +# +# UniFFI gives each checksum function a return type in the Kotlin bindings. The bindings then +# compare the result with the expected checksum. Both available forms of this comparison are +# defective: +# +# - uniffi 0.31.1 uses the type Short. On 32-bit ARM, in release mode, the upper bits of a +# checksum above 32767 are incorrect. The test fails. See mozilla/uniffi-rs#2740. +# +# - uniffi 0.31.2 and 0.32.0 use the type Int, but they do not mask the upper 16 bits. On 64-bit +# ARM, Rust extends the sign of the 16-bit value. A checksum of 0x8000 or more then has 0xFFFF +# in its upper bits. The test fails. See https://github.com/mozilla/uniffi-rs/pull/2897. +# +# mozilla/uniffi-rs#2935 adds the mask, but no release contains that change. +# +# The AAR contains code for arm64-v8a and for armeabi-v7a. For this reason, one of the two defects +# applies to every uniffi release that this crate can use. +# +# It is safe to omit this test. The test finds a shared library that does not agree with the +# bindings. `cargo make android-package-local` builds the library and the bindings together, thus +# they always agree. Swift, Python, Dart and Node keep their checksum tests. +# +# Remove this line when a uniffi release contains mozilla/uniffi-rs#2935. +omit_checksums = true + # Two names that are perfectly fine in Rust but do not compile in Kotlin. Renamed for Kotlin only, # so the Rust source and the Swift/Python/Node bindings keep the original names. [bindings.kotlin.rename] From 70846ab4171991196623a0286edc1d8bf5ba66ed Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 11 Aug 2026 15:35:22 -0400 Subject: [PATCH 14/24] fix: add livekit-datatrack to knope changeset --- .changeset/data_streams_v2_uniffi.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/data_streams_v2_uniffi.md b/.changeset/data_streams_v2_uniffi.md index 4bf50596a..423326be3 100644 --- a/.changeset/data_streams_v2_uniffi.md +++ b/.changeset/data_streams_v2_uniffi.md @@ -3,6 +3,7 @@ livekit: patch livekit-data-stream: patch livekit-ffi: patch livekit-uniffi: patch +livekit-datatrack: patch --- Add data streams v2 to exposed uniffi interface - #1286 (@1egoman) From 61f5165edde0f8404fe04f39a95040284f95e91f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 17 Aug 2026 16:09:43 -0400 Subject: [PATCH 15/24] feat: emit StreamClosed for incoming data streams and forward it over FFI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The incoming manager previously signaled wire-level stream closure only via the deprecated TrailerReceived output, which the FFI layer intentionally does not forward — leaving hosts that deliver streams on ordered topics (e.g. transcription) no way to know when a stream's handler chain can advance, so a still-open stream would head-of-line-block every later stream from that sender. A trailer alone is also insufficient: inline single-packet streams never receive one. Add a StreamClosed output event emitted exactly once per opened stream on every terminal path (trailer close, inline completion, error, abort) and surface it through IncomingDataStreamManagerDelegate::on_stream_closed plus a next_closed_stream() pull on the polled adapter. --- livekit-data-stream/src/incoming/events.rs | 17 +- livekit-data-stream/src/incoming/manager.rs | 162 ++++++++++++++++++-- livekit-uniffi/src/data_stream/incoming.rs | 20 ++- livekit-uniffi/src/data_stream/polled.rs | 44 +++++- livekit-uniffi/src/data_stream/tests.rs | 117 ++++++++++++++ livekit/src/room/mod.rs | 3 + 6 files changed, 345 insertions(+), 18 deletions(-) diff --git a/livekit-data-stream/src/incoming/events.rs b/livekit-data-stream/src/incoming/events.rs index 37a9cdf16..7fbd071c8 100644 --- a/livekit-data-stream/src/incoming/events.rs +++ b/livekit-data-stream/src/incoming/events.rs @@ -17,7 +17,7 @@ use livekit_common::ParticipantIdentity; use crate::{ incoming::AnyStreamReader, - types::{Chunk, Packet, Trailer}, + types::{Chunk, Packet, StreamId, Trailer}, }; pub struct PacketReceived { @@ -53,6 +53,20 @@ pub struct StreamOpened { pub participant_identity: ParticipantIdentity, } +/// A stream previously announced via [`StreamOpened`] has terminated and will produce no further +/// data: its trailer arrived, its inline payload completed, it failed with an error, or it was +/// aborted. +/// +/// Emitted exactly once per opened stream. Hosts delivering streams on ordered topics use this to +/// know when a stream's handler can be considered finished on the wire (a trailer alone is not +/// enough: inline single-packet streams never receive one). +pub struct StreamClosed { + pub stream_id: StreamId, + pub participant_identity: ParticipantIdentity, + /// Topic the stream was opened on. + pub topic: String, +} + /// A "raw chunk received" notification, which is used to trigger /// the deprecated [RoomEvent:::StreamChunkReceived] event. pub struct ChunkReceived { @@ -82,6 +96,7 @@ pub struct TrailerReceived { #[derive(FromVariants)] pub enum OutputEvent { StreamOpened(StreamOpened), + StreamClosed(StreamClosed), ChunkReceived(ChunkReceived), TrailerReceived(TrailerReceived), } diff --git a/livekit-data-stream/src/incoming/manager.rs b/livekit-data-stream/src/incoming/manager.rs index fb979b255..e157933bf 100644 --- a/livekit-data-stream/src/incoming/manager.rs +++ b/livekit-data-stream/src/incoming/manager.rs @@ -29,7 +29,8 @@ use crate::{ use super::{ events::{ - ChunkReceived, InputEvent, OutputEvent, PacketReceived, StreamOpened, TrailerReceived, + ChunkReceived, InputEvent, OutputEvent, PacketReceived, StreamClosed, StreamOpened, + TrailerReceived, }, stream_reader::AnyStreamReader, }; @@ -184,15 +185,14 @@ impl ManagerInput { pub struct Manager { inner: ManagerInner, input_rx: UnboundedReceiver, - output_tx: UnboundedSender, /// Max number of bytes that a data stream can contain before it is deemed to be malicious max_payload_byte_length: usize, } -#[derive(Default)] struct ManagerInner { open_streams: HashMap, + output_tx: UnboundedSender, } impl Manager { @@ -204,9 +204,8 @@ impl Manager { let (input_tx, input_rx) = mpsc::unbounded_channel(); let (output_tx, output_rx) = mpsc::unbounded_channel(); let manager = Self { - inner: ManagerInner::default(), + inner: ManagerInner { open_streams: HashMap::new(), output_tx }, input_rx, - output_tx, max_payload_byte_length: max_payload_byte_length .unwrap_or(DEFAULT_MAX_PAYLOAD_BYTE_LENGTH), @@ -288,18 +287,20 @@ impl Manager { } let (stream_reader, chunk_tx, progress_tx) = AnyStreamReader::from(info); - let _ = self.output_tx.send( + let _ = self.inner.output_tx.send( StreamOpened { stream_reader, participant_identity: participant_identity.clone() } .into(), ); if bytes_total.is_some_and(|total| total > self.max_payload_byte_length as u64) { let _ = chunk_tx.send(Err(StreamError::PayloadTooLarge)); + self.inner.emit_stream_closed(&id, participant_identity, topic); return; } // Inline single-packet stream: synthesize the complete content now; no chunk/trailer - // packets will follow, so we never register an open descriptor. + // packets will follow, so we never register an open descriptor. Every path below + // terminates the stream, so each emits `StreamClosed` (there is no trailer to do it). if let Some(content) = inline_content { let content = if is_compressed { match inflate_raw(&content, self.max_payload_byte_length).await { @@ -308,12 +309,14 @@ impl Manager { // Defensive: a conforming sender never sends a compressed stream we // can't read, but drop gracefully if it happens. let _ = chunk_tx.send(Err(error)); + self.inner.emit_stream_closed(&id, participant_identity, topic); return; } } } else { if content.len() > self.max_payload_byte_length { let _ = chunk_tx.send(Err(StreamError::PayloadTooLarge)); + self.inner.emit_stream_closed(&id, participant_identity, topic); return; } content @@ -329,6 +332,7 @@ impl Manager { let _ = chunk_tx.send(Ok(Bytes::from(content))); } // Dropping `chunk_tx` closes the reader. + self.inner.emit_stream_closed(&id, participant_identity, topic); return; } @@ -364,7 +368,7 @@ impl Manager { encryption_type: EncryptionType, ) { let id = chunk.stream_id.clone(); - let _ = self.output_tx.send(OutputEvent::ChunkReceived(ChunkReceived { + let _ = self.inner.output_tx.send(OutputEvent::ChunkReceived(ChunkReceived { chunk: chunk.clone(), participant_identity, topic: self.topic_associated_with_stream_id(&id), @@ -466,7 +470,7 @@ impl Manager { /// Handles an incoming trailer packet. fn handle_trailer(&mut self, trailer: Trailer, participant_identity: ParticipantIdentity) { let id = trailer.stream_id.clone(); - let _ = self.output_tx.send( + let _ = self.inner.output_tx.send( TrailerReceived { trailer: trailer.clone(), participant_identity, @@ -553,12 +557,15 @@ impl ManagerInner { fn close_stream(&mut self, id: &StreamId) { // Dropping the sender closes the channel. - self.open_streams.remove(id); + if let Some(descriptor) = self.open_streams.remove(id) { + self.emit_stream_closed(id, descriptor.sender_identity, descriptor.topic); + } } fn close_stream_with_error(&mut self, id: &StreamId, error: StreamError) { if let Some(descriptor) = self.open_streams.remove(id) { let _ = descriptor.chunk_tx.send(Err(error)); + self.emit_stream_closed(id, descriptor.sender_identity, descriptor.topic); } } @@ -566,14 +573,29 @@ impl ManagerInner { &mut self, checker: impl Fn(&StreamId, &Descriptor) -> Result<(), StreamError>, ) { - self.open_streams.retain(|id, descriptor| match checker(id, &descriptor) { + let Self { open_streams, output_tx } = self; + open_streams.retain(|id, descriptor| match checker(id, &descriptor) { Ok(_) => true, Err(error) => { let _ = descriptor.chunk_tx.send(Err(error)); + let _ = output_tx.send(OutputEvent::StreamClosed(StreamClosed { + stream_id: id.clone(), + participant_identity: descriptor.sender_identity.clone(), + topic: descriptor.topic.clone(), + })); false } }); } + + /// Announces that a stream previously announced via [`StreamOpened`] is terminated. + fn emit_stream_closed(&self, id: &StreamId, identity: ParticipantIdentity, topic: String) { + let _ = self.output_tx.send(OutputEvent::StreamClosed(StreamClosed { + stream_id: id.clone(), + participant_identity: identity, + topic, + })); + } } #[cfg(test)] @@ -724,6 +746,22 @@ mod tests { } } } + + /// Awaits the next closed stream, returning its id, sender identity, and topic. + async fn next_closed(&mut self) -> (String, String, String) { + loop { + match self.output_rx.recv().await.expect("a stream should be closed") { + OutputEvent::StreamClosed(StreamClosed { + stream_id, + participant_identity, + topic, + }) => { + return (stream_id.to_string(), participant_identity.to_string(), topic); + } + _ => continue, + } + } + } } mod v1_legacy_multi_packet { @@ -1528,6 +1566,106 @@ mod tests { } } + /// Every opened stream terminates with exactly one `StreamClosed`, whatever the terminal + /// path — hosts rely on it to sequence handler invocations for ordered topics. + mod stream_closed { + use super::*; + + #[tokio::test] + async fn trailer_close_emits_stream_closed() { + let mut h = Harness::new(); + let text = "hello world"; + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, text.as_bytes().to_vec()), + encryption_type: EncryptionType::None, + }); + h.send_packet(Packet::Trailer(trailer("s1"))); + assert_eq!(h.next_closed().await, ("s1".into(), SENDER.into(), "topic".into())); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn inline_stream_emits_stream_closed() { + // Inline single-packet streams never receive a trailer, so the closed signal must be + // synthesized when the inline payload completes. + let mut h = Harness::new(); + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(5), + HashMap::new(), + Some(b"hello".to_vec()), + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + assert_eq!(h.next_closed().await, ("s1".into(), SENDER.into(), "topic".into())); + assert_eq!(read_text(reader).await.unwrap(), "hello"); + } + + #[tokio::test] + async fn error_close_emits_stream_closed() { + let mut h = Harness::new(); + h.send_packet(Packet::Header { + header: text_header("s1", Some(10), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + // A chunk-index gap closes the stream with `MissedChunk`. + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 5, b"hello".to_vec()), + encryption_type: EncryptionType::None, + }); + assert_eq!(h.next_closed().await, ("s1".into(), SENDER.into(), "topic".into())); + assert!(matches!(read_text(reader).await, Err(StreamError::MissedChunk))); + } + + #[tokio::test] + async fn abort_emits_stream_closed() { + let mut h = Harness::new(); + h.send_packet(Packet::Header { + header: text_header("s1", Some(10), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.abort(ParticipantIdentity::from(SENDER)); + assert_eq!(h.next_closed().await, ("s1".into(), SENDER.into(), "topic".into())); + assert!(matches!(read_text(reader).await, Err(StreamError::AbnormalEnd(_)))); + } + + #[tokio::test] + async fn trailer_for_unopened_stream_emits_no_stream_closed() { + let mut h = Harness::new(); + h.send_packet(Packet::Trailer(trailer("never-opened"))); + // A second, well-formed inline stream: if the orphan trailer had produced a closed + // event, it would be observed before this stream's. + h.send_packet(Packet::Header { + header: text_header( + "s2", + Some(2), + HashMap::new(), + Some(b"hi".to_vec()), + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (closed_id, _, _) = h.next_closed().await; + assert_eq!(closed_id, "s2"); + } + } + #[tokio::test] async fn empty_chunks_are_ignored() { let mut h = Harness::new(); @@ -1632,7 +1770,7 @@ mod tests { | OutputEvent::TrailerReceived(TrailerReceived { topic, .. }) => { return topic; } - OutputEvent::StreamOpened(_) => continue, + _ => continue, } } } diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index e722dadf6..f62700908 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -38,8 +38,8 @@ pub struct IncomingDataStreamManager { /// Delegate for receiving output events from [`IncomingDataStreamManager`]. /// -/// Only stream-open events are surfaced. The manager's deprecated v1 raw chunk/trailer -/// notifications are intentionally not forwarded over the FFI boundary. +/// Only stream lifecycle events (opened/closed) are surfaced. The manager's deprecated v1 raw +/// chunk/trailer notifications are intentionally not forwarded over the FFI boundary. #[uniffi::export(with_foreign)] pub trait IncomingDataStreamManagerDelegate: Send + Sync { /// A byte stream was opened by `identity` and is ready to be read. @@ -47,6 +47,14 @@ pub trait IncomingDataStreamManagerDelegate: Send + Sync { /// A text stream was opened by `identity` and is ready to be read. fn on_text_stream_opened(&self, reader: Arc, identity: String); + + /// A previously opened stream terminated on the wire and will produce no further data: its + /// trailer arrived, its (single-packet) inline payload completed, it failed, or it was + /// aborted. Emitted exactly once per opened stream, after the corresponding open event. + /// + /// Hosts delivering streams on ordered topics use this to know when a stream's handler chain + /// can advance — a stream that is still open must not block streams opened after it forever. + fn on_stream_closed(&self, stream_id: String, identity: String); } #[uniffi::export] @@ -203,6 +211,14 @@ impl DelegateForwardTask { } } } + ds::incoming::OutputEvent::StreamClosed(ds::incoming::StreamClosed { + stream_id, + participant_identity, + topic: _, + }) => { + self.delegate + .on_stream_closed(stream_id.to_string(), participant_identity.to_string()); + } // Deprecated v1 raw chunk/trailer notifications are not surfaced over the FFI boundary. ds::incoming::OutputEvent::ChunkReceived(_) | ds::incoming::OutputEvent::TrailerReceived(_) => {} diff --git a/livekit-uniffi/src/data_stream/polled.rs b/livekit-uniffi/src/data_stream/polled.rs index e090895cc..b5a0bc142 100644 --- a/livekit-uniffi/src/data_stream/polled.rs +++ b/livekit-uniffi/src/data_stream/polled.rs @@ -166,7 +166,17 @@ pub struct OpenedStream { pub text_reader: Option>, } -/// Buffers opened streams so they can be pulled instead of pushed. +/// A stream closed by a remote participant (or terminated by an error/abort); see +/// [`IncomingDataStreamManagerDelegate::on_stream_closed`]. +#[derive(uniffi::Record)] +pub struct ClosedStream { + /// Id of the stream that closed. + pub stream_id: String, + /// Identity of the participant that opened the stream. + pub identity: String, +} + +/// Buffers opened and closed streams so they can be pulled instead of pushed. /// /// Implements [`IncomingDataStreamManagerDelegate`] in Rust; see the module docs. #[derive(uniffi::Object)] @@ -174,16 +184,23 @@ pub struct IncomingStreamQueue { tx: UnboundedSender, rx: Mutex>, depth: AtomicUsize, + closed_tx: UnboundedSender, + closed_rx: Mutex>, + closed_depth: AtomicUsize, shutdown: CancellationToken, } impl IncomingStreamQueue { fn new() -> Self { let (tx, rx) = unbounded_channel(); + let (closed_tx, closed_rx) = unbounded_channel(); Self { tx, rx: Mutex::new(rx), depth: AtomicUsize::new(0), + closed_tx, + closed_rx: Mutex::new(closed_rx), + closed_depth: AtomicUsize::new(0), shutdown: CancellationToken::new(), } } @@ -203,6 +220,12 @@ impl IncomingDataStreamManagerDelegate for IncomingStreamQueue { fn on_text_stream_opened(&self, reader: Arc, identity: String) { self.push(OpenedStream { identity, byte_reader: None, text_reader: Some(reader) }); } + + fn on_stream_closed(&self, stream_id: String, identity: String) { + if self.closed_tx.send(ClosedStream { stream_id, identity }).is_ok() { + warn_if_deep("closed stream", self.closed_depth.fetch_add(1, Ordering::Relaxed) + 1); + } + } } #[uniffi::export(async_runtime = "tokio")] @@ -222,8 +245,23 @@ impl IncomingStreamQueue { Some(opened) } - /// Wakes a pending [`Self::next_opened_stream`] with `None`. See - /// [`OutgoingPacketQueue::close`]. + /// Awaits the next stream-closed notification. + /// + /// Pulled independently of [`Self::next_opened_stream`], so ordering across the two queues is + /// not guaranteed — correlate by `stream_id` (a close always follows its open on the push + /// side). Returns `None` once the manager has shut down. + pub async fn next_closed_stream(&self) -> Option { + let mut rx = self.closed_rx.lock().await; + let closed = tokio::select! { + _ = self.shutdown.cancelled() => return None, + received = rx.recv() => received?, + }; + self.closed_depth.fetch_sub(1, Ordering::Relaxed); + Some(closed) + } + + /// Wakes a pending [`Self::next_opened_stream`] or [`Self::next_closed_stream`] with `None`. + /// See [`OutgoingPacketQueue::close`]. pub fn close(&self) { self.shutdown.cancel(); } diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 1164a6a8c..c66d4dc58 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -52,6 +52,55 @@ fn inline_text_packet(identity: &str, topic: &str, text: &str) -> Bytes { Bytes::from(packet.encode_to_vec()) } +/// Builds an encoded v1 multi-packet text stream header `DataPacket` (no inline content). +fn multipacket_text_header_packet(identity: &str, topic: &str, total_length: u64) -> Bytes { + let header = proto::data_stream::Header { + stream_id: "s1".to_string(), + topic: topic.to_string(), + mime_type: "text/plain".to_string(), + timestamp: 0, + total_length: Some(total_length), + content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( + proto::data_stream::TextHeader::default(), + )), + ..Default::default() + }; + let packet = proto::DataPacket { + participant_identity: identity.to_string(), + value: Some(proto::data_packet::Value::StreamHeader(header)), + ..Default::default() + }; + Bytes::from(packet.encode_to_vec()) +} + +/// Builds an encoded chunk `DataPacket` for stream `s1`. +fn chunk_packet(identity: &str, chunk_index: u64, content: &[u8]) -> Bytes { + let chunk = proto::data_stream::Chunk { + stream_id: "s1".to_string(), + chunk_index, + content: content.to_vec(), + ..Default::default() + }; + let packet = proto::DataPacket { + participant_identity: identity.to_string(), + value: Some(proto::data_packet::Value::StreamChunk(chunk)), + ..Default::default() + }; + Bytes::from(packet.encode_to_vec()) +} + +/// Builds an encoded trailer `DataPacket` for stream `s1`. +fn trailer_packet(identity: &str) -> Bytes { + let trailer = + proto::data_stream::Trailer { stream_id: "s1".to_string(), ..Default::default() }; + let packet = proto::DataPacket { + participant_identity: identity.to_string(), + value: Some(proto::data_packet::Value::StreamTrailer(trailer)), + ..Default::default() + }; + Bytes::from(packet.encode_to_vec()) +} + /// Captures the first opened text reader. struct TextCapture(Mutex, String)>>>); @@ -63,6 +112,8 @@ impl IncomingDataStreamManagerDelegate for TextCapture { let _ = tx.send((reader, identity)); } } + + fn on_stream_closed(&self, _stream_id: String, _identity: String) {} } #[test] @@ -81,6 +132,72 @@ fn incoming_inline_text_stream_roundtrips() { }); } +/// Captures the first stream-closed notification. +struct ClosedCapture(Mutex>>); + +impl IncomingDataStreamManagerDelegate for ClosedCapture { + fn on_byte_stream_opened(&self, _reader: Arc, _identity: String) {} + + fn on_text_stream_opened(&self, _reader: Arc, _identity: String) {} + + fn on_stream_closed(&self, stream_id: String, identity: String) { + if let Some(tx) = self.0.lock().unwrap().take() { + let _ = tx.send((stream_id, identity)); + } + } +} + +#[test] +fn incoming_trailer_fires_stream_closed() { + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(ClosedCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + + manager.handle_packet_received(multipacket_text_header_packet("alice", "my-topic", 5)); + manager.handle_packet_received(chunk_packet("alice", 0, b"hello")); + manager.handle_packet_received(trailer_packet("alice")); + + let (stream_id, identity) = rx.await.expect("the stream should close"); + assert_eq!(stream_id, "s1"); + assert_eq!(identity, "alice"); + }); +} + +#[test] +fn incoming_inline_stream_fires_stream_closed() { + // Inline single-packet streams never receive a trailer, so the closed signal must still fire + // once their payload is delivered. + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(ClosedCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + + manager.handle_packet_received(inline_text_packet("alice", "my-topic", "hello world")); + + let (stream_id, identity) = rx.await.expect("the stream should close"); + assert_eq!(stream_id, "s1"); + assert_eq!(identity, "alice"); + }); +} + +#[test] +fn incoming_abort_fires_stream_closed() { + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(ClosedCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + + // Announce a multi-packet stream, then abort before its trailer ever arrives. + manager.handle_packet_received(multipacket_text_header_packet("alice", "my-topic", 5)); + manager.abort_all_streams(); + + let (stream_id, identity) = rx.await.expect("the stream should close"); + assert_eq!(stream_id, "s1"); + assert_eq!(identity, "alice"); + }); +} + /// Collects every outbound packet the manager emits. struct PacketCapture(Mutex>); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 0e4adc2b3..7633c5c73 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -2438,6 +2438,9 @@ async fn incoming_data_stream_task( dispatcher.dispatch(&RoomEvent::StreamTrailerReceived { trailer: trailer.into(), participant_identity: participant_identity.into() }); } } + // The Rust SDK observes completion through the reader itself; the explicit + // closed signal exists for FFI hosts sequencing handlers on ordered topics. + ds::incoming::OutputEvent::StreamClosed(_) => {} }, _ = close_rx.recv() => { _ = session.incoming_data_stream_input.send(ds::incoming::InputEvent::Shutdown); From 119bc2e1b54076d22f1017f39b9ec912809a57b3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 17 Aug 2026 16:15:43 -0400 Subject: [PATCH 16/24] feat: propagate transport errors through the outgoing delegate and batch one-shot sends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OutgoingDataStreamManagerDelegate::on_packets_available returned (), so a packet that never reached the wire could not fail the originating call: write() couldn't throw, is_open() couldn't go false on a send failure, and the responder acked once the host had merely buffered — letting a producer looping on write() queue unboundedly. The delegate now returns Result<(), PacketDeliveryError> (a dedicated error carrying a host-provided reason, convertible into DataStreamError::Internal); throwing it fails the originating send_*/write call with SendFailed and closes the affected stream, and returning only after handing packets to the transport is what provides back-pressure. The Vec signature also always carried exactly one packet. Keep the shape (matching the data-track PacketsAvailable interface) but make it true: the packet channel now carries ordered batches acknowledged as a whole. One-shot sends (send_text/send_bytes) emit their entire stream — header, chunks, trailer — as a single request, i.e. one FFI crossing per send; every other call site (incremental writers, send_file's unbuffered streaming, inline sends) sends vec![packet]. --- livekit-data-stream/src/outgoing/manager.rs | 190 ++++++++++++++---- .../src/outgoing/raw_stream.rs | 21 +- livekit-uniffi/src/data_stream/common.rs | 26 +++ livekit-uniffi/src/data_stream/outgoing.rs | 35 +++- livekit-uniffi/src/data_stream/polled.rs | 6 +- livekit-uniffi/src/data_stream/tests.rs | 91 ++++++++- livekit/src/room/mod.rs | 34 ++-- 7 files changed, 335 insertions(+), 68 deletions(-) diff --git a/livekit-data-stream/src/outgoing/manager.rs b/livekit-data-stream/src/outgoing/manager.rs index 0764b5593..48e1cd00e 100644 --- a/livekit-data-stream/src/outgoing/manager.rs +++ b/livekit-data-stream/src/outgoing/manager.rs @@ -43,12 +43,15 @@ fn create_random_uuid() -> String { #[derive(Clone)] pub struct Manager { - /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, + /// Request channel for sending packet batches. Each request is an ordered batch the + /// transport acknowledges as a whole: one-shot sends (`send_text`/`send_bytes`) emit their + /// entire stream as a single request, while every other call site sends one packet at a time. + packet_tx: UnboundedRequestSender, Result<(), SendError>>, } impl Manager { - pub fn new() -> (Self, UnboundedRequestReceiver>) { + pub fn new() -> (Self, UnboundedRequestReceiver, Result<(), SendError>>) + { let (packet_tx, packet_rx) = bmrng::unbounded_channel(); let manager = Self { packet_tx }; (manager, packet_rx) @@ -159,27 +162,30 @@ impl Manager { return Ok(TextStreamInfo::from_headers(header, text_header)); } - // 2/3. Chunked, compressed when eligible else uncompressed. + // 2/3. Chunked, compressed when eligible else uncompressed. The entire stream — header, + // chunks, trailer — goes out as one transport request. header.inline_content = None; enforce_header_size(&header, &options.destination_identities)?; - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - sender_identity: options.sender_identity, - packet_tx: self.packet_tx.clone(), - }; - let info = TextStreamInfo::from_headers(header, text_header); - let mut stream = RawStream::open(open_options).await?; + let info = TextStreamInfo::from_headers(header.clone(), text_header); if use_compression { let compressed_bytes = maybe_compressed.as_bytes().await?; - stream.write_raw_chunks(compressed_bytes).await?; + self.send_one_shot_stream( + header, + compressed_bytes.chunks(constants::STREAM_CHUNK_SIZE_BYTES), + options.destination_identities, + options.sender_identity, + ) + .await?; } else { - for chunk in text_bytes.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES) { - stream.write_chunk(chunk).await?; - } + self.send_one_shot_stream( + header, + text_bytes.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES), + options.destination_identities, + options.sender_identity, + ) + .await?; } - stream.close(None, None).await?; Ok(info) } @@ -253,25 +259,20 @@ impl Manager { return Ok(ByteStreamInfo::from_headers(header, byte_header)); } - // 2/3. Chunked, compressed when eligible else uncompressed. + // 2/3. Chunked, compressed when eligible else uncompressed. The entire stream — header, + // chunks, trailer — goes out as one transport request. header.inline_content = None; enforce_header_size(&header, &options.destination_identities)?; - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - sender_identity: options.sender_identity, - packet_tx: self.packet_tx.clone(), - }; - let info = ByteStreamInfo::from_headers(header, byte_header); - let mut stream = RawStream::open(open_options).await?; - if use_compression { - let compressed_bytes = maybe_compressed.as_bytes().await?; - stream.write_raw_chunks(compressed_bytes).await?; - } else { - stream.write_raw_chunks(bytes).await?; - } - stream.close(None, None).await?; + let info = ByteStreamInfo::from_headers(header.clone(), byte_header); + let content = if use_compression { maybe_compressed.as_bytes().await? } else { bytes }; + self.send_one_shot_stream( + header, + content.chunks(constants::STREAM_CHUNK_SIZE_BYTES), + options.destination_identities, + options.sender_identity, + ) + .await?; Ok(info) } @@ -318,6 +319,36 @@ impl Manager { stream.close(None, None).await?; Ok(info) } + + /// Sends a complete one-shot stream — header, pre-split content chunks, trailer — as a + /// single transport request acknowledged as a whole, rather than one request per packet. + /// + /// Only for sends whose full content is already in memory (`send_text`/`send_bytes`); + /// incremental writers and `send_file` stream packet-by-packet instead. + async fn send_one_shot_stream<'a>( + &self, + header: Header, + chunks: impl IntoIterator, + destination_identities: Vec, + sender_identity: Option, + ) -> StreamResult<()> { + let stream_id = header.stream_id.to_string(); + let mut packets = + vec![RawStream::create_header_packet(header.into(), destination_identities)]; + packets.extend( + chunks.into_iter().enumerate().map(|(index, chunk)| { + RawStream::create_chunk_packet(&stream_id, index as u64, chunk) + }), + ); + packets.push(RawStream::create_trailer_packet(&stream_id, None, None)); + if let Some(sender_identity) = sender_identity { + let identity: String = sender_identity.into(); + for packet in &mut packets { + packet.participant_identity = identity.clone(); + } + } + RawStream::send_packets(&self.packet_tx, packets).await + } } /// Inline / compression eligibility evaluated over a send's recipients. @@ -537,14 +568,29 @@ mod tests { // --- Capture harness ----------------------------------------------------------------- type Sent = Arc>>; + type SentBatches = Arc>>>; fn setup() -> (Manager, Sent) { let (manager, mut packet_rx) = Manager::new(); let sent: Sent = Arc::new(StdMutex::new(Vec::new())); let sink = sent.clone(); tokio::spawn(async move { - while let Ok((packet, responder)) = packet_rx.recv().await { - sink.lock().unwrap().push(packet); + while let Ok((packets, responder)) = packet_rx.recv().await { + sink.lock().unwrap().extend(packets); + let _ = responder.respond(Ok(())); + } + }); + (manager, sent) + } + + /// Like [`setup`], but records the batch boundaries of each transport request. + fn setup_batched() -> (Manager, SentBatches) { + let (manager, mut packet_rx) = Manager::new(); + let sent: SentBatches = Arc::new(StdMutex::new(Vec::new())); + let sink = sent.clone(); + tokio::spawn(async move { + while let Ok((packets, responder)) = packet_rx.recv().await { + sink.lock().unwrap().push(packets); let _ = responder.respond(Ok(())); } }); @@ -1263,7 +1309,7 @@ mod tests { let raw_stream = rt.block_on(async { let (packet_tx, mut packet_rx) = - bmrng::unbounded_channel::>(); + bmrng::unbounded_channel::, Result<(), SendError>>(); tokio::spawn(async move { while let Ok((_packet, responder)) = packet_rx.recv().await { @@ -1299,6 +1345,78 @@ mod tests { drop_thread.join().expect("Dropping RawStream on a non-Tokio thread must not panic"); } + // --- Batching --------------------------------------------------------------------------- + + mod packet_batching { + use super::*; + + #[tokio::test] + async fn one_shot_send_text_is_a_single_transport_request() { + let (m, sent) = setup_batched(); + // 40 KB uncompressed to a pre-v2 room: header + 3 chunks (15k/15k/10k) + trailer, + // delivered as ONE transport request rather than one per packet. + let text = "A".repeat(40_000); + m.send_text(&text, text_opts("chat", &[]), &pre_v2_room()).await.unwrap(); + let batches = sent.lock().unwrap().clone(); + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![5]); + let batch = &batches[0]; + assert!(matches!(batch[0].value, Some(proto::data_packet::Value::StreamHeader(_)))); + for (i, packet) in batch[1..4].iter().enumerate() { + assert_eq!(chunk(packet).chunk_index, i as u64); + } + assert_trailer(&batch[4]); + } + + #[tokio::test] + async fn one_shot_send_bytes_is_a_single_transport_request() { + let (m, sent) = setup_batched(); + let payload = vec![0x07u8; 40_000]; + let opts = byte_opts("blob", &["alice", "bob"]).with_compress(false); + m.send_bytes(&payload, opts, &all_v2_room()).await.unwrap(); + let batches = sent.lock().unwrap().clone(); + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![5]); + } + + #[tokio::test] + async fn one_shot_send_with_sender_identity_stamps_every_packet() { + let (m, sent) = setup_batched(); + let opts = text_opts("chat", &[]).with_sender_identity("impostor"); + m.send_text(&"A".repeat(20_000), opts, &pre_v2_room()).await.unwrap(); + let batches = sent.lock().unwrap().clone(); + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![4]); + assert!(batches[0].iter().all(|pkt| pkt.participant_identity == "impostor")); + } + + #[tokio::test] + async fn incremental_writer_sends_per_write() { + let (m, sent) = setup_batched(); + let writer = m.stream_text(text_opts("chat", &[])).await.unwrap(); + writer.write("hello").await.unwrap(); + writer.write("world").await.unwrap(); + writer.close().await.unwrap(); + // Incremental writes are flushed as they happen — never coalesced across writes. + let batches = sent.lock().unwrap().clone(); + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![1, 1, 1, 1]); + } + + #[tokio::test] + async fn send_file_streams_one_packet_per_request() { + // send_file deliberately never buffers the whole file, so it keeps per-packet + // requests instead of the one-shot batch. + let (m, sent) = setup_batched(); + let path = + std::env::temp_dir().join(format!("lk_ds_batch_{}.bin", create_random_uuid())); + tokio::fs::write(&path, vec![0x07u8; 20_000]).await.unwrap(); + m.send_file(&path, byte_opts("file", &[]).with_compress(false), &all_v2_room()) + .await + .unwrap(); + let _ = tokio::fs::remove_file(&path).await; + let batches = sent.lock().unwrap().clone(); + // Header + 15k chunk + 5k chunk + trailer, each its own request. + assert_eq!(batches.iter().map(Vec::len).collect::>(), vec![1, 1, 1, 1]); + } + } + // --- Additional spec-conformance cases ------------------------------------------------ mod stream_text_bytes { diff --git a/livekit-data-stream/src/outgoing/raw_stream.rs b/livekit-data-stream/src/outgoing/raw_stream.rs index 6688eccf1..c75055f7a 100644 --- a/livekit-data-stream/src/outgoing/raw_stream.rs +++ b/livekit-data-stream/src/outgoing/raw_stream.rs @@ -30,7 +30,7 @@ pub(crate) struct RawStreamOpenOptions { /// Identity the stream's packets are attributed to; empty means the server attributes /// them to the sending participant. pub(crate) sender_identity: Option, - pub(crate) packet_tx: UnboundedRequestSender>, + pub(crate) packet_tx: UnboundedRequestSender, Result<(), SendError>>, } pub(crate) struct RawStream { @@ -38,8 +38,8 @@ pub(crate) struct RawStream { sender_identity: Option, progress: StreamProgress, is_closed: bool, - /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, + /// Request channel for sending packet batches. + packet_tx: UnboundedRequestSender, Result<(), SendError>>, } impl RawStream { @@ -83,7 +83,8 @@ impl RawStream { Ok(()) } - /// Writes opaque bytes split into MTU-sized chunks on raw byte boundaries. + /// Writes opaque bytes split into MTU-sized chunks on raw byte boundaries, one transport + /// request per chunk. /// /// Used for byte payloads and for compressed (deflate-raw) content, where the bytes /// are opaque and must not be split on UTF-8 boundaries. @@ -171,10 +172,18 @@ impl RawStream { } pub(crate) async fn send_packet( - tx: &UnboundedRequestSender>, + tx: &UnboundedRequestSender, Result<(), SendError>>, packet: proto::DataPacket, ) -> StreamResult<()> { - tx.send_receive(packet) + Self::send_packets(tx, vec![packet]).await + } + + /// Sends a batch of packets as a single transport request, acknowledged as a whole. + pub(crate) async fn send_packets( + tx: &UnboundedRequestSender, Result<(), SendError>>, + packets: Vec, + ) -> StreamResult<()> { + tx.send_receive(packets) .await .map_err(|_| StreamError::Internal)? // request channel closed .map_err(|_| StreamError::SendFailed) // data channel error diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs index d185f231e..1a40ce24e 100644 --- a/livekit-uniffi/src/data_stream/common.rs +++ b/livekit-uniffi/src/data_stream/common.rs @@ -322,6 +322,32 @@ pub enum DataStreamError { InvalidFileName, } +/// A foreign transport failed to deliver outbound packets; thrown by hosts from +/// [`OutgoingDataStreamManagerDelegate::on_packets_available`](super::outgoing::OutgoingDataStreamManagerDelegate::on_packets_available). +/// +/// Morally `struct PacketDeliveryError(String)`, but uniffi error types must be enums, so the +/// string travels as the single variant's `reason` (free-form host context: logged, not parsed). +#[derive(uniffi::Error, thiserror::Error, Debug)] +pub enum PacketDeliveryError { + #[error("failed to deliver packets: {reason}")] + Failed { reason: String }, +} + +// Required because foreign code implements delegate methods returning this error: an exception +// that is NOT a `PacketDeliveryError` surfaces through this catch-all rather than aborting. +impl From for PacketDeliveryError { + fn from(error: uniffi::UnexpectedUniFFICallbackError) -> Self { + Self::Failed { reason: error.reason } + } +} + +impl From for DataStreamError { + fn from(error: PacketDeliveryError) -> Self { + log::error!("outbound packet delivery failed: {error}"); + Self::Internal + } +} + impl From for DataStreamError { fn from(error: ds_api::StreamError) -> Self { match error { diff --git a/livekit-uniffi/src/data_stream/outgoing.rs b/livekit-uniffi/src/data_stream/outgoing.rs index cb9e2fd07..a3f29e592 100644 --- a/livekit-uniffi/src/data_stream/outgoing.rs +++ b/livekit-uniffi/src/data_stream/outgoing.rs @@ -21,8 +21,8 @@ use prost::Message as _; use tokio_util::sync::{CancellationToken, DropGuard}; use super::common::{ - ByteStreamInfo, ClientCapability, DataStreamError, StreamByteOptions, StreamTextOptions, - TextStreamInfo, + ByteStreamInfo, ClientCapability, DataStreamError, PacketDeliveryError, StreamByteOptions, + StreamTextOptions, TextStreamInfo, }; use ds_api::StreamWriter as _; @@ -38,8 +38,17 @@ pub struct OutgoingDataStreamManager { /// Delegate for receiving outbound packets from [`OutgoingDataStreamManager`]. #[uniffi::export(with_foreign)] pub trait OutgoingDataStreamManagerDelegate: Send + Sync { - /// Encoded [`livekit_protocol::DataPacket`]s to be sent over the data channel transport. - fn on_packets_available(&self, packets: Vec); + /// Encoded [`livekit_protocol::DataPacket`]s to be sent over the data channel transport, in + /// order. One-shot sends (`send_text`/`send_bytes`) deliver their entire stream — header, + /// chunks, trailer — in a single call; incremental writers and `send_file` deliver one packet + /// per call. + /// + /// Return only once the packets have been handed to the transport: the originating + /// `send_*`/`write` call stays pending until then, which is what bounds how fast a producer + /// can enqueue. Throwing [`PacketDeliveryError`] fails that call with + /// [`DataStreamError::SendFailed`](super::common::DataStreamError::SendFailed) and closes the + /// affected stream (`is_open` becomes false for writers). + fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError>; } /// Read access to remote participants' advertised protocol and capabilities, implemented by the @@ -88,18 +97,24 @@ impl OutgoingDataStreamManager { let token = CancellationToken::new(); let (manager, mut packet_rx) = ds::outgoing::Manager::new(); - // Forward each outbound packet to the transport delegate and acknowledge the send. Wire - // send-failures are not propagated back to the originating `send_*` call for now (matches - // the data-track delegate); can be upgraded to a Result-returning delegate later. + // Forward each outbound packet batch to the transport delegate, acknowledging the send + // with the delegate's own result so wire failures propagate back to the originating + // `send_*`/`write` call (which surfaces them as `DataStreamError::SendFailed`). let forward_token = token.clone(); crate::runtime::runtime().spawn(async move { loop { tokio::select! { _ = forward_token.cancelled() => break, recv = packet_rx.recv() => match recv { - Ok((packet, responder)) => { - delegate.on_packets_available(vec![Bytes::from(packet.encode_to_vec())]); - let _ = responder.respond(Ok(())); + Ok((packets, responder)) => { + let encoded = packets + .iter() + .map(|packet| Bytes::from(packet.encode_to_vec())) + .collect(); + let result = delegate + .on_packets_available(encoded) + .map_err(|_| ds_api::SendError); + let _ = responder.respond(result); } Err(_) => break, } diff --git a/livekit-uniffi/src/data_stream/polled.rs b/livekit-uniffi/src/data_stream/polled.rs index b5a0bc142..3bcbcbe41 100644 --- a/livekit-uniffi/src/data_stream/polled.rs +++ b/livekit-uniffi/src/data_stream/polled.rs @@ -43,6 +43,7 @@ use tokio::sync::mpsc::{unbounded_channel, UnboundedReceiver, UnboundedSender}; use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; +use super::common::PacketDeliveryError; use super::incoming::{ ByteStreamReader, IncomingDataStreamManager, IncomingDataStreamManagerDelegate, TextStreamReader, @@ -90,12 +91,15 @@ impl OutgoingPacketQueue { } impl OutgoingDataStreamManagerDelegate for OutgoingPacketQueue { - fn on_packets_available(&self, packets: Vec) { + // Acknowledges once buffered: a pull adapter has no synchronous transport feedback, so send + // failures observed while draining must be handled host-side. + fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { for packet in packets { if self.tx.send(packet).is_ok() { warn_if_deep("outgoing packet", self.depth.fetch_add(1, Ordering::Relaxed) + 1); } } + Ok(()) } } diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index c66d4dc58..4ef0c1454 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -21,7 +21,7 @@ use livekit_protocol as proto; use prost::Message as _; use tokio::sync::oneshot; -use super::common::{ClientCapability, StreamTextOptions}; +use super::common::{ClientCapability, DataStreamError, PacketDeliveryError, StreamTextOptions}; use super::incoming::{ ByteStreamReader, IncomingDataStreamManager, IncomingDataStreamManagerDelegate, TextStreamReader, @@ -202,8 +202,9 @@ fn incoming_abort_fires_stream_closed() { struct PacketCapture(Mutex>); impl OutgoingDataStreamManagerDelegate for PacketCapture { - fn on_packets_available(&self, packets: Vec) { + fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { self.0.lock().unwrap().extend(packets); + Ok(()) } } @@ -256,6 +257,92 @@ fn outgoing_all_v2_text_inlines_compressed() { }); } +/// A transport delegate that accepts a fixed number of calls, then fails every subsequent one. +struct FailingTransport(std::sync::atomic::AtomicUsize); + +impl FailingTransport { + fn failing_after(successful_calls: usize) -> Self { + Self(std::sync::atomic::AtomicUsize::new(successful_calls)) + } +} + +impl OutgoingDataStreamManagerDelegate for FailingTransport { + fn on_packets_available(&self, _packets: Vec) -> Result<(), PacketDeliveryError> { + let remaining = &self.0; + if remaining + .fetch_update( + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + |n| n.checked_sub(1), + ) + .is_ok() + { + Ok(()) + } else { + Err(PacketDeliveryError::Failed { reason: "transport is down".to_string() }) + } + } +} + +/// Collects the batch boundaries of each delegate invocation. +struct BatchCapture(Mutex>); + +impl OutgoingDataStreamManagerDelegate for BatchCapture { + fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { + self.0.lock().unwrap().push(packets.len()); + Ok(()) + } +} + +#[test] +fn outgoing_send_failure_propagates() { + crate::runtime::runtime().block_on(async { + let delegate = Arc::new(FailingTransport::failing_after(0)); + let manager = OutgoingDataStreamManager::new(delegate, Arc::new(AllV2Registry)); + + let options = + StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; + let result = manager.send_text("hello".to_string(), options).await; + assert!(matches!(result, Err(DataStreamError::SendFailed))); + }); +} + +#[test] +fn outgoing_write_failure_errors_and_closes_writer() { + crate::runtime::runtime().block_on(async { + // Allow the header through, then fail: the failure lands on the write. + let delegate = Arc::new(FailingTransport::failing_after(1)); + let manager = OutgoingDataStreamManager::new(delegate, Arc::new(AllV2Registry)); + + let options = + StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; + let writer = manager.stream_text(options).await.expect("opening the stream should work"); + assert!(writer.is_open().await); + + let result = writer.write("hello".to_string()).await; + assert!(matches!(result, Err(DataStreamError::SendFailed))); + assert!(!writer.is_open().await, "a failed send should close the stream"); + }); +} + +#[test] +fn outgoing_one_shot_send_is_a_single_delegate_call() { + crate::runtime::runtime().block_on(async { + let delegate = Arc::new(BatchCapture(Mutex::new(Vec::new()))); + let manager = OutgoingDataStreamManager::new(delegate.clone(), Arc::new(PreV2Registry)); + + // 40 KB to a pre-v2 recipient: legacy framing, header + 3 chunks + trailer — the whole + // stream must arrive as ONE delegate call, not one call per packet. + let options = StreamTextOptions { + topic: "chat".to_string(), + destination_identities: vec!["bob".to_string()], + ..Default::default() + }; + manager.send_text("A".repeat(40_000), options).await.expect("send_text should succeed"); + assert_eq!(*delegate.0.lock().unwrap(), vec![5]); + }); +} + /// A room where every recipient predates v2. struct PreV2Registry; diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 7633c5c73..adc1568d8 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -2458,25 +2458,33 @@ fn is_internal_topic(topic: &str) -> bool { INTERNAL_DATA_STREAM_TOPICS.contains(&topic) } -/// Receives packets from the outgoing stream manager and send them. +/// Receives packet batches from the outgoing stream manager and send them. async fn outgoing_data_stream_task( - mut packet_rx: UnboundedRequestReceiver>, + mut packet_rx: UnboundedRequestReceiver, Result<(), SendError>>, engine: Arc, mut close_rx: broadcast::Receiver<()>, ) { loop { tokio::select! { - Ok((packet, responder)) = packet_rx.recv() => { - // A packet stamped with an explicit sender identity (impersonation, e.g. an - // agent attributing a stream to another participant) must be sent raw so the - // session doesn't overwrite the identity with the local participant's. - let is_raw_packet = !packet.participant_identity.is_empty(); - // Bridge the engine error into the data-stream crate's opaque `SendError` - // (the crate only needs to know whether the send failed). - let result = engine - .publish_data(packet, DataPacketKind::Reliable, is_raw_packet) - .await - .map_err(|_| SendError); + Ok((packets, responder)) = packet_rx.recv() => { + // The batch is acknowledged as a whole; the first failure fails the request. + let mut result = Ok(()); + for packet in packets { + // A packet stamped with an explicit sender identity (impersonation, e.g. an + // agent attributing a stream to another participant) must be sent raw so the + // session doesn't overwrite the identity with the local participant's. + let is_raw_packet = !packet.participant_identity.is_empty(); + // Bridge the engine error into the data-stream crate's opaque `SendError` + // (the crate only needs to know whether the send failed). + if engine + .publish_data(packet, DataPacketKind::Reliable, is_raw_packet) + .await + .is_err() + { + result = Err(SendError); + break; + } + } let _ = responder.respond(result); }, _ = close_rx.recv() => { From b295f17bc55e9f4a93edccd1d47da3761cf2bc72 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 17 Aug 2026 16:18:40 -0400 Subject: [PATCH 17/24] feat: take the wire encryption type in handle_packet_received and carry it in mismatch errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FFI decode path hard-coded EncryptionType::None, and it cannot do better from the bytes alone: encrypted_packet is a member of the DataPacket.value oneof, so a host decrypting E2EE traffic replaces it with the decrypted stream packet — by the time the bytes reach the FFI, the field is absent from the wire format. That made the encryption guard in handle_chunk dead code over the FFI while still reading as active. handle_packet_received now requires the encryption type the host received (or decrypted) the packet with, making the guard live. EncryptionTypeMismatch also gains expected/received fields so hosts can report which types disagreed instead of fabricating them (Swift's error carries both). --- livekit-data-stream/src/incoming/manager.rs | 16 ++++- livekit-data-stream/src/utils.rs | 10 +++- livekit-uniffi/src/data_stream/common.rs | 42 +++++++++---- livekit-uniffi/src/data_stream/incoming.rs | 15 ++++- livekit-uniffi/src/data_stream/tests.rs | 65 ++++++++++++++++----- 5 files changed, 115 insertions(+), 33 deletions(-) diff --git a/livekit-data-stream/src/incoming/manager.rs b/livekit-data-stream/src/incoming/manager.rs index e157933bf..740c9f99f 100644 --- a/livekit-data-stream/src/incoming/manager.rs +++ b/livekit-data-stream/src/incoming/manager.rs @@ -379,8 +379,12 @@ impl Manager { return; }; - if descriptor.encryption_type != encryption_type.into() { - inner.close_stream_with_error(&id, StreamError::EncryptionTypeMismatch); + if descriptor.encryption_type != encryption_type { + let expected = descriptor.encryption_type; + inner.close_stream_with_error( + &id, + StreamError::EncryptionTypeMismatch { expected, received: encryption_type }, + ); return; } @@ -929,7 +933,13 @@ mod tests { chunk: chunk("s1", 0, vec![b'h', b'i']), encryption_type: EncryptionType::Gcm, }); - assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); + assert!(matches!( + read_text(reader).await, + Err(StreamError::EncryptionTypeMismatch { + expected: EncryptionType::None, + received: EncryptionType::Gcm, + }) + )); } #[tokio::test] diff --git a/livekit-data-stream/src/utils.rs b/livekit-data-stream/src/utils.rs index bb1a49c12..e1efebe3d 100644 --- a/livekit-data-stream/src/utils.rs +++ b/livekit-data-stream/src/utils.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use livekit_common::EncryptionType; use thiserror::Error; /// Error returned by the packet transport when a data-stream packet fails to send. @@ -59,8 +60,13 @@ pub enum StreamError { #[error("internal error")] Internal, - #[error("encryption type mismatch")] - EncryptionTypeMismatch, + #[error("encryption type mismatch: expected {expected:?}, received {received:?}")] + EncryptionTypeMismatch { + /// The encryption type the stream's header declared. + expected: EncryptionType, + /// The encryption type of the offending chunk. + received: EncryptionType, + }, #[error("stream header exceeds maximum size")] HeaderTooLarge, diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs index 1a40ce24e..0cd23d2db 100644 --- a/livekit-uniffi/src/data_stream/common.rs +++ b/livekit-uniffi/src/data_stream/common.rs @@ -46,6 +46,16 @@ impl From for EncryptionType { } } +impl From for common::EncryptionType { + fn from(value: EncryptionType) -> Self { + match value { + EncryptionType::None => Self::None, + EncryptionType::Gcm => Self::Gcm, + EncryptionType::Custom => Self::Custom, + } + } +} + /// Operation type for text streams, mirroring [`ds_api::OperationType`]. #[derive(uniffi::Enum, Clone, Copy, Debug, PartialEq, Eq)] pub enum OperationType { @@ -306,8 +316,8 @@ pub enum DataStreamError { #[error("internal error")] Internal, - #[error("encryption type mismatch")] - EncryptionTypeMismatch, + #[error("encryption type mismatch: expected {expected:?}, received {received:?}")] + EncryptionTypeMismatch { expected: EncryptionType, received: EncryptionType }, #[error("stream header exceeds maximum size")] HeaderTooLarge, @@ -361,7 +371,12 @@ impl From for DataStreamError { ds_api::StreamError::SendFailed => Self::SendFailed, ds_api::StreamError::Io(error) => Self::Io { reason: error.to_string() }, ds_api::StreamError::Internal => Self::Internal, - ds_api::StreamError::EncryptionTypeMismatch => Self::EncryptionTypeMismatch, + ds_api::StreamError::EncryptionTypeMismatch { expected, received } => { + Self::EncryptionTypeMismatch { + expected: expected.into(), + received: received.into(), + } + } ds_api::StreamError::HeaderTooLarge => Self::HeaderTooLarge, ds_api::StreamError::PayloadTooLarge => Self::PayloadTooLarge, ds_api::StreamError::Decompression => Self::Decompression, @@ -376,18 +391,23 @@ impl From for DataStreamError { /// incoming-manager input event. Returns `None` if the bytes don't decode or the packet isn't a /// data-stream packet. /// -/// Encryption is defaulted to `None`: end-to-end encryption for data streams over this FFI is a -/// follow-up (the foreign side is expected to hand us already-decrypted packets). -pub(crate) fn decode_data_packet(bytes: &[u8]) -> Option { +/// The encryption type must be supplied by the caller and CANNOT be recovered from the bytes: +/// `encrypted_packet` is a member of the `DataPacket.value` oneof, so a host decrypting E2EE +/// traffic replaces it with the decrypted stream header/chunk/trailer — by the time these bytes +/// arrive, the field is absent from the wire format. Hosts without E2EE pass +/// [`common::EncryptionType::None`]. +pub(crate) fn decode_data_packet( + bytes: &[u8], + encryption_type: common::EncryptionType, +) -> Option { let mut packet = proto::DataPacket::decode(bytes).ok()?; let identity: common::ParticipantIdentity = packet.participant_identity.clone().into(); let ds_packet = match packet.value.take()? { - proto::data_packet::Value::StreamHeader(header) => ds::Packet::Header { - header: header.into(), - encryption_type: common::EncryptionType::None, - }, + proto::data_packet::Value::StreamHeader(header) => { + ds::Packet::Header { header: header.into(), encryption_type } + } proto::data_packet::Value::StreamChunk(chunk) => { - ds::Packet::Chunk { chunk: chunk.into(), encryption_type: common::EncryptionType::None } + ds::Packet::Chunk { chunk: chunk.into(), encryption_type } } proto::data_packet::Value::StreamTrailer(trailer) => ds::Packet::Trailer(trailer.into()), _ => return None, diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index f62700908..6045c329a 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -21,7 +21,9 @@ use tokio::sync::mpsc::UnboundedReceiver; use tokio::sync::Mutex; use tokio_util::sync::{CancellationToken, DropGuard}; -use super::common::{decode_data_packet, ByteStreamInfo, DataStreamError, TextStreamInfo}; +use super::common::{ + decode_data_packet, ByteStreamInfo, DataStreamError, EncryptionType, TextStreamInfo, +}; use ds_api::StreamReader as _; /// Receives inbound data-stream packets and processes them on the incoming manager's actor loop, @@ -81,8 +83,15 @@ impl IncomingDataStreamManager { /// /// Fire-and-forget: the packet is decoded and enqueued in order; processing happens on the /// manager's run loop. Non-data-stream or undecodable packets are ignored. - pub fn handle_packet_received(&self, packet: Bytes) { - if let Some(event) = decode_data_packet(&packet) { + /// + /// `encryption_type` is how this packet arrived on the wire, and must be passed by the host + /// because it cannot be recovered from the bytes: `encrypted_packet` is a member of the + /// `DataPacket.value` oneof, so decrypting replaces it with the decrypted stream packet. + /// Hosts without end-to-end encryption pass [`EncryptionType::None`]; hosts with E2EE pass + /// the type they decrypted with, letting the manager reject chunks whose encryption doesn't + /// match their stream's header ([`DataStreamError::EncryptionTypeMismatch`]). + pub fn handle_packet_received(&self, packet: Bytes, encryption_type: EncryptionType) { + if let Some(event) = decode_data_packet(&packet, encryption_type.into()) { let _ = self.input.send(event.into()); } } diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 4ef0c1454..3b82e9758 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -21,7 +21,9 @@ use livekit_protocol as proto; use prost::Message as _; use tokio::sync::oneshot; -use super::common::{ClientCapability, DataStreamError, PacketDeliveryError, StreamTextOptions}; +use super::common::{ + ClientCapability, DataStreamError, EncryptionType, PacketDeliveryError, StreamTextOptions, +}; use super::incoming::{ ByteStreamReader, IncomingDataStreamManager, IncomingDataStreamManagerDelegate, TextStreamReader, @@ -91,8 +93,7 @@ fn chunk_packet(identity: &str, chunk_index: u64, content: &[u8]) -> Bytes { /// Builds an encoded trailer `DataPacket` for stream `s1`. fn trailer_packet(identity: &str) -> Bytes { - let trailer = - proto::data_stream::Trailer { stream_id: "s1".to_string(), ..Default::default() }; + let trailer = proto::data_stream::Trailer { stream_id: "s1".to_string(), ..Default::default() }; let packet = proto::DataPacket { participant_identity: identity.to_string(), value: Some(proto::data_packet::Value::StreamTrailer(trailer)), @@ -123,7 +124,10 @@ fn incoming_inline_text_stream_roundtrips() { let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); let manager = IncomingDataStreamManager::new(delegate, None); - manager.handle_packet_received(inline_text_packet("alice", "my-topic", "hello world")); + manager.handle_packet_received( + inline_text_packet("alice", "my-topic", "hello world"), + EncryptionType::None, + ); let (reader, identity) = rx.await.expect("a stream should open"); assert_eq!(identity, "alice"); @@ -132,6 +136,32 @@ fn incoming_inline_text_stream_roundtrips() { }); } +#[test] +fn incoming_chunk_with_mismatched_encryption_errors_reader() { + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + + // The stream is announced unencrypted, but a chunk arrives claiming GCM encryption. + manager.handle_packet_received( + multipacket_text_header_packet("alice", "my-topic", 5), + EncryptionType::None, + ); + let (reader, _) = rx.await.expect("a stream should open"); + manager.handle_packet_received(chunk_packet("alice", 0, b"hello"), EncryptionType::Gcm); + + let result = reader.read_all().await; + assert!(matches!( + result, + Err(DataStreamError::EncryptionTypeMismatch { + expected: EncryptionType::None, + received: EncryptionType::Gcm, + }) + )); + }); +} + /// Captures the first stream-closed notification. struct ClosedCapture(Mutex>>); @@ -154,9 +184,12 @@ fn incoming_trailer_fires_stream_closed() { let delegate = Arc::new(ClosedCapture(Mutex::new(Some(tx)))); let manager = IncomingDataStreamManager::new(delegate, None); - manager.handle_packet_received(multipacket_text_header_packet("alice", "my-topic", 5)); - manager.handle_packet_received(chunk_packet("alice", 0, b"hello")); - manager.handle_packet_received(trailer_packet("alice")); + manager.handle_packet_received( + multipacket_text_header_packet("alice", "my-topic", 5), + EncryptionType::None, + ); + manager.handle_packet_received(chunk_packet("alice", 0, b"hello"), EncryptionType::None); + manager.handle_packet_received(trailer_packet("alice"), EncryptionType::None); let (stream_id, identity) = rx.await.expect("the stream should close"); assert_eq!(stream_id, "s1"); @@ -173,7 +206,10 @@ fn incoming_inline_stream_fires_stream_closed() { let delegate = Arc::new(ClosedCapture(Mutex::new(Some(tx)))); let manager = IncomingDataStreamManager::new(delegate, None); - manager.handle_packet_received(inline_text_packet("alice", "my-topic", "hello world")); + manager.handle_packet_received( + inline_text_packet("alice", "my-topic", "hello world"), + EncryptionType::None, + ); let (stream_id, identity) = rx.await.expect("the stream should close"); assert_eq!(stream_id, "s1"); @@ -189,7 +225,10 @@ fn incoming_abort_fires_stream_closed() { let manager = IncomingDataStreamManager::new(delegate, None); // Announce a multi-packet stream, then abort before its trailer ever arrives. - manager.handle_packet_received(multipacket_text_header_packet("alice", "my-topic", 5)); + manager.handle_packet_received( + multipacket_text_header_packet("alice", "my-topic", 5), + EncryptionType::None, + ); manager.abort_all_streams(); let (stream_id, identity) = rx.await.expect("the stream should close"); @@ -300,8 +339,7 @@ fn outgoing_send_failure_propagates() { let delegate = Arc::new(FailingTransport::failing_after(0)); let manager = OutgoingDataStreamManager::new(delegate, Arc::new(AllV2Registry)); - let options = - StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; + let options = StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; let result = manager.send_text("hello".to_string(), options).await; assert!(matches!(result, Err(DataStreamError::SendFailed))); }); @@ -314,8 +352,7 @@ fn outgoing_write_failure_errors_and_closes_writer() { let delegate = Arc::new(FailingTransport::failing_after(1)); let manager = OutgoingDataStreamManager::new(delegate, Arc::new(AllV2Registry)); - let options = - StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; + let options = StreamTextOptions { topic: "chat".to_string(), ..Default::default() }; let writer = manager.stream_text(options).await.expect("opening the stream should work"); assert!(writer.is_open().await); @@ -385,7 +422,7 @@ async fn polled_roundtrip( { for packet in packets { packet_count += 1; - incoming.manager.handle_packet_received(packet); + incoming.manager.handle_packet_received(packet, EncryptionType::None); } } From 928578595f9af7072ce04fd8b2fa40d3563bda6e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 17 Aug 2026 16:19:21 -0400 Subject: [PATCH 18/24] docs: call out that max_payload_byte_length is fixed at construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap has no setter, and on the host side it typically comes from per-connection options that aren't final until connect and can differ between sessions of the same host object. The obvious host implementation — construct lazily, memoize for the object's lifetime — silently pins the cap to the first session's value. Document the intended pattern (rebuild the manager per session) instead of adding reconfiguration complexity. --- livekit-uniffi/src/data_stream/incoming.rs | 10 ++++++++++ livekit-uniffi/src/data_stream/polled.rs | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index 6045c329a..796e13e49 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -61,6 +61,16 @@ pub trait IncomingDataStreamManagerDelegate: Send + Sync { #[uniffi::export] impl IncomingDataStreamManager { + /// Creates a manager that surfaces opened streams to `delegate`. + /// + /// `max_payload_byte_length` caps the decompressed size of a single incoming stream + /// (`None` = default, 5 GB) and is **fixed for the lifetime of the manager**. If the host + /// sources it from per-connection options that aren't final until connect — and can differ + /// between sessions of the same host object — construct a fresh manager for each session + /// rather than lazily memoizing one, or the first session's value is silently pinned. (Same + /// class of rough edge as a single room instance being `connect()`ed multiple times.) + /// Rebuilding is cheap and safe: dropping the manager cancels its tasks, and handler wiring + /// lives on the foreign side. #[uniffi::constructor] pub fn new( delegate: Arc, diff --git a/livekit-uniffi/src/data_stream/polled.rs b/livekit-uniffi/src/data_stream/polled.rs index 3bcbcbe41..926031109 100644 --- a/livekit-uniffi/src/data_stream/polled.rs +++ b/livekit-uniffi/src/data_stream/polled.rs @@ -279,6 +279,10 @@ pub struct PolledIncomingDataStreamManager { } /// Builds an incoming manager whose opened streams are pulled rather than pushed. +/// +/// `max_payload_byte_length` is fixed for the lifetime of the manager; see +/// [`IncomingDataStreamManager::new`]. Hosts sourcing it from per-connection options should build +/// a fresh manager per session rather than memoizing one. #[uniffi::export] pub fn polled_incoming_data_stream_manager( max_payload_byte_length: Option, From 00ecec73d40906c29ba782a0d949acc307bb08a1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 17 Aug 2026 16:21:18 -0400 Subject: [PATCH 19/24] feat: expose open_stream_count on the incoming data stream manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to observe how many incoming streams are open, so tests exercising the abort paths had to infer "open" by signalling from inside a handler — which measures handler-dispatched rather than descriptor-registered and breaks if the two ever move relative to each other. The count is answered through the manager's input queue, so it is processed in order with previously enqueued packets: feed a header (or an abort), then await open_stream_count() to know it has landed, no sleeps or handler side-channels needed. Inline single-packet streams complete during header handling and are never counted. --- livekit-data-stream/src/incoming/events.rs | 5 ++ livekit-data-stream/src/incoming/manager.rs | 68 +++++++++++++++++++++ livekit-uniffi/src/data_stream/incoming.rs | 17 +++++- livekit-uniffi/src/data_stream/tests.rs | 22 +++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/livekit-data-stream/src/incoming/events.rs b/livekit-data-stream/src/incoming/events.rs index 7fbd071c8..1b5fa36c4 100644 --- a/livekit-data-stream/src/incoming/events.rs +++ b/livekit-data-stream/src/incoming/events.rs @@ -42,6 +42,11 @@ pub enum InputEvent { /// Abort every open stream (e.g. the local connection is going away). Unlike /// [`InputEvent::Shutdown`], the run loop keeps going so streams opened later are still handled. AbortAllStreams, + /// Reply with the number of currently open streams (registered by a header and awaiting more + /// packets). Processed in order with the other events, so the answer reflects everything + /// enqueued before it. + #[from_variants(skip)] + QueryOpenStreamCount(tokio::sync::oneshot::Sender), /// Stop the run loop. Shutdown, } diff --git a/livekit-data-stream/src/incoming/manager.rs b/livekit-data-stream/src/incoming/manager.rs index 740c9f99f..a018cc429 100644 --- a/livekit-data-stream/src/incoming/manager.rs +++ b/livekit-data-stream/src/incoming/manager.rs @@ -234,6 +234,9 @@ impl Manager { } InputEvent::AbortStreamsFrom(identity) => self.handle_abort(identity), InputEvent::AbortAllStreams => self.handle_abort_all(), + InputEvent::QueryOpenStreamCount(respond_to) => { + let _ = respond_to.send(self.inner.open_streams.len()); + } InputEvent::Shutdown => break, } } @@ -736,6 +739,13 @@ mod tests { self.input.send(InputEvent::AbortStreamsFrom(identity)).unwrap(); } + /// Queries the number of currently open (descriptor-registered) streams. + async fn open_stream_count(&self) -> usize { + let (respond_to, response) = tokio::sync::oneshot::channel(); + self.input.send(InputEvent::QueryOpenStreamCount(respond_to)).unwrap(); + response.await.expect("the manager should answer the query") + } + /// Awaits the next opened stream's reader (skipping back-compat chunk/trailer outputs). async fn next_opened(&mut self) -> (AnyStreamReader, ParticipantIdentity) { loop { @@ -1576,6 +1586,64 @@ mod tests { } } + /// The open-stream count reflects descriptor-registered streams, answered in order with the + /// events enqueued before the query — tests use it to wait for a header/abort to land. + mod open_stream_count { + use super::*; + + #[tokio::test] + async fn counts_streams_across_their_lifecycle() { + let mut h = Harness::new(); + assert_eq!(h.open_stream_count().await, 0); + + h.send_packet(Packet::Header { + header: text_header("s1", Some(5), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + assert_eq!(h.open_stream_count().await, 1); + + h.send_packet(Packet::Header { + header: text_header("s2", Some(5), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + assert_eq!(h.open_stream_count().await, 2); + + // Keep the readers alive so the streams aren't closed by reader drop. + let (reader1, _) = h.next_opened().await; + let (reader2, _) = h.next_opened().await; + + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, b"hello".to_vec()), + encryption_type: EncryptionType::None, + }); + h.send_packet(Packet::Trailer(trailer("s1"))); + assert_eq!(h.open_stream_count().await, 1); + + h.abort(ParticipantIdentity::from(SENDER)); + assert_eq!(h.open_stream_count().await, 0); + drop((reader1, reader2)); + } + + #[tokio::test] + async fn inline_streams_are_never_counted() { + let mut h = Harness::new(); + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(5), + HashMap::new(), + Some(b"hello".to_vec()), + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + // The inline stream completes during header handling; no descriptor is registered. + assert_eq!(h.open_stream_count().await, 0); + assert_eq!(read_text(reader).await.unwrap(), "hello"); + } + } + /// Every opened stream terminates with exactly one `StreamClosed`, whatever the terminal /// path — hosts rely on it to sequence handler invocations for ordered topics. mod stream_closed { diff --git a/livekit-uniffi/src/data_stream/incoming.rs b/livekit-uniffi/src/data_stream/incoming.rs index 796e13e49..16bc16143 100644 --- a/livekit-uniffi/src/data_stream/incoming.rs +++ b/livekit-uniffi/src/data_stream/incoming.rs @@ -59,7 +59,7 @@ pub trait IncomingDataStreamManagerDelegate: Send + Sync { fn on_stream_closed(&self, stream_id: String, identity: String); } -#[uniffi::export] +#[uniffi::export(async_runtime = "tokio")] impl IncomingDataStreamManager { /// Creates a manager that surfaces opened streams to `delegate`. /// @@ -118,6 +118,21 @@ impl IncomingDataStreamManager { pub fn abort_streams_from(&self, identity: String) { let _ = self.input.send(ds::incoming::InputEvent::AbortStreamsFrom(identity.into())); } + + /// Number of currently open incoming streams: streams announced by a header that are still + /// awaiting more packets. Inline (single-packet) streams complete immediately and are never + /// counted. + /// + /// The query runs on the manager's loop in order with previously submitted events, so a + /// packet or abort passed beforehand is reflected in the answer — useful in tests to wait for + /// a header to register (or an abort to land) without racing the run loop. + pub async fn open_stream_count(&self) -> u64 { + let (respond_to, response) = tokio::sync::oneshot::channel(); + if self.input.send(ds::incoming::InputEvent::QueryOpenStreamCount(respond_to)).is_err() { + return 0; + } + response.await.unwrap_or(0) as u64 + } } /// Reader for an incoming byte data stream. diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 3b82e9758..75cf1ff8d 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -162,6 +162,28 @@ fn incoming_chunk_with_mismatched_encryption_errors_reader() { }); } +#[test] +fn incoming_open_stream_count_tracks_headers_and_aborts() { + crate::runtime::runtime().block_on(async { + let (tx, _rx) = oneshot::channel(); + let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + assert_eq!(manager.open_stream_count().await, 0); + + // The count query is processed in order with the packets enqueued before it, so this + // waits for the (orphaned) header to register without racing the run loop — exactly what + // exercising the abort paths requires. + manager.handle_packet_received( + multipacket_text_header_packet("alice", "my-topic", 5), + EncryptionType::None, + ); + assert_eq!(manager.open_stream_count().await, 1); + + manager.abort_all_streams(); + assert_eq!(manager.open_stream_count().await, 0); + }); +} + /// Captures the first stream-closed notification. struct ClosedCapture(Mutex>>); From f91c9441e54aee15b785e8521e8f7ca4e17e6448 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:54:30 +0200 Subject: [PATCH 20/24] feat(uniffi): make on_packets_available async so hosts can honor its contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The delegate documents that it returns only once the packets have reached the transport, which is what orders packets, bounds a producer, and lets a failed send fail the originating `send_*`/`write`. A synchronous callback can't deliver that on hosts whose transport is async: the Swift SDK has no synchronous send path, so honoring it would mean blocking the calling thread on an async result — a Rust runtime thread, via a synchronisation primitive that SDK forbids. The practical outcome was that hosts acknowledged on buffering, leaving back-pressure and transport errors unimplemented. uniffi supports async methods on foreign traits: it applies `#[async_trait]` to the generated impl and dispatches through `foreign_async_call`, so the trait stays dyn-compatible. Awaiting the call in the pump keeps packets strictly ordered, since the next one isn't pulled until this returns. `async-trait` was already in the lockfile through livekit-api and livekit-net. The in-tree implementors — the Dart polling adapter and the test doubles — become `async fn` and are otherwise unchanged. Verified against the Swift SDK (client-sdk-swift#1075): generates `func onPacketsAvailable(packets:) async throws`, lets the host delete its ordering pump entirely, and a stream whose transport goes away now fails its `write` and reports `isOpen == false`, neither of which was observable before. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 9 +++++---- livekit-uniffi/Cargo.toml | 1 + livekit-uniffi/src/data_stream/outgoing.rs | 4 +++- livekit-uniffi/src/data_stream/polled.rs | 3 ++- livekit-uniffi/src/data_stream/tests.rs | 9 ++++++--- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdcb9155e..910649b19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4124,6 +4124,7 @@ dependencies = [ name = "livekit-uniffi" version = "0.1.9" dependencies = [ + "async-trait", "bytes", "camino", "futures-util", @@ -8110,8 +8111,8 @@ dependencies = [ [[package]] name = "uniffi-dart" -version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart?rev=90f2c6f29cbf88c8bc2cf515e6a0c2314a48844c#90f2c6f29cbf88c8bc2cf515e6a0c2314a48844c" +version = "0.2.1+v0.31.2" +source = "git+https://github.com/1egoman/uniffi-dart?rev=4633a7d3c93186ac6a96007b5f109268b4746190#4633a7d3c93186ac6a96007b5f109268b4746190" dependencies = [ "anyhow", "camino", @@ -8181,8 +8182,8 @@ dependencies = [ [[package]] name = "uniffi_dart_macro" -version = "0.1.0+v0.30.0" -source = "git+https://github.com/Uniffi-Dart/uniffi-dart?rev=90f2c6f29cbf88c8bc2cf515e6a0c2314a48844c#90f2c6f29cbf88c8bc2cf515e6a0c2314a48844c" +version = "0.2.1+v0.31.2" +source = "git+https://github.com/1egoman/uniffi-dart?rev=4633a7d3c93186ac6a96007b5f109268b4746190#4633a7d3c93186ac6a96007b5f109268b4746190" dependencies = [ "futures", "proc-macro2", diff --git a/livekit-uniffi/Cargo.toml b/livekit-uniffi/Cargo.toml index 6a68f9221..54974a725 100644 --- a/livekit-uniffi/Cargo.toml +++ b/livekit-uniffi/Cargo.toml @@ -28,6 +28,7 @@ futures-util = { workspace = true, default-features = false, features = ["sink"] bytes = { workspace = true } once_cell = "1.21.3" thiserror = { workspace = true } +async-trait = "0.1" # Dart binding generator. Not published to crates.io, so pinned by git rev. The # rev must target the same uniffi-rs release (0.31) as the `uniffi` dependency # above, or it cannot read this crate's compiled metadata. diff --git a/livekit-uniffi/src/data_stream/outgoing.rs b/livekit-uniffi/src/data_stream/outgoing.rs index a3f29e592..ac6344b84 100644 --- a/livekit-uniffi/src/data_stream/outgoing.rs +++ b/livekit-uniffi/src/data_stream/outgoing.rs @@ -37,6 +37,7 @@ pub struct OutgoingDataStreamManager { /// Delegate for receiving outbound packets from [`OutgoingDataStreamManager`]. #[uniffi::export(with_foreign)] +#[async_trait::async_trait] pub trait OutgoingDataStreamManagerDelegate: Send + Sync { /// Encoded [`livekit_protocol::DataPacket`]s to be sent over the data channel transport, in /// order. One-shot sends (`send_text`/`send_bytes`) deliver their entire stream — header, @@ -48,7 +49,7 @@ pub trait OutgoingDataStreamManagerDelegate: Send + Sync { /// can enqueue. Throwing [`PacketDeliveryError`] fails that call with /// [`DataStreamError::SendFailed`](super::common::DataStreamError::SendFailed) and closes the /// affected stream (`is_open` becomes false for writers). - fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError>; + async fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError>; } /// Read access to remote participants' advertised protocol and capabilities, implemented by the @@ -113,6 +114,7 @@ impl OutgoingDataStreamManager { .collect(); let result = delegate .on_packets_available(encoded) + .await .map_err(|_| ds_api::SendError); let _ = responder.respond(result); } diff --git a/livekit-uniffi/src/data_stream/polled.rs b/livekit-uniffi/src/data_stream/polled.rs index 926031109..2c21c5f28 100644 --- a/livekit-uniffi/src/data_stream/polled.rs +++ b/livekit-uniffi/src/data_stream/polled.rs @@ -90,10 +90,11 @@ impl OutgoingPacketQueue { } } +#[async_trait::async_trait] impl OutgoingDataStreamManagerDelegate for OutgoingPacketQueue { // Acknowledges once buffered: a pull adapter has no synchronous transport feedback, so send // failures observed while draining must be handled host-side. - fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { + async fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { for packet in packets { if self.tx.send(packet).is_ok() { warn_if_deep("outgoing packet", self.depth.fetch_add(1, Ordering::Relaxed) + 1); diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 75cf1ff8d..898ef6fa5 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -262,8 +262,9 @@ fn incoming_abort_fires_stream_closed() { /// Collects every outbound packet the manager emits. struct PacketCapture(Mutex>); +#[async_trait::async_trait] impl OutgoingDataStreamManagerDelegate for PacketCapture { - fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { + async fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { self.0.lock().unwrap().extend(packets); Ok(()) } @@ -327,8 +328,9 @@ impl FailingTransport { } } +#[async_trait::async_trait] impl OutgoingDataStreamManagerDelegate for FailingTransport { - fn on_packets_available(&self, _packets: Vec) -> Result<(), PacketDeliveryError> { + async fn on_packets_available(&self, _packets: Vec) -> Result<(), PacketDeliveryError> { let remaining = &self.0; if remaining .fetch_update( @@ -348,8 +350,9 @@ impl OutgoingDataStreamManagerDelegate for FailingTransport { /// Collects the batch boundaries of each delegate invocation. struct BatchCapture(Mutex>); +#[async_trait::async_trait] impl OutgoingDataStreamManagerDelegate for BatchCapture { - fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { + async fn on_packets_available(&self, packets: Vec) -> Result<(), PacketDeliveryError> { self.0.lock().unwrap().push(packets.len()); Ok(()) } From 7f042c137d8842888021c434710a917257dfc95c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 18 Aug 2026 12:10:56 -0400 Subject: [PATCH 21/24] fix: hold stream trailers to the stream's encryption type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_chunk rejects a chunk whose encryption doesn't match its stream's header, but trailers carried no encryption type at all — so in an E2EE room, an unencrypted peer could close another participant's encrypted stream cleanly and inject trailer attributes while doing so. The SDKs' hand-rolled v1 implementations (web, and the Android port's SDK-side check) validated trailers; the core did not, which blocked deleting those host-side checks. Packet::Trailer now carries the encryption type like Header and Chunk, the livekit crate threads it through Session/Engine events (it was already in hand at the emit site), and the manager rejects a mismatched trailer with EncryptionTypeMismatch before merging its attributes. --- livekit-data-stream/src/incoming/manager.rs | 187 ++++++++++++++++---- livekit-data-stream/src/types/packet.rs | 2 +- livekit-uniffi/src/data_stream/common.rs | 4 +- livekit-uniffi/src/data_stream/tests.rs | 27 +++ livekit/src/room/mod.rs | 10 +- livekit/src/rtc_engine/mod.rs | 11 +- livekit/src/rtc_engine/rtc_session.rs | 7 +- 7 files changed, 201 insertions(+), 47 deletions(-) diff --git a/livekit-data-stream/src/incoming/manager.rs b/livekit-data-stream/src/incoming/manager.rs index a018cc429..28fd279da 100644 --- a/livekit-data-stream/src/incoming/manager.rs +++ b/livekit-data-stream/src/incoming/manager.rs @@ -227,8 +227,8 @@ impl Manager { Packet::Chunk { chunk, encryption_type } => { self.handle_chunk(chunk, participant_identity, encryption_type).await } - Packet::Trailer(trailer) => { - self.handle_trailer(trailer, participant_identity) + Packet::Trailer { trailer, encryption_type } => { + self.handle_trailer(trailer, participant_identity, encryption_type) } } } @@ -475,7 +475,12 @@ impl Manager { } /// Handles an incoming trailer packet. - fn handle_trailer(&mut self, trailer: Trailer, participant_identity: ParticipantIdentity) { + fn handle_trailer( + &mut self, + trailer: Trailer, + participant_identity: ParticipantIdentity, + encryption_type: EncryptionType, + ) { let id = trailer.stream_id.clone(); let _ = self.inner.output_tx.send( TrailerReceived { @@ -491,6 +496,18 @@ impl Manager { return; }; + // Checked before the attribute merge: a trailer that arrived under the wrong encryption + // must not close the stream cleanly, nor inject its attributes (an unencrypted peer could + // otherwise forge a clean close for an encrypted stream). + if descriptor.encryption_type != encryption_type { + let expected = descriptor.encryption_type; + inner.close_stream_with_error( + &id, + StreamError::EncryptionTypeMismatch { expected, received: encryption_type }, + ); + return; + } + // Move over any attributes from the trailer into the stream-scoped attribute list. { let mut attributes_write = descriptor.attributes_map.write(); @@ -802,7 +819,10 @@ mod tests { chunk: chunk("s1", 0, text.as_bytes().to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -818,7 +838,10 @@ mod tests { chunk: chunk("s1", 0, vec![1, 2, 3, 4]), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3, 4])); } @@ -841,10 +864,10 @@ mod tests { chunk: chunk("s1", 0, text.as_bytes().to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer_with_attrs( - "s1", - attrs(&[("hello", "world"), ("foo", "updated")]), - ))); + h.send_packet(Packet::Trailer { + trailer: trailer_with_attrs("s1", attrs(&[("hello", "world"), ("foo", "updated")])), + encryption_type: EncryptionType::None, + }); // NOTE: trailer-attribute merging is asserted via the reader info after close. let info_attrs = text_info(&reader).attributes().clone(); assert_eq!(read_text(reader).await.unwrap(), text); @@ -864,7 +887,10 @@ mod tests { chunk: chunk("s1", 0, vec![b'x']), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert!(matches!(read_text(reader).await, Err(StreamError::Incomplete))); } @@ -880,7 +906,10 @@ mod tests { chunk: chunk("s1", 0, vec![1, 2, 3, 4, 5]), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert!(matches!(read_bytes(reader).await, Err(StreamError::LengthExceeded))); } @@ -927,7 +956,10 @@ mod tests { chunk: chunk("s1", 0, vec![7u8; 1_000]), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_bytes(reader).await.unwrap().len(), 1_000); } @@ -952,6 +984,36 @@ mod tests { )); } + /// A trailer is held to the stream's encryption too: an unencrypted peer must not be + /// able to forge a clean close (or inject trailer attributes) for an encrypted stream. + #[tokio::test] + async fn v1_drops_on_trailer_encryption_type_mismatch() { + let mut h = Harness::new(); + h.send_packet(Packet::Header { + header: text_header("s1", Some(2), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::Gcm, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![b'h', b'i']), + encryption_type: EncryptionType::Gcm, + }); + let info = text_info(&reader).clone(); + h.send_packet(Packet::Trailer { + trailer: trailer_with_attrs("s1", attrs(&[("forged", "yes")])), + encryption_type: EncryptionType::None, + }); + assert!(matches!( + read_text(reader).await, + Err(StreamError::EncryptionTypeMismatch { + expected: EncryptionType::Gcm, + received: EncryptionType::None, + }) + )); + // The forged trailer's attributes must not have been merged. + assert_eq!(info.attributes().get("forged"), None); + } + #[tokio::test] async fn v1_trailer_attributes_merged_after_close() { let mut h = Harness::new(); @@ -972,10 +1034,10 @@ mod tests { chunk: chunk("s1", 0, text.as_bytes().to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer_with_attrs( - "s1", - attrs(&[("hello", "world"), ("foo", "updated")]), - ))); + h.send_packet(Packet::Trailer { + trailer: trailer_with_attrs("s1", attrs(&[("hello", "world"), ("foo", "updated")])), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); // The trailer attributes are merged into the stream's attributes, overriding the header's. let merged = info.attributes(); @@ -1141,7 +1203,10 @@ mod tests { encryption_type: EncryptionType::None, }); } - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -1182,7 +1247,10 @@ mod tests { ); // A different participant disconnecting must not disturb bob's stream. h.abort(ParticipantIdentity::from(SENDER)); - h.send_packet_from(Packet::Trailer(trailer("s1")), "bob"); + h.send_packet_from( + Packet::Trailer { trailer: trailer("s1"), encryption_type: EncryptionType::None }, + "bob", + ); assert_eq!(read_text(reader).await.unwrap(), "hello"); } @@ -1271,7 +1339,10 @@ mod tests { encryption_type: EncryptionType::None, }); } - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(data)); } @@ -1295,7 +1366,10 @@ mod tests { chunk: chunk("s1", 0, compressed), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); // The receiver counts DECOMPRESSED bytes against totalLength. assert!(matches!(read_text(reader).await, Err(StreamError::Incomplete))); } @@ -1320,7 +1394,10 @@ mod tests { chunk: chunk("s1", 0, compressed), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert!(matches!(read_text(reader).await, Err(StreamError::LengthExceeded))); } @@ -1359,7 +1436,10 @@ mod tests { encryption_type: EncryptionType::None, }); } - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -1391,7 +1471,10 @@ mod tests { chunk: chunk("s1", 1, compressed[split..].to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -1455,7 +1538,10 @@ mod tests { chunk: chunk("s1", 0, compressed), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer_with_attrs("s1", attrs(&[("hello", "world")])))); + h.send_packet(Packet::Trailer { + trailer: trailer_with_attrs("s1", attrs(&[("hello", "world")])), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); let merged = info.attributes(); assert_eq!(merged.get("foo"), Some(&"bar".to_string())); @@ -1522,7 +1608,10 @@ mod tests { encryption_type: EncryptionType::None, }); } - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); let values = collect_progress(progress).await; assert_progress_completes(&values, total); @@ -1556,7 +1645,10 @@ mod tests { encryption_type: EncryptionType::None, }); } - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); let values = collect_progress(progress).await; assert_progress_completes(&values, total); @@ -1616,7 +1708,10 @@ mod tests { chunk: chunk("s1", 0, b"hello".to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(h.open_stream_count().await, 1); h.abort(ParticipantIdentity::from(SENDER)); @@ -1668,7 +1763,10 @@ mod tests { chunk: chunk("s1", 0, text.as_bytes().to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(h.next_closed().await, ("s1".into(), SENDER.into(), "topic".into())); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -1726,7 +1824,10 @@ mod tests { #[tokio::test] async fn trailer_for_unopened_stream_emits_no_stream_closed() { let mut h = Harness::new(); - h.send_packet(Packet::Trailer(trailer("never-opened"))); + h.send_packet(Packet::Trailer { + trailer: trailer("never-opened"), + encryption_type: EncryptionType::None, + }); // A second, well-formed inline stream: if the orphan trailer had produced a closed // event, it would be observed before this stream's. h.send_packet(Packet::Header { @@ -1768,7 +1869,10 @@ mod tests { chunk: chunk("s1", 1, text.as_bytes().to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -1784,11 +1888,14 @@ mod tests { chunk: chunk("s1", 0, b"hello".to_vec()), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(Trailer { - stream_id: StreamId::from("s1"), - reason: "cancelled".to_string(), - attributes: HashMap::new(), - })); + h.send_packet(Packet::Trailer { + trailer: Trailer { + stream_id: StreamId::from("s1"), + reason: "cancelled".to_string(), + attributes: HashMap::new(), + }, + encryption_type: EncryptionType::None, + }); assert!( matches!(read_text(reader).await, Err(StreamError::AbnormalEnd(r)) if r == "cancelled") ); @@ -1830,7 +1937,10 @@ mod tests { chunk: chunk("att1", 0, vec![1, 2, 3]), encryption_type: EncryptionType::None, }); - h.send_packet(Packet::Trailer(trailer("att1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("att1"), + encryption_type: EncryptionType::None, + }); assert_eq!(read_bytes(byte_reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); } @@ -1871,7 +1981,10 @@ mod tests { }); assert_eq!(next_raw_topic(&mut h).await.as_deref(), Some("lk.rpc_request")); - h.send_packet(Packet::Trailer(trailer("s1"))); + h.send_packet(Packet::Trailer { + trailer: trailer("s1"), + encryption_type: EncryptionType::None, + }); assert_eq!(next_raw_topic(&mut h).await.as_deref(), Some("lk.rpc_request")); // The stream itself still opens and reads normally; reporting the topic does not diff --git a/livekit-data-stream/src/types/packet.rs b/livekit-data-stream/src/types/packet.rs index 11f15c0b5..9606b3619 100644 --- a/livekit-data-stream/src/types/packet.rs +++ b/livekit-data-stream/src/types/packet.rs @@ -310,5 +310,5 @@ impl From for proto::Trailer { pub enum Packet { Header { header: Header, encryption_type: EncryptionType }, Chunk { chunk: Chunk, encryption_type: EncryptionType }, - Trailer(Trailer), + Trailer { trailer: Trailer, encryption_type: EncryptionType }, } diff --git a/livekit-uniffi/src/data_stream/common.rs b/livekit-uniffi/src/data_stream/common.rs index 0cd23d2db..4a92a8598 100644 --- a/livekit-uniffi/src/data_stream/common.rs +++ b/livekit-uniffi/src/data_stream/common.rs @@ -409,7 +409,9 @@ pub(crate) fn decode_data_packet( proto::data_packet::Value::StreamChunk(chunk) => { ds::Packet::Chunk { chunk: chunk.into(), encryption_type } } - proto::data_packet::Value::StreamTrailer(trailer) => ds::Packet::Trailer(trailer.into()), + proto::data_packet::Value::StreamTrailer(trailer) => { + ds::Packet::Trailer { trailer: trailer.into(), encryption_type } + } _ => return None, }; Some(ds::incoming::PacketReceived::new(ds_packet, identity)) diff --git a/livekit-uniffi/src/data_stream/tests.rs b/livekit-uniffi/src/data_stream/tests.rs index 898ef6fa5..2498c5e2c 100644 --- a/livekit-uniffi/src/data_stream/tests.rs +++ b/livekit-uniffi/src/data_stream/tests.rs @@ -162,6 +162,33 @@ fn incoming_chunk_with_mismatched_encryption_errors_reader() { }); } +#[test] +fn incoming_trailer_with_mismatched_encryption_errors_reader() { + crate::runtime::runtime().block_on(async { + let (tx, rx) = oneshot::channel(); + let delegate = Arc::new(TextCapture(Mutex::new(Some(tx)))); + let manager = IncomingDataStreamManager::new(delegate, None); + + // The stream is announced under GCM, but its trailer arrives in plaintext. + manager.handle_packet_received( + multipacket_text_header_packet("alice", "my-topic", 5), + EncryptionType::Gcm, + ); + let (reader, _) = rx.await.expect("a stream should open"); + manager.handle_packet_received(chunk_packet("alice", 0, b"hello"), EncryptionType::Gcm); + manager.handle_packet_received(trailer_packet("alice"), EncryptionType::None); + + let result = reader.read_all().await; + assert!(matches!( + result, + Err(DataStreamError::EncryptionTypeMismatch { + expected: EncryptionType::Gcm, + received: EncryptionType::None, + }) + )); + }); +} + #[test] fn incoming_open_stream_count_tracks_headers_and_aborts() { crate::runtime::runtime().block_on(async { diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index adc1568d8..957bc3dbb 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -1130,8 +1130,8 @@ impl RoomSession { EngineEvent::DataStreamChunk { chunk, participant_identity, encryption_type } => { self.handle_data_stream_chunk(chunk, participant_identity, encryption_type); } - EngineEvent::DataStreamTrailer { trailer, participant_identity } => { - self.handle_data_stream_trailer(trailer, participant_identity); + EngineEvent::DataStreamTrailer { trailer, participant_identity, encryption_type } => { + self.handle_data_stream_trailer(trailer, participant_identity, encryption_type); } EngineEvent::DataChannelBufferedAmountLowThresholdChanged { kind, threshold } => { self.handle_data_channel_buffered_low_threshold_change(kind, threshold); @@ -1951,10 +1951,14 @@ impl RoomSession { &self, trailer: proto::data_stream::Trailer, participant_identity: String, + encryption_type: proto::encryption::Type, ) { let _ = self.incoming_data_stream_input.send( ds::incoming::PacketReceived::new( - ds::Packet::Trailer(trailer.into()), + ds::Packet::Trailer { + trailer: trailer.into(), + encryption_type: encryption_type.into(), + }, participant_identity.into(), ) .into(), diff --git a/livekit/src/rtc_engine/mod.rs b/livekit/src/rtc_engine/mod.rs index 2895cfab5..3d3c228a1 100644 --- a/livekit/src/rtc_engine/mod.rs +++ b/livekit/src/rtc_engine/mod.rs @@ -209,6 +209,7 @@ pub enum EngineEvent { DataStreamTrailer { trailer: proto::data_stream::Trailer, participant_identity: String, + encryption_type: proto::encryption::Type, }, DataChannelBufferedAmountLowThresholdChanged { kind: DataPacketKind, @@ -718,10 +719,12 @@ impl EngineInner { encryption_type, }); } - SessionEvent::DataStreamTrailer { trailer, participant_identity } => { - let _ = self - .engine_tx - .send(EngineEvent::DataStreamTrailer { trailer, participant_identity }); + SessionEvent::DataStreamTrailer { trailer, participant_identity, encryption_type } => { + let _ = self.engine_tx.send(EngineEvent::DataStreamTrailer { + trailer, + participant_identity, + encryption_type, + }); } SessionEvent::DataChannelBufferedAmountLowThresholdChanged { kind, threshold } => { let _ = self.engine_tx.send( diff --git a/livekit/src/rtc_engine/rtc_session.rs b/livekit/src/rtc_engine/rtc_session.rs index 3478da91c..ae6d0f8a6 100644 --- a/livekit/src/rtc_engine/rtc_session.rs +++ b/livekit/src/rtc_engine/rtc_session.rs @@ -202,6 +202,7 @@ pub enum SessionEvent { DataStreamTrailer { trailer: proto::data_stream::Trailer, participant_identity: String, + encryption_type: proto::encryption::Type, }, DataChannelBufferedAmountLowThresholdChanged { kind: DataPacketKind, @@ -1791,7 +1792,11 @@ impl SessionInner { proto::data_packet::Value::StreamTrailer(trailer) => { let participant_identity = participant_identity.map_or("".into(), |identity| identity.0); - self.emitter.send(SessionEvent::DataStreamTrailer { trailer, participant_identity }) + self.emitter.send(SessionEvent::DataStreamTrailer { + trailer, + participant_identity, + encryption_type, + }) } proto::data_packet::Value::EncryptedPacket(encrypted_packet) => { // Handle encrypted data packets From dfe6071148bc812f81e98e1bbeec04d5c509544a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 18 Aug 2026 12:21:14 -0400 Subject: [PATCH 22/24] chore(uniffi): raise the Android size budget to 1.5 MiB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The arm64-v8a slice is 1490 KiB with the data streams v2 surface (async foreign-trait dispatch, stream-closed and open-stream-count, trailer encryption checks), over the 1280 KiB budget set in May before that work. cargo's strip = "symbols" is already applied to the artifact, so AGP's strip pass has nothing further to remove — the growth is real surface, landed intentionally. --- livekit-uniffi/Makefile.toml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/livekit-uniffi/Makefile.toml b/livekit-uniffi/Makefile.toml index faca84ee4..d8aebea49 100644 --- a/livekit-uniffi/Makefile.toml +++ b/livekit-uniffi/Makefile.toml @@ -529,8 +529,13 @@ run_task = "swift-package-flow" # Measured on the stripped .so inside the release AAR (AGP strips debug symbols # during assemble; the raw target/ artifact is larger and not what ships). # Override the limit per release with ANDROID_SIZE_LIMIT_BYTES. +# +# 1536 KiB as of data streams v2: the async foreign-trait machinery, the stream-closed and +# open-stream-count surface, and trailer encryption checks put the arm64-v8a slice at 1490 KiB +# (measured 2026-08; cargo's `strip = "symbols"` is already applied, so AGP's strip pass has +# nothing further to remove). [tasks.android-check-size.env] -ANDROID_SIZE_LIMIT_BYTES = { value = "1310720", condition = { env_not_set = ["ANDROID_SIZE_LIMIT_BYTES"] } } +ANDROID_SIZE_LIMIT_BYTES = { value = "1572864", condition = { env_not_set = ["ANDROID_SIZE_LIMIT_BYTES"] } } [tasks.android-check-size] extend = "android-shared" From 2010c63bc45bc49cde2a753bf44b56a6a2856d9c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Tue, 25 Aug 2026 11:08:07 -0400 Subject: [PATCH 23/24] fix: remove duplicate livekit-common entry --- livekit-uniffi/Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/livekit-uniffi/Cargo.toml b/livekit-uniffi/Cargo.toml index 54974a725..4bb9248f1 100644 --- a/livekit-uniffi/Cargo.toml +++ b/livekit-uniffi/Cargo.toml @@ -18,7 +18,6 @@ livekit-token = { workspace = true } livekit-datatrack = { workspace = true, features = ["uniffi"] } livekit-net = { workspace = true, features = ["uniffi"] } livekit-data-stream = { workspace = true } -livekit-common = { workspace = true } uniffi = { workspace = true, features = ["scaffolding-ffi-buffer-fns", "tokio"] } log = { workspace = true } tokio = { workspace = true, features = ["sync", "rt-multi-thread"] } From 72bdb589fb1c9c667569df5b805be7ab2eea90e4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 26 Aug 2026 12:12:41 -0400 Subject: [PATCH 24/24] fix: remove data stream uniffi testing script --- datastream_uniffi_test.py | 73 --------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 datastream_uniffi_test.py diff --git a/datastream_uniffi_test.py b/datastream_uniffi_test.py deleted file mode 100644 index f5cb4cd75..000000000 --- a/datastream_uniffi_test.py +++ /dev/null @@ -1,73 +0,0 @@ -import asyncio -import livekit_uniffi - -class OutgoingDelegate(livekit_uniffi.OutgoingDataStreamManagerDelegate): - def on_packets_available(self, packets): - print('PACKETS:', packets) - -class RemoteParticipantRegistry(livekit_uniffi.RemoteParticipantRegistryDelegate): - def remote_capabilities(self, identity): - return [] # typing.List[ClientCapability] - - def remote_client_protocol(self, identity): - return 2 - - def remote_identities(self): - return ["alice", "bob", "randy"] - -class IncomingDelegate(livekit_uniffi.IncomingDataStreamManagerDelegate): - """Forwards opened readers onto the main asyncio loop. - - Delegate callbacks fire on a Rust tokio thread, so they must not block or await; - hand the reader off to the main loop and let it drive the async reads. - """ - - def __init__(self, loop: asyncio.AbstractEventLoop, opened: asyncio.Queue): - self._loop = loop - self._opened = opened - - def on_byte_stream_opened(self, reader, identity: str): - self._loop.call_soon_threadsafe(self._opened.put_nowait, ("byte", reader, identity)) - - def on_text_stream_opened(self, reader, identity: str): - self._loop.call_soon_threadsafe(self._opened.put_nowait, ("text", reader, identity)) - -# Encoded livekit.DataPacket envelopes (participant_identity = "alice") carrying a -# DataStream.Header / Chunk / Trailer for an 11-byte "hello world" text stream. -DATA_STREAM_HEADER_BYTES = b'"\x05alicej@\n\x11example-stream-id\x10\xad\xf5\xcb\xae\xf93\x1a\x08my-topic"\ntext/plain(\x0bB\n\n\x03foo\x12\x03barJ\x00' -DATA_STREAM_CHUNK_BYTES = b'"\x05alicer \n\x11example-stream-id\x1a\x0bhello world' -DATA_STREAM_TRAILER_BYTES = b'"\x05alicez\'\n\x11example-stream-id\x1a\x12\n\x06status\x12\x08complete' - -async def main(): - opened = asyncio.Queue() - - print("--- OUTGOING:") - outgoing_delegate = OutgoingDelegate() - remote_participant_registry = RemoteParticipantRegistry() - outgoing = livekit_uniffi.OutgoingDataStreamManager(outgoing_delegate, remote_participant_registry) - await outgoing.send_text('hello world', livekit_uniffi.StreamTextOptions( - topic="test", - attributes={}, - # destination_identities: 'typing.List[str]' = , - # id: 'typing.Optional[str]' = , - # operation_type: 'typing.Optional[OperationType]' = , - # version: 'typing.Optional[int]' = , - # reply_to_stream_id: 'typing.Optional[str]' = , - # attached_stream_ids: 'typing.List[str]' = , - # generated: 'typing.Optional[bool]' = , - # compress: 'typing.Optional[bool]' = , - # sender_identity: 'typing.Optional[str]' = - )) - - print("--- INCOMING:") - incoming_delegate = IncomingDelegate(asyncio.get_running_loop(), opened) - incoming = livekit_uniffi.IncomingDataStreamManager(incoming_delegate, [], None) - incoming.handle_packet_received(DATA_STREAM_HEADER_BYTES) - incoming.handle_packet_received(DATA_STREAM_CHUNK_BYTES) - incoming.handle_packet_received(DATA_STREAM_TRAILER_BYTES) - - kind, reader, identity = await asyncio.wait_for(opened.get(), timeout=5) - print(f"{kind.upper()} STREAM OPENED:", identity, "CONTENTS:", await reader.read_all()) - -if __name__ == '__main__': - asyncio.run(main())