diff --git a/.changeset/data_streams_v2.md b/.changeset/data_streams_v2.md new file mode 100644 index 000000000..48e58599d --- /dev/null +++ b/.changeset/data_streams_v2.md @@ -0,0 +1,10 @@ +--- +livekit: patch +livekit-api: patch +livekit-datatrack: patch +livekit-ffi: patch +livekit-protocol: patch +livekit-uniffi: patch +--- + +Add data streams v2 - #1192 (@1egoman) diff --git a/Cargo.lock b/Cargo.lock index 16498e82f..62fa51a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -412,6 +412,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "futures-io", + "pin-project-lite", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -1246,6 +1258,22 @@ dependencies = [ "memchr", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -3205,7 +3233,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -3924,6 +3952,7 @@ dependencies = [ "bmrng", "bytes", "chrono", + "flate2", "futures-util", "http 1.4.0", "lazy_static", @@ -3938,6 +3967,7 @@ dependencies = [ "log", "parking_lot", "prost 0.12.6", + "rand 0.9.3", "semver", "serde", "serde_json", @@ -3998,16 +4028,19 @@ dependencies = [ name = "livekit-data-stream" version = "0.1.0" dependencies = [ + "async-compression", "bmrng", "bytes", "chrono", "flate2", + "from_variants", "futures-util", "livekit-common", "livekit-protocol", "log", "parking_lot", "prost 0.12.6", + "rand 0.9.3", "thiserror 2.0.18", "tokio", "uuid", @@ -5919,7 +5952,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -5956,7 +5989,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -7152,7 +7185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand 2.3.0", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 36d0870c0..098b32413 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -57,14 +57,21 @@ const VALIDATE_TIMEOUT: Duration = Duration::from_secs(3); pub const PROTOCOL_VERSION: u32 = 17; /// Capabilities the Rust SDK advertises to the SFU at connect time. -const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = - &[proto::client_info::Capability::CapPacketTrailer]; - -pub use livekit_common::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; +/// +/// `CapCompressionDeflateRaw` is always advertised because the SDK's deflate-raw codec +/// (flate2/miniz_oxide) is pure-Rust and compiled in unconditionally. +const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = &[ + proto::client_info::Capability::CapPacketTrailer, + proto::client_info::Capability::CapCompressionDeflateRaw, +]; + +pub use livekit_common::{ + CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DATA_STREAM_V2, CLIENT_PROTOCOL_DEFAULT, +}; /// The client protocol which is sent to other clients and indicates the set of apis that other /// clients should assume this client supports. -const CLIENT_PROTOCOL_VERSION: i32 = CLIENT_PROTOCOL_DATA_STREAM_RPC; +const CLIENT_PROTOCOL_VERSION: i32 = CLIENT_PROTOCOL_DATA_STREAM_V2; #[derive(Error, Debug)] pub enum SignalError { diff --git a/livekit-common/src/lib.rs b/livekit-common/src/lib.rs index aa629a604..550af50f2 100644 --- a/livekit-common/src/lib.rs +++ b/livekit-common/src/lib.rs @@ -13,7 +13,8 @@ // limitations under the License. //! Foundational types shared across LiveKit crates: participant identities, the -//! encryption enum, and client-protocol constants. +//! encryption/capability enums, client-protocol constants, and the remote-participant +//! registry trait consulted by the data-stream and RPC send paths. use std::fmt::Display; @@ -31,6 +32,9 @@ pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; /// RPC v2 (see RPC spec). pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; +/// Understands inline single-packet data streams (data streams v2). +pub const CLIENT_PROTOCOL_DATA_STREAM_V2: i32 = 2; + // ------------------------------------------------------------------------------------------------- // ParticipantIdentity // ------------------------------------------------------------------------------------------------- @@ -109,3 +113,66 @@ impl From for i32 { } } } + +// ------------------------------------------------------------------------------------------------- +// ClientCapability +// ------------------------------------------------------------------------------------------------- + +/// A capability a participant's client advertises, mirroring the `ClientInfo.Capability` protobuf +/// enum. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[non_exhaustive] +pub enum ClientCapability { + Unused, + PacketTrailer, + CompressionDeflateRaw, +} + +impl TryFrom for ClientCapability { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + match proto::client_info::Capability::try_from(value) { + Ok(proto::client_info::Capability::CapPacketTrailer) => Ok(Self::PacketTrailer), + Ok(proto::client_info::Capability::CapCompressionDeflateRaw) => { + Ok(Self::CompressionDeflateRaw) + } + Ok(proto::client_info::Capability::CapUnused) => Ok(Self::Unused), + Err(_) => Err("unknown client capability"), + } + } +} + +impl From for i32 { + fn from(value: ClientCapability) -> Self { + match value { + ClientCapability::Unused => proto::client_info::Capability::CapUnused as i32, + ClientCapability::PacketTrailer => { + proto::client_info::Capability::CapPacketTrailer as i32 + } + ClientCapability::CompressionDeflateRaw => { + proto::client_info::Capability::CapCompressionDeflateRaw as i32 + } + } + } +} + +// ------------------------------------------------------------------------------------------------- +// RemoteParticipantRegistry +// ------------------------------------------------------------------------------------------------- + +/// Read access to remote participants' advertised protocol and capabilities. +/// +/// Used by downstream modules like the the RPC transport (v1/v2 transport selection) and +/// the data-stream send path (inline / compression eligibility) to determine what level of support +/// a participant has for protocol level features. +pub trait RemoteParticipantRegistry: Send + Sync { + /// A remote participant's `client_protocol`, or `CLIENT_PROTOCOL_DEFAULT` (0) if unknown. + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32; + + /// A remote participant's advertised capabilities, or empty if unknown. + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec; + + /// The identities of every remote participant, used to resolve a broadcast send. + fn remote_identities(&self) -> Vec; +} diff --git a/livekit-data-stream/Cargo.toml b/livekit-data-stream/Cargo.toml index 2a2b2c213..b3920d9e1 100644 --- a/livekit-data-stream/Cargo.toml +++ b/livekit-data-stream/Cargo.toml @@ -8,8 +8,10 @@ edition.workspace = true repository.workspace = true [features] -# Exposes test-only hooks used by downstream crates' test suites (e.g. -# `TextStreamReader::new_for_test`). Forwarded from the `livekit` crate's `__lk-e2e-test` feature. +# End-to-end testing hooks (exposes is_compressed/is_inline on stream info). Forwarded from +# the `livekit` crate's `__lk-e2e-test` feature. +__e2e-test = [] +# Exposes constructors used by downstream crates' test suites (e.g. `TextStreamReader::new_for_test`). test-utils = [] [dependencies] @@ -20,12 +22,18 @@ thiserror = { workspace = true } parking_lot = { workspace = true } bytes = { workspace = true } tokio = { workspace = true, default-features = false, features = ["sync", "fs", "io-util", "rt"] } -futures-util = { workspace = true, default-features = false, features = ["sink"] } +futures-util = { workspace = true, default-features = false, features = ["sink", "io"] } prost = "0.12" chrono = "0.4.38" +# Still used by the streaming file-compression path (`send_file`/`write_file`) and the test +# suite's cross-implementation interop check. The in-memory (de)compression paths use +# `async-compression` instead. flate2 = "1" bmrng = "0.5.2" uuid = { version = "1", features = ["v4"] } +async-compression = { version = "0.4.42", features = ["deflate", "futures-io"] } +from_variants.workspace = true [dev-dependencies] tokio = { workspace = true, default-features = false, features = ["macros", "rt", "rt-multi-thread", "time"] } +rand = { workspace = true } diff --git a/livekit-data-stream/src/incoming.rs b/livekit-data-stream/src/incoming.rs deleted file mode 100644 index 163f0dd82..000000000 --- a/livekit-data-stream/src/incoming.rs +++ /dev/null @@ -1,581 +0,0 @@ -// Copyright 2025 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 super::{ - AnyStreamInfo, ByteStreamInfo, StreamError, StreamProgress, StreamResult, TextStreamInfo, -}; -use bytes::{Bytes, BytesMut}; -use futures_util::{Stream, StreamExt}; -use livekit_common::EncryptionType; -use livekit_protocol::data_stream as proto; -use parking_lot::Mutex; -use std::{ - collections::HashMap, - fmt::Debug, - pin::Pin, - sync::Arc, - task::{Context, Poll}, -}; -use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; - -/// Reader for an incoming data stream. -/// -/// The stream being read from is kept open as long as its reader exists; -/// dropping the reader will close the stream. -/// -pub trait StreamReader: Stream> { - /// Type of output this reader produces. - type Output; - - /// Information about the underlying data stream. - type Info; - - /// Returns a reference to the stream info. - fn info(&self) -> &Self::Info; - - /// Reads all incoming chunks from the byte stream, concatenating them - /// into a single value which is returned once the stream closes normally. - /// - /// Returns the data consisting of all concatenated chunks. - /// - fn read_all(self) -> impl std::future::Future> + Send; -} - -/// Reader for an incoming byte data stream. -pub struct ByteStreamReader { - info: ByteStreamInfo, - chunk_rx: UnboundedReceiver>, -} - -/// Reader for an incoming text data stream. -pub struct TextStreamReader { - info: TextStreamInfo, - chunk_rx: UnboundedReceiver>, -} - -impl StreamReader for ByteStreamReader { - type Output = Bytes; - type Info = ByteStreamInfo; - - fn info(&self) -> &ByteStreamInfo { - &self.info - } - - async fn read_all(mut self) -> StreamResult { - let mut buffer = BytesMut::new(); - while let Some(result) = self.next().await { - match result { - Ok(bytes) => buffer.extend_from_slice(&bytes), - Err(e) => return Err(e), - } - } - Ok(buffer.freeze()) - } -} - -impl ByteStreamReader { - /// Reads incoming chunks from the byte stream, writing them to a file as they are received. - /// - /// Parameters: - /// - directory: The directory to write the file in. The system temporary directory is used if not specified. - /// - name_override: The name to use for the written file, overriding stream name. - /// - /// Returns: The path of the written file on disk. - /// - pub async fn write_to_file( - mut self, - directory: Option>, - name_override: Option<&str>, - ) -> StreamResult { - let directory = - directory.map(|d| d.as_ref().to_path_buf()).unwrap_or_else(|| std::env::temp_dir()); - let name = name_override.unwrap_or_else(|| &self.info.name); - let file_path = directory.join(name); - - let mut file = tokio::fs::File::create(&file_path).await.map_err(StreamError::Io)?; - - while let Some(result) = self.next().await { - let bytes = result?; - tokio::io::AsyncWriteExt::write_all(&mut file, &bytes) - .await - .map_err(StreamError::Io)?; - } - tokio::io::AsyncWriteExt::flush(&mut file).await.map_err(StreamError::Io)?; - - Ok(file_path) - } -} - -impl Stream for ByteStreamReader { - type Item = StreamResult; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - match Pin::new(&mut this.chunk_rx).poll_recv(cx) { - Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))), - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -#[cfg(feature = "test-utils")] -impl TextStreamReader { - /// Create a TextStreamReader for testing purposes. - pub fn new_for_test( - info: TextStreamInfo, - chunk_rx: UnboundedReceiver>, - ) -> Self { - Self { info, chunk_rx } - } -} - -impl StreamReader for TextStreamReader { - type Output = String; - type Info = TextStreamInfo; - - fn info(&self) -> &TextStreamInfo { - &self.info - } - - async fn read_all(mut self) -> StreamResult { - let mut result = String::new(); - while let Some(chunk) = self.next().await { - match chunk { - Ok(text) => result.push_str(&text), - Err(e) => return Err(e), - } - } - Ok(result) - } -} - -impl Stream for TextStreamReader { - type Item = StreamResult; - - fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { - let this = self.get_mut(); - match Pin::new(&mut this.chunk_rx).poll_recv(cx) { - Poll::Ready(Some(Ok(chunk))) => match String::from_utf8(chunk.into()) { - Ok(content) => Poll::Ready(Some(Ok(content))), - Err(e) => { - this.chunk_rx.close(); - Poll::Ready(Some(Err(StreamError::from(e)))) - } - }, - Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), - Poll::Ready(None) => Poll::Ready(None), - Poll::Pending => Poll::Pending, - } - } -} - -impl Debug for ByteStreamReader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ByteStreamReader") - .field("id", &self.info.id()) - .field("topic", &self.info.topic) - .finish() - } -} - -impl Debug for TextStreamReader { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("TextStreamReader") - .field("id", &self.info.id()) - .field("topic", &self.info.topic) - .finish() - } -} - -pub enum AnyStreamReader { - Byte(ByteStreamReader), - Text(TextStreamReader), -} - -impl AnyStreamReader { - /// Creates a stream reader for the stream with the given info. - pub(super) fn from(info: AnyStreamInfo) -> (Self, UnboundedSender>) { - let (chunk_tx, chunk_rx) = mpsc::unbounded_channel(); - let reader = match info { - AnyStreamInfo::Byte(info) => Self::Byte(ByteStreamReader { info, chunk_rx }), - AnyStreamInfo::Text(info) => Self::Text(TextStreamReader { info, chunk_rx }), - }; - return (reader, chunk_tx); - } -} -struct Descriptor { - progress: StreamProgress, - chunk_tx: UnboundedSender>, - encryption_type: EncryptionType, - is_internal: bool, - // TODO(ladvoc): keep track of open time. -} - -#[derive(Clone)] -pub struct IncomingStreamManager { - inner: Arc>, - open_tx: UnboundedSender<(AnyStreamReader, String)>, - /// Topics whose streams are handled internally by the SDK (e.g. RPC) and never surfaced as - /// application events. Supplied by the host crate so this crate stays decoupled from RPC. - reserved_topics: Arc<[String]>, -} - -#[derive(Default)] -struct ManagerInner { - open_streams: HashMap, -} - -impl IncomingStreamManager { - pub fn new( - reserved_topics: Vec, - ) -> (Self, UnboundedReceiver<(AnyStreamReader, String)>) { - let (open_tx, open_rx) = mpsc::unbounded_channel(); - ( - Self { - inner: Arc::new(Mutex::new(Default::default())), - open_tx, - reserved_topics: reserved_topics.into(), - }, - open_rx, - ) - } - - /// Handles an incoming header packet. - pub fn handle_header( - &self, - header: proto::Header, - identity: String, - encryption_type: livekit_protocol::encryption::Type, - ) { - let is_internal = self.reserved_topics.iter().any(|t| t == &header.topic); - let Ok(info) = AnyStreamInfo::try_from_with_encryption(header, encryption_type.into()) - .inspect_err(|e| log::error!("Invalid header: {}", e)) - else { - return; - }; - - let id = info.id().to_owned(); - let bytes_total = info.total_length(); - let stream_encryption_type = info.encryption_type(); - - let mut inner = self.inner.lock(); - if inner.open_streams.contains_key(&id) { - log::error!("Stream '{}' already open", id); - return; - } - - let (reader, chunk_tx) = AnyStreamReader::from(info); - let _ = self.open_tx.send((reader, identity)); - - let descriptor = Descriptor { - progress: StreamProgress { bytes_total, ..Default::default() }, - chunk_tx, - encryption_type: stream_encryption_type, - is_internal, - }; - inner.open_streams.insert(id, descriptor); - } - - /// Returns whether the given open stream belongs to an internal topic - /// (e.g. `lk.rpc_request`). Used to suppress `RoomEvent::Stream*Received` - /// dispatches for traffic the SDK handles itself. - pub fn is_internal(&self, stream_id: &str) -> bool { - self.inner.lock().open_streams.get(stream_id).is_some_and(|d| d.is_internal) - } - - /// Handles an incoming chunk packet. - pub fn handle_chunk( - &self, - chunk: proto::Chunk, - encryption_type: livekit_protocol::encryption::Type, - ) { - let id = chunk.stream_id; - let mut inner = self.inner.lock(); - let Some(descriptor) = inner.open_streams.get_mut(&id) else { - return; - }; - - if descriptor.encryption_type != encryption_type.into() { - inner.close_stream_with_error(&id, StreamError::EncryptionTypeMismatch); - return; - } - - if descriptor.progress.chunk_index != chunk.chunk_index { - inner.close_stream_with_error(&id, StreamError::MissedChunk); - return; - } - - descriptor.progress.chunk_index += 1; - descriptor.progress.bytes_processed += chunk.content.len() as u64; - - if match descriptor.progress.bytes_total { - Some(total) => descriptor.progress.bytes_processed > total as u64, - None => false, - } { - inner.close_stream_with_error(&id, StreamError::LengthExceeded); - return; - } - inner.yield_chunk(&id, Bytes::from(chunk.content)); - // TODO: also yield progress - } - - /// Handles an incoming trailer packet. - pub fn handle_trailer(&self, trailer: proto::Trailer) { - let id = trailer.stream_id; - let mut inner = self.inner.lock(); - let Some(descriptor) = inner.open_streams.get_mut(&id) else { - return; - }; - - if !match descriptor.progress.bytes_total { - Some(total) => descriptor.progress.bytes_processed >= total as u64, - None => true, - } { - inner.close_stream_with_error(&id, StreamError::Incomplete); - return; - } - if !trailer.reason.is_empty() { - inner.close_stream_with_error(&id, StreamError::AbnormalEnd(trailer.reason)); - return; - } - inner.close_stream(&id); - } -} - -impl ManagerInner { - fn yield_chunk(&mut self, id: &str, chunk: Bytes) { - let Some(descriptor) = self.open_streams.get_mut(id) else { - return; - }; - if descriptor.chunk_tx.send(Ok(chunk)).is_err() { - // Reader has been dropped, close the stream. - self.close_stream(id); - } - } - - fn close_stream(&mut self, id: &str) { - // Dropping the sender closes the channel. - self.open_streams.remove(id); - } - - fn close_stream_with_error(&mut self, id: &str, error: StreamError) { - if let Some(descriptor) = self.open_streams.remove(id) { - let _ = descriptor.chunk_tx.send(Err(error)); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use livekit_protocol::encryption::Type as EncType; - use std::collections::HashMap; - - const SENDER: &str = "alice"; - - fn attrs(pairs: &[(&str, &str)]) -> HashMap { - pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() - } - - fn text_header( - id: &str, - total_length: Option, - attributes: HashMap, - inline_content: Option>, - compression: proto::CompressionType, - ) -> proto::Header { - proto::Header { - stream_id: id.to_string(), - timestamp: 0, - topic: "topic".to_string(), - mime_type: "text/plain".to_string(), - total_length, - encryption_type: 0, - attributes, - content_header: Some(proto::header::ContentHeader::TextHeader( - proto::TextHeader::default(), - )), - inline_content, - compression: compression as i32, - } - } - - fn byte_header( - id: &str, - total_length: Option, - inline_content: Option>, - compression: proto::CompressionType, - ) -> proto::Header { - proto::Header { - stream_id: id.to_string(), - timestamp: 0, - topic: "topic".to_string(), - mime_type: "application/octet-stream".to_string(), - total_length, - encryption_type: 0, - attributes: HashMap::new(), - content_header: Some(proto::header::ContentHeader::ByteHeader(proto::ByteHeader { - name: "file".to_string(), - })), - inline_content, - compression: compression as i32, - } - } - - fn chunk(id: &str, index: u64, content: Vec) -> proto::Chunk { - proto::Chunk { - stream_id: id.to_string(), - chunk_index: index, - content, - ..Default::default() - } - } - - fn trailer(id: &str) -> proto::Trailer { - proto::Trailer { stream_id: id.to_string(), ..Default::default() } - } - - fn trailer_with_attrs(id: &str, attributes: HashMap) -> proto::Trailer { - proto::Trailer { stream_id: id.to_string(), reason: String::new(), attributes } - } - - async fn read_text(reader: AnyStreamReader) -> StreamResult { - match reader { - AnyStreamReader::Text(r) => r.read_all().await, - _ => panic!("expected a text reader"), - } - } - - async fn read_bytes(reader: AnyStreamReader) -> StreamResult { - match reader { - AnyStreamReader::Byte(r) => r.read_all().await, - _ => panic!("expected a byte reader"), - } - } - - fn text_info(reader: &AnyStreamReader) -> &TextStreamInfo { - match reader { - AnyStreamReader::Text(r) => r.info(), - _ => panic!("expected a text reader"), - } - } - - #[tokio::test] - async fn text_stream_round_trips() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - let text = "hello world"; - mgr.handle_header( - text_header( - "s1", - Some(text.len() as u64), - attrs(&[("foo", "bar")]), - None, - proto::CompressionType::None, - ), - SENDER.to_string(), - EncType::None, - ); - let (reader, identity) = rx.recv().await.expect("a reader should be dispatched"); - assert_eq!(identity, SENDER); - assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); - mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); - mgr.handle_trailer(trailer("s1")); - assert_eq!(read_text(reader).await.unwrap(), text); - } - - #[tokio::test] - async fn byte_stream_round_trips() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - mgr.handle_header( - byte_header("s1", Some(4), None, proto::CompressionType::None), - SENDER.to_string(), - EncType::None, - ); - let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); - mgr.handle_chunk(chunk("s1", 0, vec![1, 2, 3, 4]), EncType::None); - mgr.handle_trailer(trailer("s1")); - assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3, 4])); - } - - #[tokio::test] - async fn merges_trailer_attributes() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - let text = "hi"; - mgr.handle_header( - text_header( - "s1", - Some(text.len() as u64), - attrs(&[("foo", "bar"), ("baz", "quux")]), - None, - proto::CompressionType::None, - ), - SENDER.to_string(), - EncType::None, - ); - let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); - mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); - mgr.handle_trailer(trailer_with_attrs( - "s1", - attrs(&[("hello", "world"), ("foo", "updated")]), - )); - // 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); - // The header attributes are present on the reader info at open time. - assert_eq!(info_attrs.get("baz"), Some(&"quux".to_string())); - } - - #[tokio::test] - async fn errors_when_too_few_bytes() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - mgr.handle_header( - text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), - SENDER.to_string(), - EncType::None, - ); - let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); - mgr.handle_chunk(chunk("s1", 0, vec![b'x']), EncType::None); - mgr.handle_trailer(trailer("s1")); - assert!(matches!(read_text(reader).await, Err(StreamError::Incomplete))); - } - - #[tokio::test] - async fn errors_when_too_many_bytes() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - mgr.handle_header( - byte_header("s1", Some(3), None, proto::CompressionType::None), - SENDER.to_string(), - EncType::None, - ); - let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); - mgr.handle_chunk(chunk("s1", 0, vec![1, 2, 3, 4, 5]), EncType::None); - mgr.handle_trailer(trailer("s1")); - assert!(matches!(read_bytes(reader).await, Err(StreamError::LengthExceeded))); - } - - #[tokio::test] - async fn drops_on_encryption_type_mismatch() { - let (mgr, mut rx) = IncomingStreamManager::new(vec![]); - mgr.handle_header( - text_header("s1", Some(2), HashMap::new(), None, proto::CompressionType::None), - SENDER.to_string(), - EncType::None, - ); - let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); - mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'i']), EncType::Gcm); - assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); - } -} diff --git a/livekit-data-stream/src/incoming/events.rs b/livekit-data-stream/src/incoming/events.rs new file mode 100644 index 000000000..ab7e41674 --- /dev/null +++ b/livekit-data-stream/src/incoming/events.rs @@ -0,0 +1,70 @@ +// 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 from_variants::FromVariants; +use livekit_common::ParticipantIdentity; + +use crate::incoming::AnyStreamReader; +use crate::types::{Chunk, Packet, Trailer}; + +pub struct PacketReceived { + pub packet: Packet, + pub participant_identity: ParticipantIdentity, +} + +impl PacketReceived { + pub fn new(packet: Packet, participant_identity: ParticipantIdentity) -> Self { + Self { packet, participant_identity } + } +} + +/// An event fed into [`IncomingStreamManager::run`] by the host crate. Each corresponds to an +/// inbound data-stream packet (or a lifecycle signal) and carries everything the manager needs to +/// process it without reaching back into room state. +#[derive(FromVariants)] +pub enum InputEvent { + PacketReceived(PacketReceived), + /// Abort every open stream sent by this participant (they disconnected mid-send). + AbortStreamsFrom(ParticipantIdentity), + /// Stop the run loop. + Shutdown, +} + +/// A new stream was opened; its reader should be delivered to the application (or routed +/// internally for reserved topics). Carries the sender's identity. +pub struct StreamOpened { + pub stream_reader: AnyStreamReader, + pub participant_identity: ParticipantIdentity, +} + +/// Back-compat "raw chunk received" notification, emitted only for non-internal streams. +pub struct ChunkReceived { + pub chunk: Chunk, + pub participant_identity: ParticipantIdentity, +} + +/// Back-compat "raw trailer received" notification, emitted only for non-internal streams. +pub struct TrailerReceived { + pub trailer: Trailer, + pub participant_identity: ParticipantIdentity, +} + +/// An event emitted by [`IncomingStreamManager::run`] for the host crate to surface. The manager +/// stays decoupled from `RoomEvent`; the host maps these onto its own event types. +#[derive(FromVariants)] +pub enum OutputEvent { + StreamOpened(StreamOpened), + ChunkReceived(ChunkReceived), + TrailerReceived(TrailerReceived), +} diff --git a/livekit-data-stream/src/incoming/mod.rs b/livekit-data-stream/src/incoming/mod.rs new file mode 100644 index 000000000..671f728d8 --- /dev/null +++ b/livekit-data-stream/src/incoming/mod.rs @@ -0,0 +1,902 @@ +// Copyright 2025 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 bytes::Bytes; +use livekit_common::{EncryptionType, ParticipantIdentity}; +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +use crate::info::{AnyStreamInfo, ByteStreamInfo, TextStreamInfo}; +use crate::types::{Chunk, CompressionType, Header, Packet, Trailer}; +use crate::utils::{StreamError, StreamProgress, StreamResult}; +use crate::StreamId; + +pub mod events; + +mod stream_reader; +pub use stream_reader::{AnyStreamReader, ByteStreamReader, StreamReader, TextStreamReader}; + +use events::{ + ChunkReceived, InputEvent, OutputEvent, PacketReceived, StreamOpened, TrailerReceived, +}; + +struct Descriptor { + progress: StreamProgress, + chunk_tx: UnboundedSender>, + encryption_type: EncryptionType, + /// Identity of the participant sending this stream; used to abort the stream + /// if that participant disconnects mid-send. + sender_identity: ParticipantIdentity, + is_internal: bool, + /// Whether this is a text stream (decompressed output is reframed on UTF-8 boundaries). + is_text: bool, + /// Per-stream deflate-raw decompressor; `Some` if the header declared `DEFLATE_RAW`. + decompressor: Option, + /// Highest chunk index processed so far (compressed streams; for dedup/gap detection). + last_chunk_index: Option, + // TODO(ladvoc): keep track of open time. +} + +/// Streaming deflate-raw decompressor state for one compressed stream. +/// +/// Backed by `async-compression`'s push-style (`AsyncWrite`) decoder: ordered compressed chunks +/// are written into it and the decompressed output lands in the inner `Vec`, which is drained per +/// chunk. Because the manager runs as an actor (see [`IncomingStreamManager::run`]), the decode is +/// awaited directly on the run-loop task — no lock is held across the `.await`, and it behaves +/// identically across every async backend the SDK supports. +struct DeflateDecompressState { + decoder: async_compression::futures::write::DeflateDecoder>, + /// Decompressed text bytes not yet yielded because they end mid-codepoint. + pending_text: Vec, +} + +impl DeflateDecompressState { + fn new() -> Self { + // The `deflate` algorithm is raw DEFLATE (no zlib header/checksum), matching the wire + // contract. + Self { + decoder: async_compression::futures::write::DeflateDecoder::new(Vec::new()), + pending_text: Vec::new(), + } + } + + /// Feeds compressed `input` through the stateful decompressor, returning all + /// decompressed output produced so far. + async fn push(&mut self, input: &[u8]) -> StreamResult> { + use futures_util::io::AsyncWriteExt; + self.decoder.write_all(input).await.map_err(|_| StreamError::Decompression)?; + // Flush so all currently-decodable output lands in the inner `Vec`. + self.decoder.flush().await.map_err(|_| StreamError::Decompression)?; + Ok(std::mem::take(self.decoder.get_mut())) + } + + /// Appends `decompressed` text bytes and returns the longest valid-UTF-8 prefix, + /// retaining any trailing incomplete codepoint for the next chunk. + fn reframe_text(&mut self, decompressed: Vec) -> Bytes { + self.pending_text.extend_from_slice(&decompressed); + let valid = match std::str::from_utf8(&self.pending_text) { + Ok(_) => self.pending_text.len(), + Err(e) => e.valid_up_to(), + }; + Bytes::from(self.pending_text.drain(..valid).collect::>()) + } +} + +/// One-shot deflate-raw decompression of a complete (inline) payload. +async fn inflate_raw(data: &[u8]) -> StreamResult> { + use futures_util::io::AsyncReadExt; + let mut decoder = async_compression::futures::bufread::DeflateDecoder::new( + futures_util::io::Cursor::new(data), + ); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).await.map_err(|_| StreamError::Decompression)?; + Ok(out) +} + +/// Cheap, cloneable, `Send + Sync` handle used to feed [`InputEvent`]s into the manager's run +/// loop. +/// +/// Dropping the last handle stops the loop (via [`InputEvent::Shutdown`]). +#[derive(Clone)] +pub struct IncomingDataStreamInput { + input_tx: UnboundedSender, + _drop_guard: Arc, +} + +/// Sends [`IncomingEvent::Shutdown`] when the last [`IncomingStreamInput`] is dropped. +struct DropGuard { + input_tx: UnboundedSender, +} + +impl Drop for DropGuard { + fn drop(&mut self) { + let _ = self.input_tx.send(InputEvent::Shutdown); + } +} + +impl IncomingDataStreamInput { + fn new(input_tx: UnboundedSender) -> Self { + Self { input_tx: input_tx.clone(), _drop_guard: Arc::new(DropGuard { input_tx }) } + } + + /// Feeds an event to the manager's run loop. Fails only if the loop has already stopped. + pub fn send(&self, event: InputEvent) -> StreamResult<()> { + self.input_tx.send(event).map_err(|_| StreamError::Internal) + } +} + +/// Actor that owns all incoming-stream state and processes [`IncomingEvent`]s on a single task +/// (see [`Self::run`]). Because it owns its state directly (no shared `Mutex`), its handlers can +/// `.await` decompression on the run-loop task. +pub struct IncomingDataStreamManager { + inner: ManagerInner, + input_rx: UnboundedReceiver, + output_tx: UnboundedSender, + + /// Topics whose streams are handled internally by the SDK (e.g. RPC) and never surfaced as + /// application events. Supplied by the host crate so this crate stays decoupled from RPC. + reserved_topics: Vec<&'static str>, +} + +#[derive(Default)] +struct ManagerInner { + open_streams: HashMap, +} + +impl IncomingDataStreamManager { + pub fn new( + reserved_topics: Vec<&'static str>, + ) -> (Self, IncomingDataStreamInput, UnboundedReceiver) { + // Unbounded: inbound wire packets must never be dropped (a dropped chunk is an + // unrecoverable `MissedChunk`) and must not head-of-line-block the engine event loop. + let (input_tx, input_rx) = mpsc::unbounded_channel(); + let (output_tx, output_rx) = mpsc::unbounded_channel(); + let manager = Self { inner: ManagerInner::default(), reserved_topics, input_rx, output_tx }; + (manager, IncomingDataStreamInput::new(input_tx), output_rx) + } + + /// Runs the manager's event loop until the input channel closes (all + /// [`IncomingStreamInput`]s dropped) or [`IncomingEvent::Shutdown`] is received. On exit, + /// dropping `self` closes every open reader. + pub async fn run(mut self) { + while let Some(event) = self.input_rx.recv().await { + match event { + InputEvent::PacketReceived(PacketReceived { packet, participant_identity }) => { + match packet { + Packet::Header { header, encryption_type } => { + self.on_header(header, participant_identity, encryption_type).await + } + Packet::Chunk { chunk, encryption_type } => { + self.on_chunk(chunk, participant_identity, encryption_type).await + } + Packet::Trailer(trailer) => self.on_trailer(trailer, participant_identity), + } + } + InputEvent::AbortStreamsFrom(identity) => self.on_abort(identity), + InputEvent::Shutdown => break, + } + } + } + + /// Handles an incoming header packet. + async fn on_header( + &mut self, + mut header: Header, + participant_identity: ParticipantIdentity, + encryption_type: EncryptionType, + ) { + let is_internal = self.is_internal_topic(&header.topic); + // Read the v2 signals before `try_from_with_encryption` consumes the header. + let inline_content = header.inline_content.take(); + let is_compressed = header.compression == CompressionType::DeflateRaw; + + let Ok(info) = AnyStreamInfo::try_from_with_encryption(header, encryption_type) + .inspect_err(|e| log::error!("Invalid header: {}", e)) + else { + return; + }; + + let id: StreamId = info.id().into(); + let is_text = matches!(info, AnyStreamInfo::Text(_)); + let bytes_total = info.total_length(); + let stream_encryption_type = info.encryption_type(); + + if self.inner.open_streams.contains_key(&id) { + log::error!("Stream '{}' already open", id); + return; + } + + let (stream_reader, chunk_tx) = AnyStreamReader::from(info); + let _ = self.output_tx.send( + StreamOpened { stream_reader, participant_identity: participant_identity.clone() } + .into(), + ); + + // Inline single-packet stream: synthesize the complete content now; no chunk/trailer + // packets will follow, so we never register an open descriptor. + if let Some(content) = inline_content { + let content = if is_compressed { + match inflate_raw(&content).await { + Ok(decompressed) => decompressed, + Err(error) => { + // 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)); + return; + } + } + } else { + content + }; + // The full payload is complete and (for text) valid UTF-8, so deliver it as one chunk. + if !content.is_empty() { + let _ = chunk_tx.send(Ok(Bytes::from(content))); + } + // Dropping `chunk_tx` closes the reader. + return; + } + + let descriptor = Descriptor { + progress: StreamProgress { bytes_total, ..Default::default() }, + chunk_tx, + encryption_type: stream_encryption_type, + sender_identity: participant_identity, + is_internal, + is_text, + decompressor: is_compressed.then(DeflateDecompressState::new), + last_chunk_index: None, + }; + self.inner.open_streams.insert(id, descriptor); + } + + /// Returns whether streams created on the given topic are handled internally by the SDK + /// (e.g. `lk.rpc_request`) and should not be surfaced to the application. + fn is_internal_topic(&self, topic: &str) -> bool { + self.reserved_topics.iter().any(|t| t == &topic) + } + + /// Handles an incoming chunk packet. + async fn on_chunk( + &mut self, + chunk: Chunk, + participant_identity: ParticipantIdentity, + encryption_type: EncryptionType, + ) { + let id = chunk.stream_id.clone(); + + // Back-compat raw-chunk notification: emitted for any non-internal stream regardless of + // whether the chunk turns out to be valid (an unknown stream counts as non-internal), + // matching the previous synchronous behavior. + let is_internal = self.inner.open_streams.get(&id).is_some_and(|d| d.is_internal); + if !is_internal { + let _ = self.output_tx.send(OutputEvent::ChunkReceived(ChunkReceived { + chunk: chunk.clone(), + participant_identity, + })); + } + + let inner = &mut self.inner; + let Some(descriptor) = inner.open_streams.get_mut(&id) else { + return; + }; + + if descriptor.encryption_type != encryption_type.into() { + inner.close_stream_with_error(&id, StreamError::EncryptionTypeMismatch); + return; + } + + if let Some(decompressor) = &mut descriptor.decompressor { + // --- Compressed stream: feed chunks through one stateful decompressor. --- + // Duplicate index (reconnect replay): drop with a warning. + if let Some(last) = descriptor.last_chunk_index { + if chunk.chunk_index <= last { + log::warn!( + "Dropping duplicate chunk {} for compressed stream '{}'", + chunk.chunk_index, + id + ); + return; + } + } + // A gap is unrecoverable for a stateful decompressor. + let expected = descriptor.last_chunk_index.map(|i| i + 1).unwrap_or(0); + if chunk.chunk_index != expected { + inner.close_stream_with_error(&id, StreamError::MissedChunk); + return; + } + descriptor.last_chunk_index = Some(chunk.chunk_index); + + let is_text = descriptor.is_text; + // Confine the decompressor borrow so we can re-borrow `inner` afterwards. + let result: StreamResult<(u64, Bytes)> = { + match decompressor.push(&chunk.content).await { + Ok(decompressed) => { + let produced = decompressed.len() as u64; + let yielded = if is_text { + decompressor.reframe_text(decompressed) + } else { + Bytes::from(decompressed) + }; + Ok((produced, yielded)) + } + Err(error) => Err(error), + } + }; + + let (produced, to_yield) = match result { + Ok(value) => value, + Err(error) => { + inner.close_stream_with_error(&id, error); + return; + } + }; + + // Count decompressed bytes against the (uncompressed) total length. + descriptor.progress.bytes_processed += produced; + if matches!(descriptor.progress.bytes_total, Some(total) if descriptor.progress.bytes_processed > total) + { + inner.close_stream_with_error(&id, StreamError::LengthExceeded); + return; + } + if !to_yield.is_empty() { + inner.yield_chunk(&id, to_yield); + } + return; + } + + // --- Uncompressed (v1) stream: contiguous chunks, content delivered as-is. --- + if descriptor.progress.chunk_index != chunk.chunk_index { + inner.close_stream_with_error(&id, StreamError::MissedChunk); + return; + } + + descriptor.progress.chunk_index += 1; + descriptor.progress.bytes_processed += chunk.content.len() as u64; + + if match descriptor.progress.bytes_total { + Some(total) => descriptor.progress.bytes_processed > total, + None => false, + } { + inner.close_stream_with_error(&id, StreamError::LengthExceeded); + return; + } + inner.yield_chunk(&id, Bytes::from(chunk.content)); + // TODO: also yield progress + } + + /// Handles an incoming trailer packet. + fn on_trailer(&mut self, trailer: Trailer, participant_identity: ParticipantIdentity) { + let id = trailer.stream_id.clone(); + + // Back-compat raw-trailer notification (non-internal streams only). + let is_internal = self.inner.open_streams.get(&id).is_some_and(|d| d.is_internal); + if !is_internal { + let _ = self + .output_tx + .send(TrailerReceived { trailer: trailer.clone(), participant_identity }.into()); + } + + let inner = &mut self.inner; + let Some(descriptor) = inner.open_streams.get_mut(&id) else { + return; + }; + + if !match descriptor.progress.bytes_total { + Some(total) => descriptor.progress.bytes_processed >= total, + None => true, + } { + inner.close_stream_with_error(&id, StreamError::Incomplete); + return; + } + if !trailer.reason.is_empty() { + inner.close_stream_with_error(&id, StreamError::AbnormalEnd(trailer.reason)); + return; + } + inner.close_stream(&id); + } + + /// Aborts every open stream being sent by the given participant, erroring each + /// reader with [`StreamError::AbnormalEnd`]. + /// + /// Called when a remote participant disconnects: any streams it had in flight to + /// this receiver are terminated so their readers observe an error rather than + /// hanging forever waiting for chunks that will never arrive. + fn on_abort(&mut self, identity: ParticipantIdentity) { + let inner = &mut self.inner; + let ids: Vec = inner + .open_streams + .iter() + .filter(|(_, descriptor)| descriptor.sender_identity == identity) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + let reason = format!( + "Participant {} unexpectedly disconnected in the middle of sending data", + identity + ); + inner.close_stream_with_error(&id, StreamError::AbnormalEnd(reason)); + } + } +} + +impl ManagerInner { + fn yield_chunk(&mut self, id: &StreamId, chunk: Bytes) { + let Some(descriptor) = self.open_streams.get_mut(id) else { + return; + }; + if descriptor.chunk_tx.send(Ok(chunk)).is_err() { + // Reader has been dropped, close the stream. + self.close_stream(id); + } + } + + fn close_stream(&mut self, id: &StreamId) { + // Dropping the sender closes the channel. + self.open_streams.remove(id); + } + + 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)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ByteHeader, StreamId, TextHeader}; + use std::collections::HashMap; + + const SENDER: &str = "alice"; + + fn deflate_raw(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + fn attrs(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + /// Deterministic, barely-compressible lowercase text (so its deflate output spans chunks). + /// + /// Seeded with a fixed value so the output is identical on every run; the letters carry + /// enough entropy that deflate can't shrink them away, unlike repetitive text ("aaaa…"). + fn pseudo_random_text(len: usize) -> String { + use rand::{rngs::StdRng, Rng, SeedableRng}; + + /// Fixed RNG seed that keeps `pseudo_random_text` output identical on every run. + const RANDOM_SEED: u64 = 0xdead_beef_cafe_babe; + + let mut rng = StdRng::seed_from_u64(RANDOM_SEED); + (0..len).map(|_| rng.random_range(b'a'..=b'z') as char).collect() + } + + #[allow(clippy::too_many_arguments)] + fn text_header( + id: &str, + total_length: Option, + attributes: HashMap, + inline_content: Option>, + compression: CompressionType, + ) -> Header { + Header { + stream_id: StreamId::from(id), + timestamp: 0, + topic: "topic".to_string(), + mime_type: "text/plain".to_string(), + total_length, + attributes, + content_header: Some(TextHeader::default().into()), + inline_content, + compression, + } + } + + fn byte_header( + id: &str, + total_length: Option, + inline_content: Option>, + compression: CompressionType, + ) -> Header { + Header { + stream_id: StreamId::from(id), + timestamp: 0, + topic: "topic".to_string(), + mime_type: "application/octet-stream".to_string(), + total_length, + attributes: HashMap::new(), + content_header: Some(ByteHeader { name: "file".to_string() }.into()), + inline_content, + compression, + } + } + + fn chunk(id: &str, index: u64, content: Vec) -> Chunk { + Chunk { stream_id: StreamId::from(id), chunk_index: index, content, ..Default::default() } + } + + fn trailer(id: &str) -> Trailer { + Trailer { stream_id: StreamId::from(id), ..Default::default() } + } + + fn trailer_with_attrs(id: &str, attributes: HashMap) -> Trailer { + Trailer { stream_id: StreamId::from(id), reason: String::new(), attributes } + } + + async fn read_text(reader: AnyStreamReader) -> StreamResult { + match reader { + AnyStreamReader::Text(r) => r.read_all().await, + _ => panic!("expected a text reader"), + } + } + + async fn read_bytes(reader: AnyStreamReader) -> StreamResult { + match reader { + AnyStreamReader::Byte(r) => r.read_all().await, + _ => panic!("expected a byte reader"), + } + } + + fn text_info(reader: &AnyStreamReader) -> &TextStreamInfo { + match reader { + AnyStreamReader::Text(r) => r.info(), + _ => panic!("expected a text reader"), + } + } + + /// Drives an [`IncomingStreamManager`] actor for tests: spawns its `run` loop, exposes + /// `send_*` helpers to feed events, and `next_opened` to await the reader for a new stream. + struct Harness { + input: IncomingDataStreamInput, + output_rx: UnboundedReceiver, + } + + impl Harness { + fn new(reserved_topics: Vec<&'static str>) -> Self { + let (manager, input, output_rx) = IncomingDataStreamManager::new(reserved_topics); + tokio::spawn(manager.run()); + Self { input, output_rx } + } + + fn send_packet(&self, packet: Packet) { + self.send_packet_from(packet, SENDER); + } + + fn send_packet_from(&self, packet: Packet, identity: &str) { + let event = InputEvent::PacketReceived(PacketReceived { + packet, + participant_identity: ParticipantIdentity::from(identity), + }); + self.input.send(event).expect("Harness::send_packet failed"); + } + + fn abort(&self, identity: ParticipantIdentity) { + self.input.send(InputEvent::AbortStreamsFrom(identity)).unwrap(); + } + + /// Awaits the next opened stream's reader (skipping back-compat chunk/trailer outputs). + async fn next_opened(&mut self) -> (AnyStreamReader, ParticipantIdentity) { + loop { + match self.output_rx.recv().await.expect("a stream should be opened") { + OutputEvent::StreamOpened(StreamOpened { + stream_reader, + participant_identity, + }) => { + return (stream_reader, participant_identity); + } + _ => continue, + } + } + } + } + + // --- v1 (legacy multi-packet) -------------------------------------------------------- + + #[tokio::test] + async fn v1_text_stream_round_trips() { + let mut h = Harness::new(vec![]); + let text = "hello world"; + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + None, + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (reader, identity) = h.next_opened().await; + assert_eq!(identity.as_str(), SENDER); + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + 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!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v1_byte_stream_round_trips() { + let mut h = Harness::new(vec![]); + h.send_packet(Packet::Header { + header: byte_header("s1", Some(4), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![1, 2, 3, 4]), + encryption_type: EncryptionType::None, + }); + h.send_packet(Packet::Trailer(trailer("s1"))); + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3, 4])); + } + + #[tokio::test] + async fn v1_merges_trailer_attributes() { + let mut h = Harness::new(vec![]); + let text = "hi"; + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar"), ("baz", "quux")]), + 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_with_attrs( + "s1", + attrs(&[("hello", "world"), ("foo", "updated")]), + ))); + // 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); + // The header attributes are present on the reader info at open time. + assert_eq!(info_attrs.get("baz"), Some(&"quux".to_string())); + } + + #[tokio::test] + async fn v1_errors_when_too_few_bytes() { + let mut h = Harness::new(vec![]); + h.send_packet(Packet::Header { + header: text_header("s1", Some(5), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![b'x']), + encryption_type: EncryptionType::None, + }); + h.send_packet(Packet::Trailer(trailer("s1"))); + assert!(matches!(read_text(reader).await, Err(StreamError::Incomplete))); + } + + #[tokio::test] + async fn v1_errors_when_too_many_bytes() { + let mut h = Harness::new(vec![]); + h.send_packet(Packet::Header { + header: byte_header("s1", Some(3), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![1, 2, 3, 4, 5]), + encryption_type: EncryptionType::None, + }); + h.send_packet(Packet::Trailer(trailer("s1"))); + assert!(matches!(read_bytes(reader).await, Err(StreamError::LengthExceeded))); + } + + #[tokio::test] + async fn v1_drops_on_encryption_type_mismatch() { + let mut h = Harness::new(vec![]); + h.send_packet(Packet::Header { + header: text_header("s1", Some(2), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![b'h', b'i']), + encryption_type: EncryptionType::Gcm, + }); + assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); + } + + // --- v2 inline ----------------------------------------------------------------------- + + #[tokio::test] + async fn v2_inline_uncompressed_text() { + let mut h = Harness::new(vec![]); + let text = "inline hello"; + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(text.as_bytes().to_vec()), + CompressionType::None, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + // No chunk/trailer packets are fed. + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v2_inline_uncompressed_byte() { + let mut h = Harness::new(vec![]); + h.send_packet(Packet::Header { + header: byte_header("s1", Some(3), Some(vec![1, 2, 3]), CompressionType::None), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); + } + + #[tokio::test] + async fn v2_inline_compressed_text() { + let mut h = Harness::new(vec![]); + let text = "hello hello compressible world"; + let compressed = deflate_raw(text.as_bytes()); + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(compressed), + CompressionType::DeflateRaw, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v2_inline_compressed_byte() { + let mut h = Harness::new(vec![]); + let payload: Vec = (0..2000).map(|i| (i % 7) as u8).collect(); + let compressed = deflate_raw(&payload); + h.send_packet(Packet::Header { + header: byte_header( + "s1", + Some(payload.len() as u64), + Some(compressed), + CompressionType::DeflateRaw, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); + } + + // --- v2 multi-packet compressed ------------------------------------------------------ + + #[tokio::test] + async fn v2_multipacket_compressed_text() { + let mut h = Harness::new(vec![]); + // ~60 KB of pseudo-random lowercase so the compressed output spans multiple chunks. + let text = pseudo_random_text(60_000); + let compressed = deflate_raw(text.as_bytes()); + let chunk_pieces: Vec<&[u8]> = compressed.chunks(15_000).collect(); + assert!(chunk_pieces.len() >= 2, "expected multi-packet compressed stream"); + + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + CompressionType::DeflateRaw, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + for (i, piece) in chunk_pieces.iter().enumerate() { + h.send_packet(Packet::Chunk { + chunk: chunk("s1", i as u64, piece.to_vec()), + encryption_type: EncryptionType::None, + }); + } + h.send_packet(Packet::Trailer(trailer("s1"))); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn errors_open_streams_on_sender_disconnect() { + let mut h = Harness::new(vec![]); + 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; + // Partial content, no trailer: the sender then drops. + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), + encryption_type: EncryptionType::None, + }); + h.abort(ParticipantIdentity::from(SENDER)); + assert!(matches!(read_text(reader).await, Err(StreamError::AbnormalEnd(_)))); + } + + #[tokio::test] + async fn abort_only_affects_matching_sender() { + let mut h = Harness::new(vec![]); + h.send_packet_from( + Packet::Header { + header: text_header("s1", Some(5), HashMap::new(), None, CompressionType::None), + encryption_type: EncryptionType::None, + }, + "bob", + ); + let (reader, _) = h.next_opened().await; + h.send_packet_from( + Packet::Chunk { + chunk: chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), + encryption_type: EncryptionType::None, + }, + "bob", + ); + // A different participant disconnecting must not disturb bob's stream. + h.abort(ParticipantIdentity::from(SENDER)); + h.send_packet_from(Packet::Trailer(trailer("s1")), "bob"); + assert_eq!(read_text(reader).await.unwrap(), "hello"); + } + + #[tokio::test] + async fn v2_compressed_gap_errors() { + let mut h = Harness::new(vec![]); + let text = pseudo_random_text(60_000); + let compressed = deflate_raw(text.as_bytes()); + let pieces: Vec<&[u8]> = compressed.chunks(15_000).collect(); + assert!(pieces.len() >= 2); + h.send_packet(Packet::Header { + header: text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + CompressionType::DeflateRaw, + ), + encryption_type: EncryptionType::None, + }); + let (reader, _) = h.next_opened().await; + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 0, pieces[0].to_vec()), + encryption_type: EncryptionType::None, + }); + // Skip index 1 -> feed index 2: a gap is a hard error. + h.send_packet(Packet::Chunk { + chunk: chunk("s1", 2, pieces[1].to_vec()), + encryption_type: EncryptionType::None, + }); + assert!(matches!(read_text(reader).await, Err(StreamError::MissedChunk))); + } +} diff --git a/livekit-data-stream/src/incoming/stream_reader.rs b/livekit-data-stream/src/incoming/stream_reader.rs new file mode 100644 index 000000000..d5afe7d24 --- /dev/null +++ b/livekit-data-stream/src/incoming/stream_reader.rs @@ -0,0 +1,214 @@ +// 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 super::{AnyStreamInfo, ByteStreamInfo, StreamError, StreamResult, TextStreamInfo}; +use bytes::{Bytes, BytesMut}; +use futures_util::{Stream, StreamExt}; +use std::{ + fmt::Debug, + pin::Pin, + task::{Context, Poll}, +}; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + +/// Reader for an incoming data stream. +/// +/// The stream being read from is kept open as long as its reader exists; +/// dropping the reader will close the stream. +/// +pub trait StreamReader: Stream> { + /// Type of output this reader produces. + type Output; + + /// Information about the underlying data stream. + type Info; + + /// Returns a reference to the stream info. + fn info(&self) -> &Self::Info; + + /// Reads all incoming chunks from the byte stream, concatenating them + /// into a single value which is returned once the stream closes normally. + /// + /// Returns the data consisting of all concatenated chunks. + /// + fn read_all(self) -> impl std::future::Future> + Send; +} + +/// Reader for an incoming byte data stream. +pub struct ByteStreamReader { + info: ByteStreamInfo, + chunk_rx: UnboundedReceiver>, +} + +/// Reader for an incoming text data stream. +pub struct TextStreamReader { + pub(crate) info: TextStreamInfo, + chunk_rx: UnboundedReceiver>, +} + +impl StreamReader for ByteStreamReader { + type Output = Bytes; + type Info = ByteStreamInfo; + + fn info(&self) -> &ByteStreamInfo { + &self.info + } + + async fn read_all(mut self) -> StreamResult { + let mut buffer = BytesMut::new(); + while let Some(result) = self.next().await { + match result { + Ok(bytes) => buffer.extend_from_slice(&bytes), + Err(e) => return Err(e), + } + } + Ok(buffer.freeze()) + } +} + +impl ByteStreamReader { + /// Reads incoming chunks from the byte stream, writing them to a file as they are received. + /// + /// Parameters: + /// - directory: The directory to write the file in. The system temporary directory is used if not specified. + /// - name_override: The name to use for the written file, overriding stream name. + /// + /// Returns: The path of the written file on disk. + /// + pub async fn write_to_file( + mut self, + directory: Option>, + name_override: Option<&str>, + ) -> StreamResult { + let directory = + directory.map(|d| d.as_ref().to_path_buf()).unwrap_or_else(|| std::env::temp_dir()); + let name = name_override.unwrap_or_else(|| &self.info.name); + let file_path = directory.join(name); + + let mut file = tokio::fs::File::create(&file_path).await.map_err(StreamError::Io)?; + + while let Some(result) = self.next().await { + let bytes = result?; + tokio::io::AsyncWriteExt::write_all(&mut file, &bytes) + .await + .map_err(StreamError::Io)?; + } + tokio::io::AsyncWriteExt::flush(&mut file).await.map_err(StreamError::Io)?; + + Ok(file_path) + } +} + +impl Stream for ByteStreamReader { + type Item = StreamResult; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.chunk_rx).poll_recv(cx) { + Poll::Ready(Some(Ok(chunk))) => Poll::Ready(Some(Ok(chunk))), + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +#[cfg(any(test, feature = "test-utils"))] +impl TextStreamReader { + /// Create a TextStreamReader for testing purposes. + /// + /// Exposed under the `test-utils` feature so downstream crates (e.g. `livekit`'s RPC tests) + /// can construct a reader directly. + pub fn new_for_test( + info: TextStreamInfo, + chunk_rx: UnboundedReceiver>, + ) -> Self { + Self { info, chunk_rx } + } +} + +impl StreamReader for TextStreamReader { + type Output = String; + type Info = TextStreamInfo; + + fn info(&self) -> &TextStreamInfo { + &self.info + } + + async fn read_all(mut self) -> StreamResult { + let mut result = String::new(); + while let Some(chunk) = self.next().await { + match chunk { + Ok(text) => result.push_str(&text), + Err(e) => return Err(e), + } + } + Ok(result) + } +} + +impl Stream for TextStreamReader { + type Item = StreamResult; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + match Pin::new(&mut this.chunk_rx).poll_recv(cx) { + Poll::Ready(Some(Ok(chunk))) => match String::from_utf8(chunk.into()) { + Ok(content) => Poll::Ready(Some(Ok(content))), + Err(e) => { + this.chunk_rx.close(); + Poll::Ready(Some(Err(StreamError::from(e)))) + } + }, + Poll::Ready(Some(Err(e))) => Poll::Ready(Some(Err(e))), + Poll::Ready(None) => Poll::Ready(None), + Poll::Pending => Poll::Pending, + } + } +} + +impl Debug for ByteStreamReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ByteStreamReader") + .field("id", &self.info.id()) + .field("topic", &self.info.topic) + .finish() + } +} + +impl Debug for TextStreamReader { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TextStreamReader") + .field("id", &self.info.id()) + .field("topic", &self.info.topic) + .finish() + } +} + +pub enum AnyStreamReader { + Byte(ByteStreamReader), + Text(TextStreamReader), +} + +impl AnyStreamReader { + /// Creates a stream reader for the stream with the given info. + pub(super) fn from(info: AnyStreamInfo) -> (Self, UnboundedSender>) { + let (chunk_tx, chunk_rx) = mpsc::unbounded_channel(); + let reader = match info { + AnyStreamInfo::Byte(info) => Self::Byte(ByteStreamReader { info, chunk_rx }), + AnyStreamInfo::Text(info) => Self::Text(TextStreamReader { info, chunk_rx }), + }; + return (reader, chunk_tx); + } +} diff --git a/livekit-data-stream/src/info.rs b/livekit-data-stream/src/info.rs new file mode 100644 index 000000000..af4dc182b --- /dev/null +++ b/livekit-data-stream/src/info.rs @@ -0,0 +1,217 @@ +// 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 chrono::{DateTime, Utc}; +use livekit_common::EncryptionType; +use std::collections::HashMap; + +use crate::types::{ByteHeader, ContentHeader, Header, OperationType, TextHeader}; +use crate::utils::StreamError; + +/// Information about a byte data stream. +#[derive(Clone, Debug)] +pub struct ByteStreamInfo { + /// Unique identifier of the stream. + pub id: String, + /// Topic name used to route the stream to the appropriate handler. + pub topic: String, + /// When the stream was created. + pub timestamp: DateTime, + /// Total expected size in bytes, if known. + pub total_length: Option, + /// Additional attributes as needed for your application. + pub attributes: HashMap, + /// The MIME type of the stream data. + pub mime_type: String, + /// The name of the file being sent. + pub name: String, + /// The encryption used + pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + #[cfg(feature = "__e2e-test")] + pub is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + #[cfg(feature = "__e2e-test")] + pub is_inline: bool, +} + +/// Information about a text data stream. +#[derive(Clone, Debug)] +pub struct TextStreamInfo { + /// Unique identifier of the stream. + pub id: String, + /// Topic name used to route the stream to the appropriate handler. + pub topic: String, + /// When the stream was created. + pub timestamp: DateTime, + /// Total expected size in bytes, if known. + pub total_length: Option, + /// Additional attributes as needed for your application. + pub attributes: HashMap, + /// The MIME type of the stream data. + 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, + /// The encryption used + pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + #[cfg(feature = "__e2e-test")] + pub is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + #[cfg(feature = "__e2e-test")] + pub is_inline: bool, +} + +// MARK: - Type conversion + +impl TryFrom
for AnyStreamInfo { + type Error = StreamError; + + fn try_from(header: Header) -> Result { + Self::try_from_with_encryption(header, EncryptionType::None) + } +} + +impl AnyStreamInfo { + pub fn try_from_with_encryption( + mut header: Header, + encryption_type: EncryptionType, + ) -> Result { + let Some(content_header) = header.content_header.take() else { + Err(StreamError::InvalidHeader)? + }; + let info = match content_header { + ContentHeader::ByteHeader(byte_header) => Self::Byte( + ByteStreamInfo::from_headers_with_encryption(header, byte_header, encryption_type), + ), + ContentHeader::TextHeader(text_header) => Self::Text( + TextStreamInfo::from_headers_with_encryption(header, text_header, encryption_type), + ), + }; + Ok(info) + } +} + +impl ByteStreamInfo { + pub(crate) fn from_headers(header: Header, byte_header: ByteHeader) -> Self { + Self::from_headers_with_encryption(header, byte_header, EncryptionType::None) + } + + pub(crate) fn from_headers_with_encryption( + header: Header, + byte_header: ByteHeader, + encryption_type: EncryptionType, + ) -> Self { + Self { + id: header.stream_id.to_string(), + topic: header.topic, + timestamp: DateTime::::from_timestamp_millis(header.timestamp) + .unwrap_or_else(|| Utc::now()), + total_length: header.total_length, + attributes: header.attributes, + mime_type: header.mime_type, + name: byte_header.name, + encryption_type, + #[cfg(feature = "__e2e-test")] + is_compressed: header.compression != crate::types::CompressionType::None, + #[cfg(feature = "__e2e-test")] + is_inline: header.inline_content.is_some_and(|c| !c.is_empty()), + } + } +} + +impl TextStreamInfo { + pub(crate) fn from_headers(header: Header, text_header: TextHeader) -> Self { + Self::from_headers_with_encryption(header, text_header, EncryptionType::None) + } + + pub(crate) fn from_headers_with_encryption( + header: Header, + text_header: TextHeader, + encryption_type: EncryptionType, + ) -> Self { + Self { + id: header.stream_id.to_string(), + topic: header.topic, + timestamp: DateTime::::from_timestamp_millis(header.timestamp) + .unwrap_or_else(|| Utc::now()), + total_length: header.total_length, + attributes: header.attributes, + mime_type: header.mime_type, + operation_type: text_header.operation_type, + version: text_header.version, + reply_to_stream_id: text_header.reply_to_stream_id.map(|stream_id| stream_id.into()), + attached_stream_ids: text_header + .attached_stream_ids + .into_iter() + .map(Into::into) + .collect(), + generated: text_header.generated, + encryption_type, + #[cfg(feature = "__e2e-test")] + is_compressed: header.compression != crate::types::CompressionType::None, + #[cfg(feature = "__e2e-test")] + is_inline: header.inline_content.is_some_and(|c| !c.is_empty()), + } + } +} + +// MARK: - Dispatch + +#[derive(Clone, Debug)] +pub(crate) enum AnyStreamInfo { + Byte(ByteStreamInfo), + Text(TextStreamInfo), +} + +impl AnyStreamInfo { + livekit_common::enum_dispatch!( + [Byte, Text]; + pub fn id(self: &Self) -> &str; + pub fn total_length(self: &Self) -> Option; + pub fn encryption_type(self: &Self) -> EncryptionType; + ); +} + +#[rustfmt::skip] +macro_rules! stream_info { + () => { + pub(crate) fn id(&self) -> &str { &self.id } + pub(crate) fn total_length(&self) -> Option { self.total_length } + pub(crate) fn encryption_type(&self) -> EncryptionType { self.encryption_type } + }; +} + +impl ByteStreamInfo { + stream_info!(); +} + +impl TextStreamInfo { + stream_info!(); +} + +impl From for AnyStreamInfo { + fn from(info: ByteStreamInfo) -> Self { + Self::Byte(info) + } +} + +impl From for AnyStreamInfo { + fn from(info: TextStreamInfo) -> Self { + Self::Text(info) + } +} diff --git a/livekit-data-stream/src/lib.rs b/livekit-data-stream/src/lib.rs index 41bce7da1..f669779dc 100644 --- a/livekit-data-stream/src/lib.rs +++ b/livekit-data-stream/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2025 LiveKit, Inc. +// 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. @@ -14,286 +14,24 @@ #![doc = include_str!("../README.md")] -use chrono::{DateTime, Utc}; -use livekit_common::EncryptionType; -use livekit_protocol::data_stream as proto; -use std::collections::HashMap; -use thiserror::Error; +mod utils; +pub use utils::{SendError, StreamError, StreamResult}; -mod incoming; -mod outgoing; -mod utf8_chunk; - -pub use incoming::{ - AnyStreamReader, ByteStreamReader, IncomingStreamManager, StreamReader, TextStreamReader, +mod types; +pub use types::{ + ByteHeader, Chunk, CompressionType, ContentHeader, Header, OperationType, Packet, StreamId, + TextHeader, Trailer, }; -pub use outgoing::{ - ByteStreamWriter, OutgoingStreamManager, StreamByteOptions, StreamTextOptions, StreamWriter, - TextStreamWriter, -}; - -/// Error returned by the packet transport when a data-stream packet fails to send. -/// -/// The stream managers only need to know that a send failed (they map it to -/// [`StreamError::SendFailed`]); the concrete engine error type stays in the `livekit` crate, -/// which bridges the outgoing packet channel to the RTC engine. -#[derive(Debug, Clone)] -pub struct SendError; - -/// Generates a random stream identifier (UUID v4). -pub(crate) fn create_random_uuid() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// Result type for data stream operations. -pub type StreamResult = Result; - -/// Error type for data stream operations. -#[derive(Debug, Error)] -pub enum StreamError { - // TODO(ladvoc): standardize error cases and expose over FFI. - #[error("stream has already been closed")] - AlreadyClosed, - - #[error("stream closed abnormally: {0}")] - AbnormalEnd(String), - - #[error("UTF-8 decoding error: {0}")] - Utf8(#[from] std::string::FromUtf8Error), - - #[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: {0}")] - Io(#[from] std::io::Error), - - #[error("internal error")] - Internal, - - #[error("encryption type mismatch")] - EncryptionTypeMismatch, -} - -/// Progress of a data stream. -#[derive(Clone, Copy, Default, Debug, Hash, Eq, PartialEq)] -struct StreamProgress { - chunk_index: u64, - /// Number of bytes read or written so far. - bytes_processed: u64, - /// Total number of bytes expected to be read or written for finite streams. - bytes_total: Option, -} -impl StreamProgress { - /// Returns the completion percentage for finite streams. - #[allow(dead_code)] - fn percentage(&self) -> Option { - self.bytes_total.map(|total| self.bytes_processed as f32 / total as f32) - } -} +mod info; +pub use info::{ByteStreamInfo, TextStreamInfo}; -/// Information about a byte data stream. -#[derive(Clone, Debug)] -pub struct ByteStreamInfo { - /// Unique identifier of the stream. - pub id: String, - /// Topic name used to route the stream to the appropriate handler. - pub topic: String, - /// When the stream was created. - pub timestamp: DateTime, - /// Total expected size in bytes, if known. - pub total_length: Option, - /// Additional attributes as needed for your application. - pub attributes: HashMap, - /// The MIME type of the stream data. - pub mime_type: String, - /// The name of the file being sent. - pub name: String, - /// The encryption used - pub encryption_type: EncryptionType, -} - -/// Information about a text data stream. -#[derive(Clone, Debug)] -pub struct TextStreamInfo { - /// Unique identifier of the stream. - pub id: String, - /// Topic name used to route the stream to the appropriate handler. - pub topic: String, - /// When the stream was created. - pub timestamp: DateTime, - /// Total expected size in bytes, if known. - pub total_length: Option, - /// Additional attributes as needed for your application. - pub attributes: HashMap, - /// The MIME type of the stream data. - 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, - /// The encryption used - pub encryption_type: EncryptionType, -} - -/// Operation type for text streams. -#[derive(Clone, Copy, Default, Debug, Hash, Eq, PartialEq)] -pub enum OperationType { - #[default] - Create, - Update, - Delete, - Reaction, -} - -// MARK: - Protocol type conversion - -impl TryFrom for AnyStreamInfo { - type Error = StreamError; - - fn try_from(mut header: proto::Header) -> Result { - Self::try_from_with_encryption(header, EncryptionType::None) - } -} - -impl AnyStreamInfo { - pub fn try_from_with_encryption( - mut header: proto::Header, - encryption_type: EncryptionType, - ) -> Result { - let Some(content_header) = header.content_header.take() else { - Err(StreamError::InvalidHeader)? - }; - let info = match content_header { - proto::header::ContentHeader::ByteHeader(byte_header) => Self::Byte( - ByteStreamInfo::from_headers_with_encryption(header, byte_header, encryption_type), - ), - proto::header::ContentHeader::TextHeader(text_header) => Self::Text( - TextStreamInfo::from_headers_with_encryption(header, text_header, encryption_type), - ), - }; - Ok(info) - } -} - -impl ByteStreamInfo { - pub(crate) fn from_headers(header: proto::Header, byte_header: proto::ByteHeader) -> Self { - Self::from_headers_with_encryption(header, byte_header, EncryptionType::None) - } - - pub(crate) fn from_headers_with_encryption( - header: proto::Header, - byte_header: proto::ByteHeader, - encryption_type: EncryptionType, - ) -> Self { - Self { - id: header.stream_id, - topic: header.topic, - timestamp: DateTime::::from_timestamp_millis(header.timestamp) - .unwrap_or_else(|| Utc::now()), - total_length: header.total_length, - attributes: header.attributes, - mime_type: header.mime_type, - name: byte_header.name, - encryption_type, - } - } -} - -impl TextStreamInfo { - pub(crate) fn from_headers(header: proto::Header, text_header: proto::TextHeader) -> Self { - Self::from_headers_with_encryption(header, text_header, EncryptionType::None) - } - - pub(crate) fn from_headers_with_encryption( - header: proto::Header, - text_header: proto::TextHeader, - encryption_type: EncryptionType, - ) -> Self { - Self { - id: header.stream_id, - topic: header.topic, - timestamp: DateTime::::from_timestamp_millis(header.timestamp) - .unwrap_or_else(|| Utc::now()), - total_length: header.total_length, - attributes: header.attributes, - mime_type: header.mime_type, - operation_type: text_header.operation_type().into(), - version: text_header.version, - reply_to_stream_id: (!text_header.reply_to_stream_id.is_empty()) - .then_some(text_header.reply_to_stream_id), - attached_stream_ids: text_header.attached_stream_ids, - generated: text_header.generated, - encryption_type, - } - } -} - -impl From for OperationType { - fn from(op_type: proto::OperationType) -> Self { - match op_type { - proto::OperationType::Create => OperationType::Create, - proto::OperationType::Update => OperationType::Update, - proto::OperationType::Delete => OperationType::Delete, - proto::OperationType::Reaction => OperationType::Reaction, - } - } -} -// MARK: - Dispatch - -#[derive(Clone, Debug)] -pub(crate) enum AnyStreamInfo { - Byte(ByteStreamInfo), - Text(TextStreamInfo), -} - -impl AnyStreamInfo { - livekit_common::enum_dispatch!( - [Byte, Text]; - pub fn id(self: &Self) -> &str; - pub fn total_length(self: &Self) -> Option; - pub fn encryption_type(self: &Self) -> EncryptionType; - ); -} - -#[rustfmt::skip] -macro_rules! stream_info { - () => { - fn id(&self) -> &str { &self.id } - fn total_length(&self) -> Option { self.total_length } - fn encryption_type(&self) -> EncryptionType { self.encryption_type } - }; -} - -impl ByteStreamInfo { - stream_info!(); -} - -impl TextStreamInfo { - stream_info!(); -} +mod utf8_chunk; -impl From for AnyStreamInfo { - fn from(info: ByteStreamInfo) -> Self { - Self::Byte(info) - } -} +pub mod incoming; +pub use incoming::{AnyStreamReader, ByteStreamReader, StreamReader, TextStreamReader}; -impl From for AnyStreamInfo { - fn from(info: TextStreamInfo) -> Self { - Self::Text(info) - } -} +pub mod outgoing; +pub use outgoing::{ + ByteStreamWriter, StreamByteOptions, StreamTextOptions, StreamWriter, TextStreamWriter, +}; diff --git a/livekit-data-stream/src/outgoing.rs b/livekit-data-stream/src/outgoing.rs deleted file mode 100644 index c706b1c95..000000000 --- a/livekit-data-stream/src/outgoing.rs +++ /dev/null @@ -1,674 +0,0 @@ -// Copyright 2025 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 super::{ - create_random_uuid, ByteStreamInfo, OperationType, SendError, StreamError, StreamProgress, - StreamResult, TextStreamInfo, -}; -use crate::utf8_chunk::Utf8AwareChunkExt; -use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; -use chrono::Utc; -use livekit_common::ParticipantIdentity; -use livekit_protocol as proto; -use std::{collections::HashMap, path::Path, sync::Arc}; -use tokio::{io::AsyncReadExt, sync::Mutex}; - -/// Writer for an open data stream. -pub trait StreamWriter<'a> { - /// Type of input this writer accepts. - type Input: 'a; - - /// Information about the underlying data stream. - type Info; - - /// Returns a reference to the stream info. - fn info(&self) -> &Self::Info; - - /// Writes to the stream. - fn write( - &self, - input: Self::Input, - ) -> impl std::future::Future> + Send; - - /// Closes the stream normally. - fn close(self) -> impl std::future::Future> + Send; - - /// Closes the stream abnormally, specifying the reason for closure. - fn close_with_reason( - self, - reason: &str, - ) -> impl std::future::Future> + Send; -} - -#[derive(Clone)] -/// Writer for an open byte data stream. -pub struct ByteStreamWriter { - info: Arc, - stream: Arc>, -} - -#[derive(Clone)] -/// Writer for an open text data stream. -pub struct TextStreamWriter { - info: Arc, - stream: Arc>, -} - -impl<'a> StreamWriter<'a> for ByteStreamWriter { - type Input = &'a [u8]; - type Info = ByteStreamInfo; - - fn info(&self) -> &Self::Info { - &self.info - } - - async fn write(&self, bytes: &'a [u8]) -> StreamResult<()> { - let mut stream = self.stream.lock().await; - for chunk in bytes.chunks(CHUNK_SIZE) { - stream.write_chunk(chunk).await?; - } - Ok(()) - } - - async fn close(self) -> StreamResult<()> { - self.stream.lock().await.close(None).await - } - - async fn close_with_reason(self, reason: &str) -> StreamResult<()> { - self.stream.lock().await.close(Some(reason)).await - } -} - -impl ByteStreamWriter { - /// Writes the contents of the file incrementally. - async fn write_file_contents(&self, path: impl AsRef) -> StreamResult<()> { - let mut stream = self.stream.lock().await; - let mut file = tokio::fs::File::open(path).await?; - let mut buffer = vec![0; 8192]; // 8KB - loop { - let bytes_read = file.read(&mut buffer).await?; - if bytes_read == 0 { - break; - } - stream.write_chunk(&buffer[..bytes_read]).await?; - } - Ok(()) - } -} - -impl<'a> StreamWriter<'a> for TextStreamWriter { - type Input = &'a str; - type Info = TextStreamInfo; - - fn info(&self) -> &Self::Info { - &self.info - } - - async fn write(&self, text: &'a str) -> StreamResult<()> { - let mut stream = self.stream.lock().await; - for chunk in text.as_bytes().utf8_aware_chunks(CHUNK_SIZE) { - stream.write_chunk(chunk).await?; - } - Ok(()) - } - - async fn close(self) -> StreamResult<()> { - self.stream.lock().await.close(None).await - } - - async fn close_with_reason(self, reason: &str) -> StreamResult<()> { - self.stream.lock().await.close(Some(reason)).await - } -} - -struct RawStreamOpenOptions { - header: proto::data_stream::Header, - destination_identities: Vec, - packet_tx: UnboundedRequestSender>, -} - -struct RawStream { - id: String, - progress: StreamProgress, - is_closed: bool, - /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, -} - -impl RawStream { - async fn open(options: RawStreamOpenOptions) -> StreamResult { - let id = options.header.stream_id.to_string(); - let bytes_total = options.header.total_length; - - let packet = Self::create_header_packet(options.header, options.destination_identities); - Self::send_packet(&options.packet_tx, packet).await?; - - Ok(Self { - id, - progress: StreamProgress { bytes_total, ..Default::default() }, - is_closed: false, - packet_tx: options.packet_tx, - }) - } - - async fn write_chunk(&mut self, bytes: &[u8]) -> StreamResult<()> { - let packet = Self::create_chunk_packet(&self.id, self.progress.chunk_index, bytes); - Self::send_packet(&self.packet_tx, packet).await?; - self.progress.bytes_processed += bytes.len() as u64; - self.progress.chunk_index += 1; - Ok(()) - } - - async fn close(&mut self, reason: Option<&str>) -> StreamResult<()> { - if self.is_closed { - Err(StreamError::AlreadyClosed)? - } - let packet = Self::create_trailer_packet(&self.id, reason); - Self::send_packet(&self.packet_tx, packet).await?; - self.is_closed = true; - Ok(()) - } - - async fn send_packet( - tx: &UnboundedRequestSender>, - packet: proto::DataPacket, - ) -> StreamResult<()> { - tx.send_receive(packet) - .await - .map_err(|_| StreamError::Internal)? // request channel closed - .map_err(|_| StreamError::SendFailed) // data channel error - } - - fn create_header_packet( - header: proto::data_stream::Header, - destination_identities: Vec, - ) -> proto::DataPacket { - proto::DataPacket { - kind: proto::data_packet::Kind::Reliable.into(), - participant_identity: String::new(), // populate later - destination_identities: destination_identities.into_iter().map(|id| id.0).collect(), - value: Some(livekit_protocol::data_packet::Value::StreamHeader(header)), - // TODO: placeholder for reliable data transport - ..Default::default() - } - } - - fn create_chunk_packet(id: &str, chunk_index: u64, content: &[u8]) -> proto::DataPacket { - let chunk = proto::data_stream::Chunk { - stream_id: id.to_string(), - chunk_index, - content: content.to_vec(), - ..Default::default() - }; - proto::DataPacket { - kind: proto::data_packet::Kind::Reliable.into(), - participant_identity: String::new(), // populate later - value: Some(livekit_protocol::data_packet::Value::StreamChunk(chunk)), - ..Default::default() - } - } - - fn create_trailer_packet(id: &str, reason: Option<&str>) -> proto::DataPacket { - let trailer = proto::data_stream::Trailer { - stream_id: id.to_string(), - reason: reason.unwrap_or_default().to_owned(), - ..Default::default() - }; - proto::DataPacket { - kind: proto::data_packet::Kind::Reliable.into(), - participant_identity: String::new(), // populate later - value: Some(livekit_protocol::data_packet::Value::StreamTrailer(trailer)), - ..Default::default() - } - } -} - -impl Drop for RawStream { - /// Close stream normally if not already closed. - fn drop(&mut self) { - if self.is_closed { - return; - } - let packet = Self::create_trailer_packet(&self.id, None); - let packet_tx = self.packet_tx.clone(); - // Use try_current() instead of assuming a Tokio runtime exists. - // The drop can run on a non-Tokio thread (e.g. a GC finalizer in - // Unity/.NET) or after the runtime has shut down, in which case - // we silently skip the trailer — the connection is going away anyway. - if let Ok(handle) = tokio::runtime::Handle::try_current() { - handle.spawn(async move { Self::send_packet(&packet_tx, packet).await }); - } - } -} - -/// Options used when opening an outgoing byte data stream. -#[derive(Clone, Default, Debug, Eq, PartialEq)] -pub struct StreamByteOptions { - pub topic: String, - pub attributes: HashMap, - pub destination_identities: Vec, - pub id: Option, - pub mime_type: Option, - pub name: Option, - pub total_length: Option, -} - -/// Options used when opening an outgoing text data stream. -#[derive(Clone, Default, Debug, Eq, PartialEq)] -pub struct StreamTextOptions { - pub topic: String, - pub attributes: HashMap, - pub destination_identities: Vec, - pub id: Option, - pub operation_type: Option, - pub version: Option, - pub reply_to_stream_id: Option, - pub attached_stream_ids: Vec, - pub generated: Option, -} - -#[derive(Clone)] -pub struct OutgoingStreamManager { - /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, -} - -impl OutgoingStreamManager { - pub fn new() -> (Self, UnboundedRequestReceiver>) { - let (packet_tx, packet_rx) = bmrng::unbounded_channel(); - let manager = Self { packet_tx }; - (manager, packet_rx) - } - - pub async fn stream_text(&self, options: StreamTextOptions) -> StreamResult { - let text_header = proto::data_stream::TextHeader { - operation_type: options.operation_type.unwrap_or_default() as i32, - version: options.version.unwrap_or_default(), - reply_to_stream_id: options.reply_to_stream_id.unwrap_or_default(), - attached_stream_ids: options.attached_stream_ids, - generated: options.generated.unwrap_or_default(), - }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: TEXT_MIME_TYPE.to_owned(), - total_length: None, - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( - text_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - packet_tx: self.packet_tx.clone(), - }; - let writer = TextStreamWriter { - info: Arc::new(TextStreamInfo::from_headers(header, text_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - Ok(writer) - } - - pub async fn stream_bytes(&self, options: StreamByteOptions) -> StreamResult { - let byte_header = proto::data_stream::ByteHeader { name: options.name.unwrap_or_default() }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: options.total_length, - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - packet_tx: self.packet_tx.clone(), - }; - let writer = ByteStreamWriter { - info: Arc::new(ByteStreamInfo::from_headers(header, byte_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - Ok(writer) - } - - pub async fn send_text( - &self, - text: &str, - options: StreamTextOptions, - ) -> StreamResult { - let text_header = proto::data_stream::TextHeader { - operation_type: options.operation_type.unwrap_or_default() as i32, - version: options.version.unwrap_or_default(), - reply_to_stream_id: options.reply_to_stream_id.unwrap_or_default(), - attached_stream_ids: options.attached_stream_ids, - generated: options.generated.unwrap_or_default(), - }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: TEXT_MIME_TYPE.to_owned(), - total_length: Some(text.bytes().len() as u64), - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( - text_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - packet_tx: self.packet_tx.clone(), - }; - let writer = TextStreamWriter { - info: Arc::new(TextStreamInfo::from_headers(header, text_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - - let info = (*writer.info).clone(); - writer.write(text).await?; - writer.close().await?; - - Ok(info) - } - - /// Send bytes to participants in the room. - /// - /// This method sends an in-memory blob of bytes to participants in the room - /// as a byte stream. It opens a stream using the provided options, writes the - /// entire buffer, and closes the stream before returning. - /// - /// The `total_length` in the header is set from the provided data and is not - /// overridable by `options.total_length`. - pub async fn send_bytes( - &self, - data: impl AsRef<[u8]>, - options: StreamByteOptions, - ) -> StreamResult { - if options.total_length.is_some() { - log::warn!("Ignoring total_length option specified for send_bytes"); - } - let bytes = data.as_ref(); - - let byte_header = proto::data_stream::ByteHeader { name: options.name.unwrap_or_default() }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: Some(bytes.len() as u64), // not overridable - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - packet_tx: self.packet_tx.clone(), - }; - let writer = ByteStreamWriter { - info: Arc::new(ByteStreamInfo::from_headers(header, byte_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - - let info = (*writer.info).clone(); - writer.write(bytes).await?; - writer.close().await?; - - Ok(info) - } - - pub async fn send_file( - &self, - path: impl AsRef, - options: StreamByteOptions, - ) -> StreamResult { - let file_size = tokio::fs::metadata(path.as_ref()) - .await - .map(|metadata| metadata.len()) - .map_err(|e| StreamError::from(e))?; - let name = - path.as_ref().file_name().and_then(|n| n.to_str()).unwrap_or_default().to_owned(); - - let byte_header = proto::data_stream::ByteHeader { name }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: Some(file_size as u64), // not overridable - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - - let open_options = RawStreamOpenOptions { - header: header.clone(), - destination_identities: options.destination_identities, - packet_tx: self.packet_tx.clone(), - }; - let writer = ByteStreamWriter { - info: Arc::new(ByteStreamInfo::from_headers(header, byte_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - - let info = (*writer.info).clone(); - writer.write_file_contents(path).await?; - writer.close().await?; - - Ok(info) - } -} - -/// Maximum number of bytes to send in a single chunk. -static CHUNK_SIZE: usize = 15000; - -// Default MIME type to use for byte streams. -static BYTE_MIME_TYPE: &str = "application/octet-stream"; - -/// Default MIME type to use for text streams. -static TEXT_MIME_TYPE: &str = "text/plain"; - -#[cfg(test)] -mod tests { - use super::*; - - type Sent = Arc>>; - - fn setup() -> (OutgoingStreamManager, Sent) { - let (manager, mut packet_rx) = OutgoingStreamManager::new(); - let sent: Sent = Arc::new(std::sync::Mutex::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); - let _ = responder.respond(Ok(())); - } - }); - (manager, sent) - } - - fn ids(list: &[&str]) -> Vec { - list.iter().map(|s| ParticipantIdentity(s.to_string())).collect() - } - - fn text_opts(topic: &str, dests: &[&str]) -> StreamTextOptions { - StreamTextOptions { - topic: topic.to_string(), - destination_identities: ids(dests), - ..Default::default() - } - } - - fn byte_opts(topic: &str, dests: &[&str]) -> StreamByteOptions { - StreamByteOptions { - topic: topic.to_string(), - destination_identities: ids(dests), - ..Default::default() - } - } - - fn header(p: &proto::DataPacket) -> &proto::data_stream::Header { - match p.value.as_ref().unwrap() { - proto::data_packet::Value::StreamHeader(h) => h, - _ => panic!("expected stream header"), - } - } - - fn chunk(p: &proto::DataPacket) -> &proto::data_stream::Chunk { - match p.value.as_ref().unwrap() { - proto::data_packet::Value::StreamChunk(c) => c, - _ => panic!("expected stream chunk"), - } - } - - fn is_text_header(h: &proto::data_stream::Header) -> bool { - matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::TextHeader(_))) - } - - fn is_byte_header(h: &proto::data_stream::Header) -> bool { - matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::ByteHeader(_))) - } - - fn assert_trailer(p: &proto::DataPacket) { - match p.value.as_ref().unwrap() { - proto::data_packet::Value::StreamTrailer(t) => assert_eq!(t.reason, ""), - _ => panic!("expected stream trailer"), - } - } - - fn none_i32() -> i32 { - proto::data_stream::CompressionType::None as i32 - } - - #[tokio::test] - async fn short_text_is_legacy_multipacket() { - let (m, sent) = setup(); - m.send_text("hello world", text_opts("chat", &[])).await.unwrap(); - let p = sent.lock().unwrap().clone(); - assert_eq!(p.len(), 3); - let h = header(&p[0]); - assert!(is_text_header(h)); - assert_eq!(h.topic, "chat"); - assert_eq!(h.compression, none_i32()); - assert!(h.inline_content.is_none()); - let c = chunk(&p[1]); - assert_eq!(c.chunk_index, 0); - assert_eq!(c.content, b"hello world"); - assert_trailer(&p[2]); - } - - #[tokio::test] - async fn long_text_splits_at_mtu() { - let (m, sent) = setup(); - let text = "A".repeat(40_000); - m.send_text(&text, text_opts("chat", &[])).await.unwrap(); - let p = sent.lock().unwrap().clone(); - assert_eq!(p.len(), 5); // header + 3 chunks + trailer - assert_eq!(header(&p[0]).compression, none_i32()); - assert_eq!(chunk(&p[1]).content.len(), 15_000); - assert_eq!(chunk(&p[2]).content.len(), 15_000); - assert_eq!(chunk(&p[3]).content.len(), 10_000); - assert_eq!(chunk(&p[1]).chunk_index, 0); - assert_eq!(chunk(&p[3]).chunk_index, 2); - assert_trailer(&p[4]); - } - - #[tokio::test] - async fn bytes_is_legacy_multipacket() { - let (m, sent) = setup(); - m.send_bytes([0u8, 1, 2, 3], byte_opts("blob", &[])).await.unwrap(); - let p = sent.lock().unwrap().clone(); - assert_eq!(p.len(), 3); - let h = header(&p[0]); - assert!(is_byte_header(h)); - assert_eq!(h.compression, none_i32()); - assert!(h.inline_content.is_none()); - assert_eq!(chunk(&p[1]).content, vec![0, 1, 2, 3]); - assert_trailer(&p[2]); - } - - // Regression test for CLT-2773: dropping a `RawStream` on a thread that has - // no Tokio runtime in TLS (e.g. the .NET GC finalizer thread in the Unity - // SDK) used to panic because `Drop` called `tokio::spawn` unconditionally. - #[test] - fn drop_raw_stream_on_non_tokio_thread_does_not_panic() { - let rt = tokio::runtime::Runtime::new().unwrap(); - - let raw_stream = rt.block_on(async { - let (packet_tx, mut packet_rx) = - bmrng::unbounded_channel::>(); - - tokio::spawn(async move { - while let Ok((_packet, responder)) = packet_rx.recv().await { - let _ = responder.respond(Ok(())); - } - }); - - let header = proto::data_stream::Header { - stream_id: "gc-test-stream".to_string(), - timestamp: 0, - topic: "gc-test-topic".to_string(), - mime_type: TEXT_MIME_TYPE.to_owned(), - total_length: None, - encryption_type: proto::encryption::Type::None.into(), - attributes: HashMap::new(), - content_header: None, - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; - - RawStream::open(RawStreamOpenOptions { - header, - destination_identities: vec![], - packet_tx, - }) - .await - .expect("RawStream should open") - }); - - let drop_thread = std::thread::spawn(move || drop(raw_stream)); - - drop_thread.join().expect("Dropping RawStream on a non-Tokio thread must not panic"); - } -} diff --git a/livekit-data-stream/src/outgoing/constants.rs b/livekit-data-stream/src/outgoing/constants.rs new file mode 100644 index 000000000..3e551cc1d --- /dev/null +++ b/livekit-data-stream/src/outgoing/constants.rs @@ -0,0 +1,26 @@ +// 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. + +/// Max chunk content size AND the header-packet MTU budget. Kept below the ~16 KB +/// data-channel MTU for protocol/E2EE framing headroom. +pub(crate) const STREAM_CHUNK_SIZE_BYTES: usize = 15000; + +// Default MIME type to use for byte streams. +pub(crate) static BYTE_MIME_TYPE: &str = "application/octet-stream"; + +/// Default MIME type to use for text streams. +pub(crate) static TEXT_MIME_TYPE: &str = "text/plain"; + +/// Default name for `send_bytes` byte-stream headers. +pub(crate) static BYTE_DEFAULT_NAME: &str = "unknown"; diff --git a/livekit-data-stream/src/outgoing/mod.rs b/livekit-data-stream/src/outgoing/mod.rs new file mode 100644 index 000000000..cf34e5b7b --- /dev/null +++ b/livekit-data-stream/src/outgoing/mod.rs @@ -0,0 +1,981 @@ +// 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 bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; +use chrono::Utc; +use livekit_common::{ + ClientCapability, ParticipantIdentity, RemoteParticipantRegistry, + CLIENT_PROTOCOL_DATA_STREAM_V2, +}; +use livekit_protocol as proto; +use std::{collections::HashMap, path::Path, sync::Arc}; +use tokio::sync::Mutex; + +use crate::info::{ByteStreamInfo, TextStreamInfo}; +use crate::types::{ + ByteHeader, CompressionType, ContentHeader, Header, OperationType, StreamId, TextHeader, +}; +use crate::utf8_chunk::Utf8AwareChunkExt; +use crate::utils::{SendError, StreamError, StreamResult}; + +mod stream_writer; +pub use stream_writer::{ByteStreamWriter, StreamWriter, TextStreamWriter}; + +mod constants; + +mod raw_stream; +use raw_stream::{RawStream, RawStreamOpenOptions}; + +/// Generates a random stream identifier (UUID v4). +fn create_random_uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +/// Options used when opening an outgoing byte data stream. +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct StreamByteOptions { + pub topic: String, + pub attributes: HashMap, + pub destination_identities: Vec, + pub id: Option, + pub mime_type: Option, + pub name: Option, + pub total_length: Option, + /// Whether to deflate-raw compress the payload when all recipients support it. + /// Defaults to `true` (compression opt-out). Ignored by the incremental `stream_bytes`. + pub compress: Option, +} + +/// Options used when opening an outgoing text data stream. +#[derive(Clone, Default, Debug, Eq, PartialEq)] +pub struct StreamTextOptions { + pub topic: String, + pub attributes: HashMap, + pub destination_identities: Vec, + pub id: Option, + pub operation_type: Option, + pub version: Option, + pub reply_to_stream_id: Option, + pub attached_stream_ids: Vec, + pub generated: Option, + /// Whether to deflate-raw compress the payload when all recipients support it. + /// Defaults to `true` (compression opt-out). Ignored by the incremental `stream_text`. + pub compress: Option, +} + +#[derive(Clone)] +pub struct OutgoingDataStreamManager { + /// Request channel for sending packets. + packet_tx: UnboundedRequestSender>, +} + +impl OutgoingDataStreamManager { + pub fn new() -> (Self, UnboundedRequestReceiver>) { + let (packet_tx, packet_rx) = bmrng::unbounded_channel(); + let manager = Self { packet_tx }; + (manager, packet_rx) + } + + pub async fn stream_text(&self, options: StreamTextOptions) -> StreamResult { + // Incremental streams are never inlined or compressed (the content is unknown up front). + let stream_id: StreamId = options.id.clone().unwrap_or_else(create_random_uuid).into(); + let dests = options.destination_identities.clone(); + let (header, text_header) = + build_text_header(&options, stream_id, None, None, CompressionType::None); + enforce_header_size(&header, &dests)?; + + let open_options = RawStreamOpenOptions { + header: header.clone(), + destination_identities: dests, + packet_tx: self.packet_tx.clone(), + }; + let writer = TextStreamWriter::new( + Arc::new(TextStreamInfo::from_headers(header, text_header)), + Arc::new(Mutex::new(RawStream::open(open_options).await?)), + ); + Ok(writer) + } + + pub async fn stream_bytes(&self, options: StreamByteOptions) -> StreamResult { + let stream_id: StreamId = options.id.clone().unwrap_or_else(create_random_uuid).into(); + let name = options.name.clone().unwrap_or_default(); + let dests = options.destination_identities.clone(); + let (header, byte_header) = build_byte_header( + &options, + stream_id, + name, + options.total_length, + None, + CompressionType::None, + ); + enforce_header_size(&header, &dests)?; + + let open_options = RawStreamOpenOptions { + header: header.clone(), + destination_identities: dests, + packet_tx: self.packet_tx.clone(), + }; + let writer = ByteStreamWriter::new( + Arc::new(ByteStreamInfo::from_headers(header, byte_header)), + Arc::new(Mutex::new(RawStream::open(open_options).await?)), + ); + Ok(writer) + } + + pub async fn send_text( + &self, + text: &str, + options: StreamTextOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, + ) -> StreamResult { + let stream_id: StreamId = options.id.clone().unwrap_or_else(create_random_uuid).into(); + let total_length = text.len() as u64; + + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + + let text_bytes = text.as_bytes(); + let mut maybe_compressed = MaybeCollectedAsyncReader::from_async_reader( + async_compression::futures::bufread::DeflateEncoder::new( + futures_util::io::Cursor::new(text_bytes.to_vec()), + ), + ); + + // Compress once up front when eligible (the deflate work happens at most once, cached in + // the returned `Vec`), then decide whether it's worth using. + let use_compression = can_compress + && maybe_compressed.collect().await.is_ok_and(|c| c.len() < text_bytes.len()); + + // 1. Inline single-packet attempt (no attachments; all recipients are >= v2). + let (mut header, text_header) = if use_compression { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(maybe_compressed.collect().await?.to_owned()), + CompressionType::DeflateRaw, + ) + } else { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(text.as_bytes().to_vec()), + CompressionType::None, + ) + }; + + { + let proto_header = header.clone().into(); + if eligibility.inline + && options.attached_stream_ids.is_empty() + && header_packet_fits(&proto_header, &options.destination_identities) + { + let packet = + RawStream::create_header_packet(proto_header, options.destination_identities); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(TextStreamInfo::from_headers(header, text_header)); + } + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + header.inline_content = None; + enforce_header_size(&header, &options.destination_identities)?; + + let open_options = RawStreamOpenOptions { + header: header.clone(), + destination_identities: options.destination_identities, + packet_tx: self.packet_tx.clone(), + }; + let info = TextStreamInfo::from_headers(header, text_header); + let mut stream = RawStream::open(open_options).await?; + if use_compression { + stream.write_raw_chunks(maybe_compressed.collect().await?).await?; + } else { + for chunk in text_bytes.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES) { + stream.write_chunk(chunk).await?; + } + } + stream.close(None).await?; + Ok(info) + } + + /// Send bytes to participants in the room. + /// + /// This method sends an in-memory blob of bytes to participants in the room + /// as a byte stream. It opens a stream using the provided options, writes the + /// entire buffer, and closes the stream before returning. + /// + /// The `total_length` in the header is set from the provided data and is not + /// overridable by `options.total_length`. The header defaults `name` to `"unknown"` + /// and `mime_type` to `"application/octet-stream"`. + pub async fn send_bytes( + &self, + data: impl AsRef<[u8]>, + options: StreamByteOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, + ) -> StreamResult { + if options.total_length.is_some() { + log::warn!("Ignoring total_length option specified for send_bytes"); + } + let bytes = data.as_ref(); + let stream_id: StreamId = options.id.clone().unwrap_or_else(create_random_uuid).into(); + let name = options.name.clone().unwrap_or_else(|| constants::BYTE_DEFAULT_NAME.to_owned()); + let total_length = bytes.len() as u64; + + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + + let mut maybe_compressed = MaybeCollectedAsyncReader::from_async_reader( + async_compression::futures::bufread::DeflateEncoder::new( + futures_util::io::Cursor::new(bytes.to_vec()), + ), + ); + + // Compress once up front when eligible (the deflate work happens at most once, cached in + // the returned `Vec`), then decide whether it's worth using. + let use_compression = + can_compress && maybe_compressed.collect().await.is_ok_and(|c| c.len() < bytes.len()); + + // 1. Inline single-packet attempt (if all recipients are >= v2). + let (mut header, byte_header) = if use_compression { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(maybe_compressed.collect().await?.to_owned()), + CompressionType::DeflateRaw, + ) + } else { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(bytes.to_vec()), + CompressionType::None, + ) + }; + { + let proto_header = header.clone().into(); + if eligibility.inline + && header_packet_fits(&proto_header, &options.destination_identities) + { + let packet = + RawStream::create_header_packet(proto_header, options.destination_identities); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(ByteStreamInfo::from_headers(header, byte_header)); + } + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + header.inline_content = None; + enforce_header_size(&header, &options.destination_identities)?; + + let open_options = RawStreamOpenOptions { + header: header.clone(), + destination_identities: options.destination_identities, + 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 { + stream.write_raw_chunks(maybe_compressed.collect().await?).await?; + } else { + stream.write_raw_chunks(bytes).await?; + } + stream.close(None).await?; + Ok(info) + } + + /// Streams a file from disk to participants as a byte stream. + /// + /// Never uses the inline single-packet path (deciding inline-eligibility would require + /// buffering and compressing the whole file up front). Compresses when every recipient + /// supports it. The whole file is never buffered in memory at once. + pub async fn send_file( + &self, + path: impl AsRef, + options: StreamByteOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, + ) -> StreamResult { + let path = path.as_ref(); + let file_size = tokio::fs::metadata(path) + .await + .map(|metadata| metadata.len()) + .map_err(StreamError::from)?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_owned(); + let stream_id: StreamId = options.id.clone().unwrap_or_else(create_random_uuid).into(); + let dests = options.destination_identities.clone(); + + let eligibility = evaluate_eligibility(remote_participant_registry, &dests); + let should_compress = options.compress.unwrap_or(true) && eligibility.compression; + let compression = + if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; + + let (header, byte_header) = + build_byte_header(&options, stream_id, name, Some(file_size), None, compression); + enforce_header_size(&header, &dests)?; + + let open_options = RawStreamOpenOptions { + header: header.clone(), + destination_identities: dests, + packet_tx: self.packet_tx.clone(), + }; + let info = ByteStreamInfo::from_headers(header, byte_header); + let mut stream = RawStream::open(open_options).await?; + stream.write_file(path, should_compress).await?; + stream.close(None).await?; + Ok(info) + } +} + +/// Inline / compression eligibility evaluated over a send's recipients. +struct SendEligibility { + /// Every recipient advertises `clientProtocol >= 2`. + inline: bool, + /// Inline-eligible AND every recipient advertises `CAP_COMPRESSION_DEFLATE_RAW`. + compression: bool, +} + +/// Evaluates inline/compression eligibility over a send's recipients. +/// +/// Recipients are the named `destinations`, or every remote participant for a broadcast +/// (empty `destinations`). An empty recipient set (empty room) is eligible for everything. +fn evaluate_eligibility( + registry: &dyn RemoteParticipantRegistry, + destinations: &[ParticipantIdentity], +) -> SendEligibility { + let recipients: Vec = + if destinations.is_empty() { registry.remote_identities() } else { destinations.to_vec() }; + let inline = recipients + .iter() + .all(|id| registry.remote_client_protocol(id) >= CLIENT_PROTOCOL_DATA_STREAM_V2); + let compression = inline + && recipients.iter().all(|id| { + registry.remote_capabilities(id).contains(&ClientCapability::CompressionDeflateRaw) + }); + + SendEligibility { inline, compression } +} + +/// Wraps an [`AsyncRead`] whose bytes are produced lazily (e.g. a deflate encoder), caching them +/// on first [`Self::collect`] so the underlying work runs at most once. Later `collect` calls +/// return the cached bytes without re-reading. +/// +/// (The streaming, still-a-reader-after-collect variant needed by `send_file` will be added when +/// that path is migrated; today's callers only need `collect`.) +enum MaybeCollectedAsyncReader { + Reader(Reader), + Collected(Vec), +} + +impl MaybeCollectedAsyncReader { + fn from_async_reader(reader: Reader) -> Self { + Self::Reader(reader) + } + + async fn collect(&mut self) -> Result<&[u8], std::io::Error> { + use futures_util::io::AsyncReadExt; + match self { + Self::Collected(_) => { /* no-op, handled below */ } + Self::Reader(reader) => { + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await?; + *self = Self::Collected(buf); + } + } + let Self::Collected(bytes) = self else { unreachable!("just set to Collected") }; + Ok(bytes) + } +} + +/// Whether the serialized header `DataPacket` fits within the MTU budget. +fn header_packet_fits( + header: &proto::data_stream::Header, + destinations: &[ParticipantIdentity], +) -> bool { + use prost::Message; + let packet = RawStream::create_header_packet(header.clone(), destinations.to_vec()); + packet.encoded_len() <= constants::STREAM_CHUNK_SIZE_BYTES +} + +/// Enforces the header-packet MTU budget on the chunked path (the inline path falls back +/// gracefully instead of erroring). +fn enforce_header_size(header: &Header, destinations: &[ParticipantIdentity]) -> StreamResult<()> { + let proto_header: proto::data_stream::Header = header.clone().into(); + if header_packet_fits(&proto_header, destinations) { + Ok(()) + } else { + Err(StreamError::HeaderTooLarge) + } +} + +fn build_text_header( + options: &StreamTextOptions, + stream_id: StreamId, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (Header, TextHeader) { + let text_header = TextHeader { + operation_type: options.operation_type.unwrap_or_default(), + version: options.version.unwrap_or_default(), + reply_to_stream_id: options.reply_to_stream_id.clone().map(StreamId::from), + attached_stream_ids: options + .attached_stream_ids + .clone() + .into_iter() + .map(StreamId::from) + .collect(), + generated: options.generated.unwrap_or_default(), + }; + let header = Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: constants::TEXT_MIME_TYPE.to_owned(), + total_length, + attributes: options.attributes.clone(), + content_header: Some(ContentHeader::TextHeader(text_header.clone().into())), + inline_content, + compression, + }; + (header, text_header) +} + +fn build_byte_header( + options: &StreamByteOptions, + stream_id: StreamId, + name: String, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (Header, ByteHeader) { + let byte_header = ByteHeader { name }; + let header = Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: options + .mime_type + .clone() + .unwrap_or_else(|| constants::BYTE_MIME_TYPE.to_owned()), + total_length, + attributes: options.attributes.clone(), + content_header: Some(byte_header.clone().into()), + inline_content, + compression, + }; + (header, byte_header) +} + +#[cfg(test)] +mod tests { + use super::*; + use livekit_common::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; + use std::sync::Mutex as StdMutex; + + // --- Fake recipient registry --------------------------------------------------------- + + struct FakeRegistry { + remotes: HashMap)>, + } + + impl FakeRegistry { + fn new() -> Self { + Self { remotes: HashMap::new() } + } + + fn add(mut self, id: &str, client_protocol: i32, caps: &[ClientCapability]) -> Self { + self.remotes.insert(id.to_string(), (client_protocol, caps.to_vec())); + self + } + } + + impl RemoteParticipantRegistry for FakeRegistry { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.remotes.get(&identity.0).map(|(p, _)| *p).unwrap_or(0) + } + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.remotes.get(&identity.0).map(|(_, c)| c.clone()).unwrap_or_default() + } + fn remote_identities(&self) -> Vec { + self.remotes.keys().map(|k| ParticipantIdentity(k.clone())).collect() + } + } + + fn pre_v2_room() -> FakeRegistry { + FakeRegistry::new() + .add("alice", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("bob", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("jim", CLIENT_PROTOCOL_DATA_STREAM_RPC, &[]) + } + + fn all_v2_room() -> FakeRegistry { + FakeRegistry::new() + .add( + "alice", + CLIENT_PROTOCOL_DATA_STREAM_V2, + &[ClientCapability::CompressionDeflateRaw], + ) + .add("bob", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("noCompression", CLIENT_PROTOCOL_DATA_STREAM_V2, &[]) + } + + fn mixed_room() -> FakeRegistry { + FakeRegistry::new() + .add("alice", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("bob", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("jim", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("mallory", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("noCompression", CLIENT_PROTOCOL_DATA_STREAM_V2, &[]) + } + + // --- Capture harness ----------------------------------------------------------------- + + type Sent = Arc>>; + + fn setup() -> (OutgoingDataStreamManager, Sent) { + let (manager, mut packet_rx) = OutgoingDataStreamManager::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); + let _ = responder.respond(Ok(())); + } + }); + (manager, sent) + } + + fn ids(list: &[&str]) -> Vec { + list.iter().map(|s| ParticipantIdentity(s.to_string())).collect() + } + + fn text_opts(topic: &str, dests: &[&str]) -> StreamTextOptions { + StreamTextOptions { + topic: topic.to_string(), + destination_identities: ids(dests), + ..Default::default() + } + } + + fn byte_opts(topic: &str, dests: &[&str]) -> StreamByteOptions { + StreamByteOptions { + topic: topic.to_string(), + destination_identities: ids(dests), + ..Default::default() + } + } + + fn header(p: &proto::DataPacket) -> &proto::data_stream::Header { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamHeader(h) => h, + _ => panic!("expected stream header"), + } + } + + fn chunk(p: &proto::DataPacket) -> &proto::data_stream::Chunk { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamChunk(c) => c, + _ => panic!("expected stream chunk"), + } + } + + fn is_text_header(h: &proto::data_stream::Header) -> bool { + matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::TextHeader(_))) + } + + fn is_byte_header(h: &proto::data_stream::Header) -> bool { + matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::ByteHeader(_))) + } + + fn assert_trailer(p: &proto::DataPacket) { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamTrailer(t) => assert_eq!(t.reason, ""), + _ => panic!("expected stream trailer"), + } + } + + fn deflate_raw_i32() -> i32 { + CompressionType::DeflateRaw as i32 + } + fn none_i32() -> i32 { + CompressionType::None as i32 + } + + /// ~50 KB of deterministic, somewhat-compressible text (repeated marker + pseudo-random + /// lowercase). Compresses to >15 KB (so it can't inline) but well under its raw size. + /// + /// Seeded with a fixed value so the output is identical on every run. + fn somewhat_compressible(blocks: usize) -> String { + use rand::{rngs::StdRng, Rng, SeedableRng}; + + /// Fixed RNG seed that keeps `somewhat_compressible` output identical on every run. + const RANDOM_SEED: u64 = 0x1234_5678_9abc_def0; + + let mut rng = StdRng::seed_from_u64(RANDOM_SEED); + let mut s = String::new(); + for _ in 0..blocks { + s.push_str("hello world"); + for _ in 0..1000 { + s.push(rng.random_range(b'a'..=b'z') as char); + } + } + s + } + + // --- Pre-v2 room: legacy, uncompressed, multi-packet --------------------------------- + + #[tokio::test] + async fn pre_v2_short_text_is_legacy_multipacket() { + let (m, sent) = setup(); + m.send_text("hello world", text_opts("chat", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + let h = header(&p[0]); + assert!(is_text_header(h)); + assert_eq!(h.topic, "chat"); + assert_eq!(h.compression, none_i32()); + assert!(h.inline_content.is_none()); + let c = chunk(&p[1]); + assert_eq!(c.chunk_index, 0); + assert_eq!(c.content, b"hello world"); + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn pre_v2_long_text_splits_at_mtu() { + let (m, sent) = setup(); + let text = "A".repeat(40_000); + m.send_text(&text, text_opts("chat", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 5); // header + 3 chunks + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + assert_eq!(chunk(&p[2]).content.len(), 15_000); + assert_eq!(chunk(&p[3]).content.len(), 10_000); + assert_eq!(chunk(&p[1]).chunk_index, 0); + assert_eq!(chunk(&p[3]).chunk_index, 2); + assert_trailer(&p[4]); + } + + #[tokio::test] + async fn pre_v2_bytes_is_legacy_multipacket() { + let (m, sent) = setup(); + m.send_bytes([0u8, 1, 2, 3], byte_opts("blob", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, none_i32()); + assert!(h.inline_content.is_none()); + assert_eq!(chunk(&p[1]).content, vec![0, 1, 2, 3]); + assert_trailer(&p[2]); + } + + // --- All-v2 room: inline + compression ----------------------------------------------- + + #[tokio::test] + async fn v2_short_compressible_text_inlines_compressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert!(is_text_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + let inline = h.inline_content.as_ref().unwrap(); + assert_ne!(inline.as_slice(), text.as_bytes()); // compressed, not raw + } + + #[tokio::test] + async fn v2_short_incompressible_text_inlines_raw() { + let (m, sent) = setup(); + m.send_text("short", text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), b"short"); + } + + #[tokio::test] + async fn v2_no_compression_cap_inlines_raw() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["noCompression"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); // inline (gated on protocol) still happens + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); // compression gated off by missing cap + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn v2_large_highly_compressible_text_still_inlines() { + let (m, sent) = setup(); + let text = "hello world".repeat(20_000); + m.send_text(&text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.as_ref().unwrap().len() < text.len()); + } + + #[tokio::test] + async fn v2_somewhat_compressible_text_is_compressed_multipacket() { + let (m, sent) = setup(); + let text = somewhat_compressible(50); + m.send_text(&text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.is_none()); + let chunks: Vec<_> = p[1..p.len() - 1].iter().map(chunk).collect(); + // Multi-packet, but fewer chunks than an uncompressed send would need (ceil(len/15000)). + let uncompressed_chunks = text.len().div_ceil(constants::STREAM_CHUNK_SIZE_BYTES); + assert!(chunks.len() >= 2); + assert!(chunks.len() < uncompressed_chunks); + assert_eq!(chunks[0].content.len(), constants::STREAM_CHUNK_SIZE_BYTES); // first chunk is full MTU + let total: usize = chunks.iter().map(|c| c.content.len()).sum(); + assert!(total < text.len()); // compressed + assert_trailer(p.last().unwrap()); + } + + #[tokio::test] + async fn v2_compress_false_short_inlines_raw() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + let opts = + StreamTextOptions { compress: Some(false), ..text_opts("chat", &["alice", "bob"]) }; + m.send_text(text, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn v2_compress_false_large_is_uncompressed_multipacket() { + let (m, sent) = setup(); + let text = "B".repeat(50_000); + let opts = + StreamTextOptions { compress: Some(false), ..text_opts("chat", &["alice", "bob"]) }; + m.send_text(&text, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 6); // header + 4 chunks + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + } + + // --- Incremental writers never compress or inline ------------------------------------ + + #[tokio::test] + async fn stream_text_never_compresses_or_inlines() { + let (m, sent) = setup(); + let writer = m.stream_text(text_opts("chat", &["noCompression"])).await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + let h0 = sent.lock().unwrap()[0].clone(); + assert!(is_text_header(header(&h0))); + assert_eq!(header(&h0).compression, none_i32()); + assert!(header(&h0).inline_content.is_none()); + + writer.write("hello world").await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 2); + assert_eq!(chunk(&sent.lock().unwrap()[1]).content, b"hello world"); + + writer.close().await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn stream_bytes_never_compresses_or_inlines() { + let (m, sent) = setup(); + let writer = m.stream_bytes(byte_opts("blob", &["noCompression"])).await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + assert_eq!(header(&sent.lock().unwrap()[0]).compression, none_i32()); + + writer.write(&[0u8, 1, 2, 3]).await.unwrap(); + assert_eq!(chunk(&sent.lock().unwrap()[1]).content, vec![0, 1, 2, 3]); + + writer.close().await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_trailer(&p[2]); + } + + // --- send_bytes inline behavior ------------------------------------------------------ + + #[tokio::test] + async fn v2_send_bytes_short_compressible_inlines_compressed() { + let (m, sent) = setup(); + let payload = "hello hello compressible world".as_bytes().to_vec(); + let mut opts = byte_opts("blob", &["alice", "bob"]); + opts.attributes.insert("foo".to_string(), "bar".to_string()); + let info = m.send_bytes(&payload, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + assert_ne!(h.inline_content.as_ref().unwrap().as_slice(), payload.as_slice()); + assert_eq!(info.name, "unknown"); + assert_eq!(info.mime_type, "application/octet-stream"); + assert_eq!(info.total_length, Some(payload.len() as u64)); + assert_eq!(info.attributes.get("foo"), Some(&"bar".to_string())); + } + + // --- Mixed room ---------------------------------------------------------------------- + + #[tokio::test] + async fn mixed_broadcast_falls_back_to_legacy() { + let (m, sent) = setup(); + m.send_text("hello world", text_opts("chat", &[]), &mixed_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_eq!(header(&p[0]).compression, none_i32()); + assert!(header(&p[0]).inline_content.is_none()); + assert_eq!(chunk(&p[1]).content, b"hello world"); + } + + #[tokio::test] + async fn mixed_targeted_v2_subset_inlines_compressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["bob", "jim"]), &mixed_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert_ne!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn mixed_targeted_subset_missing_cap_inlines_uncompressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["bob", "jim", "noCompression"]), &mixed_room()) + .await + .unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + // --- send_file ----------------------------------------------------------------------- + + async fn write_temp_file(bytes: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lk_ds_test_{}.bin", create_random_uuid())); + tokio::fs::write(&path, bytes).await.unwrap(); + path + } + + #[tokio::test] + async fn send_file_never_inlines_and_compresses_when_eligible() { + let (m, sent) = setup(); + let path = write_temp_file(&vec![0x01u8; 10_000]).await; + m.send_file(&path, byte_opts("file", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let _ = tokio::fs::remove_file(&path).await; + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); // header + 1 chunk + trailer, NOT inline + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.is_none()); + assert!(chunk(&p[1]).content.len() < 10_000); // compressed + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn send_file_uncompressed_splits_at_mtu() { + let (m, sent) = setup(); + let path = write_temp_file(&vec![0x07u8; 20_000]).await; + m.send_file(&path, byte_opts("file", &[]), &pre_v2_room()).await.unwrap(); + let _ = tokio::fs::remove_file(&path).await; + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 4); // header + 15000 + 5000 + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + assert_eq!(chunk(&p[2]).content.len(), 5_000); + assert_eq!(chunk(&p[2]).chunk_index, 1); + assert_trailer(&p[3]); + } + + // --- Header size limit --------------------------------------------------------------- + + #[tokio::test] + async fn oversized_attributes_on_chunked_path_errors() { + let (m, _sent) = setup(); + let mut opts = text_opts("chat", &[]); // pre-v2 below => chunked path + opts.attributes.insert("big".to_string(), "x".repeat(20_000)); + let result = m.send_text("hello", opts, &pre_v2_room()).await; + assert!(matches!(result, Err(StreamError::HeaderTooLarge))); + } + + // Regression test for CLT-2773: dropping a `RawStream` on a thread that has + // no Tokio runtime in TLS (e.g. the .NET GC finalizer thread in the Unity + // SDK) used to panic because `Drop` called `tokio::spawn` unconditionally. + #[test] + fn drop_raw_stream_on_non_tokio_thread_does_not_panic() { + let rt = tokio::runtime::Runtime::new().unwrap(); + + let raw_stream = rt.block_on(async { + let (packet_tx, mut packet_rx) = + bmrng::unbounded_channel::>(); + + tokio::spawn(async move { + while let Ok((_packet, responder)) = packet_rx.recv().await { + let _ = responder.respond(Ok(())); + } + }); + + let header = Header { + stream_id: "gc-test-stream".into(), + timestamp: 0, + topic: "gc-test-topic".to_string(), + mime_type: constants::TEXT_MIME_TYPE.to_owned(), + total_length: None, + attributes: HashMap::new(), + content_header: None, + // Data streams v2 fields + inline_content: None, + compression: CompressionType::None, + }; + + RawStream::open(RawStreamOpenOptions { + header, + destination_identities: vec![], + packet_tx, + }) + .await + .expect("RawStream should open") + }); + + let drop_thread = std::thread::spawn(move || drop(raw_stream)); + + drop_thread.join().expect("Dropping RawStream on a non-Tokio thread must not panic"); + } +} diff --git a/livekit-data-stream/src/outgoing/raw_stream.rs b/livekit-data-stream/src/outgoing/raw_stream.rs new file mode 100644 index 000000000..57e861b63 --- /dev/null +++ b/livekit-data-stream/src/outgoing/raw_stream.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 bmrng::unbounded::UnboundedRequestSender; +use livekit_common::ParticipantIdentity; +use livekit_protocol as proto; +use std::{io::Write, path::Path}; +use tokio::io::AsyncReadExt; + +use super::constants; +use crate::types::Header; +use crate::utils::{SendError, StreamError, StreamProgress, StreamResult}; + +pub(crate) struct RawStreamOpenOptions { + pub(crate) header: Header, + pub(crate) destination_identities: Vec, + pub(crate) packet_tx: UnboundedRequestSender>, +} + +pub(crate) struct RawStream { + id: String, + progress: StreamProgress, + is_closed: bool, + /// Request channel for sending packets. + packet_tx: UnboundedRequestSender>, +} + +impl RawStream { + pub(crate) async fn open(options: RawStreamOpenOptions) -> StreamResult { + let id = options.header.stream_id.to_string(); + let bytes_total = options.header.total_length; + + let packet = + Self::create_header_packet(options.header.into(), options.destination_identities); + Self::send_packet(&options.packet_tx, packet).await?; + + Ok(Self { + id, + progress: StreamProgress { bytes_total, ..Default::default() }, + is_closed: false, + packet_tx: options.packet_tx, + }) + } + + pub(crate) async fn write_chunk(&mut self, bytes: &[u8]) -> StreamResult<()> { + let packet = Self::create_chunk_packet(&self.id, self.progress.chunk_index, bytes); + Self::send_packet(&self.packet_tx, packet).await?; + self.progress.bytes_processed += bytes.len() as u64; + self.progress.chunk_index += 1; + Ok(()) + } + + /// Writes opaque bytes split into MTU-sized chunks on raw byte boundaries. + /// + /// Used for byte payloads and for compressed (deflate-raw) content, where the bytes + /// are opaque and must not be split on UTF-8 boundaries. + pub(crate) async fn write_raw_chunks(&mut self, bytes: &[u8]) -> StreamResult<()> { + for chunk in bytes.chunks(constants::STREAM_CHUNK_SIZE_BYTES) { + self.write_chunk(chunk).await?; + } + Ok(()) + } + + /// Streams a file's contents into MTU-sized chunks, optionally deflate-raw compressing + /// on the fly. The whole file is never buffered in memory at once. + pub(crate) async fn write_file( + &mut self, + path: impl AsRef, + compress: bool, + ) -> StreamResult<()> { + let mut file = tokio::fs::File::open(path).await?; + let mut read_buf = vec![0u8; 8192]; + + if compress { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + loop { + let n = file.read(&mut read_buf).await?; + if n == 0 { + break; + } + // Writing into a `Vec` is infallible. + encoder.write_all(&read_buf[..n]).expect("deflate write to Vec is infallible"); + // Drain whole MTU-sized chunks of compressed output as they accumulate so + // we never hold the full compressed file in memory. + while encoder.get_ref().len() >= constants::STREAM_CHUNK_SIZE_BYTES { + let rest = encoder.get_mut().split_off(constants::STREAM_CHUNK_SIZE_BYTES); + let chunk = std::mem::replace(encoder.get_mut(), rest); + self.write_chunk(&chunk).await?; + } + } + // Flush the final deflate block and send whatever compressed bytes remain. + let remaining = encoder.finish().expect("deflate finish into Vec is infallible"); + self.write_raw_chunks(&remaining).await?; + } else { + let mut pending: Vec = Vec::new(); + loop { + let n = file.read(&mut read_buf).await?; + if n == 0 { + break; + } + pending.extend_from_slice(&read_buf[..n]); + while pending.len() >= constants::STREAM_CHUNK_SIZE_BYTES { + let rest = pending.split_off(constants::STREAM_CHUNK_SIZE_BYTES); + let chunk = std::mem::replace(&mut pending, rest); + self.write_chunk(&chunk).await?; + } + } + if !pending.is_empty() { + self.write_chunk(&pending).await?; + } + } + Ok(()) + } + + pub(crate) async fn close(&mut self, reason: Option<&str>) -> StreamResult<()> { + if self.is_closed { + Err(StreamError::AlreadyClosed)? + } + let packet = Self::create_trailer_packet(&self.id, reason); + Self::send_packet(&self.packet_tx, packet).await?; + self.is_closed = true; + Ok(()) + } + + pub(crate) async fn send_packet( + tx: &UnboundedRequestSender>, + packet: proto::DataPacket, + ) -> StreamResult<()> { + tx.send_receive(packet) + .await + .map_err(|_| StreamError::Internal)? // request channel closed + .map_err(|_| StreamError::SendFailed) // data channel error + } + + pub(crate) fn create_header_packet( + header: proto::data_stream::Header, + destination_identities: Vec, + ) -> proto::DataPacket { + proto::DataPacket { + kind: proto::data_packet::Kind::Reliable.into(), + participant_identity: String::new(), // populate later + destination_identities: destination_identities.into_iter().map(|id| id.0).collect(), + value: Some(livekit_protocol::data_packet::Value::StreamHeader(header.into())), + // TODO: placeholder for reliable data transport + ..Default::default() + } + } + + pub(crate) fn create_chunk_packet( + id: &str, + chunk_index: u64, + content: &[u8], + ) -> proto::DataPacket { + let chunk = proto::data_stream::Chunk { + stream_id: id.to_string(), + chunk_index, + content: content.to_vec(), + ..Default::default() + }; + proto::DataPacket { + kind: proto::data_packet::Kind::Reliable.into(), + participant_identity: String::new(), // populate later + value: Some(livekit_protocol::data_packet::Value::StreamChunk(chunk)), + ..Default::default() + } + } + + pub(crate) fn create_trailer_packet(id: &str, reason: Option<&str>) -> proto::DataPacket { + let trailer = proto::data_stream::Trailer { + stream_id: id.to_string(), + reason: reason.unwrap_or_default().to_owned(), + ..Default::default() + }; + proto::DataPacket { + kind: proto::data_packet::Kind::Reliable.into(), + participant_identity: String::new(), // populate later + value: Some(livekit_protocol::data_packet::Value::StreamTrailer(trailer)), + ..Default::default() + } + } +} + +impl Drop for RawStream { + /// Close stream normally if not already closed. + fn drop(&mut self) { + if self.is_closed { + return; + } + let packet = Self::create_trailer_packet(&self.id, None); + let packet_tx = self.packet_tx.clone(); + // Use try_current() instead of assuming a Tokio runtime exists. + // The drop can run on a non-Tokio thread (e.g. a GC finalizer in + // Unity/.NET) or after the runtime has shut down, in which case + // we silently skip the trailer — the connection is going away anyway. + if let Ok(handle) = tokio::runtime::Handle::try_current() { + handle.spawn(async move { Self::send_packet(&packet_tx, packet).await }); + } + } +} diff --git a/livekit-data-stream/src/outgoing/stream_writer.rs b/livekit-data-stream/src/outgoing/stream_writer.rs new file mode 100644 index 000000000..bac111022 --- /dev/null +++ b/livekit-data-stream/src/outgoing/stream_writer.rs @@ -0,0 +1,124 @@ +// 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 tokio::sync::Mutex; + +use crate::info::{ByteStreamInfo, TextStreamInfo}; +use crate::outgoing::{constants::STREAM_CHUNK_SIZE_BYTES, RawStream}; +use crate::utf8_chunk::Utf8AwareChunkExt; +use crate::utils::StreamResult; + +/// Writer for an open data stream. +pub trait StreamWriter<'a> { + /// Type of input this writer accepts. + type Input: 'a; + + /// Information about the underlying data stream. + type Info; + + /// Returns a reference to the stream info. + fn info(&self) -> &Self::Info; + + /// Writes to the stream. + fn write( + &self, + input: Self::Input, + ) -> impl std::future::Future> + Send; + + /// Closes the stream normally. + fn close(self) -> impl std::future::Future> + Send; + + /// Closes the stream abnormally, specifying the reason for closure. + fn close_with_reason( + self, + reason: &str, + ) -> impl std::future::Future> + Send; +} + +#[derive(Clone)] +/// Writer for an open byte data stream. +pub struct ByteStreamWriter { + info: Arc, + stream: Arc>, +} + +impl ByteStreamWriter { + pub(crate) fn new(info: Arc, stream: Arc>) -> Self { + Self { info, stream } + } +} + +#[derive(Clone)] +/// Writer for an open text data stream. +pub struct TextStreamWriter { + info: Arc, + stream: Arc>, +} + +impl TextStreamWriter { + pub(crate) fn new(info: Arc, stream: Arc>) -> Self { + Self { info, stream } + } +} + +impl<'a> StreamWriter<'a> for ByteStreamWriter { + type Input = &'a [u8]; + type Info = ByteStreamInfo; + + fn info(&self) -> &Self::Info { + &self.info + } + + async fn write(&self, bytes: &'a [u8]) -> StreamResult<()> { + let mut stream = self.stream.lock().await; + for chunk in bytes.chunks(STREAM_CHUNK_SIZE_BYTES) { + stream.write_chunk(chunk).await?; + } + Ok(()) + } + + async fn close(self) -> StreamResult<()> { + self.stream.lock().await.close(None).await + } + + async fn close_with_reason(self, reason: &str) -> StreamResult<()> { + self.stream.lock().await.close(Some(reason)).await + } +} + +impl<'a> StreamWriter<'a> for TextStreamWriter { + type Input = &'a str; + type Info = TextStreamInfo; + + fn info(&self) -> &Self::Info { + &self.info + } + + async fn write(&self, text: &'a str) -> StreamResult<()> { + let mut stream = self.stream.lock().await; + for chunk in text.as_bytes().utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { + stream.write_chunk(chunk).await?; + } + Ok(()) + } + + async fn close(self) -> StreamResult<()> { + self.stream.lock().await.close(None).await + } + + async fn close_with_reason(self, reason: &str) -> StreamResult<()> { + self.stream.lock().await.close(Some(reason)).await + } +} diff --git a/livekit-data-stream/src/types/mod.rs b/livekit-data-stream/src/types/mod.rs new file mode 100644 index 000000000..e1b5cab9c --- /dev/null +++ b/livekit-data-stream/src/types/mod.rs @@ -0,0 +1,22 @@ +// 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. + +mod stream_id; +pub use stream_id::StreamId; + +mod packet; +pub use packet::{ + ByteHeader, Chunk, CompressionType, ContentHeader, Header, OperationType, Packet, TextHeader, + Trailer, +}; diff --git a/livekit-data-stream/src/types/packet.rs b/livekit-data-stream/src/types/packet.rs new file mode 100644 index 000000000..59df5f596 --- /dev/null +++ b/livekit-data-stream/src/types/packet.rs @@ -0,0 +1,303 @@ +// 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 from_variants::FromVariants; +use livekit_common::EncryptionType; +use livekit_protocol::data_stream as proto; +use std::collections::HashMap; + +use crate::StreamId; + +/// Operation type for text streams. +#[derive(Clone, Copy, Default, Debug, Hash, Eq, PartialEq)] +pub enum OperationType { + #[default] + Create, + Update, + Delete, + Reaction, +} + +impl From for OperationType { + fn from(value: proto::OperationType) -> Self { + match value { + proto::OperationType::Create => Self::Create, + proto::OperationType::Update => Self::Update, + proto::OperationType::Delete => Self::Delete, + proto::OperationType::Reaction => Self::Reaction, + } + } +} + +impl From for proto::OperationType { + fn from(value: OperationType) -> Self { + match value { + OperationType::Create => Self::Create, + OperationType::Update => Self::Update, + OperationType::Delete => Self::Delete, + OperationType::Reaction => Self::Reaction, + } + } +} + +/// Header information included exclusively in text data streams +#[derive(Clone, Debug, Default, PartialEq)] +pub struct TextHeader { + pub(crate) operation_type: OperationType, + /// Optional: Version for updates/edits + pub(crate) version: i32, + /// Optional: Reply to specific message + pub(crate) reply_to_stream_id: Option, + /// file attachments for text streams + pub(crate) attached_stream_ids: Vec, + /// true if the text has been generated by an agent from a participant's audio transcription + pub(crate) generated: bool, +} + +impl From for TextHeader { + fn from(value: proto::TextHeader) -> Self { + Self { + operation_type: value.operation_type().into(), + version: value.version, + reply_to_stream_id: if !value.reply_to_stream_id.is_empty() { + Some(value.reply_to_stream_id.into()) + } else { + None + }, + attached_stream_ids: value.attached_stream_ids.into_iter().map(Into::into).collect(), + generated: value.generated, + } + } +} + +impl From for proto::TextHeader { + fn from(value: TextHeader) -> Self { + Self { + operation_type: proto::OperationType::from(value.operation_type) as i32, + version: value.version, + reply_to_stream_id: value.reply_to_stream_id.map(Into::into).unwrap_or_default(), + attached_stream_ids: value.attached_stream_ids.into_iter().map(Into::into).collect(), + generated: value.generated, + } + } +} + +/// Header information included exclusively in byte data streams +#[derive(Clone, Debug, Default, PartialEq)] +pub struct ByteHeader { + pub(crate) name: String, +} + +impl From for ByteHeader { + fn from(value: proto::ByteHeader) -> Self { + Self { name: value.name } + } +} + +impl From for proto::ByteHeader { + fn from(value: ByteHeader) -> Self { + Self { name: value.name } + } +} + +#[derive(Clone, Debug, PartialEq, FromVariants)] +pub enum ContentHeader { + TextHeader(TextHeader), + ByteHeader(ByteHeader), +} + +impl From for ContentHeader { + fn from(value: proto::header::ContentHeader) -> Self { + match value { + proto::header::ContentHeader::TextHeader(text_header) => { + Self::TextHeader(text_header.into()) + } + proto::header::ContentHeader::ByteHeader(text_header) => { + Self::ByteHeader(text_header.into()) + } + } + } +} + +impl From for proto::header::ContentHeader { + fn from(value: ContentHeader) -> Self { + match value { + ContentHeader::TextHeader(text_header) => Self::TextHeader(text_header.into()), + ContentHeader::ByteHeader(byte_header) => Self::ByteHeader(byte_header.into()), + } + } +} + +/// Type of compression used when sending a data stream chunk +#[derive(Clone, Debug, Default, PartialEq)] +pub enum CompressionType { + #[default] + None, + /// DEFLATE_RAW = DEFLATE without header+checksum/trailer + DeflateRaw, +} + +impl From for CompressionType { + fn from(value: proto::CompressionType) -> Self { + match value { + proto::CompressionType::DeflateRaw => Self::DeflateRaw, + proto::CompressionType::None => Self::None, + } + } +} + +impl From for proto::CompressionType { + fn from(value: CompressionType) -> Self { + match value { + CompressionType::DeflateRaw => Self::DeflateRaw, + CompressionType::None => Self::None, + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Header { + /// Unique identifier for this data stream + pub(crate) stream_id: StreamId, + /// using int64 for Unix timestamp + pub(crate) timestamp: i64, + pub(crate) topic: ::prost::alloc::string::String, + pub(crate) mime_type: ::prost::alloc::string::String, + /// only populated for finite streams, if it's a stream of unknown size this stays empty + pub(crate) total_length: ::core::option::Option, + /// user defined attributes map that can carry additional info + pub(crate) attributes: HashMap, + /// Optional inline content so that a data stream can be sent as a single packet for short payloads. + /// + /// content as binary (bytes) + pub(crate) inline_content: Option>, + pub(crate) compression: CompressionType, + /// oneof to choose between specific header types + pub(crate) content_header: Option, +} + +impl From for Header { + fn from(value: proto::Header) -> Self { + let compression: CompressionType = value.compression().into(); + let content_header: Option = + value.content_header.map(|content_header| content_header.into()); + Self { + stream_id: value.stream_id.into(), + timestamp: value.timestamp, + topic: value.topic, + mime_type: value.mime_type, + total_length: value.total_length, + attributes: value.attributes, + inline_content: value.inline_content, + compression, + content_header, + } + } +} + +impl From
for proto::Header { + fn from(value: Header) -> Self { + // `encryption_type` is deprecated on the proto (it's carried on the DataPacket instead); + // `..Default::default()` fills it without naming the deprecated field. + Self { + stream_id: value.stream_id.into(), + timestamp: value.timestamp, + topic: value.topic, + mime_type: value.mime_type, + total_length: value.total_length, + attributes: value.attributes, + inline_content: value.inline_content, + compression: proto::CompressionType::from(value.compression) as i32, + content_header: value.content_header.map(Into::into), + ..Default::default() + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Chunk { + /// Unique identifier for this data stream to map it to the correct header + pub(crate) stream_id: StreamId, + pub(crate) chunk_index: u64, + /// Content as binary (bytes) + pub(crate) content: Vec, + /// A version indicating that this chunk_index has been retroactively modified and the original one needs to be replaced + pub(crate) version: i32, + pub(crate) encryption_type: EncryptionType, +} + +impl From for Chunk { + fn from(value: proto::Chunk) -> Self { + // The proto carries encryption on the enclosing `DataPacket`, not the chunk, so the + // chunk's own `encryption_type` defaults here; the authoritative value rides on `Packet`. + Self { + stream_id: value.stream_id.into(), + chunk_index: value.chunk_index, + content: value.content, + version: value.version, + encryption_type: EncryptionType::default(), + } + } +} + +impl From for proto::Chunk { + fn from(value: Chunk) -> Self { + // `iv` is deprecated on the proto (encryption rides on the DataPacket); + // `..Default::default()` fills it without naming the deprecated field. + Self { + stream_id: value.stream_id.into(), + chunk_index: value.chunk_index, + content: value.content, + version: value.version, + ..Default::default() + } + } +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Trailer { + /// Unique identifier for this data stream + pub(crate) stream_id: StreamId, + /// Reason why the stream was closed (could contain "error" / "interrupted" / empty for expected end) + pub(crate) reason: String, + /// Any final attribute updates for the stream + pub(crate) attributes: HashMap, +} + +impl From for Trailer { + fn from(value: proto::Trailer) -> Self { + Self { + stream_id: value.stream_id.into(), + reason: value.reason, + attributes: value.attributes, + } + } +} + +impl From for proto::Trailer { + fn from(value: Trailer) -> Self { + Self { + stream_id: value.stream_id.into(), + reason: value.reason, + attributes: value.attributes, + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub enum Packet { + Header { header: Header, encryption_type: EncryptionType }, + Chunk { chunk: Chunk, encryption_type: EncryptionType }, + Trailer(Trailer), +} diff --git a/livekit-data-stream/src/types/stream_id.rs b/livekit-data-stream/src/types/stream_id.rs new file mode 100644 index 000000000..5adee2f25 --- /dev/null +++ b/livekit-data-stream/src/types/stream_id.rs @@ -0,0 +1,49 @@ +// 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::fmt::Display; + +/// A wrapped identifier for a data stream. +#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct StreamId(pub String); + +impl From for StreamId { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for StreamId { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl From for String { + fn from(value: StreamId) -> Self { + value.0 + } +} + +impl Display for StreamId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl StreamId { + pub fn as_str(&self) -> &str { + &self.0 + } +} diff --git a/livekit-data-stream/src/utils.rs b/livekit-data-stream/src/utils.rs new file mode 100644 index 000000000..36c2a5d66 --- /dev/null +++ b/livekit-data-stream/src/utils.rs @@ -0,0 +1,88 @@ +// 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 thiserror::Error; + +/// Error returned by the packet transport when a data-stream packet fails to send. +/// +/// The stream managers only need to know that a send failed (they map it to +/// [`StreamError::SendFailed`]); the concrete engine error type stays in the `livekit` crate, +/// which bridges the outgoing packet channel to the RTC engine. +#[derive(Debug, Clone)] +pub struct SendError; + +/// Result type for data stream operations. +pub type StreamResult = Result; + +/// Error type for data stream operations. +#[derive(Debug, Error)] +pub enum StreamError { + // TODO(ladvoc): standardize error cases and expose over FFI. + #[error("stream has already been closed")] + AlreadyClosed, + + #[error("stream closed abnormally: {0}")] + AbnormalEnd(String), + + #[error("UTF-8 decoding error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), + + #[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: {0}")] + Io(#[from] std::io::Error), + + #[error("internal error")] + Internal, + + #[error("encryption type mismatch")] + EncryptionTypeMismatch, + + #[error("stream header exceeds maximum size")] + HeaderTooLarge, + + #[error("decompression failed")] + Decompression, +} + +/// Progress of a data stream. +#[derive(Clone, Copy, Default, Debug, Hash, Eq, PartialEq)] +pub(crate) struct StreamProgress { + pub(crate) chunk_index: u64, + /// Number of bytes read or written so far. + pub(crate) bytes_processed: u64, + /// Total number of bytes expected to be read or written for finite streams. + pub(crate) bytes_total: Option, +} + +impl StreamProgress { + /// Returns the completion percentage for finite streams. + #[allow(dead_code)] + fn percentage(&self) -> Option { + self.bytes_total.map(|total| self.bytes_processed as f32 / total as f32) + } +} diff --git a/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts b/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts index 6c7628f37..a800395d6 100644 --- a/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts +++ b/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts @@ -1841,6 +1841,11 @@ export declare class StreamTextOptions extends Message { */ generated?: boolean; + /** + * @generated from field: optional bool compress = 10; + */ + compress?: boolean; + constructor(data?: PartialMessage); static readonly runtime: typeof proto2; @@ -1895,6 +1900,11 @@ export declare class StreamByteOptions extends Message { */ totalLength?: bigint; + /** + * @generated from field: optional bool compress = 8; + */ + compress?: boolean; + constructor(data?: PartialMessage); static readonly runtime: typeof proto2; diff --git a/livekit-ffi-node-bindings/proto/data_stream_pb.js b/livekit-ffi-node-bindings/proto/data_stream_pb.js index cd503c137..6b45dcaad 100644 --- a/livekit-ffi-node-bindings/proto/data_stream_pb.js +++ b/livekit-ffi-node-bindings/proto/data_stream_pb.js @@ -674,6 +674,7 @@ const StreamTextOptions = /*@__PURE__*/ proto2.makeMessageType( { no: 7, name: "reply_to_stream_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 8, name: "attached_stream_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, { no: 9, name: "generated", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + { no: 10, name: "compress", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ], ); @@ -690,6 +691,7 @@ const StreamByteOptions = /*@__PURE__*/ proto2.makeMessageType( { no: 5, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 6, name: "mime_type", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 7, name: "total_length", kind: "scalar", T: 4 /* ScalarType.UINT64 */, opt: true }, + { no: 8, name: "compress", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ], ); diff --git a/livekit-ffi/protocol/data_stream.proto b/livekit-ffi/protocol/data_stream.proto index 3f9fd590f..83c2ab5be 100644 --- a/livekit-ffi/protocol/data_stream.proto +++ b/livekit-ffi/protocol/data_stream.proto @@ -371,7 +371,7 @@ message StreamTextOptions { optional string reply_to_stream_id = 7; repeated string attached_stream_ids = 8; optional bool generated = 9; - + optional bool compress = 10; } message StreamByteOptions { required string topic = 1; @@ -381,6 +381,7 @@ message StreamByteOptions { optional string name = 5; optional string mime_type = 6; optional uint64 total_length = 7; + optional bool compress = 8; } // Error pertaining to a stream. diff --git a/livekit-ffi/src/conversion/data_stream.rs b/livekit-ffi/src/conversion/data_stream.rs index a59da350b..140bc74e4 100644 --- a/livekit-ffi/src/conversion/data_stream.rs +++ b/livekit-ffi/src/conversion/data_stream.rs @@ -72,6 +72,7 @@ impl From for StreamTextOptions { reply_to_stream_id: options.reply_to_stream_id, attached_stream_ids: options.attached_stream_ids, generated: options.generated, + compress: options.compress, } } } @@ -90,6 +91,7 @@ impl From for StreamByteOptions { name: options.name, mime_type: options.mime_type, total_length: options.total_length, + compress: options.compress, } } } diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index df0314e18..39fc751df 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit df0314e189f0ab695005c5edc10f087b5a36ad23 +Subproject commit 39fc751df610243c1bfdf85d3be6b3928ecef014 diff --git a/livekit-protocol/src/livekit.rs b/livekit-protocol/src/livekit.rs index 1af6a6e0e..ac2408d86 100644 --- a/livekit-protocol/src/livekit.rs +++ b/livekit-protocol/src/livekit.rs @@ -653,6 +653,36 @@ pub struct DataTrackSubscriptionOptions { #[prost(uint32, optional, tag="1")] pub target_fps: ::core::option::Option, } +/// Key used to uniquely identify a data blob for storage and retrieval. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataBlobKey { + #[prost(oneof="data_blob_key::Key", tags="1")] + pub key: ::core::option::Option, +} +/// Nested message and enum types in `DataBlobKey`. +pub mod data_blob_key { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Key { + /// Generic string key, blob contains arbitrary data. + /// + /// Add additional key types here for storing specific types of blobs. + #[prost(string, tag="1")] + Generic(::prost::alloc::string::String), + } +} +/// A blob of data stored in a room identified by a unique key. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataBlob { + /// Unique key the data blob is identified by. + #[prost(message, optional, tag="1")] + pub key: ::core::option::Option, + /// Contents of the data blob. This must not exceed 50 KB. + #[prost(bytes="vec", tag="2")] + pub contents: ::prost::alloc::vec::Vec, +} /// provide information about available spatial layers #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -3551,7 +3581,7 @@ impl AudioMixing { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalRequest { - #[prost(oneof="signal_request::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21")] + #[prost(oneof="signal_request::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23")] pub message: ::core::option::Option, } /// Nested message and enum types in `SignalRequest`. @@ -3619,12 +3649,18 @@ pub mod signal_request { /// Update subscription state for one or more data tracks #[prost(message, tag="21")] UpdateDataSubscription(super::UpdateDataSubscription), + /// Store a data blob. + #[prost(message, tag="22")] + StoreDataBlobRequest(super::StoreDataBlobRequest), + /// Retrieve a stored data blob. + #[prost(message, tag="23")] + GetDataBlobRequest(super::GetDataBlobRequest), } } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalResponse { - #[prost(oneof="signal_response::Message", tags="1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29")] + #[prost(oneof="signal_response::Message", tags="1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31")] pub message: ::core::option::Option, } /// Nested message and enum types in `SignalResponse`. @@ -3719,6 +3755,12 @@ pub mod signal_response { /// Sent to data track subscribers to provide mapping from track SIDs to handles. #[prost(message, tag="29")] DataTrackSubscriberHandles(super::DataTrackSubscriberHandles), + /// Sent in response to `StoreDataBlobRequest`. + #[prost(message, tag="30")] + StoreDataBlobResponse(super::StoreDataBlobResponse), + /// Sent in response to `GetDataBlobRequest`. + #[prost(message, tag="31")] + GetDataBlobResponse(super::GetDataBlobResponse), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3979,6 +4021,43 @@ pub mod update_data_subscription { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct StoreDataBlobRequest { + #[prost(uint32, tag="1")] + pub request_id: u32, + #[prost(message, optional, tag="2")] + pub blob: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct StoreDataBlobResponse { + #[prost(uint32, tag="1")] + pub request_id: u32, + /// Unique key the data blob was stored under. + #[prost(message, optional, tag="2")] + pub key: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDataBlobRequest { + #[prost(uint32, tag="1")] + pub request_id: u32, + /// Identity of the participant who owns the blob. + #[prost(string, tag="2")] + pub participant_identity: ::prost::alloc::string::String, + /// Unique key of the data blob to retrieve. + #[prost(message, optional, tag="3")] + pub key: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDataBlobResponse { + #[prost(uint32, tag="1")] + pub request_id: u32, + #[prost(message, optional, tag="2")] + pub blob: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateTrackSettings { #[prost(string, repeated, tag="1")] pub track_sids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, @@ -4387,6 +4466,7 @@ pub mod request_response { InvalidName = 8, DuplicateHandle = 9, DuplicateName = 10, + InvalidRequest = 11, } impl Reason { /// String value of the enum field names used in the ProtoBuf definition. @@ -4406,6 +4486,7 @@ pub mod request_response { Reason::InvalidName => "INVALID_NAME", Reason::DuplicateHandle => "DUPLICATE_HANDLE", Reason::DuplicateName => "DUPLICATE_NAME", + Reason::InvalidRequest => "INVALID_REQUEST", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -4422,6 +4503,7 @@ pub mod request_response { "INVALID_NAME" => Some(Self::InvalidName), "DUPLICATE_HANDLE" => Some(Self::DuplicateHandle), "DUPLICATE_NAME" => Some(Self::DuplicateName), + "INVALID_REQUEST" => Some(Self::InvalidRequest), _ => None, } } diff --git a/livekit-protocol/src/livekit.serde.rs b/livekit-protocol/src/livekit.serde.rs index ebd07f129..fa22db55a 100644 --- a/livekit-protocol/src/livekit.serde.rs +++ b/livekit-protocol/src/livekit.serde.rs @@ -10527,6 +10527,221 @@ impl<'de> serde::Deserialize<'de> for CreateSipTrunkRequest { deserializer.deserialize_struct("livekit.CreateSIPTrunkRequest", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for DataBlob { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.key.is_some() { + len += 1; + } + if !self.contents.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataBlob", len)?; + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; + } + if !self.contents.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("contents", pbjson::private::base64::encode(&self.contents).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataBlob { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "key", + "contents", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Key, + Contents, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "key" => Ok(GeneratedField::Key), + "contents" => Ok(GeneratedField::Contents), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataBlob; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataBlob") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut key__ = None; + let mut contents__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; + } + GeneratedField::Contents => { + if contents__.is_some() { + return Err(serde::de::Error::duplicate_field("contents")); + } + contents__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataBlob { + key: key__, + contents: contents__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("livekit.DataBlob", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for DataBlobKey { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataBlobKey", len)?; + if let Some(v) = self.key.as_ref() { + match v { + data_blob_key::Key::Generic(v) => { + struct_ser.serialize_field("generic", v)?; + } + } + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataBlobKey { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "generic", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Generic, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "generic" => Ok(GeneratedField::Generic), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataBlobKey; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataBlobKey") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut key__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Generic => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("generic")); + } + key__ = map_.next_value::<::std::option::Option<_>>()?.map(data_blob_key::Key::Generic); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataBlobKey { + key: key__, + }) + } + } + deserializer.deserialize_struct("livekit.DataBlobKey", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for DataChannelInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -18515,7 +18730,7 @@ impl<'de> serde::Deserialize<'de> for GcpUpload { deserializer.deserialize_struct("livekit.GCPUpload", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipInboundTrunkRequest { +impl serde::Serialize for GetDataBlobRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18523,30 +18738,47 @@ impl serde::Serialize for GetSipInboundTrunkRequest { { use serde::ser::SerializeStruct; let mut len = 0; - if !self.sip_trunk_id.is_empty() { + if self.request_id != 0 { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkRequest", len)?; - if !self.sip_trunk_id.is_empty() { - struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; + if !self.participant_identity.is_empty() { + len += 1; + } + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetDataBlobRequest", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if !self.participant_identity.is_empty() { + struct_ser.serialize_field("participantIdentity", &self.participant_identity)?; + } + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { +impl<'de> serde::Deserialize<'de> for GetDataBlobRequest { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { const FIELDS: &[&str] = &[ - "sip_trunk_id", - "sipTrunkId", + "request_id", + "requestId", + "participant_identity", + "participantIdentity", + "key", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { - SipTrunkId, + RequestId, + ParticipantIdentity, + Key, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -18569,7 +18801,9 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { E: serde::de::Error, { match value { - "sipTrunkId" | "sip_trunk_id" => Ok(GeneratedField::SipTrunkId), + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "participantIdentity" | "participant_identity" => Ok(GeneratedField::ParticipantIdentity), + "key" => Ok(GeneratedField::Key), _ => Ok(GeneratedField::__SkipField__), } } @@ -18579,39 +18813,57 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = GetSipInboundTrunkRequest; + type Value = GetDataBlobRequest; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.GetSIPInboundTrunkRequest") + formatter.write_str("struct livekit.GetDataBlobRequest") } - fn visit_map(self, mut map_: V) -> std::result::Result + fn visit_map(self, mut map_: V) -> std::result::Result where V: serde::de::MapAccess<'de>, { - let mut sip_trunk_id__ = None; + let mut request_id__ = None; + let mut participant_identity__ = None; + let mut key__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::SipTrunkId => { - if sip_trunk_id__.is_some() { - return Err(serde::de::Error::duplicate_field("sipTrunkId")); + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); } - sip_trunk_id__ = Some(map_.next_value()?); + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::ParticipantIdentity => { + if participant_identity__.is_some() { + return Err(serde::de::Error::duplicate_field("participantIdentity")); + } + participant_identity__ = Some(map_.next_value()?); + } + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(GetSipInboundTrunkRequest { - sip_trunk_id: sip_trunk_id__.unwrap_or_default(), + Ok(GetDataBlobRequest { + request_id: request_id__.unwrap_or_default(), + participant_identity: participant_identity__.unwrap_or_default(), + key: key__, }) } } - deserializer.deserialize_struct("livekit.GetSIPInboundTrunkRequest", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.GetDataBlobRequest", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipInboundTrunkResponse { +impl serde::Serialize for GetDataBlobResponse { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18619,29 +18871,38 @@ impl serde::Serialize for GetSipInboundTrunkResponse { { use serde::ser::SerializeStruct; let mut len = 0; - if self.trunk.is_some() { + if self.request_id != 0 { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkResponse", len)?; - if let Some(v) = self.trunk.as_ref() { - struct_ser.serialize_field("trunk", v)?; + if self.blob.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetDataBlobResponse", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.blob.as_ref() { + struct_ser.serialize_field("blob", v)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { +impl<'de> serde::Deserialize<'de> for GetDataBlobResponse { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { const FIELDS: &[&str] = &[ - "trunk", + "request_id", + "requestId", + "blob", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { - Trunk, + RequestId, + Blob, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -18664,7 +18925,8 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { E: serde::de::Error, { match value { - "trunk" => Ok(GeneratedField::Trunk), + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "blob" => Ok(GeneratedField::Blob), _ => Ok(GeneratedField::__SkipField__), } } @@ -18674,39 +18936,49 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = GetSipInboundTrunkResponse; + type Value = GetDataBlobResponse; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.GetSIPInboundTrunkResponse") + formatter.write_str("struct livekit.GetDataBlobResponse") } - fn visit_map(self, mut map_: V) -> std::result::Result + fn visit_map(self, mut map_: V) -> std::result::Result where V: serde::de::MapAccess<'de>, { - let mut trunk__ = None; + let mut request_id__ = None; + let mut blob__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::Trunk => { - if trunk__.is_some() { - return Err(serde::de::Error::duplicate_field("trunk")); + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); } - trunk__ = map_.next_value()?; + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Blob => { + if blob__.is_some() { + return Err(serde::de::Error::duplicate_field("blob")); + } + blob__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(GetSipInboundTrunkResponse { - trunk: trunk__, + Ok(GetDataBlobResponse { + request_id: request_id__.unwrap_or_default(), + blob: blob__, }) } } - deserializer.deserialize_struct("livekit.GetSIPInboundTrunkResponse", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.GetDataBlobResponse", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipOutboundTrunkRequest { +impl serde::Serialize for GetSipInboundTrunkRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18717,14 +18989,205 @@ impl serde::Serialize for GetSipOutboundTrunkRequest { if !self.sip_trunk_id.is_empty() { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPOutboundTrunkRequest", len)?; + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkRequest", len)?; if !self.sip_trunk_id.is_empty() { struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipOutboundTrunkRequest { +impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "sip_trunk_id", + "sipTrunkId", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + SipTrunkId, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "sipTrunkId" | "sip_trunk_id" => Ok(GeneratedField::SipTrunkId), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetSipInboundTrunkRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GetSIPInboundTrunkRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut sip_trunk_id__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::SipTrunkId => { + if sip_trunk_id__.is_some() { + return Err(serde::de::Error::duplicate_field("sipTrunkId")); + } + sip_trunk_id__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GetSipInboundTrunkRequest { + sip_trunk_id: sip_trunk_id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("livekit.GetSIPInboundTrunkRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetSipInboundTrunkResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.trunk.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkResponse", len)?; + if let Some(v) = self.trunk.as_ref() { + struct_ser.serialize_field("trunk", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "trunk", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Trunk, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "trunk" => Ok(GeneratedField::Trunk), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetSipInboundTrunkResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GetSIPInboundTrunkResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut trunk__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Trunk => { + if trunk__.is_some() { + return Err(serde::de::Error::duplicate_field("trunk")); + } + trunk__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GetSipInboundTrunkResponse { + trunk: trunk__, + }) + } + } + deserializer.deserialize_struct("livekit.GetSIPInboundTrunkResponse", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetSipOutboundTrunkRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.sip_trunk_id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPOutboundTrunkRequest", len)?; + if !self.sip_trunk_id.is_empty() { + struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetSipOutboundTrunkRequest { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where @@ -33107,6 +33570,7 @@ impl serde::Serialize for request_response::Reason { Self::InvalidName => "INVALID_NAME", Self::DuplicateHandle => "DUPLICATE_HANDLE", Self::DuplicateName => "DUPLICATE_NAME", + Self::InvalidRequest => "INVALID_REQUEST", }; serializer.serialize_str(variant) } @@ -33129,6 +33593,7 @@ impl<'de> serde::Deserialize<'de> for request_response::Reason { "INVALID_NAME", "DUPLICATE_HANDLE", "DUPLICATE_NAME", + "INVALID_REQUEST", ]; struct GeneratedVisitor; @@ -33180,6 +33645,7 @@ impl<'de> serde::Deserialize<'de> for request_response::Reason { "INVALID_NAME" => Ok(request_response::Reason::InvalidName), "DUPLICATE_HANDLE" => Ok(request_response::Reason::DuplicateHandle), "DUPLICATE_NAME" => Ok(request_response::Reason::DuplicateName), + "INVALID_REQUEST" => Ok(request_response::Reason::InvalidRequest), _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), } } @@ -43100,6 +43566,12 @@ impl serde::Serialize for SignalRequest { signal_request::Message::UpdateDataSubscription(v) => { struct_ser.serialize_field("updateDataSubscription", v)?; } + signal_request::Message::StoreDataBlobRequest(v) => { + struct_ser.serialize_field("storeDataBlobRequest", v)?; + } + signal_request::Message::GetDataBlobRequest(v) => { + struct_ser.serialize_field("getDataBlobRequest", v)?; + } } } struct_ser.end() @@ -43144,6 +43616,10 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { "unpublishDataTrackRequest", "update_data_subscription", "updateDataSubscription", + "store_data_blob_request", + "storeDataBlobRequest", + "get_data_blob_request", + "getDataBlobRequest", ]; #[allow(clippy::enum_variant_names)] @@ -43168,6 +43644,8 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { PublishDataTrackRequest, UnpublishDataTrackRequest, UpdateDataSubscription, + StoreDataBlobRequest, + GetDataBlobRequest, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43210,6 +43688,8 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { "publishDataTrackRequest" | "publish_data_track_request" => Ok(GeneratedField::PublishDataTrackRequest), "unpublishDataTrackRequest" | "unpublish_data_track_request" => Ok(GeneratedField::UnpublishDataTrackRequest), "updateDataSubscription" | "update_data_subscription" => Ok(GeneratedField::UpdateDataSubscription), + "storeDataBlobRequest" | "store_data_blob_request" => Ok(GeneratedField::StoreDataBlobRequest), + "getDataBlobRequest" | "get_data_blob_request" => Ok(GeneratedField::GetDataBlobRequest), _ => Ok(GeneratedField::__SkipField__), } } @@ -43369,6 +43849,20 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { return Err(serde::de::Error::duplicate_field("updateDataSubscription")); } message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::UpdateDataSubscription) +; + } + GeneratedField::StoreDataBlobRequest => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("storeDataBlobRequest")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::StoreDataBlobRequest) +; + } + GeneratedField::GetDataBlobRequest => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("getDataBlobRequest")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::GetDataBlobRequest) ; } GeneratedField::__SkipField__ => { @@ -43484,6 +43978,12 @@ impl serde::Serialize for SignalResponse { signal_response::Message::DataTrackSubscriberHandles(v) => { struct_ser.serialize_field("dataTrackSubscriberHandles", v)?; } + signal_response::Message::StoreDataBlobResponse(v) => { + struct_ser.serialize_field("storeDataBlobResponse", v)?; + } + signal_response::Message::GetDataBlobResponse(v) => { + struct_ser.serialize_field("getDataBlobResponse", v)?; + } } } struct_ser.end() @@ -43543,6 +44043,10 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { "unpublishDataTrackResponse", "data_track_subscriber_handles", "dataTrackSubscriberHandles", + "store_data_blob_response", + "storeDataBlobResponse", + "get_data_blob_response", + "getDataBlobResponse", ]; #[allow(clippy::enum_variant_names)] @@ -43575,6 +44079,8 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { PublishDataTrackResponse, UnpublishDataTrackResponse, DataTrackSubscriberHandles, + StoreDataBlobResponse, + GetDataBlobResponse, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43625,6 +44131,8 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { "publishDataTrackResponse" | "publish_data_track_response" => Ok(GeneratedField::PublishDataTrackResponse), "unpublishDataTrackResponse" | "unpublish_data_track_response" => Ok(GeneratedField::UnpublishDataTrackResponse), "dataTrackSubscriberHandles" | "data_track_subscriber_handles" => Ok(GeneratedField::DataTrackSubscriberHandles), + "storeDataBlobResponse" | "store_data_blob_response" => Ok(GeneratedField::StoreDataBlobResponse), + "getDataBlobResponse" | "get_data_blob_response" => Ok(GeneratedField::GetDataBlobResponse), _ => Ok(GeneratedField::__SkipField__), } } @@ -43839,6 +44347,20 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { return Err(serde::de::Error::duplicate_field("dataTrackSubscriberHandles")); } message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::DataTrackSubscriberHandles) +; + } + GeneratedField::StoreDataBlobResponse => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("storeDataBlobResponse")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::StoreDataBlobResponse) +; + } + GeneratedField::GetDataBlobResponse => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("getDataBlobResponse")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::GetDataBlobResponse) ; } GeneratedField::__SkipField__ => { @@ -45403,6 +45925,236 @@ impl<'de> serde::Deserialize<'de> for StorageConfig { deserializer.deserialize_struct("livekit.StorageConfig", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for StoreDataBlobRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.request_id != 0 { + len += 1; + } + if self.blob.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.StoreDataBlobRequest", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.blob.as_ref() { + struct_ser.serialize_field("blob", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for StoreDataBlobRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "request_id", + "requestId", + "blob", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + RequestId, + Blob, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "blob" => Ok(GeneratedField::Blob), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = StoreDataBlobRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.StoreDataBlobRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut request_id__ = None; + let mut blob__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); + } + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Blob => { + if blob__.is_some() { + return Err(serde::de::Error::duplicate_field("blob")); + } + blob__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(StoreDataBlobRequest { + request_id: request_id__.unwrap_or_default(), + blob: blob__, + }) + } + } + deserializer.deserialize_struct("livekit.StoreDataBlobRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for StoreDataBlobResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.request_id != 0 { + len += 1; + } + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.StoreDataBlobResponse", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for StoreDataBlobResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "request_id", + "requestId", + "key", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + RequestId, + Key, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "key" => Ok(GeneratedField::Key), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = StoreDataBlobResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.StoreDataBlobResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut request_id__ = None; + let mut key__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); + } + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(StoreDataBlobResponse { + request_id: request_id__.unwrap_or_default(), + key: key__, + }) + } + } + deserializer.deserialize_struct("livekit.StoreDataBlobResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for StreamInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index a9dd5da19..b6ca65afc 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -29,7 +29,7 @@ rustls-tls-native-roots = ["livekit-api/rustls-tls-native-roots"] rustls-tls-webpki-roots = ["livekit-api/rustls-tls-webpki-roots"] __rustls-tls = ["livekit-api/__rustls-tls"] __lk-internal = [] # internal features (used by livekit-ffi) -__lk-e2e-test = ["livekit-data-stream/test-utils"] # end-to-end testing with a LiveKit server +__lk-e2e-test = ["livekit-data-stream/__e2e-test"] # end-to-end testing with a LiveKit server [dependencies] livekit-runtime = { workspace = true } @@ -54,6 +54,7 @@ semver = "1.0" libloading = { version = "0.8.6" } bytes = "1.10.1" bmrng = "0.5.2" +flate2 = "1" base64 = "0.22" [dev-dependencies] @@ -64,3 +65,4 @@ test-log = "0.2.18" test-case = "3.3" serial_test = "3.0" http = "1.1" +rand = { workspace = true } diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index d6097dbcb..89ee3947c 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -25,14 +25,18 @@ use libwebrtc::{ RtcError, }; use livekit_api::signal_client::{ - SignalOptions, SignalSdkOptions, CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT, - SIGNAL_CONNECT_TIMEOUT, + SignalOptions, SignalSdkOptions, CLIENT_PROTOCOL_DEFAULT, SIGNAL_CONNECT_TIMEOUT, +}; +use livekit_data_stream::{ + self as ds, + incoming::{IncomingDataStreamInput, IncomingDataStreamManager}, + outgoing::OutgoingDataStreamManager, }; use livekit_datatrack::{ api::{DataTrackSid, RemoteDataTrack}, backend as dt, }; -use livekit_protocol::{self as proto, encryption}; +use livekit_protocol as proto; use livekit_runtime::JoinHandle; use parking_lot::RwLock; pub use proto::DisconnectReason; @@ -48,7 +52,7 @@ use tokio::sync::{ pub use self::{ data_stream::*, e2ee::{manager::E2eeManager, E2eeOptions}, - participant::{ParticipantKind, ParticipantKindDetail, ParticipantState}, + participant::{ClientCapability, ParticipantKind, ParticipantKindDetail, ParticipantState}, }; pub use crate::rtc_engine::SimulateScenario; use crate::{ @@ -492,8 +496,8 @@ pub(crate) struct RoomSession { local_participant: LocalParticipant, remote_participants: RwLock>, e2ee_manager: E2eeManager, - incoming_stream_manager: IncomingStreamManager, - pub(crate) outgoing_stream_manager: OutgoingStreamManager, + incoming_data_stream_input: IncomingDataStreamInput, + pub(crate) outgoing_stream_manager: OutgoingDataStreamManager, local_dt_input: dt::local::ManagerInput, remote_dt_input: dt::remote::ManagerInput, pub(crate) rpc_client: rpc::RpcClientManager, @@ -503,7 +507,8 @@ pub(crate) struct RoomSession { struct Handle { room_handle: JoinHandle<()>, - incoming_stream_handle: JoinHandle<()>, + incoming_stream_task: JoinHandle<()>, + incoming_forward_task: JoinHandle<()>, outgoing_stream_handle: JoinHandle<()>, local_dt_task: JoinHandle<()>, local_dt_forward_task: JoinHandle<()>, @@ -577,6 +582,7 @@ impl Room { e2ee_manager.encryption_type(), pi.permission, pi.client_protocol, + pi.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(), ); let dispatcher = Dispatcher::::default(); @@ -679,10 +685,9 @@ impl Room { let (remote_dt_manager, remote_dt_input, remote_dt_output) = dt::remote::Manager::new(remote_dt_options); - let (incoming_stream_manager, open_rx) = IncomingStreamManager::new( - INTERNAL_DATA_STREAM_TOPICS.iter().map(|t| t.to_string()).collect(), - ); - let (outgoing_stream_manager, packet_rx) = OutgoingStreamManager::new(); + let (incoming_stream_manager, incoming_data_stream_input, incoming_output) = + IncomingDataStreamManager::new(INTERNAL_DATA_STREAM_TOPICS.into()); + let (outgoing_stream_manager, packet_rx) = OutgoingDataStreamManager::new(); let room_info = join_response.room.unwrap(); let inner = Arc::new(RoomSession { @@ -709,7 +714,7 @@ impl Room { local_participant, dispatcher: dispatcher.clone(), e2ee_manager: e2ee_manager.clone(), - incoming_stream_manager, + incoming_data_stream_input, outgoing_stream_manager, local_dt_input, remote_dt_input, @@ -761,6 +766,10 @@ impl Room { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; participant.update_info(pi.clone()); @@ -781,8 +790,9 @@ impl Room { let (close_tx, close_rx) = broadcast::channel(1); - let incoming_stream_handle = livekit_runtime::spawn(incoming_data_stream_task( - open_rx, + let incoming_stream_task = livekit_runtime::spawn(incoming_stream_manager.run()); + let incoming_forward_task = livekit_runtime::spawn(incoming_data_stream_task( + incoming_output, dispatcher.clone(), close_rx.resubscribe(), inner.clone(), @@ -807,7 +817,8 @@ impl Room { let handle = Handle { room_handle, - incoming_stream_handle, + incoming_stream_task, + incoming_forward_task, outgoing_stream_handle, local_dt_task, local_dt_forward_task, @@ -1106,7 +1117,8 @@ impl RoomSession { self.e2ee_manager.cleanup(); let _ = handle.close_tx.send(()); - let _ = handle.incoming_stream_handle.await; + let _ = handle.incoming_forward_task.await; + let _ = handle.incoming_stream_task.await; let _ = handle.outgoing_stream_handle.await; let _ = handle.local_dt_forward_task.await; let _ = handle.local_dt_task.await; @@ -1197,6 +1209,10 @@ impl RoomSession { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; @@ -1798,14 +1814,7 @@ impl RoomSession { participant_identity: String, encryption_type: proto::encryption::Type, ) { - let is_internal = is_internal_topic(&header.topic); - self.incoming_stream_manager.handle_header( - header.clone(), - participant_identity.clone(), - encryption_type, - ); - - // Update participant's data encryption status + // Update participant's data encryption status (room state the stream actor doesn't own). if let Some(participant) = self.remote_participants.read().get(&participant_identity.clone().into()).cloned() { @@ -1814,11 +1823,23 @@ impl RoomSession { participant.update_data_encryption_status(is_encrypted); } - if !is_internal { - // For backwards compatibly - let event = RoomEvent::StreamHeaderReceived { header, participant_identity }; + // Back-compat raw-header event (non-internal topics only). The header topic alone + // determines internal-ness, so it's gated here without consulting the actor. + if !is_internal_topic(&header.topic) { + let event = RoomEvent::StreamHeaderReceived { + header: header.clone(), + participant_identity: participant_identity.clone(), + }; self.dispatcher.dispatch(&event); } + + let _ = self.incoming_data_stream_input.send( + ds::incoming::events::PacketReceived::new( + Packet::Header { header: header.into(), encryption_type: encryption_type.into() }, + participant_identity.into(), + ) + .into(), + ); } fn handle_data_stream_chunk( @@ -1827,14 +1848,13 @@ impl RoomSession { participant_identity: String, encryption_type: proto::encryption::Type, ) { - let is_internal = self.incoming_stream_manager.is_internal(&chunk.stream_id); - self.incoming_stream_manager.handle_chunk(chunk.clone(), encryption_type); - - if !is_internal { - // For backwards compatibly - let event = RoomEvent::StreamChunkReceived { chunk, participant_identity }; - self.dispatcher.dispatch(&event); - } + let _ = self.incoming_data_stream_input.send( + ds::incoming::events::PacketReceived::new( + Packet::Chunk { chunk: chunk.into(), encryption_type: encryption_type.into() }, + participant_identity.into(), + ) + .into(), + ); } fn handle_data_stream_trailer( @@ -1842,16 +1862,13 @@ impl RoomSession { trailer: proto::data_stream::Trailer, participant_identity: String, ) { - // Check is_internal *before* handle_trailer, which removes the - // descriptor from the open-streams map. - let is_internal = self.incoming_stream_manager.is_internal(&trailer.stream_id); - self.incoming_stream_manager.handle_trailer(trailer.clone()); - - if !is_internal { - // For backwards compatibly - let event = RoomEvent::StreamTrailerReceived { trailer, participant_identity }; - self.dispatcher.dispatch(&event); - } + let _ = self.incoming_data_stream_input.send( + ds::incoming::events::PacketReceived::new( + Packet::Trailer(trailer.into()), + participant_identity.into(), + ) + .into(), + ); } fn handle_data_channel_buffered_low_threshold_change( @@ -2002,6 +2019,7 @@ impl RoomSession { joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> RemoteParticipant { let participant = RemoteParticipant::new( self.rtc_engine.clone(), @@ -2017,6 +2035,7 @@ impl RoomSession { self.options.auto_subscribe, permission, client_protocol, + capabilities, ); participant.on_track_published({ @@ -2145,6 +2164,14 @@ impl RoomSession { let mut participants = self.remote_participants.write(); participants.remove(&remote_participant.identity()); + drop(participants); + + // Terminate any data streams this participant was still sending; otherwise their + // readers would hang waiting for chunks that will never arrive. + let _ = self.incoming_data_stream_input.send( + ds::incoming::events::InputEvent::AbortStreamsFrom(remote_participant.identity()), + ); + self.dispatcher.dispatch(&RoomEvent::ParticipantDisconnected(remote_participant)); } @@ -2235,27 +2262,54 @@ impl RoomSession { } } -/// Receives stream readers for newly-opened streams and dispatches room events. +impl livekit_common::RemoteParticipantRegistry for RoomSession { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.get_remote_client_protocol(identity) + } + + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.remote_participants.read().get(identity).map(|p| p.capabilities()).unwrap_or_default() + } + + fn remote_identities(&self) -> Vec { + self.remote_participants.read().keys().cloned().collect() + } +} + +/// Data stream topics reserved for internal SDK use (e.g. RPC). Events for these topics are +/// handled within the `livekit` crate and never surfaced through `RoomEvent`; the list is also +/// passed to `IncomingStreamManager` so it can flag internal streams. +const INTERNAL_DATA_STREAM_TOPICS: &[&str] = &[rpc::RPC_REQUEST_TOPIC, rpc::RPC_RESPONSE_TOPIC]; + +fn is_internal_topic(topic: &str) -> bool { + INTERNAL_DATA_STREAM_TOPICS.contains(&topic) +} + +/// Consumes [`IncomingOutput`]s from the incoming-stream actor and turns them into room events. /// -/// Intercepts text streams on RPC topics (`lk.rpc_request`, `lk.rpc_response`) -/// and routes them to the RPC managers instead of emitting them as room events. +/// For newly-opened streams, intercepts text streams on RPC topics (`lk.rpc_request`, +/// `lk.rpc_response`) and routes them to the RPC managers instead of surfacing them. Also +/// forwards the back-compat raw chunk/trailer notifications the actor emits for non-internal +/// streams. async fn incoming_data_stream_task( - mut open_rx: UnboundedReceiver<(AnyStreamReader, String)>, + mut output: UnboundedReceiver, dispatcher: Dispatcher, mut close_rx: broadcast::Receiver<()>, session: Arc, ) { loop { tokio::select! { - Some((reader, identity)) = open_rx.recv() => { - match reader { + Some(event) = output.recv() => match event { + ds::incoming::events::OutputEvent::StreamOpened( + ds::incoming::events::StreamOpened { stream_reader, participant_identity } + ) => match stream_reader { AnyStreamReader::Byte(reader) => { let topic = reader.info().topic.clone(); if !is_internal_topic(&topic) { dispatcher.dispatch(&RoomEvent::ByteStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(identity) + participant_identity, }); } } @@ -2263,13 +2317,12 @@ async fn incoming_data_stream_task( let topic = reader.info().topic.clone(); match topic.as_str() { rpc::RPC_REQUEST_TOPIC => { - let caller_identity = ParticipantIdentity(identity); let session = session.clone(); livekit_runtime::spawn(async move { let transport = rpc::SessionTransport(session.clone()); session.rpc_server.handle_v2_request_stream( reader, - caller_identity, + participant_identity, &transport, ).await; }); @@ -2285,12 +2338,18 @@ async fn incoming_data_stream_task( dispatcher.dispatch(&RoomEvent::TextStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(identity) + participant_identity, }); } } } } + }, + ds::incoming::events::OutputEvent::ChunkReceived(ds::incoming::events::ChunkReceived { chunk, participant_identity }) => { + dispatcher.dispatch(&RoomEvent::StreamChunkReceived { chunk: chunk.into(), participant_identity: participant_identity.into() }); + } + ds::incoming::events::OutputEvent::TrailerReceived(ds::incoming::events::TrailerReceived { trailer, participant_identity }) => { + dispatcher.dispatch(&RoomEvent::StreamTrailerReceived { trailer: trailer.into(), participant_identity: participant_identity.into() }); } }, _ = close_rx.recv() => { @@ -2300,15 +2359,6 @@ async fn incoming_data_stream_task( } } -/// Data stream topics reserved for internal SDK use (e.g. RPC). Events for these topics are -/// handled within the `livekit` crate and never surfaced through `RoomEvent`; the list is also -/// passed to `IncomingStreamManager` so it can flag internal streams. -const INTERNAL_DATA_STREAM_TOPICS: &[&str] = &[rpc::RPC_REQUEST_TOPIC, rpc::RPC_RESPONSE_TOPIC]; - -fn is_internal_topic(topic: &str) -> bool { - INTERNAL_DATA_STREAM_TOPICS.contains(&topic) -} - /// Receives packets from the outgoing stream manager and send them. async fn outgoing_data_stream_task( mut packet_rx: UnboundedRequestReceiver>, diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index b02e01e97..5cd04f7b4 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -23,8 +23,8 @@ use std::{ }; use super::{ - ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, ParticipantState, - ParticipantTrackPermission, + ClientCapability, ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, + ParticipantState, ParticipantTrackPermission, }; use crate::{ data_stream::{ @@ -110,6 +110,7 @@ impl LocalParticipant { encryption_type: EncryptionType, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Self { Self { inner: super::new_inner( @@ -125,6 +126,7 @@ impl LocalParticipant { joined_at, permission, client_protocol, + capabilities, ), local: Arc::new(LocalInfo { events: LocalEvents::default(), @@ -951,7 +953,8 @@ impl LocalParticipant { text: &str, options: StreamTextOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_text(text, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_text(text, options, session.as_ref()).await } /// Send a file on disk to participants in the room. @@ -971,7 +974,8 @@ impl LocalParticipant { path: impl AsRef, options: StreamByteOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_file(path, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_file(path, options, session.as_ref()).await } /// Send an in-memory blob of bytes to participants in the room. @@ -988,7 +992,8 @@ impl LocalParticipant { data: impl AsRef<[u8]>, options: StreamByteOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_bytes(data, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_bytes(data, options, session.as_ref()).await } /// Stream text incrementally to participants in the room. diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index 5dcdc83d7..9f8abad0e 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -85,6 +85,8 @@ pub enum DisconnectReason { AgentError, } +pub use livekit_common::ClientCapability; + #[derive(Debug, Clone)] pub enum Participant { Local(LocalParticipant), @@ -146,6 +148,7 @@ struct ParticipantInfo { pub joined_at: i64, pub permission: Option, pub client_protocol: i32, + pub capabilities: Vec, } type TrackMutedHandler = Box; @@ -197,6 +200,7 @@ pub(super) fn new_inner( joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Arc { Arc::new(ParticipantInner { rtc_engine, @@ -216,6 +220,7 @@ pub(super) fn new_inner( joined_at, permission, client_protocol, + capabilities, }), track_publications: Default::default(), events: Default::default(), @@ -269,6 +274,8 @@ pub(super) fn update_info( } info.client_protocol = new_info.client_protocol; + info.capabilities = + new_info.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(); } pub(super) fn set_speaking( diff --git a/livekit/src/room/participant/remote_participant.rs b/livekit/src/room/participant/remote_participant.rs index da0f6a663..6909cdc0d 100644 --- a/livekit/src/room/participant/remote_participant.rs +++ b/livekit/src/room/participant/remote_participant.rs @@ -25,8 +25,8 @@ use livekit_runtime::timeout; use parking_lot::Mutex; use super::{ - ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, ParticipantState, - TrackKind, + ClientCapability, ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, + ParticipantState, TrackKind, }; use crate::{prelude::*, rtc_engine::RtcEngine, track::TrackError}; @@ -86,6 +86,7 @@ impl RemoteParticipant { auto_subscribe: bool, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Self { Self { inner: super::new_inner( @@ -101,6 +102,7 @@ impl RemoteParticipant { joined_at, permission, client_protocol, + capabilities, ), remote: Arc::new(RemoteInfo { events: Default::default(), auto_subscribe }), } @@ -577,6 +579,11 @@ impl RemoteParticipant { self.inner.info.read().client_protocol } + /// The capabilities this remote participant's client advertised at join. + pub fn capabilities(&self) -> Vec { + self.inner.info.read().capabilities.clone() + } + pub fn is_encrypted(&self) -> bool { *self.inner.is_encrypted.read() } diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 8f14647fa..bc5f55bce 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -23,6 +23,8 @@ pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; +use crate::room::participant::ClientCapability; +use livekit_common::RemoteParticipantRegistry; use livekit_protocol::RpcError as RpcError_Proto; use std::{error::Error, fmt::Display, future::Future, time::Duration}; @@ -45,7 +47,7 @@ pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; /// /// Decouples the RPC managers from concrete engine/session types, /// enabling in-memory unit testing with a mock transport. -pub(crate) trait RpcTransport: Send + Sync { +pub(crate) trait RpcTransport: RemoteParticipantRegistry { /// Send a data packet (used for v1 RPC packets and ACKs). fn publish_data( &self, @@ -59,9 +61,6 @@ pub(crate) trait RpcTransport: Send + Sync { options: StreamTextOptions, ) -> impl Future> + Send; - /// Look up a remote participant's client_protocol value. - fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32; - /// Get the server version string, if available. fn server_version(&self) -> Option; } @@ -69,6 +68,20 @@ pub(crate) trait RpcTransport: Send + Sync { /// Production implementation of `RpcTransport` backed by a `RoomSession`. pub(crate) struct SessionTransport(pub(crate) std::sync::Arc); +impl RemoteParticipantRegistry for SessionTransport { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.0.remote_client_protocol(identity) + } + + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.0.remote_capabilities(identity) + } + + fn remote_identities(&self) -> Vec { + self.0.remote_identities() + } +} + impl RpcTransport for SessionTransport { async fn publish_data( &self, @@ -86,11 +99,7 @@ impl RpcTransport for SessionTransport { text: &str, options: StreamTextOptions, ) -> StreamResult { - self.0.outgoing_stream_manager.send_text(text, options).await - } - - fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { - self.0.get_remote_client_protocol(identity) + self.0.outgoing_stream_manager.send_text(text, options, self.0.as_ref()).await } fn server_version(&self) -> Option { diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index b4fc3d5e9..8ba573c17 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -18,10 +18,12 @@ use crate::data_stream::{ }; use crate::e2ee::EncryptionType; use crate::room::id::ParticipantIdentity; +use crate::room::participant::ClientCapability; use crate::room::RoomError; use bytes::Bytes; use chrono::Utc; use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; +use livekit_common::RemoteParticipantRegistry; use livekit_protocol as proto; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; @@ -132,15 +134,29 @@ impl RpcTransport for MockTransport { attached_stream_ids: vec![], generated: false, encryption_type: EncryptionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_compressed: false, + #[cfg(feature = "__lk-e2e-test")] + is_inline: false, }) } + fn server_version(&self) -> Option { + self.server_ver.clone() + } +} + +impl RemoteParticipantRegistry for MockTransport { fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { self.remote_protocols.get(&identity.0).copied().unwrap_or(CLIENT_PROTOCOL_DEFAULT) } - fn server_version(&self) -> Option { - self.server_ver.clone() + fn remote_capabilities(&self, _identity: &ParticipantIdentity) -> Vec { + Vec::new() + } + + fn remote_identities(&self) -> Vec { + self.remote_protocols.keys().map(|k| ParticipantIdentity(k.clone())).collect() } } @@ -170,6 +186,10 @@ fn make_text_reader( attached_stream_ids: vec![], generated: false, encryption_type: EncryptionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_compressed: false, + #[cfg(feature = "__lk-e2e-test")] + is_inline: false, }, rx, ) diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index 911a37548..882bd24a6 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -18,6 +18,7 @@ use { anyhow::{anyhow, Ok, Result}, chrono::{TimeDelta, Utc}, livekit::{RoomEvent, StreamByteOptions, StreamReader, StreamTextOptions}, + rand::{rngs::StdRng, RngCore, SeedableRng}, std::time::Duration, tokio::{time::timeout, try_join}, }; @@ -46,6 +47,8 @@ async fn test_send_bytes() -> Result<()> { assert!(stream_info.total_length.is_some()); assert_eq!(stream_info.mime_type, "application/octet-stream"); assert_eq!(stream_info.topic, "some-topic"); + assert_eq!(stream_info.is_compressed, true); + assert_eq!(stream_info.is_inline, true); Ok(()) }; @@ -70,6 +73,164 @@ async fn test_send_bytes() -> Result<()> { Ok(()) } +/// End-to-end round-trip of a large, somewhat-compressible text. Both peers are Rust SDK +/// clients advertising data streams v2 + deflate-raw, so the sender compresses across multiple +/// chunks and the receiver decompresses — validating the v2 compression path on the real wire. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_compressible_text() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + // ~50 KB of deterministic pseudo-random lowercase: too big to inline, compresses well + // under its raw size, exercising the chunked-compressed path. + let mut text = String::new(); + let mut state: u64 = 0x1234_5678_9abc_def0; + for _ in 0..50_000 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + text.push((b'a' + ((state >> 33) % 26) as u8) as char); + } + let expected = text.clone(); + + let send = async move { + let options = StreamTextOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_text(&text, options).await?; + assert_eq!(stream_info.is_compressed, true); + assert_eq!(stream_info.is_inline, false); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::TextStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload, validating the v2 byte-stream path. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_incompressible_random_bytes() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + // Uniform random bytes are genuinely incompressible: deflate cannot shrink them, so the + // send path must choose CompressionType::None. Seeded for a deterministic, reproducible test. + let mut rng = StdRng::seed_from_u64(0xC0FFEE); + let mut payload = vec![0u8; 1_800_000]; + rng.fill_bytes(&mut payload); + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, false, "is_compressed was not false"); + assert_eq!(stream_info.is_inline, false, "is_inline was not false"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload, validating the v2 byte-stream path. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_bytes() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + let payload: Vec = (0..50_000u32).map(|i| (i % 251) as u8).collect(); + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, true, "is_compressed was not true"); + assert_eq!(stream_info.is_inline, true, "is_inline was not true"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload set with compress=false doesn't +/// compress the payload +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_data_stream_compress_false() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + let payload = vec![0xFFu8; 50_000]; + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { + topic: "some-topic".into(), + compress: Some(false), // <= Explictly disable compression + ..Default::default() + }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, false, "is_compressed was not false"); + assert_eq!(stream_info.is_inline, false, "is_inline was not false"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + #[cfg(feature = "__lk-e2e-test")] #[tokio::test] async fn test_send_text() -> Result<()> {