From 2e097f0f7893f593d0ebc0070286594c4143c7bb Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 26 Jun 2026 12:35:11 -0400 Subject: [PATCH 01/46] feat: commit initial data streams v2 implementation --- Cargo.lock | 1 + livekit-api/src/signal_client/mod.rs | 14 +- livekit-ffi/src/conversion/data_stream.rs | 4 + livekit-protocol/protocol | 2 +- livekit/Cargo.toml | 1 + livekit/src/prelude.rs | 4 +- livekit/src/room/data_stream/incoming.rs | 521 +++++++++- livekit/src/room/data_stream/mod.rs | 6 + livekit/src/room/data_stream/outgoing.rs | 930 +++++++++++++++--- livekit/src/room/mod.rs | 21 +- .../src/room/participant/local_participant.rs | 15 +- livekit/src/room/participant/mod.rs | 42 + .../room/participant/remote_participant.rs | 16 +- livekit/src/room/rpc/mod.rs | 42 +- livekit/src/room/rpc/tests.rs | 15 +- livekit/tests/data_stream_test.rs | 75 ++ 16 files changed, 1531 insertions(+), 178 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f6ac222fe..fc9a2cd60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3919,6 +3919,7 @@ dependencies = [ "bmrng", "bytes", "chrono", + "flate2", "futures-util", "http 1.4.0", "lazy_static", diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 76b7ba491..8a76dcf4b 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -57,18 +57,26 @@ 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]; +/// +/// `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, +]; /// Default value for `ClientInfo.client_protocol` when a participant has not /// advertised one (treat as v1-only / no data-stream RPC support). pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; /// `ClientInfo.client_protocol` value indicating support for RPC v2 over data streams. pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; +/// `ClientInfo.client_protocol` value indicating support for data streams v2 +/// (inline single-packet sends; compression is gated separately via capabilities). +pub const CLIENT_PROTOCOL_DATA_STREAM_V2: i32 = 2; /// 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-ffi/src/conversion/data_stream.rs b/livekit-ffi/src/conversion/data_stream.rs index a59da350b..c47fe7423 100644 --- a/livekit-ffi/src/conversion/data_stream.rs +++ b/livekit-ffi/src/conversion/data_stream.rs @@ -72,6 +72,8 @@ impl From for StreamTextOptions { reply_to_stream_id: options.reply_to_stream_id, attached_stream_ids: options.attached_stream_ids, generated: options.generated, + // Compression opt-out is not yet exposed over FFI; default to on. + compress: None, } } } @@ -90,6 +92,8 @@ impl From for StreamByteOptions { name: options.name, mime_type: options.mime_type, total_length: options.total_length, + // Compression opt-out is not yet exposed over FFI; default to on. + compress: None, } } } diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index df0314e18..357f7686d 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit df0314e189f0ab695005c5edc10f087b5a36ad23 +Subproject commit 357f7686de5bab17e1d3e3f1b8b805dba5a0ff56 diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 3eb83261e..1018c3175 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -52,6 +52,7 @@ semver = "1.0" libloading = { version = "0.8.6" } bytes = "1.10.1" bmrng = "0.5.2" +flate2 = "1" base64 = "0.22" [dev-dependencies] diff --git a/livekit/src/prelude.rs b/livekit/src/prelude.rs index 374558731..bef9401af 100644 --- a/livekit/src/prelude.rs +++ b/livekit/src/prelude.rs @@ -22,8 +22,8 @@ pub use crate::{ }, id::*, participant::{ - ConnectionQuality, DisconnectReason, LocalParticipant, Participant, PerformRpcData, - RemoteParticipant, RpcError, RpcErrorCode, RpcInvocationData, + ClientCapability, ConnectionQuality, DisconnectReason, LocalParticipant, Participant, + PerformRpcData, RemoteParticipant, RpcError, RpcErrorCode, RpcInvocationData, }, publication::{LocalTrackPublication, RemoteTrackPublication, TrackPublication}, track::{ diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index 213a68c07..339f4f459 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -238,9 +238,76 @@ struct Descriptor { chunk_tx: UnboundedSender>, encryption_type: EncryptionType, 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` iff 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. +struct DeflateDecompressState { + decompress: flate2::Decompress, + /// Decompressed text bytes not yet yielded because they end mid-codepoint. + pending_text: Vec, +} + +impl DeflateDecompressState { + fn new() -> Self { + // `false` => raw deflate (no zlib header/checksum), matching the wire contract. + Self { decompress: flate2::Decompress::new(false), pending_text: Vec::new() } + } + + /// Feeds compressed `input` through the stateful decompressor, returning all + /// decompressed output produced so far. + fn run(&mut self, input: &[u8]) -> StreamResult> { + let mut out = Vec::new(); + let mut buf = vec![0u8; 16384]; + let mut offset = 0; + loop { + let in_before = self.decompress.total_in(); + let out_before = self.decompress.total_out(); + let status = self + .decompress + .decompress(&input[offset..], &mut buf, flate2::FlushDecompress::None) + .map_err(|_| StreamError::Decompression)?; + let consumed = (self.decompress.total_in() - in_before) as usize; + let produced = (self.decompress.total_out() - out_before) as usize; + offset += consumed; + out.extend_from_slice(&buf[..produced]); + match status { + flate2::Status::StreamEnd => break, + // No progress and no input left to feed: wait for the next chunk. + _ if consumed == 0 && produced == 0 => break, + _ => {} + } + } + Ok(out) + } + + /// 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. +fn inflate_raw(data: &[u8]) -> StreamResult> { + use std::io::Read; + let mut decoder = flate2::read::DeflateDecoder::new(data); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).map_err(|_| StreamError::Decompression)?; + Ok(out) +} + #[derive(Clone)] pub(crate) struct IncomingStreamManager { inner: Arc>, @@ -261,11 +328,15 @@ impl IncomingStreamManager { /// Handles an incoming header packet. pub fn handle_header( &self, - header: proto::Header, + mut header: proto::Header, identity: String, encryption_type: livekit_protocol::encryption::Type, ) { let is_internal = super::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() == proto::CompressionType::DeflateRaw; + let Ok(info) = AnyStreamInfo::try_from_with_encryption(header, encryption_type.into()) .inspect_err(|e| log::error!("Invalid header: {}", e)) else { @@ -273,6 +344,7 @@ impl IncomingStreamManager { }; let id = info.id().to_owned(); + let is_text = matches!(info, AnyStreamInfo::Text(_)); let bytes_total = info.total_length(); let stream_encryption_type = info.encryption_type(); @@ -285,11 +357,38 @@ impl IncomingStreamManager { let (reader, chunk_tx) = AnyStreamReader::from(info); let _ = self.open_tx.send((reader, identity)); + // 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) { + 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, is_internal, + is_text, + decompressor: is_compressed.then(DeflateDecompressState::new), + last_chunk_index: None, }; inner.open_streams.insert(id, descriptor); } @@ -318,6 +417,67 @@ impl IncomingStreamManager { return; } + if descriptor.decompressor.is_some() { + // --- 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)> = { + let state = descriptor.decompressor.as_mut().unwrap(); + match state.run(&chunk.content) { + Ok(decompressed) => { + let produced = decompressed.len() as u64; + let yielded = if is_text { + state.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; @@ -382,3 +542,362 @@ impl ManagerInner { } } } + +#[cfg(test)] +mod tests { + use super::*; + use livekit_protocol::encryption::Type as EncType; + 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). + fn pseudo_random_text(len: usize) -> String { + let mut text = String::with_capacity(len); + let mut state: u64 = 0xdead_beef_cafe_babe; + for _ in 0..len { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + text.push((b'a' + ((state >> 33) % 26) as u8) as char); + } + text + } + + #[allow(clippy::too_many_arguments)] + 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 recv_reader( + rx: &mut UnboundedReceiver<(AnyStreamReader, String)>, + ) -> (AnyStreamReader, String) { + rx.recv().await.expect("a reader should be dispatched") + } + + 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"), + } + } + + // --- v1 (legacy multi-packet) -------------------------------------------------------- + + #[tokio::test] + async fn v1_text_stream_round_trips() { + let (mgr, mut rx) = IncomingStreamManager::new(); + 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) = recv_reader(&mut rx).await; + 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 v1_byte_stream_round_trips() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(4), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + 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 v1_merges_trailer_attributes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + 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, _) = recv_reader(&mut rx).await; + 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 v1_errors_when_too_few_bytes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + 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 v1_errors_when_too_many_bytes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(3), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + 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 v1_drops_on_encryption_type_mismatch() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(2), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'i']), EncType::Gcm); + assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); + } + + // --- v2 inline ----------------------------------------------------------------------- + + #[tokio::test] + async fn v2_inline_uncompressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "inline hello"; + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(text.as_bytes().to_vec()), + proto::CompressionType::None, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).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 (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(3), Some(vec![1, 2, 3]), proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); + } + + #[tokio::test] + async fn v2_inline_compressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "hello hello compressible world"; + let compressed = deflate_raw(text.as_bytes()); + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(compressed), + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).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 (mgr, mut rx) = IncomingStreamManager::new(); + let payload: Vec = (0..2000).map(|i| (i % 7) as u8).collect(); + let compressed = deflate_raw(&payload); + mgr.handle_header( + byte_header( + "s1", + Some(payload.len() as u64), + Some(compressed), + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); + } + + // --- v2 multi-packet compressed ------------------------------------------------------ + + #[tokio::test] + async fn v2_multipacket_compressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + // ~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"); + + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + for (i, piece) in chunk_pieces.iter().enumerate() { + mgr.handle_chunk(chunk("s1", i as u64, piece.to_vec()), EncType::None); + } + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v2_compressed_gap_errors() { + let (mgr, mut rx) = IncomingStreamManager::new(); + 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); + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, pieces[0].to_vec()), EncType::None); + // Skip index 1 -> feed index 2: a gap is a hard error. + mgr.handle_chunk(chunk("s1", 2, pieces[1].to_vec()), EncType::None); + assert!(matches!(read_text(reader).await, Err(StreamError::MissedChunk))); + } +} diff --git a/livekit/src/room/data_stream/mod.rs b/livekit/src/room/data_stream/mod.rs index 1be6cce7b..43bac0a3c 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit/src/room/data_stream/mod.rs @@ -75,6 +75,12 @@ pub enum StreamError { #[error("encryption type mismatch")] EncryptionTypeMismatch, + + #[error("stream header exceeds maximum size")] + HeaderTooLarge, + + #[error("decompression failed")] + Decompression, } /// Progress of a data stream. diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index 5c3e4c431..6853b9b2f 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -16,13 +16,17 @@ use super::{ ByteStreamInfo, OperationType, StreamError, StreamProgress, StreamResult, TextStreamInfo, }; use crate::{ - id::ParticipantIdentity, rtc_engine::EngineError, utils::utf8_chunk::Utf8AwareChunkExt, + id::ParticipantIdentity, room::participant::ClientCapability, + room::rpc::RemoteParticipantRegistry, rtc_engine::EngineError, + utils::utf8_chunk::Utf8AwareChunkExt, }; use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; use chrono::Utc; use libwebrtc::native::create_random_uuid; +use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_V2; use livekit_protocol as proto; -use std::{collections::HashMap, path::Path, sync::Arc}; +use proto::data_stream::CompressionType; +use std::{collections::HashMap, io::Write, path::Path, sync::Arc}; use tokio::{io::AsyncReadExt, sync::Mutex}; /// Writer for an open data stream. @@ -76,7 +80,7 @@ impl<'a> StreamWriter<'a> for ByteStreamWriter { async fn write(&self, bytes: &'a [u8]) -> StreamResult<()> { let mut stream = self.stream.lock().await; - for chunk in bytes.chunks(CHUNK_SIZE) { + for chunk in bytes.chunks(STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } Ok(()) @@ -91,23 +95,6 @@ impl<'a> StreamWriter<'a> for ByteStreamWriter { } } -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; @@ -118,7 +105,7 @@ impl<'a> StreamWriter<'a> for TextStreamWriter { 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) { + for chunk in text.as_bytes().utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } Ok(()) @@ -171,6 +158,65 @@ impl RawStream { 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. + async fn write_raw_chunks(&mut self, bytes: &[u8]) -> StreamResult<()> { + for chunk in bytes.chunks(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. + 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() >= STREAM_CHUNK_SIZE_BYTES { + let rest = encoder.get_mut().split_off(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() >= STREAM_CHUNK_SIZE_BYTES { + let rest = pending.split_off(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(()) + } + async fn close(&mut self, reason: Option<&str>) -> StreamResult<()> { if self.is_closed { Err(StreamError::AlreadyClosed)? @@ -263,6 +309,9 @@ pub struct StreamByteOptions { 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. @@ -277,6 +326,9 @@ pub struct StreamTextOptions { 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)] @@ -293,31 +345,16 @@ impl OutgoingStreamManager { } 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, - }; + // Incremental streams are never inlined or compressed (the content is unknown up front). + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + 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: options.destination_identities, + destination_identities: dests, packet_tx: self.packet_tx.clone(), }; let writer = TextStreamWriter { @@ -328,26 +365,22 @@ impl OutgoingStreamManager { } 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 stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + 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: options.destination_identities, + destination_identities: dests, packet_tx: self.packet_tx.clone(), }; let writer = ByteStreamWriter { @@ -361,43 +394,56 @@ impl OutgoingStreamManager { &self, text: &str, options: StreamTextOptions, + registry: &dyn RemoteParticipantRegistry, ) -> 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 stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let total_length = text.len() as u64; + let payload = text.as_bytes(); + let dests = options.destination_identities.clone(); + + let eligibility = evaluate_eligibility(registry, &dests); + let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + + // 1. Inline single-packet attempt (no attachments; all recipients are v2). + if eligibility.inline && options.attached_stream_ids.is_empty() { + let (content, compression) = maybe_compress_inline(payload, compress_ok); + let (header, text_header) = build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(content), + compression, + ); + if header_packet_fits(&header, &dests) { + let packet = RawStream::create_header_packet(header.clone(), dests); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(TextStreamInfo::from_headers(header, text_header)); + } + // Otherwise (large payload), fall through to the chunked path. + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + let compression = + if compress_ok { CompressionType::DeflateRaw } else { CompressionType::None }; + let (header, text_header) = + build_text_header(&options, stream_id, Some(total_length), None, compression); + enforce_header_size(&header, &dests)?; + let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: options.destination_identities, + destination_identities: dests, 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?; - + let info = TextStreamInfo::from_headers(header, text_header); + let mut stream = RawStream::open(open_options).await?; + if compress_ok { + stream.write_raw_chunks(&deflate_raw(payload)).await?; + } else { + for chunk in payload.utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { + stream.write_chunk(chunk).await?; + } + } + stream.close(None).await?; Ok(info) } @@ -408,100 +454,240 @@ impl OutgoingStreamManager { /// 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`. + /// 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, + 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 = options.id.clone().unwrap_or_else(create_random_uuid); + let name = options.name.clone().unwrap_or_else(|| BYTE_DEFAULT_NAME.to_owned()); + let total_length = bytes.len() as u64; + let dests = options.destination_identities.clone(); - 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 eligibility = evaluate_eligibility(registry, &dests); + let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + + // 1. Inline single-packet attempt (all recipients are v2). + if eligibility.inline { + let (content, compression) = maybe_compress_inline(bytes, compress_ok); + let (header, byte_header) = build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), + Some(content), + compression, + ); + if header_packet_fits(&header, &dests) { + let packet = RawStream::create_header_packet(header.clone(), dests); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(ByteStreamInfo::from_headers(header, byte_header)); + } + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + let compression = + if compress_ok { CompressionType::DeflateRaw } else { CompressionType::None }; + let (header, byte_header) = + build_byte_header(&options, stream_id, name, Some(total_length), None, compression); + enforce_header_size(&header, &dests)?; let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: options.destination_identities, + destination_identities: dests, 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?; - + let info = ByteStreamInfo::from_headers(header, byte_header); + let mut stream = RawStream::open(open_options).await?; + if compress_ok { + stream.write_raw_chunks(&deflate_raw(bytes)).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, + registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { - let file_size = tokio::fs::metadata(path.as_ref()) + let path = path.as_ref(); + let file_size = tokio::fs::metadata(path) .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, - }; + .map_err(StreamError::from)?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_owned(); + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let dests = options.destination_identities.clone(); + + let eligibility = evaluate_eligibility(registry, &dests); + let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + let compression = + if compress_ok { 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: options.destination_identities, + destination_identities: dests, 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 = ByteStreamInfo::from_headers(header, byte_header); + let mut stream = RawStream::open(open_options).await?; + stream.write_file(path, compress_ok).await?; + stream.close(None).await?; + Ok(info) + } +} - let info = (*writer.info).clone(); - writer.write_file_contents(path).await?; - writer.close().await?; +/// 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, +} - Ok(info) +/// 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 } +} + +/// Returns the inline payload and its compression flag: deflate-raw compressed when +/// `compress` is set AND the compressed form is actually smaller, else the raw bytes. +fn maybe_compress_inline(payload: &[u8], compress: bool) -> (Vec, CompressionType) { + if compress { + let compressed = deflate_raw(payload); + if compressed.len() < payload.len() { + return (compressed, CompressionType::DeflateRaw); + } + } + (payload.to_vec(), CompressionType::None) +} + +/// One-shot deflate-raw (raw DEFLATE, no zlib/gzip wrapper) of the full payload. +fn deflate_raw(data: &[u8]) -> Vec { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(data).expect("deflate write to Vec is infallible"); + encoder.finish().expect("deflate finish into Vec is infallible") +} + +/// 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() <= 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: &proto::data_stream::Header, + destinations: &[ParticipantIdentity], +) -> StreamResult<()> { + if header_packet_fits(header, destinations) { + Ok(()) + } else { + Err(StreamError::HeaderTooLarge) } } -/// Maximum number of bytes to send in a single chunk. -static CHUNK_SIZE: usize = 15000; +fn build_text_header( + options: &StreamTextOptions, + stream_id: String, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (proto::data_stream::Header, proto::data_stream::TextHeader) { + 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.clone().unwrap_or_default(), + attached_stream_ids: options.attached_stream_ids.clone(), + generated: options.generated.unwrap_or_default(), + }; + let header = proto::data_stream::Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: TEXT_MIME_TYPE.to_owned(), + total_length, + encryption_type: proto::encryption::Type::None.into(), + attributes: options.attributes.clone(), + content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( + text_header.clone(), + )), + inline_content, + compression: compression as i32, + }; + (header, text_header) +} + +fn build_byte_header( + options: &StreamByteOptions, + stream_id: String, + name: String, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (proto::data_stream::Header, proto::data_stream::ByteHeader) { + let byte_header = proto::data_stream::ByteHeader { name }; + let header = proto::data_stream::Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: options.mime_type.clone().unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), + total_length, + encryption_type: proto::encryption::Type::None.into(), + attributes: options.attributes.clone(), + content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( + byte_header.clone(), + )), + inline_content, + compression: compression as i32, + }; + (header, byte_header) +} + +/// Max chunk content size AND the header-packet MTU budget. Kept below the ~16 KB +/// data-channel MTU for protocol/E2EE framing headroom. +const STREAM_CHUNK_SIZE_BYTES: usize = 15000; // Default MIME type to use for byte streams. static BYTE_MIME_TYPE: &str = "application/octet-stream"; @@ -509,9 +695,449 @@ static BYTE_MIME_TYPE: &str = "application/octet-stream"; /// Default MIME type to use for text streams. static TEXT_MIME_TYPE: &str = "text/plain"; +/// Default name for `send_bytes` byte-stream headers. +static BYTE_DEFAULT_NAME: &str = "unknown"; + #[cfg(test)] mod tests { use super::*; + use std::sync::Mutex as StdMutex; + + const V2: i32 = CLIENT_PROTOCOL_DATA_STREAM_V2; + const DEFLATE: ClientCapability = ClientCapability::CompressionDeflateRaw; + + // --- 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", 0, &[]).add("bob", 0, &[]).add("jim", 1, &[]) + } + + fn all_v2_room() -> FakeRegistry { + FakeRegistry::new().add("alice", V2, &[DEFLATE]).add("bob", V2, &[DEFLATE]).add( + "noCompression", + V2, + &[], + ) + } + + fn mixed_room() -> FakeRegistry { + FakeRegistry::new() + .add("alice", 0, &[]) + .add("bob", V2, &[DEFLATE]) + .add("jim", V2, &[DEFLATE]) + .add("mallory", 1, &[]) + .add("noCompression", V2, &[]) + } + + // --- Capture harness ----------------------------------------------------------------- + + type Sent = Arc>>; + + fn setup() -> (OutgoingStreamManager, Sent) { + let (manager, mut packet_rx) = OutgoingStreamManager::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. + fn somewhat_compressible(blocks: usize) -> String { + let mut s = String::new(); + let mut state: u64 = 0x1234_5678_9abc_def0; + for _ in 0..blocks { + s.push_str("hello world"); + for _ in 0..1000 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + s.push((b'a' + ((state >> 33) % 26) as u8) 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(STREAM_CHUNK_SIZE_BYTES); + assert!(chunks.len() >= 2); + assert!(chunks.len() < uncompressed_chunks); + assert_eq!(chunks[0].content.len(), 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 diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 6cfd57605..0d6a9c8a0 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -48,7 +48,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::{ @@ -577,6 +577,7 @@ impl Room { e2ee_manager.encryption_type(), pi.permission, pi.client_protocol, + pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), ); let dispatcher = Dispatcher::::default(); @@ -759,6 +760,7 @@ impl Room { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), ) }; participant.update_info(pi.clone()); @@ -1195,6 +1197,7 @@ impl RoomSession { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), ) }; @@ -2000,6 +2003,7 @@ impl RoomSession { joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> RemoteParticipant { let participant = RemoteParticipant::new( self.rtc_engine.clone(), @@ -2015,6 +2019,7 @@ impl RoomSession { self.options.auto_subscribe, permission, client_protocol, + capabilities, ); participant.on_track_published({ @@ -2233,6 +2238,20 @@ impl RoomSession { } } +impl rpc::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() + } +} + /// Receives stream readers for newly-opened streams and dispatches room events. /// /// Intercepts text streams on RPC topics (`lk.rpc_request`, `lk.rpc_response`) 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..1ff2f6b58 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -85,6 +85,44 @@ pub enum DisconnectReason { AgentError, } +/// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). +/// +/// Stored typed rather than as the raw protobuf `i32` so the public accessor doesn't leak +/// protobuf types, while `Unknown` preserves values this SDK build doesn't recognize. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum ClientCapability { + PacketTrailer, + CompressionDeflateRaw, + Unknown(i32), +} + +impl From for ClientCapability { + fn from(value: i32) -> Self { + match proto::client_info::Capability::try_from(value) { + Ok(proto::client_info::Capability::CapPacketTrailer) => Self::PacketTrailer, + Ok(proto::client_info::Capability::CapCompressionDeflateRaw) => { + Self::CompressionDeflateRaw + } + // `CapUnused` and any value not recognized by this build. + _ => Self::Unknown(value), + } + } +} + +impl From for i32 { + fn from(value: ClientCapability) -> Self { + match value { + ClientCapability::PacketTrailer => { + proto::client_info::Capability::CapPacketTrailer as i32 + } + ClientCapability::CompressionDeflateRaw => { + proto::client_info::Capability::CapCompressionDeflateRaw as i32 + } + ClientCapability::Unknown(value) => value, + } + } +} + #[derive(Debug, Clone)] pub enum Participant { Local(LocalParticipant), @@ -146,6 +184,7 @@ struct ParticipantInfo { pub joined_at: i64, pub permission: Option, pub client_protocol: i32, + pub capabilities: Vec, } type TrackMutedHandler = Box; @@ -197,6 +236,7 @@ pub(super) fn new_inner( joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Arc { Arc::new(ParticipantInner { rtc_engine, @@ -216,6 +256,7 @@ pub(super) fn new_inner( joined_at, permission, client_protocol, + capabilities, }), track_publications: Default::default(), events: Default::default(), @@ -269,6 +310,7 @@ pub(super) fn update_info( } info.client_protocol = new_info.client_protocol; + info.capabilities = new_info.capabilities.iter().map(|&c| ClientCapability::from(c)).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..618da0cca 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,16 @@ 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() + } + + /// Whether this remote participant can decompress deflate-raw data streams. + pub(crate) fn supports_compression(&self) -> bool { + self.inner.info.read().capabilities.contains(&ClientCapability::CompressionDeflateRaw) + } + 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..2202163c3 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -23,6 +23,7 @@ pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; +use crate::room::participant::ClientCapability; use livekit_protocol::RpcError as RpcError_Proto; use std::{error::Error, fmt::Display, future::Future, time::Duration}; @@ -41,11 +42,27 @@ pub(crate) const ATTR_METHOD: &str = "lk.rpc_request_method"; pub(crate) const ATTR_RESPONSE_TIMEOUT_MS: &str = "lk.rpc_request_response_timeout_ms"; pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; +/// Read access to remote participants' advertised protocol and capabilities. +/// +/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send +/// path (inline / compression eligibility), so both consult a single abstraction over +/// the room's remote participants and both are unit-testable with a fake. +pub(crate) 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; +} + /// Transport abstraction for RPC operations. /// /// 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 +76,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 +83,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 +114,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..ce40e78c6 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -18,6 +18,7 @@ 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; @@ -135,12 +136,22 @@ impl RpcTransport for MockTransport { }) } + 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() } } diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index 911a37548..240a142e8 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -70,6 +70,81 @@ 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() }; + sending_room.local_participant().send_text(&text, options).await?; + 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_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() }; + sending_room.local_participant().send_bytes(&payload, options).await?; + 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<()> { From f5beb6998a61e8d91cb990ab6421307d6115f5cf Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 26 Jun 2026 16:47:11 -0400 Subject: [PATCH 02/46] fix: Create data_streams_v2.md --- .changeset/data_streams_v2.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/data_streams_v2.md 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) From 8054338db3d9e3c2ffb8582e5ad64ede485073b6 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 12:43:04 -0400 Subject: [PATCH 03/46] fix: remove ClientCapability from prelude --- livekit/src/prelude.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit/src/prelude.rs b/livekit/src/prelude.rs index bef9401af..0e0458d3e 100644 --- a/livekit/src/prelude.rs +++ b/livekit/src/prelude.rs @@ -22,7 +22,7 @@ pub use crate::{ }, id::*, participant::{ - ClientCapability, ConnectionQuality, DisconnectReason, LocalParticipant, Participant, + ConnectionQuality, DisconnectReason, LocalParticipant, Participant, PerformRpcData, RemoteParticipant, RpcError, RpcErrorCode, RpcInvocationData, }, publication::{LocalTrackPublication, RemoteTrackPublication, TrackPublication}, From bddbe067e91c73d63ec3c042967aaf0747324a30 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 12:46:45 -0400 Subject: [PATCH 04/46] feat: allow data stream compress option to be toggled via livekit-ffi --- livekit-ffi/protocol/data_stream.proto | 3 ++- livekit-ffi/src/conversion/data_stream.rs | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) 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 c47fe7423..140bc74e4 100644 --- a/livekit-ffi/src/conversion/data_stream.rs +++ b/livekit-ffi/src/conversion/data_stream.rs @@ -72,8 +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, - // Compression opt-out is not yet exposed over FFI; default to on. - compress: None, + compress: options.compress, } } } @@ -92,8 +91,7 @@ impl From for StreamByteOptions { name: options.name, mime_type: options.mime_type, total_length: options.total_length, - // Compression opt-out is not yet exposed over FFI; default to on. - compress: None, + compress: options.compress, } } } From e988375a9c9a0d32f8c95621fead17ebf2525b9d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 12:47:42 -0400 Subject: [PATCH 05/46] fix: run cargo fmt --- livekit/src/prelude.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/livekit/src/prelude.rs b/livekit/src/prelude.rs index 0e0458d3e..374558731 100644 --- a/livekit/src/prelude.rs +++ b/livekit/src/prelude.rs @@ -22,8 +22,8 @@ pub use crate::{ }, id::*, participant::{ - ConnectionQuality, DisconnectReason, LocalParticipant, Participant, - PerformRpcData, RemoteParticipant, RpcError, RpcErrorCode, RpcInvocationData, + ConnectionQuality, DisconnectReason, LocalParticipant, Participant, PerformRpcData, + RemoteParticipant, RpcError, RpcErrorCode, RpcInvocationData, }, publication::{LocalTrackPublication, RemoteTrackPublication, TrackPublication}, track::{ From 8fa2d11248c6b6da846225f63a1970e54dba89d5 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:48:28 +0000 Subject: [PATCH 06/46] generated protobuf --- livekit-ffi-node-bindings/proto/data_stream_pb.d.ts | 10 ++++++++++ livekit-ffi-node-bindings/proto/data_stream_pb.js | 2 ++ 2 files changed, 12 insertions(+) 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 }, ], ); From 57aa3b082b332445f86f258d171d182d523fd78e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 13:00:12 -0400 Subject: [PATCH 07/46] fix: address toc-tou issue with decompressor state --- livekit/src/room/data_stream/incoming.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index 339f4f459..b1fc5f525 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -417,7 +417,7 @@ impl IncomingStreamManager { return; } - if descriptor.decompressor.is_some() { + 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 { @@ -441,12 +441,11 @@ impl IncomingStreamManager { let is_text = descriptor.is_text; // Confine the decompressor borrow so we can re-borrow `inner` afterwards. let result: StreamResult<(u64, Bytes)> = { - let state = descriptor.decompressor.as_mut().unwrap(); - match state.run(&chunk.content) { + match decompressor.run(&chunk.content) { Ok(decompressed) => { let produced = decompressed.len() as u64; let yielded = if is_text { - state.reframe_text(decompressed) + decompressor.reframe_text(decompressed) } else { Bytes::from(decompressed) }; From 0bb0e8d65edf74ad1640f79e13f126f253d440f7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 13:31:13 -0400 Subject: [PATCH 08/46] fix: cleanup data stream v2 test fixture initialization --- livekit/src/room/data_stream/outgoing.rs | 28 ++++++++++++------------ 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index 6853b9b2f..3ebba7193 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -702,9 +702,7 @@ static BYTE_DEFAULT_NAME: &str = "unknown"; mod tests { use super::*; use std::sync::Mutex as StdMutex; - - const V2: i32 = CLIENT_PROTOCOL_DATA_STREAM_V2; - const DEFLATE: ClientCapability = ClientCapability::CompressionDeflateRaw; + use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; // --- Fake recipient registry --------------------------------------------------------- @@ -736,24 +734,26 @@ mod tests { } fn pre_v2_room() -> FakeRegistry { - FakeRegistry::new().add("alice", 0, &[]).add("bob", 0, &[]).add("jim", 1, &[]) + 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", V2, &[DEFLATE]).add("bob", V2, &[DEFLATE]).add( - "noCompression", - V2, - &[], - ) + 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", 0, &[]) - .add("bob", V2, &[DEFLATE]) - .add("jim", V2, &[DEFLATE]) - .add("mallory", 1, &[]) - .add("noCompression", V2, &[]) + .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 ----------------------------------------------------------------- From b2012ad30353a74f37da359fd152b43999ee06e1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 13:31:47 -0400 Subject: [PATCH 09/46] feat: make client capabilities proto deserialization use TryFrom --- livekit/src/room/mod.rs | 12 +++++++++--- livekit/src/room/participant/mod.rs | 22 +++++++++++++--------- 2 files changed, 22 insertions(+), 12 deletions(-) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 0d6a9c8a0..773ab5856 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -577,7 +577,7 @@ impl Room { e2ee_manager.encryption_type(), pi.permission, pi.client_protocol, - pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), + pi.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(), ); let dispatcher = Dispatcher::::default(); @@ -760,7 +760,10 @@ impl Room { pi.joined_at_ms, pi.permission, pi.client_protocol, - pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; participant.update_info(pi.clone()); @@ -1197,7 +1200,10 @@ impl RoomSession { pi.joined_at_ms, pi.permission, pi.client_protocol, - pi.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(), + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index 1ff2f6b58..ef049845f 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -90,21 +90,24 @@ pub enum DisconnectReason { /// Stored typed rather than as the raw protobuf `i32` so the public accessor doesn't leak /// protobuf types, while `Unknown` preserves values this SDK build doesn't recognize. #[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[non_exhaustive] pub enum ClientCapability { + Unused, PacketTrailer, CompressionDeflateRaw, - Unknown(i32), } -impl From for ClientCapability { - fn from(value: i32) -> Self { +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) => Self::PacketTrailer, + Ok(proto::client_info::Capability::CapPacketTrailer) => Ok(Self::PacketTrailer), Ok(proto::client_info::Capability::CapCompressionDeflateRaw) => { - Self::CompressionDeflateRaw + Ok(Self::CompressionDeflateRaw) } - // `CapUnused` and any value not recognized by this build. - _ => Self::Unknown(value), + Ok(proto::client_info::Capability::CapUnused) => Ok(Self::Unused), + Err(_) => Err("unknown client capability"), } } } @@ -112,13 +115,13 @@ impl From for ClientCapability { 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 } - ClientCapability::Unknown(value) => value, } } } @@ -310,7 +313,8 @@ pub(super) fn update_info( } info.client_protocol = new_info.client_protocol; - info.capabilities = new_info.capabilities.iter().map(|&c| ClientCapability::from(c)).collect(); + info.capabilities = + new_info.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(); } pub(super) fn set_speaking( From c0523248c483edc5a3555cff7ee0cf9094801db1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 13:32:40 -0400 Subject: [PATCH 10/46] fix: run cargo fmt --- livekit/src/room/data_stream/outgoing.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index 3ebba7193..bbf7e1f77 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -701,8 +701,8 @@ static BYTE_DEFAULT_NAME: &str = "unknown"; #[cfg(test)] mod tests { use super::*; - use std::sync::Mutex as StdMutex; use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; + use std::sync::Mutex as StdMutex; // --- Fake recipient registry --------------------------------------------------------- @@ -742,7 +742,11 @@ mod tests { fn all_v2_room() -> FakeRegistry { FakeRegistry::new() - .add("alice", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .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, &[]) } From c89353451d372db7ec45d6a49b55eadd8cfeb9cd Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 13:42:10 -0400 Subject: [PATCH 11/46] fix: remove dead code --- livekit/src/room/participant/remote_participant.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/livekit/src/room/participant/remote_participant.rs b/livekit/src/room/participant/remote_participant.rs index 618da0cca..6909cdc0d 100644 --- a/livekit/src/room/participant/remote_participant.rs +++ b/livekit/src/room/participant/remote_participant.rs @@ -584,11 +584,6 @@ impl RemoteParticipant { self.inner.info.read().capabilities.clone() } - /// Whether this remote participant can decompress deflate-raw data streams. - pub(crate) fn supports_compression(&self) -> bool { - self.inner.info.read().capabilities.contains(&ClientCapability::CompressionDeflateRaw) - } - pub fn is_encrypted(&self) -> bool { *self.inner.is_encrypted.read() } From 79ab33cc70de1784046d859712049d6877be546c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 14:39:10 -0400 Subject: [PATCH 12/46] fix: bump protocol version to try to fix ci build error --- livekit-protocol/protocol | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index 357f7686d..672b5c86a 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit 357f7686de5bab17e1d3e3f1b8b805dba5a0ff56 +Subproject commit 672b5c86a96b47cb617cb0f3a9c06f73a4660c0a From 479ac1e47acdb13be65461eb9909c5773e44e816 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 18:41:24 +0000 Subject: [PATCH 13/46] generated protobuf --- livekit-protocol/src/livekit.rs | 267 ++++- livekit-protocol/src/livekit.serde.rs | 1492 ++++++++++++++++++++++++- 2 files changed, 1697 insertions(+), 62 deletions(-) diff --git a/livekit-protocol/src/livekit.rs b/livekit-protocol/src/livekit.rs index 1af6a6e0e..ab6b74e52 100644 --- a/livekit-protocol/src/livekit.rs +++ b/livekit-protocol/src/livekit.rs @@ -94,6 +94,8 @@ pub struct MetricsRecordingHeader { pub room_name: ::prost::alloc::string::String, #[prost(message, optional, tag="7")] pub room_start_time: ::core::option::Option<::pbjson_types::Timestamp>, + #[prost(string, tag="8")] + pub job_id: ::prost::alloc::string::String, } // // Protocol used to record metrics for a specific session. @@ -636,6 +638,177 @@ pub struct DataTrackInfo { /// Method used for end-to-end encryption (E2EE) on packet payloads. #[prost(enumeration="encryption::Type", tag="4")] pub encryption: i32, + /// Encoding for frame payloads on this track. If unspecified, the track is untyped. + #[prost(message, optional, tag="5")] + pub frame_encoding: ::core::option::Option, + /// ID of the schema used by frames on this track if the track is typed. + #[prost(message, optional, tag="6")] + pub schema: ::core::option::Option, +} +/// Encoding for frame payloads. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataTrackFrameEncoding { + #[prost(oneof="data_track_frame_encoding::Value", tags="1, 2")] + pub value: ::core::option::Option, +} +/// Nested message and enum types in `DataTrackFrameEncoding`. +pub mod data_track_frame_encoding { + /// Well-known encoding for frame payloads. + /// + /// Mirrors the well-known message encodings from the MCAP spec: + /// + /// + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum WellKnownFrameEncoding { + Unspecified = 0, + /// ROS 1: must be described by `ROS1_MSG` schema encoding. + Ros1 = 1, + /// CDR: must be described by `ROS2_MSG`, `ROS2_IDL`, or `OMG_IDL` schema encoding. + Cdr = 2, + /// Protocol Buffer: must be described by `PROTOBUF` schema encoding. + Protobuf = 3, + /// FlatBuffer: must be described by `FLATBUFFER` schema encoding. + Flatbuffer = 4, + /// CBOR: self-describing. + Cbor = 5, + /// MessagePack: self-describing. + Msgpack = 6, + /// JSON: self-describing or described by `JSON_SCHEMA` schema encoding. + Json = 7, + } + impl WellKnownFrameEncoding { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + WellKnownFrameEncoding::Unspecified => "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", + WellKnownFrameEncoding::Ros1 => "WELL_KNOWN_FRAME_ENCODING_ROS1", + WellKnownFrameEncoding::Cdr => "WELL_KNOWN_FRAME_ENCODING_CDR", + WellKnownFrameEncoding::Protobuf => "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", + WellKnownFrameEncoding::Flatbuffer => "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", + WellKnownFrameEncoding::Cbor => "WELL_KNOWN_FRAME_ENCODING_CBOR", + WellKnownFrameEncoding::Msgpack => "WELL_KNOWN_FRAME_ENCODING_MSGPACK", + WellKnownFrameEncoding::Json => "WELL_KNOWN_FRAME_ENCODING_JSON", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED" => Some(Self::Unspecified), + "WELL_KNOWN_FRAME_ENCODING_ROS1" => Some(Self::Ros1), + "WELL_KNOWN_FRAME_ENCODING_CDR" => Some(Self::Cdr), + "WELL_KNOWN_FRAME_ENCODING_PROTOBUF" => Some(Self::Protobuf), + "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER" => Some(Self::Flatbuffer), + "WELL_KNOWN_FRAME_ENCODING_CBOR" => Some(Self::Cbor), + "WELL_KNOWN_FRAME_ENCODING_MSGPACK" => Some(Self::Msgpack), + "WELL_KNOWN_FRAME_ENCODING_JSON" => Some(Self::Json), + _ => None, + } + } + } + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Value { + #[prost(enumeration="WellKnownFrameEncoding", tag="1")] + WellKnown(i32), + /// Identifier of a custom encoding not covered by the well-known cases. + /// This must be non-empty and no longer than 32 characters. + #[prost(string, tag="2")] + Custom(::prost::alloc::string::String), + } +} +/// Encoding for schema definition. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataTrackSchemaEncoding { + #[prost(oneof="data_track_schema_encoding::Value", tags="1, 2")] + pub value: ::core::option::Option, +} +/// Nested message and enum types in `DataTrackSchemaEncoding`. +pub mod data_track_schema_encoding { + /// Well-known encoding for schema definition. + /// + /// Mirrors the well-known schema encodings from the MCAP spec: + /// + /// + #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] + #[repr(i32)] + pub enum WellKnownSchemaEncoding { + Unspecified = 0, + /// Protocol Buffer IDL: describes `PROTOBUF` frame encoding. + Protobuf = 1, + /// FlatBuffer IDL: describes `FLATBUFFER` frame encoding. + Flatbuffer = 2, + /// ROS 1 Message: describes `ROS1` frame encoding. + Ros1Msg = 3, + /// ROS 2 Message: describes `CDR` frame encoding. + Ros2Msg = 4, + /// ROS 2 IDL: describes `CDR` frame encoding. + Ros2Idl = 5, + /// OMG IDL: describes `CDR` frame encoding. + OmgIdl = 6, + /// JSON Schema: describes `JSON` frame encoding. + JsonSchema = 7, + } + impl WellKnownSchemaEncoding { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + WellKnownSchemaEncoding::Unspecified => "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", + WellKnownSchemaEncoding::Protobuf => "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", + WellKnownSchemaEncoding::Flatbuffer => "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", + WellKnownSchemaEncoding::Ros1Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", + WellKnownSchemaEncoding::Ros2Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", + WellKnownSchemaEncoding::Ros2Idl => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", + WellKnownSchemaEncoding::OmgIdl => "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", + WellKnownSchemaEncoding::JsonSchema => "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED" => Some(Self::Unspecified), + "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF" => Some(Self::Protobuf), + "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER" => Some(Self::Flatbuffer), + "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG" => Some(Self::Ros1Msg), + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG" => Some(Self::Ros2Msg), + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL" => Some(Self::Ros2Idl), + "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL" => Some(Self::OmgIdl), + "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA" => Some(Self::JsonSchema), + _ => None, + } + } + } + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Value { + #[prost(enumeration="WellKnownSchemaEncoding", tag="1")] + WellKnown(i32), + /// Identifier of a custom encoding not covered by the well-known cases. + /// This must be non-empty and no longer than 32 characters. + #[prost(string, tag="2")] + Custom(::prost::alloc::string::String), + } +} +/// Identifier for a data track schema. +/// +/// Schemas with the same name but different encodings are distinct. +/// +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataTrackSchemaId { + /// This must be non-empty and no longer than 256 characters. + #[prost(string, tag="1")] + pub name: ::prost::alloc::string::String, + #[prost(message, optional, tag="2")] + pub encoding: ::core::option::Option, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -653,6 +826,37 @@ 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, 2")] + 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. + #[prost(string, tag="1")] + Generic(::prost::alloc::string::String), + /// Data track schema identifier, blob contains schema definition. + #[prost(message, tag="2")] + SchemaId(super::DataTrackSchemaId), + } +} +/// 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 +3755,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 +3823,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 +3929,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)] @@ -3799,6 +4015,13 @@ pub struct PublishDataTrackRequest { /// Method used for end-to-end encryption (E2EE) on frame payloads. #[prost(enumeration="encryption::Type", tag="3")] pub encryption: i32, + /// Encoding for frame payloads on this track. If unspecified, the track is untyped. + #[prost(message, optional, tag="4")] + pub frame_encoding: ::core::option::Option, + /// ID of the schema used by frames on this track if the track is typed. + /// If set, the associated schema must be stored with `StoreDataBlobRequest`. + #[prost(message, optional, tag="5")] + pub schema: ::core::option::Option, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -3979,6 +4202,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 +4647,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 +4667,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 +4684,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..3ff3c3c0f 100644 --- a/livekit-protocol/src/livekit.serde.rs +++ b/livekit-protocol/src/livekit.serde.rs @@ -10527,6 +10527,235 @@ 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)?; + } + data_blob_key::Key::SchemaId(v) => { + struct_ser.serialize_field("schemaId", 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", + "schema_id", + "schemaId", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Generic, + SchemaId, + __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), + "schemaId" | "schema_id" => Ok(GeneratedField::SchemaId), + _ => 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::SchemaId => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("schemaId")); + } + key__ = map_.next_value::<::std::option::Option<_>>()?.map(data_blob_key::Key::SchemaId) +; + } + 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 @@ -12703,6 +12932,209 @@ impl<'de> serde::Deserialize<'de> for DataTrackExtensionParticipantSid { deserializer.deserialize_struct("livekit.DataTrackExtensionParticipantSid", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for DataTrackFrameEncoding { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.value.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataTrackFrameEncoding", len)?; + if let Some(v) = self.value.as_ref() { + match v { + data_track_frame_encoding::Value::WellKnown(v) => { + let v = data_track_frame_encoding::WellKnownFrameEncoding::try_from(*v) + .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", *v)))?; + struct_ser.serialize_field("wellKnown", &v)?; + } + data_track_frame_encoding::Value::Custom(v) => { + struct_ser.serialize_field("custom", v)?; + } + } + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataTrackFrameEncoding { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "well_known", + "wellKnown", + "custom", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + WellKnown, + Custom, + __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 { + "wellKnown" | "well_known" => Ok(GeneratedField::WellKnown), + "custom" => Ok(GeneratedField::Custom), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataTrackFrameEncoding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataTrackFrameEncoding") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut value__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::WellKnown => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("wellKnown")); + } + value__ = map_.next_value::<::std::option::Option>()?.map(|x| data_track_frame_encoding::Value::WellKnown(x as i32)); + } + GeneratedField::Custom => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("custom")); + } + value__ = map_.next_value::<::std::option::Option<_>>()?.map(data_track_frame_encoding::Value::Custom); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataTrackFrameEncoding { + value: value__, + }) + } + } + deserializer.deserialize_struct("livekit.DataTrackFrameEncoding", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for data_track_frame_encoding::WellKnownFrameEncoding { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let variant = match self { + Self::Unspecified => "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", + Self::Ros1 => "WELL_KNOWN_FRAME_ENCODING_ROS1", + Self::Cdr => "WELL_KNOWN_FRAME_ENCODING_CDR", + Self::Protobuf => "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", + Self::Flatbuffer => "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", + Self::Cbor => "WELL_KNOWN_FRAME_ENCODING_CBOR", + Self::Msgpack => "WELL_KNOWN_FRAME_ENCODING_MSGPACK", + Self::Json => "WELL_KNOWN_FRAME_ENCODING_JSON", + }; + serializer.serialize_str(variant) + } +} +impl<'de> serde::Deserialize<'de> for data_track_frame_encoding::WellKnownFrameEncoding { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", + "WELL_KNOWN_FRAME_ENCODING_ROS1", + "WELL_KNOWN_FRAME_ENCODING_CDR", + "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", + "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", + "WELL_KNOWN_FRAME_ENCODING_CBOR", + "WELL_KNOWN_FRAME_ENCODING_MSGPACK", + "WELL_KNOWN_FRAME_ENCODING_JSON", + ]; + + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = data_track_frame_encoding::WellKnownFrameEncoding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + fn visit_i64(self, v: i64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self) + }) + } + + fn visit_u64(self, v: u64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self) + }) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Unspecified), + "WELL_KNOWN_FRAME_ENCODING_ROS1" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Ros1), + "WELL_KNOWN_FRAME_ENCODING_CDR" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Cdr), + "WELL_KNOWN_FRAME_ENCODING_PROTOBUF" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Protobuf), + "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Flatbuffer), + "WELL_KNOWN_FRAME_ENCODING_CBOR" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Cbor), + "WELL_KNOWN_FRAME_ENCODING_MSGPACK" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Msgpack), + "WELL_KNOWN_FRAME_ENCODING_JSON" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Json), + _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), + } + } + } + deserializer.deserialize_any(GeneratedVisitor) + } +} impl serde::Serialize for DataTrackInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -12723,6 +13155,12 @@ impl serde::Serialize for DataTrackInfo { if self.encryption != 0 { len += 1; } + if self.frame_encoding.is_some() { + len += 1; + } + if self.schema.is_some() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("livekit.DataTrackInfo", len)?; if self.pub_handle != 0 { struct_ser.serialize_field("pubHandle", &self.pub_handle)?; @@ -12738,6 +13176,12 @@ impl serde::Serialize for DataTrackInfo { .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", self.encryption)))?; struct_ser.serialize_field("encryption", &v)?; } + if let Some(v) = self.frame_encoding.as_ref() { + struct_ser.serialize_field("frameEncoding", v)?; + } + if let Some(v) = self.schema.as_ref() { + struct_ser.serialize_field("schema", v)?; + } struct_ser.end() } } @@ -12753,6 +13197,9 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { "sid", "name", "encryption", + "frame_encoding", + "frameEncoding", + "schema", ]; #[allow(clippy::enum_variant_names)] @@ -12761,6 +13208,361 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { Sid, Name, Encryption, + FrameEncoding, + Schema, + __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 { + "pubHandle" | "pub_handle" => Ok(GeneratedField::PubHandle), + "sid" => Ok(GeneratedField::Sid), + "name" => Ok(GeneratedField::Name), + "encryption" => Ok(GeneratedField::Encryption), + "frameEncoding" | "frame_encoding" => Ok(GeneratedField::FrameEncoding), + "schema" => Ok(GeneratedField::Schema), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataTrackInfo; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataTrackInfo") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut pub_handle__ = None; + let mut sid__ = None; + let mut name__ = None; + let mut encryption__ = None; + let mut frame_encoding__ = None; + let mut schema__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::PubHandle => { + if pub_handle__.is_some() { + return Err(serde::de::Error::duplicate_field("pubHandle")); + } + pub_handle__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Sid => { + if sid__.is_some() { + return Err(serde::de::Error::duplicate_field("sid")); + } + sid__ = Some(map_.next_value()?); + } + GeneratedField::Name => { + if name__.is_some() { + return Err(serde::de::Error::duplicate_field("name")); + } + name__ = Some(map_.next_value()?); + } + GeneratedField::Encryption => { + if encryption__.is_some() { + return Err(serde::de::Error::duplicate_field("encryption")); + } + encryption__ = Some(map_.next_value::()? as i32); + } + GeneratedField::FrameEncoding => { + if frame_encoding__.is_some() { + return Err(serde::de::Error::duplicate_field("frameEncoding")); + } + frame_encoding__ = map_.next_value()?; + } + GeneratedField::Schema => { + if schema__.is_some() { + return Err(serde::de::Error::duplicate_field("schema")); + } + schema__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataTrackInfo { + pub_handle: pub_handle__.unwrap_or_default(), + sid: sid__.unwrap_or_default(), + name: name__.unwrap_or_default(), + encryption: encryption__.unwrap_or_default(), + frame_encoding: frame_encoding__, + schema: schema__, + }) + } + } + deserializer.deserialize_struct("livekit.DataTrackInfo", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for DataTrackSchemaEncoding { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.value.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataTrackSchemaEncoding", len)?; + if let Some(v) = self.value.as_ref() { + match v { + data_track_schema_encoding::Value::WellKnown(v) => { + let v = data_track_schema_encoding::WellKnownSchemaEncoding::try_from(*v) + .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", *v)))?; + struct_ser.serialize_field("wellKnown", &v)?; + } + data_track_schema_encoding::Value::Custom(v) => { + struct_ser.serialize_field("custom", v)?; + } + } + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataTrackSchemaEncoding { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "well_known", + "wellKnown", + "custom", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + WellKnown, + Custom, + __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 { + "wellKnown" | "well_known" => Ok(GeneratedField::WellKnown), + "custom" => Ok(GeneratedField::Custom), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataTrackSchemaEncoding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataTrackSchemaEncoding") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut value__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::WellKnown => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("wellKnown")); + } + value__ = map_.next_value::<::std::option::Option>()?.map(|x| data_track_schema_encoding::Value::WellKnown(x as i32)); + } + GeneratedField::Custom => { + if value__.is_some() { + return Err(serde::de::Error::duplicate_field("custom")); + } + value__ = map_.next_value::<::std::option::Option<_>>()?.map(data_track_schema_encoding::Value::Custom); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataTrackSchemaEncoding { + value: value__, + }) + } + } + deserializer.deserialize_struct("livekit.DataTrackSchemaEncoding", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for data_track_schema_encoding::WellKnownSchemaEncoding { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + let variant = match self { + Self::Unspecified => "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", + Self::Protobuf => "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", + Self::Flatbuffer => "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", + Self::Ros1Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", + Self::Ros2Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", + Self::Ros2Idl => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", + Self::OmgIdl => "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", + Self::JsonSchema => "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", + }; + serializer.serialize_str(variant) + } +} +impl<'de> serde::Deserialize<'de> for data_track_schema_encoding::WellKnownSchemaEncoding { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", + "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", + "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", + "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", + "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", + "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", + ]; + + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = data_track_schema_encoding::WellKnownSchemaEncoding; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + fn visit_i64(self, v: i64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self) + }) + } + + fn visit_u64(self, v: u64) -> std::result::Result + where + E: serde::de::Error, + { + i32::try_from(v) + .ok() + .and_then(|x| x.try_into().ok()) + .ok_or_else(|| { + serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self) + }) + } + + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Unspecified), + "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Protobuf), + "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Flatbuffer), + "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros1Msg), + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros2Msg), + "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros2Idl), + "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::OmgIdl), + "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::JsonSchema), + _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), + } + } + } + deserializer.deserialize_any(GeneratedVisitor) + } +} +impl serde::Serialize for DataTrackSchemaId { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.name.is_empty() { + len += 1; + } + if self.encoding.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataTrackSchemaId", len)?; + if !self.name.is_empty() { + struct_ser.serialize_field("name", &self.name)?; + } + if let Some(v) = self.encoding.as_ref() { + struct_ser.serialize_field("encoding", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataTrackSchemaId { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "name", + "encoding", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Name, + Encoding, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -12783,10 +13585,8 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { E: serde::de::Error, { match value { - "pubHandle" | "pub_handle" => Ok(GeneratedField::PubHandle), - "sid" => Ok(GeneratedField::Sid), "name" => Ok(GeneratedField::Name), - "encryption" => Ok(GeneratedField::Encryption), + "encoding" => Ok(GeneratedField::Encoding), _ => Ok(GeneratedField::__SkipField__), } } @@ -12796,62 +13596,44 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = DataTrackInfo; + type Value = DataTrackSchemaId; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.DataTrackInfo") + formatter.write_str("struct livekit.DataTrackSchemaId") } - 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 pub_handle__ = None; - let mut sid__ = None; let mut name__ = None; - let mut encryption__ = None; + let mut encoding__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::PubHandle => { - if pub_handle__.is_some() { - return Err(serde::de::Error::duplicate_field("pubHandle")); - } - pub_handle__ = - Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) - ; - } - GeneratedField::Sid => { - if sid__.is_some() { - return Err(serde::de::Error::duplicate_field("sid")); - } - sid__ = Some(map_.next_value()?); - } GeneratedField::Name => { if name__.is_some() { return Err(serde::de::Error::duplicate_field("name")); } name__ = Some(map_.next_value()?); } - GeneratedField::Encryption => { - if encryption__.is_some() { - return Err(serde::de::Error::duplicate_field("encryption")); + GeneratedField::Encoding => { + if encoding__.is_some() { + return Err(serde::de::Error::duplicate_field("encoding")); } - encryption__ = Some(map_.next_value::()? as i32); + encoding__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(DataTrackInfo { - pub_handle: pub_handle__.unwrap_or_default(), - sid: sid__.unwrap_or_default(), + Ok(DataTrackSchemaId { name: name__.unwrap_or_default(), - encryption: encryption__.unwrap_or_default(), + encoding: encoding__, }) } } - deserializer.deserialize_struct("livekit.DataTrackInfo", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.DataTrackSchemaId", FIELDS, GeneratedVisitor) } } impl serde::Serialize for DataTrackSubscriberHandles { @@ -18455,9 +19237,263 @@ impl<'de> serde::Deserialize<'de> for GcpUpload { E: serde::de::Error, { match value { - "credentials" => Ok(GeneratedField::Credentials), - "bucket" => Ok(GeneratedField::Bucket), - "proxy" => Ok(GeneratedField::Proxy), + "credentials" => Ok(GeneratedField::Credentials), + "bucket" => Ok(GeneratedField::Bucket), + "proxy" => Ok(GeneratedField::Proxy), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GcpUpload; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GCPUpload") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut credentials__ = None; + let mut bucket__ = None; + let mut proxy__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Credentials => { + if credentials__.is_some() { + return Err(serde::de::Error::duplicate_field("credentials")); + } + credentials__ = Some(map_.next_value()?); + } + GeneratedField::Bucket => { + if bucket__.is_some() { + return Err(serde::de::Error::duplicate_field("bucket")); + } + bucket__ = Some(map_.next_value()?); + } + GeneratedField::Proxy => { + if proxy__.is_some() { + return Err(serde::de::Error::duplicate_field("proxy")); + } + proxy__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GcpUpload { + credentials: credentials__.unwrap_or_default(), + bucket: bucket__.unwrap_or_default(), + proxy: proxy__, + }) + } + } + deserializer.deserialize_struct("livekit.GCPUpload", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetDataBlobRequest { + #[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.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 GetDataBlobRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "request_id", + "requestId", + "participant_identity", + "participantIdentity", + "key", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + RequestId, + ParticipantIdentity, + 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), + "participantIdentity" | "participant_identity" => Ok(GeneratedField::ParticipantIdentity), + "key" => Ok(GeneratedField::Key), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetDataBlobRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GetDataBlobRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut request_id__ = None; + let mut participant_identity__ = 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::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(GetDataBlobRequest { + request_id: request_id__.unwrap_or_default(), + participant_identity: participant_identity__.unwrap_or_default(), + key: key__, + }) + } + } + deserializer.deserialize_struct("livekit.GetDataBlobRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetDataBlobResponse { + #[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.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 GetDataBlobResponse { + #[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__), } } @@ -18467,52 +19503,46 @@ impl<'de> serde::Deserialize<'de> for GcpUpload { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = GcpUpload; + type Value = GetDataBlobResponse; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.GCPUpload") + 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 credentials__ = None; - let mut bucket__ = None; - let mut proxy__ = None; + let mut request_id__ = None; + let mut blob__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::Credentials => { - if credentials__.is_some() { - return Err(serde::de::Error::duplicate_field("credentials")); - } - credentials__ = Some(map_.next_value()?); - } - GeneratedField::Bucket => { - if bucket__.is_some() { - return Err(serde::de::Error::duplicate_field("bucket")); + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); } - bucket__ = Some(map_.next_value()?); + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; } - GeneratedField::Proxy => { - if proxy__.is_some() { - return Err(serde::de::Error::duplicate_field("proxy")); + GeneratedField::Blob => { + if blob__.is_some() { + return Err(serde::de::Error::duplicate_field("blob")); } - proxy__ = map_.next_value()?; + blob__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(GcpUpload { - credentials: credentials__.unwrap_or_default(), - bucket: bucket__.unwrap_or_default(), - proxy: proxy__, + Ok(GetDataBlobResponse { + request_id: request_id__.unwrap_or_default(), + blob: blob__, }) } } - deserializer.deserialize_struct("livekit.GCPUpload", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.GetDataBlobResponse", FIELDS, GeneratedVisitor) } } impl serde::Serialize for GetSipInboundTrunkRequest { @@ -26099,6 +27129,9 @@ impl serde::Serialize for MetricsRecordingHeader { if self.room_start_time.is_some() { len += 1; } + if !self.job_id.is_empty() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("livekit.MetricsRecordingHeader", len)?; if !self.room_id.is_empty() { struct_ser.serialize_field("roomId", &self.room_id)?; @@ -26120,6 +27153,9 @@ impl serde::Serialize for MetricsRecordingHeader { if let Some(v) = self.room_start_time.as_ref() { struct_ser.serialize_field("roomStartTime", v)?; } + if !self.job_id.is_empty() { + struct_ser.serialize_field("jobId", &self.job_id)?; + } struct_ser.end() } } @@ -26141,6 +27177,8 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { "roomName", "room_start_time", "roomStartTime", + "job_id", + "jobId", ]; #[allow(clippy::enum_variant_names)] @@ -26151,6 +27189,7 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { RoomTags, RoomName, RoomStartTime, + JobId, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -26179,6 +27218,7 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { "roomTags" | "room_tags" => Ok(GeneratedField::RoomTags), "roomName" | "room_name" => Ok(GeneratedField::RoomName), "roomStartTime" | "room_start_time" => Ok(GeneratedField::RoomStartTime), + "jobId" | "job_id" => Ok(GeneratedField::JobId), _ => Ok(GeneratedField::__SkipField__), } } @@ -26204,6 +27244,7 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { let mut room_tags__ = None; let mut room_name__ = None; let mut room_start_time__ = None; + let mut job_id__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::RoomId => { @@ -26246,6 +27287,12 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { } room_start_time__ = map_.next_value()?; } + GeneratedField::JobId => { + if job_id__.is_some() { + return Err(serde::de::Error::duplicate_field("jobId")); + } + job_id__ = Some(map_.next_value()?); + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -26258,6 +27305,7 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { room_tags: room_tags__.unwrap_or_default(), room_name: room_name__.unwrap_or_default(), room_start_time: room_start_time__, + job_id: job_id__.unwrap_or_default(), }) } } @@ -29782,6 +30830,12 @@ impl serde::Serialize for PublishDataTrackRequest { if self.encryption != 0 { len += 1; } + if self.frame_encoding.is_some() { + len += 1; + } + if self.schema.is_some() { + len += 1; + } let mut struct_ser = serializer.serialize_struct("livekit.PublishDataTrackRequest", len)?; if self.pub_handle != 0 { struct_ser.serialize_field("pubHandle", &self.pub_handle)?; @@ -29794,6 +30848,12 @@ impl serde::Serialize for PublishDataTrackRequest { .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", self.encryption)))?; struct_ser.serialize_field("encryption", &v)?; } + if let Some(v) = self.frame_encoding.as_ref() { + struct_ser.serialize_field("frameEncoding", v)?; + } + if let Some(v) = self.schema.as_ref() { + struct_ser.serialize_field("schema", v)?; + } struct_ser.end() } } @@ -29808,6 +30868,9 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { "pubHandle", "name", "encryption", + "frame_encoding", + "frameEncoding", + "schema", ]; #[allow(clippy::enum_variant_names)] @@ -29815,6 +30878,8 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { PubHandle, Name, Encryption, + FrameEncoding, + Schema, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -29840,6 +30905,8 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { "pubHandle" | "pub_handle" => Ok(GeneratedField::PubHandle), "name" => Ok(GeneratedField::Name), "encryption" => Ok(GeneratedField::Encryption), + "frameEncoding" | "frame_encoding" => Ok(GeneratedField::FrameEncoding), + "schema" => Ok(GeneratedField::Schema), _ => Ok(GeneratedField::__SkipField__), } } @@ -29862,6 +30929,8 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { let mut pub_handle__ = None; let mut name__ = None; let mut encryption__ = None; + let mut frame_encoding__ = None; + let mut schema__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::PubHandle => { @@ -29884,6 +30953,18 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { } encryption__ = Some(map_.next_value::()? as i32); } + GeneratedField::FrameEncoding => { + if frame_encoding__.is_some() { + return Err(serde::de::Error::duplicate_field("frameEncoding")); + } + frame_encoding__ = map_.next_value()?; + } + GeneratedField::Schema => { + if schema__.is_some() { + return Err(serde::de::Error::duplicate_field("schema")); + } + schema__ = map_.next_value()?; + } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -29893,6 +30974,8 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { pub_handle: pub_handle__.unwrap_or_default(), name: name__.unwrap_or_default(), encryption: encryption__.unwrap_or_default(), + frame_encoding: frame_encoding__, + schema: schema__, }) } } @@ -33107,6 +34190,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 +34213,7 @@ impl<'de> serde::Deserialize<'de> for request_response::Reason { "INVALID_NAME", "DUPLICATE_HANDLE", "DUPLICATE_NAME", + "INVALID_REQUEST", ]; struct GeneratedVisitor; @@ -33180,6 +34265,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 +44186,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 +44236,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 +44264,8 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { PublishDataTrackRequest, UnpublishDataTrackRequest, UpdateDataSubscription, + StoreDataBlobRequest, + GetDataBlobRequest, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43210,6 +44308,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 +44469,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 +44598,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 +44663,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 +44699,8 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { PublishDataTrackResponse, UnpublishDataTrackResponse, DataTrackSubscriberHandles, + StoreDataBlobResponse, + GetDataBlobResponse, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43625,6 +44751,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 +44967,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 +46545,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 From 7c8490969473bc099c1fba473065709a9bc14243 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:00:57 -0400 Subject: [PATCH 14/46] fix: downgrade protocol to before data track schema metadata I'll let jacob's in flight pull request push this all the way to the latest --- livekit-protocol/protocol | 2 +- livekit-protocol/src/livekit.rs | 187 +------- livekit-protocol/src/livekit.serde.rs | 620 -------------------------- 3 files changed, 4 insertions(+), 805 deletions(-) diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index 672b5c86a..39fc751df 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit 672b5c86a96b47cb617cb0f3a9c06f73a4660c0a +Subproject commit 39fc751df610243c1bfdf85d3be6b3928ecef014 diff --git a/livekit-protocol/src/livekit.rs b/livekit-protocol/src/livekit.rs index ab6b74e52..ac2408d86 100644 --- a/livekit-protocol/src/livekit.rs +++ b/livekit-protocol/src/livekit.rs @@ -94,8 +94,6 @@ pub struct MetricsRecordingHeader { pub room_name: ::prost::alloc::string::String, #[prost(message, optional, tag="7")] pub room_start_time: ::core::option::Option<::pbjson_types::Timestamp>, - #[prost(string, tag="8")] - pub job_id: ::prost::alloc::string::String, } // // Protocol used to record metrics for a specific session. @@ -638,177 +636,6 @@ pub struct DataTrackInfo { /// Method used for end-to-end encryption (E2EE) on packet payloads. #[prost(enumeration="encryption::Type", tag="4")] pub encryption: i32, - /// Encoding for frame payloads on this track. If unspecified, the track is untyped. - #[prost(message, optional, tag="5")] - pub frame_encoding: ::core::option::Option, - /// ID of the schema used by frames on this track if the track is typed. - #[prost(message, optional, tag="6")] - pub schema: ::core::option::Option, -} -/// Encoding for frame payloads. -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DataTrackFrameEncoding { - #[prost(oneof="data_track_frame_encoding::Value", tags="1, 2")] - pub value: ::core::option::Option, -} -/// Nested message and enum types in `DataTrackFrameEncoding`. -pub mod data_track_frame_encoding { - /// Well-known encoding for frame payloads. - /// - /// Mirrors the well-known message encodings from the MCAP spec: - /// - /// - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] - #[repr(i32)] - pub enum WellKnownFrameEncoding { - Unspecified = 0, - /// ROS 1: must be described by `ROS1_MSG` schema encoding. - Ros1 = 1, - /// CDR: must be described by `ROS2_MSG`, `ROS2_IDL`, or `OMG_IDL` schema encoding. - Cdr = 2, - /// Protocol Buffer: must be described by `PROTOBUF` schema encoding. - Protobuf = 3, - /// FlatBuffer: must be described by `FLATBUFFER` schema encoding. - Flatbuffer = 4, - /// CBOR: self-describing. - Cbor = 5, - /// MessagePack: self-describing. - Msgpack = 6, - /// JSON: self-describing or described by `JSON_SCHEMA` schema encoding. - Json = 7, - } - impl WellKnownFrameEncoding { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - WellKnownFrameEncoding::Unspecified => "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", - WellKnownFrameEncoding::Ros1 => "WELL_KNOWN_FRAME_ENCODING_ROS1", - WellKnownFrameEncoding::Cdr => "WELL_KNOWN_FRAME_ENCODING_CDR", - WellKnownFrameEncoding::Protobuf => "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", - WellKnownFrameEncoding::Flatbuffer => "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", - WellKnownFrameEncoding::Cbor => "WELL_KNOWN_FRAME_ENCODING_CBOR", - WellKnownFrameEncoding::Msgpack => "WELL_KNOWN_FRAME_ENCODING_MSGPACK", - WellKnownFrameEncoding::Json => "WELL_KNOWN_FRAME_ENCODING_JSON", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED" => Some(Self::Unspecified), - "WELL_KNOWN_FRAME_ENCODING_ROS1" => Some(Self::Ros1), - "WELL_KNOWN_FRAME_ENCODING_CDR" => Some(Self::Cdr), - "WELL_KNOWN_FRAME_ENCODING_PROTOBUF" => Some(Self::Protobuf), - "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER" => Some(Self::Flatbuffer), - "WELL_KNOWN_FRAME_ENCODING_CBOR" => Some(Self::Cbor), - "WELL_KNOWN_FRAME_ENCODING_MSGPACK" => Some(Self::Msgpack), - "WELL_KNOWN_FRAME_ENCODING_JSON" => Some(Self::Json), - _ => None, - } - } - } - #[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Value { - #[prost(enumeration="WellKnownFrameEncoding", tag="1")] - WellKnown(i32), - /// Identifier of a custom encoding not covered by the well-known cases. - /// This must be non-empty and no longer than 32 characters. - #[prost(string, tag="2")] - Custom(::prost::alloc::string::String), - } -} -/// Encoding for schema definition. -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DataTrackSchemaEncoding { - #[prost(oneof="data_track_schema_encoding::Value", tags="1, 2")] - pub value: ::core::option::Option, -} -/// Nested message and enum types in `DataTrackSchemaEncoding`. -pub mod data_track_schema_encoding { - /// Well-known encoding for schema definition. - /// - /// Mirrors the well-known schema encodings from the MCAP spec: - /// - /// - #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] - #[repr(i32)] - pub enum WellKnownSchemaEncoding { - Unspecified = 0, - /// Protocol Buffer IDL: describes `PROTOBUF` frame encoding. - Protobuf = 1, - /// FlatBuffer IDL: describes `FLATBUFFER` frame encoding. - Flatbuffer = 2, - /// ROS 1 Message: describes `ROS1` frame encoding. - Ros1Msg = 3, - /// ROS 2 Message: describes `CDR` frame encoding. - Ros2Msg = 4, - /// ROS 2 IDL: describes `CDR` frame encoding. - Ros2Idl = 5, - /// OMG IDL: describes `CDR` frame encoding. - OmgIdl = 6, - /// JSON Schema: describes `JSON` frame encoding. - JsonSchema = 7, - } - impl WellKnownSchemaEncoding { - /// String value of the enum field names used in the ProtoBuf definition. - /// - /// The values are not transformed in any way and thus are considered stable - /// (if the ProtoBuf definition does not change) and safe for programmatic use. - pub fn as_str_name(&self) -> &'static str { - match self { - WellKnownSchemaEncoding::Unspecified => "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", - WellKnownSchemaEncoding::Protobuf => "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", - WellKnownSchemaEncoding::Flatbuffer => "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", - WellKnownSchemaEncoding::Ros1Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", - WellKnownSchemaEncoding::Ros2Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", - WellKnownSchemaEncoding::Ros2Idl => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", - WellKnownSchemaEncoding::OmgIdl => "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", - WellKnownSchemaEncoding::JsonSchema => "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", - } - } - /// Creates an enum from field names used in the ProtoBuf definition. - pub fn from_str_name(value: &str) -> ::core::option::Option { - match value { - "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED" => Some(Self::Unspecified), - "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF" => Some(Self::Protobuf), - "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER" => Some(Self::Flatbuffer), - "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG" => Some(Self::Ros1Msg), - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG" => Some(Self::Ros2Msg), - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL" => Some(Self::Ros2Idl), - "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL" => Some(Self::OmgIdl), - "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA" => Some(Self::JsonSchema), - _ => None, - } - } - } - #[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Oneof)] - pub enum Value { - #[prost(enumeration="WellKnownSchemaEncoding", tag="1")] - WellKnown(i32), - /// Identifier of a custom encoding not covered by the well-known cases. - /// This must be non-empty and no longer than 32 characters. - #[prost(string, tag="2")] - Custom(::prost::alloc::string::String), - } -} -/// Identifier for a data track schema. -/// -/// Schemas with the same name but different encodings are distinct. -/// -#[allow(clippy::derive_partial_eq_without_eq)] -#[derive(Clone, PartialEq, ::prost::Message)] -pub struct DataTrackSchemaId { - /// This must be non-empty and no longer than 256 characters. - #[prost(string, tag="1")] - pub name: ::prost::alloc::string::String, - #[prost(message, optional, tag="2")] - pub encoding: ::core::option::Option, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -830,7 +657,7 @@ pub struct DataTrackSubscriptionOptions { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct DataBlobKey { - #[prost(oneof="data_blob_key::Key", tags="1, 2")] + #[prost(oneof="data_blob_key::Key", tags="1")] pub key: ::core::option::Option, } /// Nested message and enum types in `DataBlobKey`. @@ -839,11 +666,10 @@ pub mod data_blob_key { #[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), - /// Data track schema identifier, blob contains schema definition. - #[prost(message, tag="2")] - SchemaId(super::DataTrackSchemaId), } } /// A blob of data stored in a room identified by a unique key. @@ -4015,13 +3841,6 @@ pub struct PublishDataTrackRequest { /// Method used for end-to-end encryption (E2EE) on frame payloads. #[prost(enumeration="encryption::Type", tag="3")] pub encryption: i32, - /// Encoding for frame payloads on this track. If unspecified, the track is untyped. - #[prost(message, optional, tag="4")] - pub frame_encoding: ::core::option::Option, - /// ID of the schema used by frames on this track if the track is typed. - /// If set, the associated schema must be stored with `StoreDataBlobRequest`. - #[prost(message, optional, tag="5")] - pub schema: ::core::option::Option, } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] diff --git a/livekit-protocol/src/livekit.serde.rs b/livekit-protocol/src/livekit.serde.rs index 3ff3c3c0f..fa22db55a 100644 --- a/livekit-protocol/src/livekit.serde.rs +++ b/livekit-protocol/src/livekit.serde.rs @@ -10660,9 +10660,6 @@ impl serde::Serialize for DataBlobKey { data_blob_key::Key::Generic(v) => { struct_ser.serialize_field("generic", v)?; } - data_blob_key::Key::SchemaId(v) => { - struct_ser.serialize_field("schemaId", v)?; - } } } struct_ser.end() @@ -10676,14 +10673,11 @@ impl<'de> serde::Deserialize<'de> for DataBlobKey { { const FIELDS: &[&str] = &[ "generic", - "schema_id", - "schemaId", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { Generic, - SchemaId, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -10707,7 +10701,6 @@ impl<'de> serde::Deserialize<'de> for DataBlobKey { { match value { "generic" => Ok(GeneratedField::Generic), - "schemaId" | "schema_id" => Ok(GeneratedField::SchemaId), _ => Ok(GeneratedField::__SkipField__), } } @@ -10736,13 +10729,6 @@ impl<'de> serde::Deserialize<'de> for DataBlobKey { } key__ = map_.next_value::<::std::option::Option<_>>()?.map(data_blob_key::Key::Generic); } - GeneratedField::SchemaId => { - if key__.is_some() { - return Err(serde::de::Error::duplicate_field("schemaId")); - } - key__ = map_.next_value::<::std::option::Option<_>>()?.map(data_blob_key::Key::SchemaId) -; - } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -12932,209 +12918,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackExtensionParticipantSid { deserializer.deserialize_struct("livekit.DataTrackExtensionParticipantSid", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for DataTrackFrameEncoding { - #[allow(deprecated)] - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut len = 0; - if self.value.is_some() { - len += 1; - } - let mut struct_ser = serializer.serialize_struct("livekit.DataTrackFrameEncoding", len)?; - if let Some(v) = self.value.as_ref() { - match v { - data_track_frame_encoding::Value::WellKnown(v) => { - let v = data_track_frame_encoding::WellKnownFrameEncoding::try_from(*v) - .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", *v)))?; - struct_ser.serialize_field("wellKnown", &v)?; - } - data_track_frame_encoding::Value::Custom(v) => { - struct_ser.serialize_field("custom", v)?; - } - } - } - struct_ser.end() - } -} -impl<'de> serde::Deserialize<'de> for DataTrackFrameEncoding { - #[allow(deprecated)] - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - "well_known", - "wellKnown", - "custom", - ]; - - #[allow(clippy::enum_variant_names)] - enum GeneratedField { - WellKnown, - Custom, - __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 { - "wellKnown" | "well_known" => Ok(GeneratedField::WellKnown), - "custom" => Ok(GeneratedField::Custom), - _ => Ok(GeneratedField::__SkipField__), - } - } - } - deserializer.deserialize_identifier(GeneratedVisitor) - } - } - struct GeneratedVisitor; - impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = DataTrackFrameEncoding; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.DataTrackFrameEncoding") - } - - fn visit_map(self, mut map_: V) -> std::result::Result - where - V: serde::de::MapAccess<'de>, - { - let mut value__ = None; - while let Some(k) = map_.next_key()? { - match k { - GeneratedField::WellKnown => { - if value__.is_some() { - return Err(serde::de::Error::duplicate_field("wellKnown")); - } - value__ = map_.next_value::<::std::option::Option>()?.map(|x| data_track_frame_encoding::Value::WellKnown(x as i32)); - } - GeneratedField::Custom => { - if value__.is_some() { - return Err(serde::de::Error::duplicate_field("custom")); - } - value__ = map_.next_value::<::std::option::Option<_>>()?.map(data_track_frame_encoding::Value::Custom); - } - GeneratedField::__SkipField__ => { - let _ = map_.next_value::()?; - } - } - } - Ok(DataTrackFrameEncoding { - value: value__, - }) - } - } - deserializer.deserialize_struct("livekit.DataTrackFrameEncoding", FIELDS, GeneratedVisitor) - } -} -impl serde::Serialize for data_track_frame_encoding::WellKnownFrameEncoding { - #[allow(deprecated)] - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - let variant = match self { - Self::Unspecified => "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", - Self::Ros1 => "WELL_KNOWN_FRAME_ENCODING_ROS1", - Self::Cdr => "WELL_KNOWN_FRAME_ENCODING_CDR", - Self::Protobuf => "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", - Self::Flatbuffer => "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", - Self::Cbor => "WELL_KNOWN_FRAME_ENCODING_CBOR", - Self::Msgpack => "WELL_KNOWN_FRAME_ENCODING_MSGPACK", - Self::Json => "WELL_KNOWN_FRAME_ENCODING_JSON", - }; - serializer.serialize_str(variant) - } -} -impl<'de> serde::Deserialize<'de> for data_track_frame_encoding::WellKnownFrameEncoding { - #[allow(deprecated)] - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED", - "WELL_KNOWN_FRAME_ENCODING_ROS1", - "WELL_KNOWN_FRAME_ENCODING_CDR", - "WELL_KNOWN_FRAME_ENCODING_PROTOBUF", - "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER", - "WELL_KNOWN_FRAME_ENCODING_CBOR", - "WELL_KNOWN_FRAME_ENCODING_MSGPACK", - "WELL_KNOWN_FRAME_ENCODING_JSON", - ]; - - struct GeneratedVisitor; - - impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = data_track_frame_encoding::WellKnownFrameEncoding; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", &FIELDS) - } - - fn visit_i64(self, v: i64) -> std::result::Result - where - E: serde::de::Error, - { - i32::try_from(v) - .ok() - .and_then(|x| x.try_into().ok()) - .ok_or_else(|| { - serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self) - }) - } - - fn visit_u64(self, v: u64) -> std::result::Result - where - E: serde::de::Error, - { - i32::try_from(v) - .ok() - .and_then(|x| x.try_into().ok()) - .ok_or_else(|| { - serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self) - }) - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: serde::de::Error, - { - match value { - "WELL_KNOWN_FRAME_ENCODING_UNSPECIFIED" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Unspecified), - "WELL_KNOWN_FRAME_ENCODING_ROS1" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Ros1), - "WELL_KNOWN_FRAME_ENCODING_CDR" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Cdr), - "WELL_KNOWN_FRAME_ENCODING_PROTOBUF" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Protobuf), - "WELL_KNOWN_FRAME_ENCODING_FLATBUFFER" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Flatbuffer), - "WELL_KNOWN_FRAME_ENCODING_CBOR" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Cbor), - "WELL_KNOWN_FRAME_ENCODING_MSGPACK" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Msgpack), - "WELL_KNOWN_FRAME_ENCODING_JSON" => Ok(data_track_frame_encoding::WellKnownFrameEncoding::Json), - _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), - } - } - } - deserializer.deserialize_any(GeneratedVisitor) - } -} impl serde::Serialize for DataTrackInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -13155,12 +12938,6 @@ impl serde::Serialize for DataTrackInfo { if self.encryption != 0 { len += 1; } - if self.frame_encoding.is_some() { - len += 1; - } - if self.schema.is_some() { - len += 1; - } let mut struct_ser = serializer.serialize_struct("livekit.DataTrackInfo", len)?; if self.pub_handle != 0 { struct_ser.serialize_field("pubHandle", &self.pub_handle)?; @@ -13176,12 +12953,6 @@ impl serde::Serialize for DataTrackInfo { .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", self.encryption)))?; struct_ser.serialize_field("encryption", &v)?; } - if let Some(v) = self.frame_encoding.as_ref() { - struct_ser.serialize_field("frameEncoding", v)?; - } - if let Some(v) = self.schema.as_ref() { - struct_ser.serialize_field("schema", v)?; - } struct_ser.end() } } @@ -13197,9 +12968,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { "sid", "name", "encryption", - "frame_encoding", - "frameEncoding", - "schema", ]; #[allow(clippy::enum_variant_names)] @@ -13208,8 +12976,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { Sid, Name, Encryption, - FrameEncoding, - Schema, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -13236,8 +13002,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { "sid" => Ok(GeneratedField::Sid), "name" => Ok(GeneratedField::Name), "encryption" => Ok(GeneratedField::Encryption), - "frameEncoding" | "frame_encoding" => Ok(GeneratedField::FrameEncoding), - "schema" => Ok(GeneratedField::Schema), _ => Ok(GeneratedField::__SkipField__), } } @@ -13261,8 +13025,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { let mut sid__ = None; let mut name__ = None; let mut encryption__ = None; - let mut frame_encoding__ = None; - let mut schema__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::PubHandle => { @@ -13291,18 +13053,6 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { } encryption__ = Some(map_.next_value::()? as i32); } - GeneratedField::FrameEncoding => { - if frame_encoding__.is_some() { - return Err(serde::de::Error::duplicate_field("frameEncoding")); - } - frame_encoding__ = map_.next_value()?; - } - GeneratedField::Schema => { - if schema__.is_some() { - return Err(serde::de::Error::duplicate_field("schema")); - } - schema__ = map_.next_value()?; - } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -13313,329 +13063,12 @@ impl<'de> serde::Deserialize<'de> for DataTrackInfo { sid: sid__.unwrap_or_default(), name: name__.unwrap_or_default(), encryption: encryption__.unwrap_or_default(), - frame_encoding: frame_encoding__, - schema: schema__, }) } } deserializer.deserialize_struct("livekit.DataTrackInfo", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for DataTrackSchemaEncoding { - #[allow(deprecated)] - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut len = 0; - if self.value.is_some() { - len += 1; - } - let mut struct_ser = serializer.serialize_struct("livekit.DataTrackSchemaEncoding", len)?; - if let Some(v) = self.value.as_ref() { - match v { - data_track_schema_encoding::Value::WellKnown(v) => { - let v = data_track_schema_encoding::WellKnownSchemaEncoding::try_from(*v) - .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", *v)))?; - struct_ser.serialize_field("wellKnown", &v)?; - } - data_track_schema_encoding::Value::Custom(v) => { - struct_ser.serialize_field("custom", v)?; - } - } - } - struct_ser.end() - } -} -impl<'de> serde::Deserialize<'de> for DataTrackSchemaEncoding { - #[allow(deprecated)] - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - "well_known", - "wellKnown", - "custom", - ]; - - #[allow(clippy::enum_variant_names)] - enum GeneratedField { - WellKnown, - Custom, - __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 { - "wellKnown" | "well_known" => Ok(GeneratedField::WellKnown), - "custom" => Ok(GeneratedField::Custom), - _ => Ok(GeneratedField::__SkipField__), - } - } - } - deserializer.deserialize_identifier(GeneratedVisitor) - } - } - struct GeneratedVisitor; - impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = DataTrackSchemaEncoding; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.DataTrackSchemaEncoding") - } - - fn visit_map(self, mut map_: V) -> std::result::Result - where - V: serde::de::MapAccess<'de>, - { - let mut value__ = None; - while let Some(k) = map_.next_key()? { - match k { - GeneratedField::WellKnown => { - if value__.is_some() { - return Err(serde::de::Error::duplicate_field("wellKnown")); - } - value__ = map_.next_value::<::std::option::Option>()?.map(|x| data_track_schema_encoding::Value::WellKnown(x as i32)); - } - GeneratedField::Custom => { - if value__.is_some() { - return Err(serde::de::Error::duplicate_field("custom")); - } - value__ = map_.next_value::<::std::option::Option<_>>()?.map(data_track_schema_encoding::Value::Custom); - } - GeneratedField::__SkipField__ => { - let _ = map_.next_value::()?; - } - } - } - Ok(DataTrackSchemaEncoding { - value: value__, - }) - } - } - deserializer.deserialize_struct("livekit.DataTrackSchemaEncoding", FIELDS, GeneratedVisitor) - } -} -impl serde::Serialize for data_track_schema_encoding::WellKnownSchemaEncoding { - #[allow(deprecated)] - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - let variant = match self { - Self::Unspecified => "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", - Self::Protobuf => "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", - Self::Flatbuffer => "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", - Self::Ros1Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", - Self::Ros2Msg => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", - Self::Ros2Idl => "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", - Self::OmgIdl => "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", - Self::JsonSchema => "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", - }; - serializer.serialize_str(variant) - } -} -impl<'de> serde::Deserialize<'de> for data_track_schema_encoding::WellKnownSchemaEncoding { - #[allow(deprecated)] - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED", - "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF", - "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER", - "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG", - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG", - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL", - "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL", - "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA", - ]; - - struct GeneratedVisitor; - - impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = data_track_schema_encoding::WellKnownSchemaEncoding; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(formatter, "expected one of: {:?}", &FIELDS) - } - - fn visit_i64(self, v: i64) -> std::result::Result - where - E: serde::de::Error, - { - i32::try_from(v) - .ok() - .and_then(|x| x.try_into().ok()) - .ok_or_else(|| { - serde::de::Error::invalid_value(serde::de::Unexpected::Signed(v), &self) - }) - } - - fn visit_u64(self, v: u64) -> std::result::Result - where - E: serde::de::Error, - { - i32::try_from(v) - .ok() - .and_then(|x| x.try_into().ok()) - .ok_or_else(|| { - serde::de::Error::invalid_value(serde::de::Unexpected::Unsigned(v), &self) - }) - } - - fn visit_str(self, value: &str) -> std::result::Result - where - E: serde::de::Error, - { - match value { - "WELL_KNOWN_SCHEMA_ENCODING_UNSPECIFIED" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Unspecified), - "WELL_KNOWN_SCHEMA_ENCODING_PROTOBUF" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Protobuf), - "WELL_KNOWN_SCHEMA_ENCODING_FLATBUFFER" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Flatbuffer), - "WELL_KNOWN_SCHEMA_ENCODING_ROS1_MSG" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros1Msg), - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_MSG" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros2Msg), - "WELL_KNOWN_SCHEMA_ENCODING_ROS2_IDL" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::Ros2Idl), - "WELL_KNOWN_SCHEMA_ENCODING_OMG_IDL" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::OmgIdl), - "WELL_KNOWN_SCHEMA_ENCODING_JSON_SCHEMA" => Ok(data_track_schema_encoding::WellKnownSchemaEncoding::JsonSchema), - _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), - } - } - } - deserializer.deserialize_any(GeneratedVisitor) - } -} -impl serde::Serialize for DataTrackSchemaId { - #[allow(deprecated)] - fn serialize(&self, serializer: S) -> std::result::Result - where - S: serde::Serializer, - { - use serde::ser::SerializeStruct; - let mut len = 0; - if !self.name.is_empty() { - len += 1; - } - if self.encoding.is_some() { - len += 1; - } - let mut struct_ser = serializer.serialize_struct("livekit.DataTrackSchemaId", len)?; - if !self.name.is_empty() { - struct_ser.serialize_field("name", &self.name)?; - } - if let Some(v) = self.encoding.as_ref() { - struct_ser.serialize_field("encoding", v)?; - } - struct_ser.end() - } -} -impl<'de> serde::Deserialize<'de> for DataTrackSchemaId { - #[allow(deprecated)] - fn deserialize(deserializer: D) -> std::result::Result - where - D: serde::Deserializer<'de>, - { - const FIELDS: &[&str] = &[ - "name", - "encoding", - ]; - - #[allow(clippy::enum_variant_names)] - enum GeneratedField { - Name, - Encoding, - __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 { - "name" => Ok(GeneratedField::Name), - "encoding" => Ok(GeneratedField::Encoding), - _ => Ok(GeneratedField::__SkipField__), - } - } - } - deserializer.deserialize_identifier(GeneratedVisitor) - } - } - struct GeneratedVisitor; - impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = DataTrackSchemaId; - - fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.DataTrackSchemaId") - } - - fn visit_map(self, mut map_: V) -> std::result::Result - where - V: serde::de::MapAccess<'de>, - { - let mut name__ = None; - let mut encoding__ = None; - while let Some(k) = map_.next_key()? { - match k { - GeneratedField::Name => { - if name__.is_some() { - return Err(serde::de::Error::duplicate_field("name")); - } - name__ = Some(map_.next_value()?); - } - GeneratedField::Encoding => { - if encoding__.is_some() { - return Err(serde::de::Error::duplicate_field("encoding")); - } - encoding__ = map_.next_value()?; - } - GeneratedField::__SkipField__ => { - let _ = map_.next_value::()?; - } - } - } - Ok(DataTrackSchemaId { - name: name__.unwrap_or_default(), - encoding: encoding__, - }) - } - } - deserializer.deserialize_struct("livekit.DataTrackSchemaId", FIELDS, GeneratedVisitor) - } -} impl serde::Serialize for DataTrackSubscriberHandles { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -27129,9 +26562,6 @@ impl serde::Serialize for MetricsRecordingHeader { if self.room_start_time.is_some() { len += 1; } - if !self.job_id.is_empty() { - len += 1; - } let mut struct_ser = serializer.serialize_struct("livekit.MetricsRecordingHeader", len)?; if !self.room_id.is_empty() { struct_ser.serialize_field("roomId", &self.room_id)?; @@ -27153,9 +26583,6 @@ impl serde::Serialize for MetricsRecordingHeader { if let Some(v) = self.room_start_time.as_ref() { struct_ser.serialize_field("roomStartTime", v)?; } - if !self.job_id.is_empty() { - struct_ser.serialize_field("jobId", &self.job_id)?; - } struct_ser.end() } } @@ -27177,8 +26604,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { "roomName", "room_start_time", "roomStartTime", - "job_id", - "jobId", ]; #[allow(clippy::enum_variant_names)] @@ -27189,7 +26614,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { RoomTags, RoomName, RoomStartTime, - JobId, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -27218,7 +26642,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { "roomTags" | "room_tags" => Ok(GeneratedField::RoomTags), "roomName" | "room_name" => Ok(GeneratedField::RoomName), "roomStartTime" | "room_start_time" => Ok(GeneratedField::RoomStartTime), - "jobId" | "job_id" => Ok(GeneratedField::JobId), _ => Ok(GeneratedField::__SkipField__), } } @@ -27244,7 +26667,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { let mut room_tags__ = None; let mut room_name__ = None; let mut room_start_time__ = None; - let mut job_id__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::RoomId => { @@ -27287,12 +26709,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { } room_start_time__ = map_.next_value()?; } - GeneratedField::JobId => { - if job_id__.is_some() { - return Err(serde::de::Error::duplicate_field("jobId")); - } - job_id__ = Some(map_.next_value()?); - } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -27305,7 +26721,6 @@ impl<'de> serde::Deserialize<'de> for MetricsRecordingHeader { room_tags: room_tags__.unwrap_or_default(), room_name: room_name__.unwrap_or_default(), room_start_time: room_start_time__, - job_id: job_id__.unwrap_or_default(), }) } } @@ -30830,12 +30245,6 @@ impl serde::Serialize for PublishDataTrackRequest { if self.encryption != 0 { len += 1; } - if self.frame_encoding.is_some() { - len += 1; - } - if self.schema.is_some() { - len += 1; - } let mut struct_ser = serializer.serialize_struct("livekit.PublishDataTrackRequest", len)?; if self.pub_handle != 0 { struct_ser.serialize_field("pubHandle", &self.pub_handle)?; @@ -30848,12 +30257,6 @@ impl serde::Serialize for PublishDataTrackRequest { .map_err(|_| serde::ser::Error::custom(format!("Invalid variant {}", self.encryption)))?; struct_ser.serialize_field("encryption", &v)?; } - if let Some(v) = self.frame_encoding.as_ref() { - struct_ser.serialize_field("frameEncoding", v)?; - } - if let Some(v) = self.schema.as_ref() { - struct_ser.serialize_field("schema", v)?; - } struct_ser.end() } } @@ -30868,9 +30271,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { "pubHandle", "name", "encryption", - "frame_encoding", - "frameEncoding", - "schema", ]; #[allow(clippy::enum_variant_names)] @@ -30878,8 +30278,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { PubHandle, Name, Encryption, - FrameEncoding, - Schema, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -30905,8 +30303,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { "pubHandle" | "pub_handle" => Ok(GeneratedField::PubHandle), "name" => Ok(GeneratedField::Name), "encryption" => Ok(GeneratedField::Encryption), - "frameEncoding" | "frame_encoding" => Ok(GeneratedField::FrameEncoding), - "schema" => Ok(GeneratedField::Schema), _ => Ok(GeneratedField::__SkipField__), } } @@ -30929,8 +30325,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { let mut pub_handle__ = None; let mut name__ = None; let mut encryption__ = None; - let mut frame_encoding__ = None; - let mut schema__ = None; while let Some(k) = map_.next_key()? { match k { GeneratedField::PubHandle => { @@ -30953,18 +30347,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { } encryption__ = Some(map_.next_value::()? as i32); } - GeneratedField::FrameEncoding => { - if frame_encoding__.is_some() { - return Err(serde::de::Error::duplicate_field("frameEncoding")); - } - frame_encoding__ = map_.next_value()?; - } - GeneratedField::Schema => { - if schema__.is_some() { - return Err(serde::de::Error::duplicate_field("schema")); - } - schema__ = map_.next_value()?; - } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } @@ -30974,8 +30356,6 @@ impl<'de> serde::Deserialize<'de> for PublishDataTrackRequest { pub_handle: pub_handle__.unwrap_or_default(), name: name__.unwrap_or_default(), encryption: encryption__.unwrap_or_default(), - frame_encoding: frame_encoding__, - schema: schema__, }) } } From 232a3bb5df9e3741a4b8e247e43e3f836c90d7bf Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:04:47 -0400 Subject: [PATCH 15/46] refactor: convert data stream open tuple to struct and add is_compressed / is_inline --- livekit/src/room/data_stream/incoming.rs | 57 ++++++++++++++++-------- livekit/src/room/mod.rs | 22 ++++++--- 2 files changed, 55 insertions(+), 24 deletions(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index b1fc5f525..5f590ed69 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -308,10 +308,25 @@ fn inflate_raw(data: &[u8]) -> StreamResult> { Ok(out) } +/// Metadata about a data stream which has just been opened +pub(crate) struct DataStreamOpenInfo { + pub(crate) reader: AnyStreamReader, + pub(crate) identity: String, + + /// Whether the payload was deflate-raw compressed (data streams v2). + #[allow(unused)] + pub(crate) is_compressed: bool, + + /// Whether the whole payload was sent inline in the header as a single packet + /// (data streams v2), rather than as separate chunk packets. + #[allow(unused)] + pub(crate) is_inline: bool, +} + #[derive(Clone)] pub(crate) struct IncomingStreamManager { inner: Arc>, - open_tx: UnboundedSender<(AnyStreamReader, String)>, + open_tx: UnboundedSender, } #[derive(Default)] @@ -320,7 +335,7 @@ struct ManagerInner { } impl IncomingStreamManager { - pub fn new() -> (Self, UnboundedReceiver<(AnyStreamReader, String)>) { + pub fn new() -> (Self, UnboundedReceiver) { let (open_tx, open_rx) = mpsc::unbounded_channel(); (Self { inner: Arc::new(Mutex::new(Default::default())), open_tx }, open_rx) } @@ -355,7 +370,12 @@ impl IncomingStreamManager { } let (reader, chunk_tx) = AnyStreamReader::from(info); - let _ = self.open_tx.send((reader, identity)); + let _ = self.open_tx.send(DataStreamOpenInfo { + reader, + identity, + is_compressed, + is_inline: inline_content.is_some(), + }); // Inline single-packet stream: synthesize the complete content now; no chunk/trailer // packets will follow, so we never register an open descriptor. @@ -635,9 +655,7 @@ mod tests { proto::Trailer { stream_id: id.to_string(), reason: String::new(), attributes } } - async fn recv_reader( - rx: &mut UnboundedReceiver<(AnyStreamReader, String)>, - ) -> (AnyStreamReader, String) { + async fn recv_reader(rx: &mut UnboundedReceiver) -> DataStreamOpenInfo { rx.recv().await.expect("a reader should be dispatched") } @@ -679,8 +697,11 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, identity) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, identity, is_compressed, is_inline } = + recv_reader(&mut rx).await; assert_eq!(identity, SENDER); + assert_eq!(compressed, false); + assert_eq!(inline, false); 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")); @@ -695,7 +716,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; 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])); @@ -716,7 +737,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); mgr.handle_trailer(trailer_with_attrs( "s1", @@ -737,7 +758,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; 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))); @@ -751,7 +772,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; 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))); @@ -765,7 +786,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'i']), EncType::Gcm); assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); } @@ -787,7 +808,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).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); @@ -801,7 +822,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); } @@ -821,7 +842,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -841,7 +862,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); } @@ -867,7 +888,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; for (i, piece) in chunk_pieces.iter().enumerate() { mgr.handle_chunk(chunk("s1", i as u64, piece.to_vec()), EncType::None); } @@ -893,7 +914,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, pieces[0].to_vec()), EncType::None); // Skip index 1 -> feed index 2: a gap is a hard error. mgr.handle_chunk(chunk("s1", 2, pieces[1].to_vec()), EncType::None); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 773ab5856..96f780869 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -219,6 +219,12 @@ pub enum RoomEvent { reader: TakeCell, topic: String, participant_identity: ParticipantIdentity, + /// Test-only: expose whether the byte stream is being compressed or not. + #[cfg(feature = "__lk-e2e-test")] + is_compressed: bool, + /// Test-only: expose whether the byte stream has been sent inline on the header packet + #[cfg(feature = "__lk-e2e-test")] + is_inline: bool, }, TextStreamOpened { reader: TakeCell, @@ -2263,22 +2269,26 @@ impl rpc::RemoteParticipantRegistry for RoomSession { /// 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. async fn incoming_data_stream_task( - mut open_rx: UnboundedReceiver<(AnyStreamReader, String)>, + mut open_rx: UnboundedReceiver, dispatcher: Dispatcher, mut close_rx: broadcast::Receiver<()>, session: Arc, ) { loop { tokio::select! { - Some((reader, identity)) = open_rx.recv() => { - match reader { + Some(open_info) = open_rx.recv() => { + match open_info.reader { AnyStreamReader::Byte(reader) => { let topic = reader.info().topic.clone(); if !data_stream::is_internal_topic(&topic) { dispatcher.dispatch(&RoomEvent::ByteStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(identity) + participant_identity: ParticipantIdentity(open_info.identity), + #[cfg(feature = "__lk-e2e-test")] + is_compressed: open_info.is_compressed, + #[cfg(feature = "__lk-e2e-test")] + is_inline: open_info.is_inline, }); } } @@ -2286,7 +2296,7 @@ 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 caller_identity = ParticipantIdentity(open_info.identity); let session = session.clone(); livekit_runtime::spawn(async move { let transport = rpc::SessionTransport(session.clone()); @@ -2308,7 +2318,7 @@ async fn incoming_data_stream_task( dispatcher.dispatch(&RoomEvent::TextStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(identity) + participant_identity: ParticipantIdentity(open_info.identity) }); } } From e2f837003a2edd4fce06cb111546b9830ead7354 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:15:14 -0400 Subject: [PATCH 16/46] feat: add is_compressed / is_inline to ByteStreamInfo / TextStreamInfo --- livekit/src/room/data_stream/mod.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/livekit/src/room/data_stream/mod.rs b/livekit/src/room/data_stream/mod.rs index 43bac0a3c..2c589faad 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit/src/room/data_stream/mod.rs @@ -120,6 +120,12 @@ pub struct ByteStreamInfo { pub name: String, /// The encryption used pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + // #[cfg(feature = "__lk-e2e-test")] + is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + // #[cfg(feature = "__lk-e2e-test")] + is_inline: bool, } /// Information about a text data stream. @@ -144,6 +150,12 @@ pub struct TextStreamInfo { pub generated: bool, /// The encryption used pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + // #[cfg(feature = "__lk-e2e-test")] + is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + // #[cfg(feature = "__lk-e2e-test")] + is_inline: bool, } /// Operation type for text streams. @@ -196,6 +208,8 @@ impl ByteStreamInfo { byte_header: proto::ByteHeader, encryption_type: EncryptionType, ) -> Self { + let is_compressed = header.compression() != proto::CompressionType::None; + let is_inline = !header.inline_content().is_empty(); Self { id: header.stream_id, topic: header.topic, @@ -206,6 +220,8 @@ impl ByteStreamInfo { mime_type: header.mime_type, name: byte_header.name, encryption_type, + is_compressed, + is_inline, } } } @@ -220,6 +236,8 @@ impl TextStreamInfo { text_header: proto::TextHeader, encryption_type: EncryptionType, ) -> Self { + let was_compressed = header.compression() != proto::CompressionType::None; + let was_inline = !header.inline_content().is_empty(); Self { id: header.stream_id, topic: header.topic, @@ -235,6 +253,8 @@ impl TextStreamInfo { attached_stream_ids: text_header.attached_stream_ids, generated: text_header.generated, encryption_type, + is_compressed: was_compressed, + is_inline: was_inline, } } } From 07166a5b366ab3e55ea97eae5a49ee1f09ac74cf Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:16:16 -0400 Subject: [PATCH 17/46] Revert "refactor: convert data stream open tuple to struct and add is_compressed / is_inline" This reverts commit 37687234d3fa7415752aeac23b771a15c59ed1f6. --- livekit/src/room/data_stream/incoming.rs | 57 ++++++++---------------- livekit/src/room/mod.rs | 22 +++------ 2 files changed, 24 insertions(+), 55 deletions(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index 5f590ed69..b1fc5f525 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -308,25 +308,10 @@ fn inflate_raw(data: &[u8]) -> StreamResult> { Ok(out) } -/// Metadata about a data stream which has just been opened -pub(crate) struct DataStreamOpenInfo { - pub(crate) reader: AnyStreamReader, - pub(crate) identity: String, - - /// Whether the payload was deflate-raw compressed (data streams v2). - #[allow(unused)] - pub(crate) is_compressed: bool, - - /// Whether the whole payload was sent inline in the header as a single packet - /// (data streams v2), rather than as separate chunk packets. - #[allow(unused)] - pub(crate) is_inline: bool, -} - #[derive(Clone)] pub(crate) struct IncomingStreamManager { inner: Arc>, - open_tx: UnboundedSender, + open_tx: UnboundedSender<(AnyStreamReader, String)>, } #[derive(Default)] @@ -335,7 +320,7 @@ struct ManagerInner { } impl IncomingStreamManager { - pub fn new() -> (Self, UnboundedReceiver) { + pub fn new() -> (Self, UnboundedReceiver<(AnyStreamReader, String)>) { let (open_tx, open_rx) = mpsc::unbounded_channel(); (Self { inner: Arc::new(Mutex::new(Default::default())), open_tx }, open_rx) } @@ -370,12 +355,7 @@ impl IncomingStreamManager { } let (reader, chunk_tx) = AnyStreamReader::from(info); - let _ = self.open_tx.send(DataStreamOpenInfo { - reader, - identity, - is_compressed, - is_inline: inline_content.is_some(), - }); + let _ = self.open_tx.send((reader, identity)); // Inline single-packet stream: synthesize the complete content now; no chunk/trailer // packets will follow, so we never register an open descriptor. @@ -655,7 +635,9 @@ mod tests { proto::Trailer { stream_id: id.to_string(), reason: String::new(), attributes } } - async fn recv_reader(rx: &mut UnboundedReceiver) -> DataStreamOpenInfo { + async fn recv_reader( + rx: &mut UnboundedReceiver<(AnyStreamReader, String)>, + ) -> (AnyStreamReader, String) { rx.recv().await.expect("a reader should be dispatched") } @@ -697,11 +679,8 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, identity, is_compressed, is_inline } = - recv_reader(&mut rx).await; + let (reader, identity) = recv_reader(&mut rx).await; assert_eq!(identity, SENDER); - assert_eq!(compressed, false); - assert_eq!(inline, false); 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")); @@ -716,7 +695,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; 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])); @@ -737,7 +716,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); mgr.handle_trailer(trailer_with_attrs( "s1", @@ -758,7 +737,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; 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))); @@ -772,7 +751,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; 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))); @@ -786,7 +765,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'i']), EncType::Gcm); assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); } @@ -808,7 +787,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).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); @@ -822,7 +801,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); } @@ -842,7 +821,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -862,7 +841,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); } @@ -888,7 +867,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; for (i, piece) in chunk_pieces.iter().enumerate() { mgr.handle_chunk(chunk("s1", i as u64, piece.to_vec()), EncType::None); } @@ -914,7 +893,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let DataStreamOpenInfo { reader, .. } = recv_reader(&mut rx).await; + let (reader, _) = recv_reader(&mut rx).await; mgr.handle_chunk(chunk("s1", 0, pieces[0].to_vec()), EncType::None); // Skip index 1 -> feed index 2: a gap is a hard error. mgr.handle_chunk(chunk("s1", 2, pieces[1].to_vec()), EncType::None); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 96f780869..773ab5856 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -219,12 +219,6 @@ pub enum RoomEvent { reader: TakeCell, topic: String, participant_identity: ParticipantIdentity, - /// Test-only: expose whether the byte stream is being compressed or not. - #[cfg(feature = "__lk-e2e-test")] - is_compressed: bool, - /// Test-only: expose whether the byte stream has been sent inline on the header packet - #[cfg(feature = "__lk-e2e-test")] - is_inline: bool, }, TextStreamOpened { reader: TakeCell, @@ -2269,26 +2263,22 @@ impl rpc::RemoteParticipantRegistry for RoomSession { /// 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. async fn incoming_data_stream_task( - mut open_rx: UnboundedReceiver, + mut open_rx: UnboundedReceiver<(AnyStreamReader, String)>, dispatcher: Dispatcher, mut close_rx: broadcast::Receiver<()>, session: Arc, ) { loop { tokio::select! { - Some(open_info) = open_rx.recv() => { - match open_info.reader { + Some((reader, identity)) = open_rx.recv() => { + match reader { AnyStreamReader::Byte(reader) => { let topic = reader.info().topic.clone(); if !data_stream::is_internal_topic(&topic) { dispatcher.dispatch(&RoomEvent::ByteStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(open_info.identity), - #[cfg(feature = "__lk-e2e-test")] - is_compressed: open_info.is_compressed, - #[cfg(feature = "__lk-e2e-test")] - is_inline: open_info.is_inline, + participant_identity: ParticipantIdentity(identity) }); } } @@ -2296,7 +2286,7 @@ async fn incoming_data_stream_task( let topic = reader.info().topic.clone(); match topic.as_str() { rpc::RPC_REQUEST_TOPIC => { - let caller_identity = ParticipantIdentity(open_info.identity); + let caller_identity = ParticipantIdentity(identity); let session = session.clone(); livekit_runtime::spawn(async move { let transport = rpc::SessionTransport(session.clone()); @@ -2318,7 +2308,7 @@ async fn incoming_data_stream_task( dispatcher.dispatch(&RoomEvent::TextStreamOpened { topic, reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(open_info.identity) + participant_identity: ParticipantIdentity(identity) }); } } From f819cfab7b0793d73bf74f05177929b25f54f9ea Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:27:40 -0400 Subject: [PATCH 18/46] feat: explicitly check is_compressed / is_inline in e2e tests --- livekit/src/room/data_stream/mod.rs | 1 + livekit/tests/data_stream_test.rs | 46 +++++++++++++++++++++++++++-- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/livekit/src/room/data_stream/mod.rs b/livekit/src/room/data_stream/mod.rs index 2c589faad..1d488bf54 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit/src/room/data_stream/mod.rs @@ -14,6 +14,7 @@ use chrono::{DateTime, Utc}; use libwebrtc::enum_dispatch; +use libwebrtc::native::create_random_uuid; use livekit_protocol::data_stream as proto; use std::collections::HashMap; use thiserror::Error; diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index 240a142e8..ce19a0b75 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -17,6 +17,7 @@ use { crate::common::test_rooms, anyhow::{anyhow, Ok, Result}, chrono::{TimeDelta, Utc}, + libwebrtc::native::create_random_uuid, livekit::{RoomEvent, StreamByteOptions, StreamReader, StreamTextOptions}, 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, false); + assert_eq!(stream_info.is_inline, true); Ok(()) }; @@ -92,7 +95,9 @@ async fn test_send_large_compressible_text() -> Result<()> { let send = async move { let options = StreamTextOptions { topic: "some-topic".into(), ..Default::default() }; - sending_room.local_participant().send_text(&text, options).await?; + 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 { @@ -112,6 +117,41 @@ async fn test_send_large_compressible_text() -> Result<()> { 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(); + + let payload: Vec = (0..50_000).flat_map(|_| create_random_uuid().into_bytes()).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, false); + assert_eq!(stream_info.is_inline, 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] @@ -125,7 +165,9 @@ async fn test_send_large_bytes() -> Result<()> { let send = async move { let options = StreamByteOptions { topic: "some-topic".into(), ..Default::default() }; - sending_room.local_participant().send_bytes(&payload, options).await?; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, true); + assert_eq!(stream_info.is_inline, false); Ok(()) }; let receive = async move { From 502986a0c983ecdd2c856fbaf0ca873c72d0ca65 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:35:27 -0400 Subject: [PATCH 19/46] fix: add e2e test that tests compress=false path --- livekit/tests/data_stream_test.rs | 40 +++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index ce19a0b75..12b1daf75 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -187,6 +187,46 @@ async fn test_send_large_bytes() -> Result<()> { 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); + assert_eq!(stream_info.is_inline, 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<()> { From ad3d729cdea38c0adf07ebb620ef29d8952f8d3d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 15:49:30 -0400 Subject: [PATCH 20/46] feat: add e2e feature on compressed / inline fields --- livekit/src/room/data_stream/mod.rs | 34 +++++++++++++++-------------- livekit/src/room/rpc/tests.rs | 8 +++++++ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/livekit/src/room/data_stream/mod.rs b/livekit/src/room/data_stream/mod.rs index 1d488bf54..a3d9c5182 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit/src/room/data_stream/mod.rs @@ -122,11 +122,11 @@ pub struct ByteStreamInfo { /// The encryption used pub encryption_type: EncryptionType, /// Test-only: expose whether the byte stream was compressed or not. - // #[cfg(feature = "__lk-e2e-test")] - is_compressed: bool, + #[cfg(feature = "__lk-e2e-test")] + pub is_compressed: bool, /// Test-only: expose whether the byte stream was sent inline on the header packet - // #[cfg(feature = "__lk-e2e-test")] - is_inline: bool, + #[cfg(feature = "__lk-e2e-test")] + pub is_inline: bool, } /// Information about a text data stream. @@ -152,11 +152,11 @@ pub struct TextStreamInfo { /// The encryption used pub encryption_type: EncryptionType, /// Test-only: expose whether the byte stream was compressed or not. - // #[cfg(feature = "__lk-e2e-test")] - is_compressed: bool, + #[cfg(feature = "__lk-e2e-test")] + pub is_compressed: bool, /// Test-only: expose whether the byte stream was sent inline on the header packet - // #[cfg(feature = "__lk-e2e-test")] - is_inline: bool, + #[cfg(feature = "__lk-e2e-test")] + pub is_inline: bool, } /// Operation type for text streams. @@ -209,9 +209,12 @@ impl ByteStreamInfo { byte_header: proto::ByteHeader, encryption_type: EncryptionType, ) -> Self { - let is_compressed = header.compression() != proto::CompressionType::None; - let is_inline = !header.inline_content().is_empty(); Self { + #[cfg(feature = "__lk-e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_inline: !header.inline_content().is_empty(), + id: header.stream_id, topic: header.topic, timestamp: DateTime::::from_timestamp_millis(header.timestamp) @@ -221,8 +224,6 @@ impl ByteStreamInfo { mime_type: header.mime_type, name: byte_header.name, encryption_type, - is_compressed, - is_inline, } } } @@ -237,9 +238,12 @@ impl TextStreamInfo { text_header: proto::TextHeader, encryption_type: EncryptionType, ) -> Self { - let was_compressed = header.compression() != proto::CompressionType::None; - let was_inline = !header.inline_content().is_empty(); Self { + #[cfg(feature = "__lk-e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_inline: !header.inline_content().is_empty(), + id: header.stream_id, topic: header.topic, timestamp: DateTime::::from_timestamp_millis(header.timestamp) @@ -254,8 +258,6 @@ impl TextStreamInfo { attached_stream_ids: text_header.attached_stream_ids, generated: text_header.generated, encryption_type, - is_compressed: was_compressed, - is_inline: was_inline, } } } diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index ce40e78c6..5b61d4ef6 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -133,6 +133,10 @@ 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, }) } @@ -181,6 +185,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, ) From d873c1b4d9d969238e24156e9cce2e61cf741efb Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 1 Jul 2026 17:03:57 -0400 Subject: [PATCH 21/46] feat: get random bytes e2e test to pass by using much higher quality random data --- Cargo.lock | 1 + livekit/Cargo.toml | 1 + livekit/src/room/data_stream/outgoing.rs | 20 ++++++++------------ livekit/tests/data_stream_test.rs | 12 ++++++++---- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc9a2cd60..32a41a1a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3932,6 +3932,7 @@ dependencies = [ "log", "parking_lot", "prost 0.12.6", + "rand 0.9.3", "semver", "serde", "serde_json", diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 1018c3175..06875fa0c 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -61,3 +61,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/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index bbf7e1f77..030f22650 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -406,7 +406,7 @@ impl OutgoingStreamManager { // 1. Inline single-packet attempt (no attachments; all recipients are v2). if eligibility.inline && options.attached_stream_ids.is_empty() { - let (content, compression) = maybe_compress_inline(payload, compress_ok); + let (content, compression) = maybe_compress(payload, compress_ok); let (header, text_header) = build_text_header( &options, stream_id.clone(), @@ -476,7 +476,7 @@ impl OutgoingStreamManager { // 1. Inline single-packet attempt (all recipients are v2). if eligibility.inline { - let (content, compression) = maybe_compress_inline(bytes, compress_ok); + let (content, compression) = maybe_compress(bytes, compress_ok); let (header, byte_header) = build_byte_header( &options, stream_id.clone(), @@ -492,9 +492,9 @@ impl OutgoingStreamManager { } } - // 2/3. Chunked, compressed when eligible else uncompressed. - let compression = - if compress_ok { CompressionType::DeflateRaw } else { CompressionType::None }; + // 2/3. Chunked. Compress only when eligible AND it actually shrinks the payload. + // Decide compression before building the header so its flag matches what is written. + let (payload, compression) = maybe_compress(bytes, compress_ok); let (header, byte_header) = build_byte_header(&options, stream_id, name, Some(total_length), None, compression); enforce_header_size(&header, &dests)?; @@ -506,11 +506,7 @@ impl OutgoingStreamManager { }; let info = ByteStreamInfo::from_headers(header, byte_header); let mut stream = RawStream::open(open_options).await?; - if compress_ok { - stream.write_raw_chunks(&deflate_raw(bytes)).await?; - } else { - stream.write_raw_chunks(bytes).await?; - } + stream.write_raw_chunks(&payload).await?; stream.close(None).await?; Ok(info) } @@ -585,9 +581,9 @@ fn evaluate_eligibility( SendEligibility { inline, compression } } -/// Returns the inline payload and its compression flag: deflate-raw compressed when +/// Returns the payload and its compression flag: deflate-raw compressed when /// `compress` is set AND the compressed form is actually smaller, else the raw bytes. -fn maybe_compress_inline(payload: &[u8], compress: bool) -> (Vec, CompressionType) { +fn maybe_compress(payload: &[u8], compress: bool) -> (Vec, CompressionType) { if compress { let compressed = deflate_raw(payload); if compressed.len() < payload.len() { diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index 12b1daf75..ccdde1ed0 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -17,8 +17,8 @@ use { crate::common::test_rooms, anyhow::{anyhow, Ok, Result}, chrono::{TimeDelta, Utc}, - libwebrtc::native::create_random_uuid, livekit::{RoomEvent, StreamByteOptions, StreamReader, StreamTextOptions}, + rand::{rngs::StdRng, RngCore, SeedableRng}, std::time::Duration, tokio::{time::timeout, try_join}, }; @@ -47,7 +47,7 @@ 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, false); + assert_eq!(stream_info.is_compressed, true); assert_eq!(stream_info.is_inline, true); Ok(()) @@ -125,7 +125,11 @@ async fn test_send_large_incompressible_random_bytes() -> Result<()> { let (sending_room, _) = rooms.pop().unwrap(); let (_, mut receiving_event_rx) = rooms.pop().unwrap(); - let payload: Vec = (0..50_000).flat_map(|_| create_random_uuid().into_bytes()).collect(); + // 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 { @@ -167,7 +171,7 @@ async fn test_send_large_bytes() -> Result<()> { 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); - assert_eq!(stream_info.is_inline, false); + assert_eq!(stream_info.is_inline, true); Ok(()) }; let receive = async move { From 816a550527cb8f82065ade806ba423c817422263 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 12:11:30 -0400 Subject: [PATCH 22/46] refactor: move RemoteParticipantRegistry to participant/registry.rs --- livekit/src/room/data_stream/outgoing.rs | 5 ++-- livekit/src/room/mod.rs | 2 +- livekit/src/room/participant/mod.rs | 3 +++ livekit/src/room/participant/registry.rs | 32 ++++++++++++++++++++++++ livekit/src/room/rpc/mod.rs | 18 +------------ livekit/src/room/rpc/tests.rs | 2 +- 6 files changed, 41 insertions(+), 21 deletions(-) create mode 100644 livekit/src/room/participant/registry.rs diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index 030f22650..f3cb2d5d2 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -16,8 +16,9 @@ use super::{ ByteStreamInfo, OperationType, StreamError, StreamProgress, StreamResult, TextStreamInfo, }; use crate::{ - id::ParticipantIdentity, room::participant::ClientCapability, - room::rpc::RemoteParticipantRegistry, rtc_engine::EngineError, + id::ParticipantIdentity, + room::participant::{ClientCapability, RemoteParticipantRegistry}, + rtc_engine::EngineError, utils::utf8_chunk::Utf8AwareChunkExt, }; use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 773ab5856..7bde2350c 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -2244,7 +2244,7 @@ impl RoomSession { } } -impl rpc::RemoteParticipantRegistry for RoomSession { +impl participant::RemoteParticipantRegistry for RoomSession { fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { self.get_remote_client_protocol(identity) } diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index ef049845f..53557bedd 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -21,6 +21,7 @@ use parking_lot::{Mutex, RwLock}; use crate::{prelude::*, rtc_engine::RtcEngine}; mod local_participant; +mod registry; mod remote_participant; mod rpc; @@ -28,6 +29,8 @@ pub use local_participant::*; pub use remote_participant::*; pub use rpc::*; +pub(crate) use registry::RemoteParticipantRegistry; + #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ConnectionQuality { Excellent, diff --git a/livekit/src/room/participant/registry.rs b/livekit/src/room/participant/registry.rs new file mode 100644 index 000000000..c240556ed --- /dev/null +++ b/livekit/src/room/participant/registry.rs @@ -0,0 +1,32 @@ +// 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::ClientCapability; +use crate::room::id::ParticipantIdentity; + +/// Read access to remote participants' advertised protocol and capabilities. +/// +/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send +/// path (inline / compression eligibility), so both consult a single abstraction over +/// the room's remote participants and both are unit-testable with a fake. +pub(crate) 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/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 2202163c3..c6d0b409b 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -23,7 +23,7 @@ pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; -use crate::room::participant::ClientCapability; +use crate::room::participant::{ClientCapability, RemoteParticipantRegistry}; use livekit_protocol::RpcError as RpcError_Proto; use std::{error::Error, fmt::Display, future::Future, time::Duration}; @@ -42,22 +42,6 @@ pub(crate) const ATTR_METHOD: &str = "lk.rpc_request_method"; pub(crate) const ATTR_RESPONSE_TIMEOUT_MS: &str = "lk.rpc_request_response_timeout_ms"; pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; -/// Read access to remote participants' advertised protocol and capabilities. -/// -/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send -/// path (inline / compression eligibility), so both consult a single abstraction over -/// the room's remote participants and both are unit-testable with a fake. -pub(crate) 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; -} - /// Transport abstraction for RPC operations. /// /// Decouples the RPC managers from concrete engine/session types, diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index 5b61d4ef6..1dfd8052f 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -18,7 +18,7 @@ use crate::data_stream::{ }; use crate::e2ee::EncryptionType; use crate::room::id::ParticipantIdentity; -use crate::room::participant::ClientCapability; +use crate::room::participant::{ClientCapability, RemoteParticipantRegistry}; use crate::room::RoomError; use bytes::Bytes; use chrono::Utc; From c805709152e14a264199b5ab52fa71ded49a159f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 13:09:20 -0400 Subject: [PATCH 23/46] refactor: restructure send_text / send_bytes Add MaybeCompressed so that compression only ever happens at most once even if it has to happen speculatively sometimes --- livekit/src/room/data_stream/outgoing.rs | 161 +++++++++++++++-------- 1 file changed, 104 insertions(+), 57 deletions(-) diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index f3cb2d5d2..ab37916d4 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -395,28 +395,40 @@ impl OutgoingStreamManager { &self, text: &str, options: StreamTextOptions, - registry: &dyn RemoteParticipantRegistry, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); let total_length = text.len() as u64; - let payload = text.as_bytes(); - let dests = options.destination_identities.clone(); + let mut payload = MaybeCompressed::new(text.as_bytes()); - let eligibility = evaluate_eligibility(registry, &dests); - let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let should_compress = options.compress.unwrap_or(true) && eligibility.compression; // 1. Inline single-packet attempt (no attachments; all recipients are v2). if eligibility.inline && options.attached_stream_ids.is_empty() { - let (content, compression) = maybe_compress(payload, compress_ok); - let (header, text_header) = build_text_header( - &options, - stream_id.clone(), - Some(total_length), - Some(content), - compression, - ); - if header_packet_fits(&header, &dests) { - let packet = RawStream::create_header_packet(header.clone(), dests); + let (header, text_header) = + if should_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; + + if header_packet_fits(&header, &options.destination_identities) { + let packet = + RawStream::create_header_packet(header.clone(), options.destination_identities); RawStream::send_packet(&self.packet_tx, packet).await?; return Ok(TextStreamInfo::from_headers(header, text_header)); } @@ -425,22 +437,22 @@ impl OutgoingStreamManager { // 2/3. Chunked, compressed when eligible else uncompressed. let compression = - if compress_ok { CompressionType::DeflateRaw } else { CompressionType::None }; + if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; let (header, text_header) = build_text_header(&options, stream_id, Some(total_length), None, compression); - enforce_header_size(&header, &dests)?; + enforce_header_size(&header, &options.destination_identities)?; let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: dests, + 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 compress_ok { - stream.write_raw_chunks(&deflate_raw(payload)).await?; + if should_compress { + stream.write_raw_chunks(payload.as_compressed()?).await?; } else { - for chunk in payload.utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { + for chunk in payload.uncompressed.utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } } @@ -461,7 +473,7 @@ impl OutgoingStreamManager { &self, data: impl AsRef<[u8]>, options: StreamByteOptions, - registry: &dyn RemoteParticipantRegistry, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { if options.total_length.is_some() { log::warn!("Ignoring total_length option specified for send_bytes"); @@ -471,21 +483,33 @@ impl OutgoingStreamManager { let name = options.name.clone().unwrap_or_else(|| BYTE_DEFAULT_NAME.to_owned()); let total_length = bytes.len() as u64; let dests = options.destination_identities.clone(); + let mut payload = MaybeCompressed::new(bytes); - let eligibility = evaluate_eligibility(registry, &dests); - let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + let eligibility = evaluate_eligibility(remote_participant_registry, &dests); + let should_compress = options.compress.unwrap_or(true) && eligibility.compression; // 1. Inline single-packet attempt (all recipients are v2). if eligibility.inline { - let (content, compression) = maybe_compress(bytes, compress_ok); - let (header, byte_header) = build_byte_header( - &options, - stream_id.clone(), - name.clone(), - Some(total_length), - Some(content), - compression, - ); + let (header, byte_header) = + if should_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; if header_packet_fits(&header, &dests) { let packet = RawStream::create_header_packet(header.clone(), dests); RawStream::send_packet(&self.packet_tx, packet).await?; @@ -493,9 +517,9 @@ impl OutgoingStreamManager { } } - // 2/3. Chunked. Compress only when eligible AND it actually shrinks the payload. - // Decide compression before building the header so its flag matches what is written. - let (payload, compression) = maybe_compress(bytes, compress_ok); + // 2/3. Chunked, compressed when eligible else uncompressed. + let compression = + if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; let (header, byte_header) = build_byte_header(&options, stream_id, name, Some(total_length), None, compression); enforce_header_size(&header, &dests)?; @@ -507,7 +531,11 @@ impl OutgoingStreamManager { }; let info = ByteStreamInfo::from_headers(header, byte_header); let mut stream = RawStream::open(open_options).await?; - stream.write_raw_chunks(&payload).await?; + if should_compress { + stream.write_raw_chunks(payload.as_compressed()?).await?; + } else { + stream.write_raw_chunks(payload.uncompressed).await?; + } stream.close(None).await?; Ok(info) } @@ -521,7 +549,7 @@ impl OutgoingStreamManager { &self, path: impl AsRef, options: StreamByteOptions, - registry: &dyn RemoteParticipantRegistry, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { let path = path.as_ref(); let file_size = tokio::fs::metadata(path) @@ -532,10 +560,10 @@ impl OutgoingStreamManager { let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); let dests = options.destination_identities.clone(); - let eligibility = evaluate_eligibility(registry, &dests); - let compress_ok = options.compress.unwrap_or(true) && eligibility.compression; + let eligibility = evaluate_eligibility(remote_participant_registry, &dests); + let should_compress = options.compress.unwrap_or(true) && eligibility.compression; let compression = - if compress_ok { CompressionType::DeflateRaw } else { CompressionType::None }; + if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; let (header, byte_header) = build_byte_header(&options, stream_id, name, Some(file_size), None, compression); @@ -548,7 +576,7 @@ impl OutgoingStreamManager { }; let info = ByteStreamInfo::from_headers(header, byte_header); let mut stream = RawStream::open(open_options).await?; - stream.write_file(path, compress_ok).await?; + stream.write_file(path, should_compress).await?; stream.close(None).await?; Ok(info) } @@ -579,27 +607,46 @@ fn evaluate_eligibility( && recipients.iter().all(|id| { registry.remote_capabilities(id).contains(&ClientCapability::CompressionDeflateRaw) }); + SendEligibility { inline, compression } } -/// Returns the payload and its compression flag: deflate-raw compressed when -/// `compress` is set AND the compressed form is actually smaller, else the raw bytes. -fn maybe_compress(payload: &[u8], compress: bool) -> (Vec, CompressionType) { - if compress { - let compressed = deflate_raw(payload); - if compressed.len() < payload.len() { - return (compressed, CompressionType::DeflateRaw); +struct MaybeCompressed<'a> { + uncompressed: &'a [u8], + compressed: Option>, +} + +impl<'a> MaybeCompressed<'a> { + fn new(uncompressed: &'a [u8]) -> Self { + Self { uncompressed, compressed: None } + } + + /// Upconverts the Uncompressed variant into the Compressed variant, and returns a reference to + /// the compressed bytes as a result. + fn as_compressed(&mut self) -> Result<&[u8], std::io::Error> { + match &mut self.compressed { + Some(compressed) => Ok(&*compressed), + compressed_option @ None => { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(self.uncompressed)?; + *compressed_option = Some(encoder.finish()?); + let Some(ref data) = compressed_option else { + unreachable!("compressed data just set") + }; + Ok(data) + } } } - (payload.to_vec(), CompressionType::None) -} -/// One-shot deflate-raw (raw DEFLATE, no zlib/gzip wrapper) of the full payload. -fn deflate_raw(data: &[u8]) -> Vec { - let mut encoder = - flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(data).expect("deflate write to Vec is infallible"); - encoder.finish().expect("deflate finish into Vec is infallible") + // /// Consumes and converts into compressed bytes + // fn into_compressed(mut self) -> Result, std::io::Error> { + // self.as_compressed()?; + // let Some(data) = self.compressed else { + // unreachable!("compressed data should be set in .as_compressed()"); + // }; + // Ok(data) + // } } /// Whether the serialized header `DataPacket` fits within the MTU budget. From 860b7f2bfd34b9be65143a6e194702503bd3a7c8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 13:47:18 -0400 Subject: [PATCH 24/46] feat: throw error when remove participant disconnects halfway through sending a data stream This matches with how web does it --- livekit/src/room/data_stream/incoming.rs | 60 +++++++++++++++++++++++- livekit/src/room/mod.rs | 6 +++ 2 files changed, 65 insertions(+), 1 deletion(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index b1fc5f525..0eb6b7a0c 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -237,6 +237,9 @@ 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: String, is_internal: bool, /// Whether this is a text stream (decompressed output is reframed on UTF-8 boundaries). is_text: bool, @@ -355,7 +358,7 @@ impl IncomingStreamManager { } let (reader, chunk_tx) = AnyStreamReader::from(info); - let _ = self.open_tx.send((reader, identity)); + let _ = self.open_tx.send((reader, identity.clone())); // Inline single-packet stream: synthesize the complete content now; no chunk/trailer // packets will follow, so we never register an open descriptor. @@ -385,6 +388,7 @@ impl IncomingStreamManager { progress: StreamProgress { bytes_total, ..Default::default() }, chunk_tx, encryption_type: stream_encryption_type, + sender_identity: identity, is_internal, is_text, decompressor: is_compressed.then(DeflateDecompressState::new), @@ -517,6 +521,29 @@ impl IncomingStreamManager { } 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. + pub fn abort_streams_from(&self, identity: &str) { + let mut inner = self.inner.lock(); + 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 { @@ -875,6 +902,37 @@ mod tests { assert_eq!(read_text(reader).await.unwrap(), text); } + #[tokio::test] + async fn errors_open_streams_on_sender_disconnect() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(10), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + // Partial content, no trailer: the sender then drops. + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); + mgr.abort_streams_from(SENDER); + assert!(matches!(read_text(reader).await, Err(StreamError::AbnormalEnd(_)))); + } + + #[tokio::test] + async fn abort_only_affects_matching_sender() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), + "bob".to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); + // A different participant disconnecting must not disturb bob's stream. + mgr.abort_streams_from(SENDER); + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_text(reader).await.unwrap(), "hello"); + } + #[tokio::test] async fn v2_compressed_gap_errors() { let (mgr, mut rx) = IncomingStreamManager::new(); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 7bde2350c..6741a0b53 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -2154,6 +2154,12 @@ 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. + self.incoming_stream_manager.abort_streams_from(remote_participant.identity().as_str()); + self.dispatcher.dispatch(&RoomEvent::ParticipantDisconnected(remote_participant)); } From 7a3be4afcc58579a71dabab946af4f80154f03cc Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 15:46:11 -0400 Subject: [PATCH 25/46] fix: adjust send_text / send_bytes to make the disambiguation between can_compress / should_compress more clear --- livekit/src/room/data_stream/outgoing.rs | 147 +++++++++++------------ 1 file changed, 67 insertions(+), 80 deletions(-) diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index ab37916d4..af07971a8 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -403,45 +403,43 @@ impl OutgoingStreamManager { let eligibility = evaluate_eligibility(remote_participant_registry, &options.destination_identities); - let should_compress = options.compress.unwrap_or(true) && eligibility.compression; - - // 1. Inline single-packet attempt (no attachments; all recipients are v2). - if eligibility.inline && options.attached_stream_ids.is_empty() { - let (header, text_header) = - if should_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { - build_text_header( - &options, - stream_id.clone(), - Some(total_length), - Some(payload.as_compressed()?.to_vec()), - CompressionType::DeflateRaw, - ) - } else { - build_text_header( - &options, - stream_id.clone(), - Some(total_length), - Some(payload.uncompressed.to_vec()), - CompressionType::None, - ) - }; - - if header_packet_fits(&header, &options.destination_identities) { - let packet = - RawStream::create_header_packet(header.clone(), options.destination_identities); - RawStream::send_packet(&self.packet_tx, packet).await?; - return Ok(TextStreamInfo::from_headers(header, text_header)); - } - // Otherwise (large payload), fall through to the chunked path. + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + + // 1. Inline single-packet attempt (no attachments; all recipients are >= v2). + let (mut header, text_header) = + if can_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; + if eligibility.inline + && options.attached_stream_ids.is_empty() + && header_packet_fits(&header, &options.destination_identities) + { + let packet = + RawStream::create_header_packet(header.clone(), 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. - let compression = - if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; - let (header, text_header) = - build_text_header(&options, stream_id, Some(total_length), None, compression); + header.inline_content = None; enforce_header_size(&header, &options.destination_identities)?; + let should_compress = + header.compression() == proto::data_stream::CompressionType::DeflateRaw; let open_options = RawStreamOpenOptions { header: header.clone(), destination_identities: options.destination_identities, @@ -482,51 +480,49 @@ impl OutgoingStreamManager { let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); let name = options.name.clone().unwrap_or_else(|| BYTE_DEFAULT_NAME.to_owned()); let total_length = bytes.len() as u64; - let dests = options.destination_identities.clone(); let mut payload = MaybeCompressed::new(bytes); - let eligibility = evaluate_eligibility(remote_participant_registry, &dests); - let should_compress = options.compress.unwrap_or(true) && eligibility.compression; - - // 1. Inline single-packet attempt (all recipients are v2). - if eligibility.inline { - let (header, byte_header) = - if should_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { - build_byte_header( - &options, - stream_id.clone(), - name.clone(), - Some(total_length), - Some(payload.as_compressed()?.to_vec()), - CompressionType::DeflateRaw, - ) - } else { - build_byte_header( - &options, - stream_id.clone(), - name.clone(), - Some(total_length), - Some(payload.uncompressed.to_vec()), - CompressionType::None, - ) - }; - if header_packet_fits(&header, &dests) { - let packet = RawStream::create_header_packet(header.clone(), dests); - RawStream::send_packet(&self.packet_tx, packet).await?; - return Ok(ByteStreamInfo::from_headers(header, byte_header)); - } + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + + // 1. Inline single-packet attempt (if all recipients are >= v2). + let (mut header, byte_header) = + if can_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; + if eligibility.inline && header_packet_fits(&header, &options.destination_identities) { + let packet = + RawStream::create_header_packet(header.clone(), 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. - let compression = - if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; - let (header, byte_header) = - build_byte_header(&options, stream_id, name, Some(total_length), None, compression); - enforce_header_size(&header, &dests)?; + header.inline_content = None; + enforce_header_size(&header, &options.destination_identities)?; + let should_compress = + header.compression() == proto::data_stream::CompressionType::DeflateRaw; let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: dests, + destination_identities: options.destination_identities, packet_tx: self.packet_tx.clone(), }; let info = ByteStreamInfo::from_headers(header, byte_header); @@ -638,15 +634,6 @@ impl<'a> MaybeCompressed<'a> { } } } - - // /// Consumes and converts into compressed bytes - // fn into_compressed(mut self) -> Result, std::io::Error> { - // self.as_compressed()?; - // let Some(data) = self.compressed else { - // unreachable!("compressed data should be set in .as_compressed()"); - // }; - // Ok(data) - // } } /// Whether the serialized header `DataPacket` fits within the MTU budget. From 3c360a2008a9a40799e5cd7a0bf6e1c1794bd77d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 15:52:35 -0400 Subject: [PATCH 26/46] fix: clean up incoming stream manager --- livekit/src/room/data_stream/incoming.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index 0eb6b7a0c..dc8b27fbb 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -243,7 +243,7 @@ struct Descriptor { 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` iff the header declared `DEFLATE_RAW`. + /// 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, @@ -265,9 +265,9 @@ impl DeflateDecompressState { /// Feeds compressed `input` through the stateful decompressor, returning all /// decompressed output produced so far. - fn run(&mut self, input: &[u8]) -> StreamResult> { + fn push(&mut self, input: &[u8]) -> StreamResult> { let mut out = Vec::new(); - let mut buf = vec![0u8; 16384]; + let mut buf = vec![]; let mut offset = 0; loop { let in_before = self.decompress.total_in(); @@ -445,7 +445,7 @@ impl IncomingStreamManager { let is_text = descriptor.is_text; // Confine the decompressor borrow so we can re-borrow `inner` afterwards. let result: StreamResult<(u64, Bytes)> = { - match decompressor.run(&chunk.content) { + match decompressor.push(&chunk.content) { Ok(decompressed) => { let produced = decompressed.len() as u64; let yielded = if is_text { From 12b32a6f24c09eb44f3f612b63367088b4721a21 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 16:05:11 -0400 Subject: [PATCH 27/46] fix: preload buffer length --- livekit/src/room/data_stream/incoming.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index dc8b27fbb..1218a012a 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -267,7 +267,7 @@ impl DeflateDecompressState { /// decompressed output produced so far. fn push(&mut self, input: &[u8]) -> StreamResult> { let mut out = Vec::new(); - let mut buf = vec![]; + let mut buf = vec![0u8; 16384 /* number of bytes to process every loop iteration */]; let mut offset = 0; loop { let in_before = self.decompress.total_in(); From 4459bd309a9d8a88fe5f1eaf476480687e9065ff Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 16:06:19 -0400 Subject: [PATCH 28/46] fix: add reason labels on e2e test assertions --- livekit/tests/data_stream_test.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index ccdde1ed0..882bd24a6 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -135,8 +135,8 @@ async fn test_send_large_incompressible_random_bytes() -> Result<()> { 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); - assert_eq!(stream_info.is_inline, false); + 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 { @@ -170,8 +170,8 @@ async fn test_send_large_bytes() -> Result<()> { 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); - assert_eq!(stream_info.is_inline, true); + 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 { @@ -210,8 +210,8 @@ async fn test_data_stream_compress_false() -> Result<()> { ..Default::default() }; let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; - assert_eq!(stream_info.is_compressed, false); - assert_eq!(stream_info.is_inline, false); + 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 { From ef4bb782b0311097481595fd3889c7dbff62bcf1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 17:05:57 -0400 Subject: [PATCH 29/46] feat: add initial livekit-common implementation --- livekit-common/Cargo.toml | 12 +++ livekit-common/README.md | 8 ++ livekit-common/src/lib.rs | 178 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 livekit-common/Cargo.toml create mode 100644 livekit-common/README.md create mode 100644 livekit-common/src/lib.rs diff --git a/livekit-common/Cargo.toml b/livekit-common/Cargo.toml new file mode 100644 index 000000000..0b83ade48 --- /dev/null +++ b/livekit-common/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "livekit-common" +description = "Common foundational types shared across LiveKit crates" +version = "0.1.0" +readme = "README.md" +license.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +livekit-protocol = { workspace = true } +serde = { workspace = true, features = ["derive"] } diff --git a/livekit-common/README.md b/livekit-common/README.md new file mode 100644 index 000000000..17f6cf712 --- /dev/null +++ b/livekit-common/README.md @@ -0,0 +1,8 @@ +# LiveKit Common + +**Important**: +This is an internal crate holding foundational types shared across the LiveKit client SDK crates +(identities, encryption/capability enums, client-protocol constants, and the remote-participant +registry trait). It is not intended to be used directly. + +To build applications with LiveKit, please use the public APIs provided by the client SDKs. diff --git a/livekit-common/src/lib.rs b/livekit-common/src/lib.rs new file mode 100644 index 000000000..69a11de77 --- /dev/null +++ b/livekit-common/src/lib.rs @@ -0,0 +1,178 @@ +// 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. + +//! Foundational types shared across LiveKit crates: participant identities, the +//! 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; + +use livekit_protocol as proto; +use serde::{Deserialize, Serialize}; + +// ------------------------------------------------------------------------------------------------- +// Client protocol +// ------------------------------------------------------------------------------------------------- + +/// Legacy client. No v2 data-stream features. +pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; + +/// RPC v2 (see RPC spec). No v2 data-stream features. +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 +// ------------------------------------------------------------------------------------------------- + +#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct ParticipantIdentity(pub String); + +impl From for ParticipantIdentity { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for ParticipantIdentity { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl From for String { + fn from(value: ParticipantIdentity) -> Self { + value.0 + } +} + +impl Display for ParticipantIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ParticipantIdentity { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// ------------------------------------------------------------------------------------------------- +// EncryptionType +// ------------------------------------------------------------------------------------------------- + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum EncryptionType { + #[default] + None, + Gcm, + Custom, +} + +impl From for EncryptionType { + fn from(value: proto::encryption::Type) -> Self { + match value { + proto::encryption::Type::None => Self::None, + proto::encryption::Type::Gcm => Self::Gcm, + proto::encryption::Type::Custom => Self::Custom, + } + } +} + +impl From for proto::encryption::Type { + fn from(value: EncryptionType) -> Self { + match value { + EncryptionType::None => Self::None, + EncryptionType::Gcm => Self::Gcm, + EncryptionType::Custom => Self::Custom, + } + } +} + +impl From for i32 { + fn from(value: EncryptionType) -> Self { + match value { + EncryptionType::None => 0, + EncryptionType::Gcm => 1, + EncryptionType::Custom => 2, + } + } +} + +// ------------------------------------------------------------------------------------------------- +// ClientCapability +// ------------------------------------------------------------------------------------------------- + +/// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). +/// +/// Stored typed rather than as the raw protobuf `i32` so accessors don't leak protobuf types. +#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[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. +/// +/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send +/// path (inline / compression eligibility), so both consult a single abstraction over +/// the room's remote participants and both are unit-testable with a fake. +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; +} From e638e56474f0627874a3156b4fef3bfaa31064d5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 17:10:36 -0400 Subject: [PATCH 30/46] refactor: depend on livekit_common::RemoteParticipantRegistry --- livekit/Cargo.toml | 1 + livekit/src/room/data_stream/outgoing.rs | 3 ++- livekit/src/room/mod.rs | 2 +- livekit/src/room/participant/mod.rs | 3 --- livekit/src/room/participant/registry.rs | 32 ------------------------ livekit/src/room/rpc/mod.rs | 3 ++- livekit/src/room/rpc/tests.rs | 3 ++- 7 files changed, 8 insertions(+), 39 deletions(-) delete mode 100644 livekit/src/room/participant/registry.rs diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 06875fa0c..1f42639a8 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -36,6 +36,7 @@ livekit-runtime = { workspace = true } livekit-api = { workspace = true } libwebrtc = { workspace = true } livekit-protocol = { workspace = true } +livekit-common = { workspace = true } livekit-datatrack = { workspace = true } prost = "0.12" serde = { version = "1", features = ["derive"] } diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index af07971a8..e94bd3242 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::{ id::ParticipantIdentity, - room::participant::{ClientCapability, RemoteParticipantRegistry}, + room::participant::ClientCapability, rtc_engine::EngineError, utils::utf8_chunk::Utf8AwareChunkExt, }; @@ -25,6 +25,7 @@ use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; use chrono::Utc; use libwebrtc::native::create_random_uuid; use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_V2; +use livekit_common::RemoteParticipantRegistry; use livekit_protocol as proto; use proto::data_stream::CompressionType; use std::{collections::HashMap, io::Write, path::Path, sync::Arc}; diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 6741a0b53..ba68efb73 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -2250,7 +2250,7 @@ impl RoomSession { } } -impl participant::RemoteParticipantRegistry for RoomSession { +impl livekit_common::RemoteParticipantRegistry for RoomSession { fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { self.get_remote_client_protocol(identity) } diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index 53557bedd..ef049845f 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -21,7 +21,6 @@ use parking_lot::{Mutex, RwLock}; use crate::{prelude::*, rtc_engine::RtcEngine}; mod local_participant; -mod registry; mod remote_participant; mod rpc; @@ -29,8 +28,6 @@ pub use local_participant::*; pub use remote_participant::*; pub use rpc::*; -pub(crate) use registry::RemoteParticipantRegistry; - #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub enum ConnectionQuality { Excellent, diff --git a/livekit/src/room/participant/registry.rs b/livekit/src/room/participant/registry.rs deleted file mode 100644 index c240556ed..000000000 --- a/livekit/src/room/participant/registry.rs +++ /dev/null @@ -1,32 +0,0 @@ -// 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::ClientCapability; -use crate::room::id::ParticipantIdentity; - -/// Read access to remote participants' advertised protocol and capabilities. -/// -/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send -/// path (inline / compression eligibility), so both consult a single abstraction over -/// the room's remote participants and both are unit-testable with a fake. -pub(crate) 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/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index c6d0b409b..19c03433e 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -23,8 +23,9 @@ pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; -use crate::room::participant::{ClientCapability, RemoteParticipantRegistry}; +use crate::room::participant::ClientCapability; use livekit_protocol::RpcError as RpcError_Proto; +use livekit_common::RemoteParticipantRegistry; use std::{error::Error, fmt::Display, future::Future, time::Duration}; // RPC protocol version constants (distinct from client_protocol; this is the diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index 1dfd8052f..096b963f5 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -18,12 +18,13 @@ use crate::data_stream::{ }; use crate::e2ee::EncryptionType; use crate::room::id::ParticipantIdentity; -use crate::room::participant::{ClientCapability, RemoteParticipantRegistry}; +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_protocol as proto; +use livekit_common::RemoteParticipantRegistry; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; use std::sync::Arc; From 02506cbfa34551ac2ea0864668037a507e5f02b1 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 17:21:27 -0400 Subject: [PATCH 31/46] fix: remove orphaned take_if_raw method --- livekit/src/utils/take_cell.rs | 20 -------------------- 1 file changed, 20 deletions(-) diff --git a/livekit/src/utils/take_cell.rs b/livekit/src/utils/take_cell.rs index c22967ea1..6f3b686ab 100644 --- a/livekit/src/utils/take_cell.rs +++ b/livekit/src/utils/take_cell.rs @@ -26,18 +26,6 @@ impl TakeCell { Self { value: Arc::new(RwLock::new(Some(value))) } } - /// Take ownership of the value in the cell if it matches some predicate. - /// - /// This method will only take the value if the provided predicate returns `true` when called with the current value. - /// If the predicate returns `false` or the value has already been taken, this method returns `None`. - pub(crate) fn take_if_raw(&self, predicate: impl FnOnce(&T) -> bool) -> Option { - if self.value.read().as_ref().map_or(false, |v| predicate(v)) { - self.take() - } else { - None - } - } - /// Take ownership of the value in the cell. If the value has, /// already been taken, the result is `None`. pub fn take(&self) -> Option { @@ -83,14 +71,6 @@ mod tests { assert_eq!(cell.is_taken(), true); } - #[test] - fn test_take_if_raw() { - let cell = TakeCell::new(1); - assert_eq!(cell.take_if_raw(|value| *value == 2), None); - assert_eq!(cell.take_if_raw(|value| *value == 1), Some(1)); - assert_eq!(cell.take_if_raw(|value| *value == 1), None); - } - #[test] fn test_debug() { let cell = TakeCell::new(1); From a2a93ebf59c5084e1f85c01daf6331532711ff17 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 17:24:24 -0400 Subject: [PATCH 32/46] refactor: update more livekit crate references to point to livekit-common instead --- Cargo.lock | 28 +++++++++++++++++++ Cargo.toml | 2 ++ livekit-api/Cargo.toml | 1 + livekit-api/src/signal_client/mod.rs | 14 ++++------ livekit/src/proto.rs | 29 -------------------- livekit/src/room/e2ee/mod.rs | 8 +----- livekit/src/room/id.rs | 18 ++---------- livekit/src/room/mod.rs | 2 +- livekit/src/room/participant/mod.rs | 41 +--------------------------- 9 files changed, 42 insertions(+), 101 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32a41a1a5..d40e67d06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3926,6 +3926,7 @@ dependencies = [ "libloading 0.8.9", "libwebrtc", "livekit-api", + "livekit-common", "livekit-datatrack", "livekit-protocol", "livekit-runtime", @@ -3959,6 +3960,7 @@ dependencies = [ "http 1.4.0", "isahc", "jsonwebtoken", + "livekit-common", "livekit-protocol", "livekit-runtime", "log", @@ -3981,6 +3983,32 @@ dependencies = [ "url", ] +[[package]] +name = "livekit-common" +version = "0.1.0" +dependencies = [ + "livekit-protocol", +] + +[[package]] +name = "livekit-data-stream" +version = "0.1.0" +dependencies = [ + "bmrng", + "bytes", + "chrono", + "flate2", + "futures-util", + "livekit-common", + "livekit-protocol", + "log", + "parking_lot", + "prost 0.12.6", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "livekit-datatrack" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index 93d4a11df..a4c208faf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "livekit", "livekit-api", "livekit-protocol", + "livekit-common", "livekit-ffi", "livekit-uniffi", "livekit-datatrack", @@ -51,6 +52,7 @@ livekit = { version = "0.7.50", path = "livekit" } livekit-api = { version = "0.5.4", path = "livekit-api" } livekit-ffi = { version = "0.12.68", path = "livekit-ffi" } livekit-datatrack = { version = "0.1.9", path = "livekit-datatrack" } +livekit-common = { version = "0.1.0", path = "livekit-common" } livekit-protocol = { version = "0.7.10", path = "livekit-protocol" } livekit-runtime = { version = "0.4.0", path = "livekit-runtime" } soxr-sys = { version = "0.1.3", path = "soxr-sys" } diff --git a/livekit-api/Cargo.toml b/livekit-api/Cargo.toml index 8afd55fc7..53183a05f 100644 --- a/livekit-api/Cargo.toml +++ b/livekit-api/Cargo.toml @@ -101,6 +101,7 @@ __rustls-tls = ["tokio-tungstenite?/__rustls-tls", "reqwest?/__rustls"] [dependencies] livekit-protocol = { workspace = true } +livekit-common = { workspace = true } thiserror = { workspace = true } serde = { workspace = true, features = ["derive"] } sha2 = "0.10" diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 8a76dcf4b..feda646fb 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -65,14 +65,12 @@ const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = &[ proto::client_info::Capability::CapCompressionDeflateRaw, ]; -/// Default value for `ClientInfo.client_protocol` when a participant has not -/// advertised one (treat as v1-only / no data-stream RPC support). -pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; -/// `ClientInfo.client_protocol` value indicating support for RPC v2 over data streams. -pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; -/// `ClientInfo.client_protocol` value indicating support for data streams v2 -/// (inline single-packet sends; compression is gated separately via capabilities). -pub const CLIENT_PROTOCOL_DATA_STREAM_V2: i32 = 2; +// The canonical `client_protocol` constants live in `livekit-common` (shared with the +// data-stream crate); re-exported here so existing `livekit_api::signal_client::CLIENT_PROTOCOL_*` +// references keep resolving. +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. diff --git a/livekit/src/proto.rs b/livekit/src/proto.rs index 78e690d73..15d5fdf71 100644 --- a/livekit/src/proto.rs +++ b/livekit/src/proto.rs @@ -141,35 +141,6 @@ impl From for DataPacketKind { } } -impl From for EncryptionType { - fn from(value: livekit_protocol::encryption::Type) -> Self { - match value { - livekit_protocol::encryption::Type::None => Self::None, - livekit_protocol::encryption::Type::Gcm => Self::Gcm, - livekit_protocol::encryption::Type::Custom => Self::Custom, - } - } -} - -impl From for encryption::Type { - fn from(value: EncryptionType) -> Self { - match value { - EncryptionType::None => Self::None, - EncryptionType::Gcm => Self::Gcm, - EncryptionType::Custom => Self::Custom, - } - } -} - -impl From for i32 { - fn from(value: EncryptionType) -> Self { - match value { - EncryptionType::None => 0, - EncryptionType::Gcm => 1, - EncryptionType::Custom => 2, - } - } -} impl From for participant::ParticipantState { fn from(value: participant_info::State) -> Self { diff --git a/livekit/src/room/e2ee/mod.rs b/livekit/src/room/e2ee/mod.rs index e1235d81d..21f6cfca7 100644 --- a/livekit/src/room/e2ee/mod.rs +++ b/livekit/src/room/e2ee/mod.rs @@ -22,13 +22,7 @@ pub mod manager; /// Provider implementations for data track. pub(crate) mod data_track; -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncryptionType { - #[default] - None, - Gcm, - Custom, -} +pub(crate) use livekit_common::EncryptionType; #[derive(Clone)] pub struct E2eeOptions { diff --git a/livekit/src/room/id.rs b/livekit/src/room/id.rs index 800496371..c1ea11a74 100644 --- a/livekit/src/room/id.rs +++ b/livekit/src/room/id.rs @@ -20,30 +20,17 @@ const ROOM_PREFIX: &str = "RM_"; const PARTICIPANT_PREFIX: &str = "PA_"; const TRACK_PREFIX: &str = "TR_"; +pub use livekit_common::ParticipantIdentity; + #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct ParticipantSid(String); -#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub struct ParticipantIdentity(pub String); - #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct TrackSid(String); #[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct RoomSid(String); -impl From for ParticipantIdentity { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for ParticipantIdentity { - fn from(value: &str) -> Self { - Self(value.to_string()) - } -} - macro_rules! impl_string_into { ($from:ty) => { impl From<$from> for String { @@ -67,7 +54,6 @@ macro_rules! impl_string_into { } impl_string_into!(ParticipantSid); -impl_string_into!(ParticipantIdentity); impl_string_into!(TrackSid); impl_string_into!(RoomSid); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index ba68efb73..264372cf5 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -63,7 +63,7 @@ use crate::{ utils::{observer::Dispatcher, promise::Promise}, }; -pub mod data_stream; +pub use livekit_data_stream as data_stream; pub mod data_track; pub mod e2ee; pub mod id; diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index ef049845f..9f8abad0e 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -85,46 +85,7 @@ pub enum DisconnectReason { AgentError, } -/// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). -/// -/// Stored typed rather than as the raw protobuf `i32` so the public accessor doesn't leak -/// protobuf types, while `Unknown` preserves values this SDK build doesn't recognize. -#[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 - } - } - } -} +pub use livekit_common::ClientCapability; #[derive(Debug, Clone)] pub enum Participant { From e713af5cc9b9a4dfb911b2a2a25d960609a7c14e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Thu, 2 Jul 2026 17:25:10 -0400 Subject: [PATCH 33/46] fix: remove serde from livekit-common Not needed for now, can be re-added with an feature later --- livekit-common/Cargo.toml | 1 - livekit-common/src/lib.rs | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/livekit-common/Cargo.toml b/livekit-common/Cargo.toml index 0b83ade48..c3ec0491d 100644 --- a/livekit-common/Cargo.toml +++ b/livekit-common/Cargo.toml @@ -9,4 +9,3 @@ repository.workspace = true [dependencies] livekit-protocol = { workspace = true } -serde = { workspace = true, features = ["derive"] } diff --git a/livekit-common/src/lib.rs b/livekit-common/src/lib.rs index 69a11de77..d2c1ac47a 100644 --- a/livekit-common/src/lib.rs +++ b/livekit-common/src/lib.rs @@ -19,7 +19,6 @@ use std::fmt::Display; use livekit_protocol as proto; -use serde::{Deserialize, Serialize}; // ------------------------------------------------------------------------------------------------- // Client protocol @@ -38,7 +37,7 @@ pub const CLIENT_PROTOCOL_DATA_STREAM_V2: i32 = 2; // ParticipantIdentity // ------------------------------------------------------------------------------------------------- -#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord, Serialize, Deserialize)] +#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct ParticipantIdentity(pub String); impl From for ParticipantIdentity { @@ -75,7 +74,7 @@ impl ParticipantIdentity { // EncryptionType // ------------------------------------------------------------------------------------------------- -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] pub enum EncryptionType { #[default] None, @@ -120,7 +119,7 @@ impl From for i32 { /// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). /// /// Stored typed rather than as the raw protobuf `i32` so accessors don't leak protobuf types. -#[derive(Debug, Clone, Copy, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq)] #[non_exhaustive] pub enum ClientCapability { Unused, From a74b901737c065781a0fcea4f4956ae7b116bb1c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 11:43:00 -0400 Subject: [PATCH 34/46] refactor: move data stream code out into new livekit-data-stream crate --- Cargo.lock | 1 + Cargo.toml | 2 + livekit-data-stream/Cargo.toml | 33 ++++++++ livekit-data-stream/README.md | 6 ++ .../src}/incoming.rs | 76 +++++++++---------- .../mod.rs => livekit-data-stream/src/lib.rs | 67 +++++++++------- .../src}/outgoing.rs | 33 ++++---- .../src}/utf8_chunk.rs | 0 livekit/Cargo.toml | 5 +- livekit/src/proto.rs | 4 +- livekit/src/room/e2ee/mod.rs | 2 +- livekit/src/room/mod.rs | 28 +++++-- livekit/src/utils/mod.rs | 1 - 13 files changed, 163 insertions(+), 95 deletions(-) create mode 100644 livekit-data-stream/Cargo.toml create mode 100644 livekit-data-stream/README.md rename {livekit/src/room/data_stream => livekit-data-stream/src}/incoming.rs (94%) rename livekit/src/room/data_stream/mod.rs => livekit-data-stream/src/lib.rs (86%) rename {livekit/src/room/data_stream => livekit-data-stream/src}/outgoing.rs (98%) rename {livekit/src/utils => livekit-data-stream/src}/utf8_chunk.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index d40e67d06..94db8b930 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3927,6 +3927,7 @@ dependencies = [ "libwebrtc", "livekit-api", "livekit-common", + "livekit-data-stream", "livekit-datatrack", "livekit-protocol", "livekit-runtime", diff --git a/Cargo.toml b/Cargo.toml index a4c208faf..75e166ca4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "livekit-api", "livekit-protocol", "livekit-common", + "livekit-data-stream", "livekit-ffi", "livekit-uniffi", "livekit-datatrack", @@ -53,6 +54,7 @@ livekit-api = { version = "0.5.4", path = "livekit-api" } livekit-ffi = { version = "0.12.68", path = "livekit-ffi" } livekit-datatrack = { version = "0.1.9", path = "livekit-datatrack" } livekit-common = { version = "0.1.0", path = "livekit-common" } +livekit-data-stream = { version = "0.1.0", path = "livekit-data-stream" } livekit-protocol = { version = "0.7.10", path = "livekit-protocol" } livekit-runtime = { version = "0.4.0", path = "livekit-runtime" } soxr-sys = { version = "0.1.3", path = "soxr-sys" } diff --git a/livekit-data-stream/Cargo.toml b/livekit-data-stream/Cargo.toml new file mode 100644 index 000000000..2ac95482f --- /dev/null +++ b/livekit-data-stream/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "livekit-data-stream" +description = "Data stream core logic for LiveKit" +version = "0.1.0" +readme = "README.md" +license.workspace = true +edition.workspace = true +repository.workspace = true + +[features] +# 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] +livekit-common = { workspace = true } +livekit-protocol = { workspace = true } +log = { workspace = true } +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"] } +prost = "0.12" +chrono = "0.4.38" +flate2 = "1" +bmrng = "0.5.2" +uuid = { version = "1", features = ["v4"] } + +[dev-dependencies] +tokio = { workspace = true, default-features = false, features = ["macros", "rt", "rt-multi-thread", "time"] } diff --git a/livekit-data-stream/README.md b/livekit-data-stream/README.md new file mode 100644 index 000000000..781b5457d --- /dev/null +++ b/livekit-data-stream/README.md @@ -0,0 +1,6 @@ +# LiveKit Data Track + +**Important**: +This is an internal crate that powers the data tracks feature in LiveKit client SDKs (including [Rust](https://crates.io/crates/livekit) and others) and is not usable directly. + +To use data tracks in your application, please use the public APIs provided by the client SDKs. diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit-data-stream/src/incoming.rs similarity index 94% rename from livekit/src/room/data_stream/incoming.rs rename to livekit-data-stream/src/incoming.rs index 1218a012a..a58168ffd 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit-data-stream/src/incoming.rs @@ -15,9 +15,9 @@ use super::{ AnyStreamInfo, ByteStreamInfo, StreamError, StreamProgress, StreamResult, TextStreamInfo, }; -use crate::{e2ee::EncryptionType, TakeCell}; 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::{ @@ -52,23 +52,6 @@ pub trait StreamReader: Stream> { fn read_all(self) -> impl std::future::Future> + Send; } -impl TakeCell -where - T: StreamReader, -{ - /// Takes the reader out of the cell if its info matches the given predicate. - /// - /// Use this method to conditionally handle incoming streams based on info fields - /// such as topic or attributes. - /// - /// This method will only take the reader if the provided predicate returns `true` when called with the reader's info. - /// If the predicate returns `false` or the reader has already been taken, this method returns `None`. - /// - pub fn take_if(&self, predicate: impl FnOnce(&T::Info) -> bool) -> Option { - self.take_if_raw(|reader| predicate(reader.info())) - } -} - /// Reader for an incoming byte data stream. pub struct ByteStreamReader { info: ByteStreamInfo, @@ -148,10 +131,13 @@ impl Stream for ByteStreamReader { } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl TextStreamReader { /// Create a TextStreamReader for testing purposes. - pub(crate) fn new_for_test( + /// + /// 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 { @@ -217,7 +203,7 @@ impl Debug for TextStreamReader { } } -pub(crate) enum AnyStreamReader { +pub enum AnyStreamReader { Byte(ByteStreamReader), Text(TextStreamReader), } @@ -312,9 +298,12 @@ fn inflate_raw(data: &[u8]) -> StreamResult> { } #[derive(Clone)] -pub(crate) struct IncomingStreamManager { +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)] @@ -323,9 +312,18 @@ struct ManagerInner { } impl IncomingStreamManager { - pub fn new() -> (Self, UnboundedReceiver<(AnyStreamReader, String)>) { + 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 }, open_rx) + ( + Self { + inner: Arc::new(Mutex::new(Default::default())), + open_tx, + reserved_topics: reserved_topics.into(), + }, + open_rx, + ) } /// Handles an incoming header packet. @@ -335,7 +333,7 @@ impl IncomingStreamManager { identity: String, encryption_type: livekit_protocol::encryption::Type, ) { - let is_internal = super::is_internal_topic(&header.topic); + let is_internal = self.reserved_topics.iter().any(|t| t == &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() == proto::CompressionType::DeflateRaw; @@ -693,7 +691,7 @@ mod tests { #[tokio::test] async fn v1_text_stream_round_trips() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); let text = "hello world"; mgr.handle_header( text_header( @@ -716,7 +714,7 @@ mod tests { #[tokio::test] async fn v1_byte_stream_round_trips() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( byte_header("s1", Some(4), None, proto::CompressionType::None), SENDER.to_string(), @@ -730,7 +728,7 @@ mod tests { #[tokio::test] async fn v1_merges_trailer_attributes() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); let text = "hi"; mgr.handle_header( text_header( @@ -758,7 +756,7 @@ mod tests { #[tokio::test] async fn v1_errors_when_too_few_bytes() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), SENDER.to_string(), @@ -772,7 +770,7 @@ mod tests { #[tokio::test] async fn v1_errors_when_too_many_bytes() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( byte_header("s1", Some(3), None, proto::CompressionType::None), SENDER.to_string(), @@ -786,7 +784,7 @@ mod tests { #[tokio::test] async fn v1_drops_on_encryption_type_mismatch() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( text_header("s1", Some(2), HashMap::new(), None, proto::CompressionType::None), SENDER.to_string(), @@ -801,7 +799,7 @@ mod tests { #[tokio::test] async fn v2_inline_uncompressed_text() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); let text = "inline hello"; mgr.handle_header( text_header( @@ -822,7 +820,7 @@ mod tests { #[tokio::test] async fn v2_inline_uncompressed_byte() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( byte_header("s1", Some(3), Some(vec![1, 2, 3]), proto::CompressionType::None), SENDER.to_string(), @@ -834,7 +832,7 @@ mod tests { #[tokio::test] async fn v2_inline_compressed_text() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); let text = "hello hello compressible world"; let compressed = deflate_raw(text.as_bytes()); mgr.handle_header( @@ -855,7 +853,7 @@ mod tests { #[tokio::test] async fn v2_inline_compressed_byte() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); let payload: Vec = (0..2000).map(|i| (i % 7) as u8).collect(); let compressed = deflate_raw(&payload); mgr.handle_header( @@ -876,7 +874,7 @@ mod tests { #[tokio::test] async fn v2_multipacket_compressed_text() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::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()); @@ -904,7 +902,7 @@ mod tests { #[tokio::test] async fn errors_open_streams_on_sender_disconnect() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( text_header("s1", Some(10), HashMap::new(), None, proto::CompressionType::None), SENDER.to_string(), @@ -919,7 +917,7 @@ mod tests { #[tokio::test] async fn abort_only_affects_matching_sender() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::new(vec![]); mgr.handle_header( text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), "bob".to_string(), @@ -935,7 +933,7 @@ mod tests { #[tokio::test] async fn v2_compressed_gap_errors() { - let (mgr, mut rx) = IncomingStreamManager::new(); + let (mgr, mut rx) = IncomingStreamManager::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(); diff --git a/livekit/src/room/data_stream/mod.rs b/livekit-data-stream/src/lib.rs similarity index 86% rename from livekit/src/room/data_stream/mod.rs rename to livekit-data-stream/src/lib.rs index a3d9c5182..a016c6a8f 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit-data-stream/src/lib.rs @@ -13,28 +13,29 @@ // limitations under the License. use chrono::{DateTime, Utc}; -use libwebrtc::enum_dispatch; -use libwebrtc::native::create_random_uuid; +use livekit_common::EncryptionType; use livekit_protocol::data_stream as proto; use std::collections::HashMap; use thiserror::Error; mod incoming; mod outgoing; +mod utf8_chunk; pub use incoming::*; pub use outgoing::*; -use crate::e2ee::EncryptionType; -use crate::room::rpc::{RPC_REQUEST_TOPIC, RPC_RESPONSE_TOPIC}; - -/// Data stream topics reserved for internal SDK use. Events for these -/// topics are handled within the `livekit` crate and never surfaced -/// through `RoomEvent`. -pub(crate) const INTERNAL_TOPICS: &[&str] = &[RPC_REQUEST_TOPIC, RPC_RESPONSE_TOPIC]; - -pub(crate) fn is_internal_topic(topic: &str) -> bool { - INTERNAL_TOPICS.contains(&topic) +/// 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. @@ -122,10 +123,10 @@ pub struct ByteStreamInfo { /// The encryption used pub encryption_type: EncryptionType, /// Test-only: expose whether the byte stream was compressed or not. - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] pub is_compressed: bool, /// Test-only: expose whether the byte stream was sent inline on the header packet - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] pub is_inline: bool, } @@ -152,10 +153,10 @@ pub struct TextStreamInfo { /// The encryption used pub encryption_type: EncryptionType, /// Test-only: expose whether the byte stream was compressed or not. - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] pub is_compressed: bool, /// Test-only: expose whether the byte stream was sent inline on the header packet - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] pub is_inline: bool, } @@ -210,9 +211,9 @@ impl ByteStreamInfo { encryption_type: EncryptionType, ) -> Self { Self { - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] is_compressed: header.compression() != proto::CompressionType::None, - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] is_inline: !header.inline_content().is_empty(), id: header.stream_id, @@ -239,9 +240,9 @@ impl TextStreamInfo { encryption_type: EncryptionType, ) -> Self { Self { - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] is_compressed: header.compression() != proto::CompressionType::None, - #[cfg(feature = "__lk-e2e-test")] + #[cfg(feature = "__e2e-test")] is_inline: !header.inline_content().is_empty(), id: header.stream_id, @@ -281,12 +282,26 @@ pub(crate) enum AnyStreamInfo { } impl AnyStreamInfo { - enum_dispatch!( - [Byte, Text]; - pub fn id(self: &Self) -> &str; - pub fn total_length(self: &Self) -> Option; - pub fn encryption_type(self: &Self) -> EncryptionType; - ); + pub fn id(&self) -> &str { + match self { + Self::Byte(info) => info.id(), + Self::Text(info) => info.id(), + } + } + + pub fn total_length(&self) -> Option { + match self { + Self::Byte(info) => info.total_length(), + Self::Text(info) => info.total_length(), + } + } + + pub fn encryption_type(&self) -> EncryptionType { + match self { + Self::Byte(info) => info.encryption_type(), + Self::Text(info) => info.encryption_type(), + } + } } #[rustfmt::skip] diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit-data-stream/src/outgoing.rs similarity index 98% rename from livekit/src/room/data_stream/outgoing.rs rename to livekit-data-stream/src/outgoing.rs index e94bd3242..2ff43ecf9 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit-data-stream/src/outgoing.rs @@ -13,19 +13,16 @@ // limitations under the License. use super::{ - ByteStreamInfo, OperationType, StreamError, StreamProgress, StreamResult, TextStreamInfo, -}; -use crate::{ - id::ParticipantIdentity, - room::participant::ClientCapability, - rtc_engine::EngineError, - utils::utf8_chunk::Utf8AwareChunkExt, + create_random_uuid, ByteStreamInfo, OperationType, SendError, StreamError, StreamProgress, + StreamResult, TextStreamInfo, }; +use crate::utf8_chunk::Utf8AwareChunkExt; use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; use chrono::Utc; -use libwebrtc::native::create_random_uuid; -use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_V2; -use livekit_common::RemoteParticipantRegistry; +use livekit_common::{ + ClientCapability, ParticipantIdentity, RemoteParticipantRegistry, + CLIENT_PROTOCOL_DATA_STREAM_V2, +}; use livekit_protocol as proto; use proto::data_stream::CompressionType; use std::{collections::HashMap, io::Write, path::Path, sync::Arc}; @@ -125,7 +122,7 @@ impl<'a> StreamWriter<'a> for TextStreamWriter { struct RawStreamOpenOptions { header: proto::data_stream::Header, destination_identities: Vec, - packet_tx: UnboundedRequestSender>, + packet_tx: UnboundedRequestSender>, } struct RawStream { @@ -133,7 +130,7 @@ struct RawStream { progress: StreamProgress, is_closed: bool, /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, + packet_tx: UnboundedRequestSender>, } impl RawStream { @@ -230,7 +227,7 @@ impl RawStream { } async fn send_packet( - tx: &UnboundedRequestSender>, + tx: &UnboundedRequestSender>, packet: proto::DataPacket, ) -> StreamResult<()> { tx.send_receive(packet) @@ -334,13 +331,13 @@ pub struct StreamTextOptions { } #[derive(Clone)] -pub(crate) struct OutgoingStreamManager { +pub struct OutgoingStreamManager { /// Request channel for sending packets. - packet_tx: UnboundedRequestSender>, + packet_tx: UnboundedRequestSender>, } impl OutgoingStreamManager { - pub fn new() -> (Self, UnboundedRequestReceiver>) { + pub fn new() -> (Self, UnboundedRequestReceiver>) { let (packet_tx, packet_rx) = bmrng::unbounded_channel(); let manager = Self { packet_tx }; (manager, packet_rx) @@ -733,7 +730,7 @@ static BYTE_DEFAULT_NAME: &str = "unknown"; #[cfg(test)] mod tests { use super::*; - use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; + use livekit_common::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; use std::sync::Mutex as StdMutex; // --- Fake recipient registry --------------------------------------------------------- @@ -1184,7 +1181,7 @@ mod tests { let raw_stream = rt.block_on(async { let (packet_tx, mut packet_rx) = - bmrng::unbounded_channel::>(); + bmrng::unbounded_channel::>(); tokio::spawn(async move { while let Ok((_packet, responder)) = packet_rx.recv().await { diff --git a/livekit/src/utils/utf8_chunk.rs b/livekit-data-stream/src/utf8_chunk.rs similarity index 100% rename from livekit/src/utils/utf8_chunk.rs rename to livekit-data-stream/src/utf8_chunk.rs diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 1f42639a8..894e7630d 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 = [] # 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 } @@ -37,6 +37,7 @@ livekit-api = { workspace = true } libwebrtc = { workspace = true } livekit-protocol = { workspace = true } livekit-common = { workspace = true } +livekit-data-stream = { workspace = true } livekit-datatrack = { workspace = true } prost = "0.12" serde = { version = "1", features = ["derive"] } @@ -57,6 +58,8 @@ flate2 = "1" base64 = "0.22" [dev-dependencies] +# Enable data-stream test constructors (e.g. TextStreamReader::new_for_test) for our test suites. +livekit-data-stream = { workspace = true, features = ["test-utils"] } anyhow = "1.0.99" test-log = "0.2.18" test-case = "3.3" diff --git a/livekit/src/proto.rs b/livekit/src/proto.rs index 15d5fdf71..443fac407 100644 --- a/livekit/src/proto.rs +++ b/livekit/src/proto.rs @@ -14,9 +14,7 @@ use livekit_protocol::*; -use crate::{ - e2ee::EncryptionType, participant, room::ChatMessage as RoomChatMessage, track, DataPacketKind, -}; +use crate::{participant, room::ChatMessage as RoomChatMessage, track, DataPacketKind}; // Conversions impl From for participant::ConnectionQuality { diff --git a/livekit/src/room/e2ee/mod.rs b/livekit/src/room/e2ee/mod.rs index 21f6cfca7..43d4a908c 100644 --- a/livekit/src/room/e2ee/mod.rs +++ b/livekit/src/room/e2ee/mod.rs @@ -22,7 +22,7 @@ pub mod manager; /// Provider implementations for data track. pub(crate) mod data_track; -pub(crate) use livekit_common::EncryptionType; +pub use livekit_common::EncryptionType; #[derive(Clone)] pub struct E2eeOptions { diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 264372cf5..00a540c8f 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -680,7 +680,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(); + 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 room_info = join_response.room.unwrap(); @@ -1805,7 +1807,7 @@ impl RoomSession { participant_identity: String, encryption_type: proto::encryption::Type, ) { - let is_internal = data_stream::is_internal_topic(&header.topic); + let is_internal = is_internal_topic(&header.topic); self.incoming_stream_manager.handle_header( header.clone(), participant_identity.clone(), @@ -2280,7 +2282,7 @@ async fn incoming_data_stream_task( match reader { AnyStreamReader::Byte(reader) => { let topic = reader.info().topic.clone(); - if !data_stream::is_internal_topic(&topic) { + if !is_internal_topic(&topic) { dispatcher.dispatch(&RoomEvent::ByteStreamOpened { topic, reader: TakeCell::new(reader), @@ -2310,7 +2312,7 @@ async fn incoming_data_stream_task( }); } _ => { - if !data_stream::is_internal_topic(&topic) { + if !is_internal_topic(&topic) { dispatcher.dispatch(&RoomEvent::TextStreamOpened { topic, reader: TakeCell::new(reader), @@ -2329,16 +2331,30 @@ 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>, + mut packet_rx: UnboundedRequestReceiver>, engine: Arc, mut close_rx: broadcast::Receiver<()>, ) { loop { tokio::select! { Ok((packet, responder)) = packet_rx.recv() => { - let result = engine.publish_data(packet, DataPacketKind::Reliable, false).await; + // Bridge the engine error into the data-stream crate's opaque `SendError` + // (the crate only needs to know whether the send failed). + let result = engine + .publish_data(packet, DataPacketKind::Reliable, false) + .await + .map_err(|_| SendError); let _ = responder.respond(result); }, _ = close_rx.recv() => { diff --git a/livekit/src/utils/mod.rs b/livekit/src/utils/mod.rs index 2c382fa7a..1590cba02 100644 --- a/livekit/src/utils/mod.rs +++ b/livekit/src/utils/mod.rs @@ -24,7 +24,6 @@ pub mod promise; pub mod take_cell; pub mod ttl_map; pub mod tx_queue; -pub mod utf8_chunk; pub(crate) fn convert_kind_details(kind_details: &[i32]) -> Vec { kind_details From 055879bc3fe73341a957d185661451a8dd795123 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 11:57:19 -0400 Subject: [PATCH 35/46] fix: remove arc wrapping around reserved_topics --- livekit-data-stream/src/incoming.rs | 6 +++--- livekit/src/room/mod.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/livekit-data-stream/src/incoming.rs b/livekit-data-stream/src/incoming.rs index a58168ffd..cfc663f89 100644 --- a/livekit-data-stream/src/incoming.rs +++ b/livekit-data-stream/src/incoming.rs @@ -303,7 +303,7 @@ pub struct IncomingStreamManager { 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]>, + reserved_topics: Vec<&'static str>, } #[derive(Default)] @@ -313,14 +313,14 @@ struct ManagerInner { impl IncomingStreamManager { pub fn new( - reserved_topics: Vec, + reserved_topics: Vec<&'static str>, ) -> (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(), + reserved_topics, }, open_rx, ) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 00a540c8f..e24f8b857 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -681,7 +681,7 @@ impl Room { 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(), + INTERNAL_DATA_STREAM_TOPICS.into() ); let (outgoing_stream_manager, packet_rx) = OutgoingStreamManager::new(); From f9aa1c6367af279e4f8197c35dadd6d69b99f663 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 12:23:24 -0400 Subject: [PATCH 36/46] refactor: rename is_topic_internal -> is_internal_topic, is_stream_internal -> is_internal --- livekit-data-stream/src/incoming.rs | 10 +++++++++- livekit/src/room/mod.rs | 20 ++++++++++---------- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/livekit-data-stream/src/incoming.rs b/livekit-data-stream/src/incoming.rs index cfc663f89..d170a4d95 100644 --- a/livekit-data-stream/src/incoming.rs +++ b/livekit-data-stream/src/incoming.rs @@ -333,7 +333,7 @@ impl IncomingStreamManager { identity: String, encryption_type: livekit_protocol::encryption::Type, ) { - let is_internal = self.reserved_topics.iter().any(|t| t == &header.topic); + 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() == proto::CompressionType::DeflateRaw; @@ -402,6 +402,14 @@ impl IncomingStreamManager { self.inner.lock().open_streams.get(stream_id).is_some_and(|d| d.is_internal) } + /// Returns whether data streams which are created on the given topic should be + /// considered "internal" and not have their raw events surfaced to users. + /// + /// When possible, prefer [Self::is_internal] instead. + pub fn is_internal_topic(&self, topic: &str) -> bool { + self.reserved_topics.iter().any(|t| t == &topic) + } + /// Handles an incoming chunk packet. pub fn handle_chunk( &self, diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index e24f8b857..1fdd3353f 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -1807,7 +1807,7 @@ impl RoomSession { participant_identity: String, encryption_type: proto::encryption::Type, ) { - let is_internal = is_internal_topic(&header.topic); + let is_internal = self.incoming_stream_manager.is_internal_topic(&header.topic); self.incoming_stream_manager.handle_header( header.clone(), participant_identity.clone(), @@ -2266,6 +2266,15 @@ impl livekit_common::RemoteParticipantRegistry for RoomSession { } } +/// 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 stream readers for newly-opened streams and dispatches room events. /// /// Intercepts text streams on RPC topics (`lk.rpc_request`, `lk.rpc_response`) @@ -2331,15 +2340,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>, From 908d7b75681fc69a6bec8123c4a6906426b6dfde Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 13:07:08 -0400 Subject: [PATCH 37/46] refactor: split up livekit-data-stream crate into multiple smaller modules --- .../src/{incoming.rs => incoming/mod.rs} | 206 +---------- .../src/incoming/stream_reader.rs | 216 ++++++++++++ livekit-data-stream/src/info.rs | 250 +++++++++++++ livekit-data-stream/src/lib.rs | 333 +----------------- livekit-data-stream/src/outgoing/constants.rs | 27 ++ .../src/{outgoing.rs => outgoing/mod.rs} | 329 ++--------------- .../src/outgoing/raw_stream.rs | 201 +++++++++++ .../src/outgoing/stream_writer.rs | 124 +++++++ livekit-data-stream/src/utils.rs | 88 +++++ 9 files changed, 957 insertions(+), 817 deletions(-) rename livekit-data-stream/src/{incoming.rs => incoming/mod.rs} (81%) create mode 100644 livekit-data-stream/src/incoming/stream_reader.rs create mode 100644 livekit-data-stream/src/info.rs create mode 100644 livekit-data-stream/src/outgoing/constants.rs rename livekit-data-stream/src/{outgoing.rs => outgoing/mod.rs} (76%) create mode 100644 livekit-data-stream/src/outgoing/raw_stream.rs create mode 100644 livekit-data-stream/src/outgoing/stream_writer.rs create mode 100644 livekit-data-stream/src/utils.rs diff --git a/livekit-data-stream/src/incoming.rs b/livekit-data-stream/src/incoming/mod.rs similarity index 81% rename from livekit-data-stream/src/incoming.rs rename to livekit-data-stream/src/incoming/mod.rs index d170a4d95..f508324c8 100644 --- a/livekit-data-stream/src/incoming.rs +++ b/livekit-data-stream/src/incoming/mod.rs @@ -12,213 +12,19 @@ // 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 bytes::Bytes; 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 std::{collections::HashMap, sync::Arc}; 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; +use crate::utils::{StreamError, StreamProgress, StreamResult}; +use crate::info::{AnyStreamInfo, ByteStreamInfo, TextStreamInfo}; - /// Information about the underlying data stream. - type Info; +mod stream_reader; +pub use stream_reader::{StreamReader, ByteStreamReader, TextStreamReader, AnyStreamReader}; - /// 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(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); - } -} struct Descriptor { progress: StreamProgress, chunk_tx: UnboundedSender>, 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..78341252b --- /dev/null +++ b/livekit-data-stream/src/incoming/stream_reader.rs @@ -0,0 +1,216 @@ +// 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..ed3e71803 --- /dev/null +++ b/livekit-data-stream/src/info.rs @@ -0,0 +1,250 @@ +// 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 livekit_protocol::data_stream as proto; +use std::collections::HashMap; + +use super::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, +} + +/// 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(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 { + #[cfg(feature = "__e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__e2e-test")] + is_inline: !header.inline_content().is_empty(), + + 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 { + #[cfg(feature = "__e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__e2e-test")] + is_inline: !header.inline_content().is_empty(), + + 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 { + pub fn id(&self) -> &str { + match self { + Self::Byte(info) => info.id(), + Self::Text(info) => info.id(), + } + } + + pub fn total_length(&self) -> Option { + match self { + Self::Byte(info) => info.total_length(), + Self::Text(info) => info.total_length(), + } + } + + pub fn encryption_type(&self) -> EncryptionType { + match self { + Self::Byte(info) => info.encryption_type(), + Self::Text(info) => info.encryption_type(), + } + } +} + +#[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 a016c6a8f..12460f64d 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. @@ -12,323 +12,22 @@ // See the License for the specific language governing permissions and // limitations under the License. -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::{StreamResult, StreamError, SendError}; -mod incoming; -mod outgoing; -mod utf8_chunk; - -pub use incoming::*; -pub use outgoing::*; - -/// 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, - - #[error("stream header exceeds maximum size")] - HeaderTooLarge, - - #[error("decompression failed")] - Decompression, -} - -/// 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) - } -} - -/// 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, -} +mod info; +pub use info::{ByteStreamInfo, TextStreamInfo}; -/// 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, -} - -/// 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 { - #[cfg(feature = "__e2e-test")] - is_compressed: header.compression() != proto::CompressionType::None, - #[cfg(feature = "__e2e-test")] - is_inline: !header.inline_content().is_empty(), - - 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 { - #[cfg(feature = "__e2e-test")] - is_compressed: header.compression() != proto::CompressionType::None, - #[cfg(feature = "__e2e-test")] - is_inline: !header.inline_content().is_empty(), - - 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 { - pub fn id(&self) -> &str { - match self { - Self::Byte(info) => info.id(), - Self::Text(info) => info.id(), - } - } - - pub fn total_length(&self) -> Option { - match self { - Self::Byte(info) => info.total_length(), - Self::Text(info) => info.total_length(), - } - } - - pub fn encryption_type(&self) -> EncryptionType { - match self { - Self::Byte(info) => info.encryption_type(), - Self::Text(info) => info.encryption_type(), - } - } -} - -#[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) - } -} +mod incoming; +pub use incoming::{ + StreamReader, + ByteStreamReader, + TextStreamReader, + AnyStreamReader, + IncomingStreamManager, +}; -impl From for AnyStreamInfo { - fn from(info: TextStreamInfo) -> Self { - Self::Text(info) - } -} +mod outgoing; +pub use outgoing::*; diff --git a/livekit-data-stream/src/outgoing/constants.rs b/livekit-data-stream/src/outgoing/constants.rs new file mode 100644 index 000000000..89dec6ea4 --- /dev/null +++ b/livekit-data-stream/src/outgoing/constants.rs @@ -0,0 +1,27 @@ +// 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.rs b/livekit-data-stream/src/outgoing/mod.rs similarity index 76% rename from livekit-data-stream/src/outgoing.rs rename to livekit-data-stream/src/outgoing/mod.rs index 2ff43ecf9..9267c1829 100644 --- a/livekit-data-stream/src/outgoing.rs +++ b/livekit-data-stream/src/outgoing/mod.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. @@ -12,11 +12,6 @@ // 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::{ @@ -26,276 +21,23 @@ use livekit_common::{ use livekit_protocol as proto; use proto::data_stream::CompressionType; use std::{collections::HashMap, io::Write, 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(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 - } -} - -struct RawStreamOpenOptions { - header: proto::data_stream::Header, - destination_identities: Vec, - packet_tx: UnboundedRequestSender>, -} +use tokio::sync::Mutex; -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(()) - } - - /// 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. - async fn write_raw_chunks(&mut self, bytes: &[u8]) -> StreamResult<()> { - for chunk in bytes.chunks(STREAM_CHUNK_SIZE_BYTES) { - self.write_chunk(chunk).await?; - } - Ok(()) - } +use crate::utf8_chunk::Utf8AwareChunkExt; +use crate::info::{OperationType, TextStreamInfo, ByteStreamInfo}; +use crate::utils::{StreamResult, StreamError, SendError}; - /// 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. - 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() >= STREAM_CHUNK_SIZE_BYTES { - let rest = encoder.get_mut().split_off(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() >= STREAM_CHUNK_SIZE_BYTES { - let rest = pending.split_off(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(()) - } +mod stream_writer; +pub use stream_writer::{StreamWriter, ByteStreamWriter, TextStreamWriter}; - 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(()) - } +mod constants; - 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 - } +mod raw_stream; +use raw_stream::{RawStream, RawStreamOpenOptions}; - 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 }); - } - } +/// 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. @@ -356,10 +98,10 @@ impl OutgoingStreamManager { destination_identities: dests, 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 writer = TextStreamWriter::new( + Arc::new(TextStreamInfo::from_headers(header, text_header)), + Arc::new(Mutex::new(RawStream::open(open_options).await?)), + ); Ok(writer) } @@ -382,10 +124,10 @@ impl OutgoingStreamManager { destination_identities: dests, 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 writer = ByteStreamWriter::new( + Arc::new(ByteStreamInfo::from_headers(header, byte_header)), + Arc::new(Mutex::new(RawStream::open(open_options).await?)), + ); Ok(writer) } @@ -448,7 +190,7 @@ impl OutgoingStreamManager { if should_compress { stream.write_raw_chunks(payload.as_compressed()?).await?; } else { - for chunk in payload.uncompressed.utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { + for chunk in payload.uncompressed.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } } @@ -476,7 +218,7 @@ impl OutgoingStreamManager { } let bytes = data.as_ref(); let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); - let name = options.name.clone().unwrap_or_else(|| BYTE_DEFAULT_NAME.to_owned()); + let name = options.name.clone().unwrap_or_else(|| constants::BYTE_DEFAULT_NAME.to_owned()); let total_length = bytes.len() as u64; let mut payload = MaybeCompressed::new(bytes); @@ -641,7 +383,7 @@ fn header_packet_fits( ) -> bool { use prost::Message; let packet = RawStream::create_header_packet(header.clone(), destinations.to_vec()); - packet.encoded_len() <= STREAM_CHUNK_SIZE_BYTES + packet.encoded_len() <= constants::STREAM_CHUNK_SIZE_BYTES } /// Enforces the header-packet MTU budget on the chunked path (the inline path falls back @@ -675,7 +417,7 @@ fn build_text_header( stream_id, timestamp: Utc::now().timestamp_millis(), topic: options.topic.clone(), - mime_type: TEXT_MIME_TYPE.to_owned(), + mime_type: constants::TEXT_MIME_TYPE.to_owned(), total_length, encryption_type: proto::encryption::Type::None.into(), attributes: options.attributes.clone(), @@ -701,7 +443,7 @@ fn build_byte_header( stream_id, timestamp: Utc::now().timestamp_millis(), topic: options.topic.clone(), - mime_type: options.mime_type.clone().unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), + mime_type: options.mime_type.clone().unwrap_or_else(|| constants::BYTE_MIME_TYPE.to_owned()), total_length, encryption_type: proto::encryption::Type::None.into(), attributes: options.attributes.clone(), @@ -714,19 +456,6 @@ fn build_byte_header( (header, byte_header) } -/// Max chunk content size AND the header-packet MTU budget. Kept below the ~16 KB -/// data-channel MTU for protocol/E2EE framing headroom. -const STREAM_CHUNK_SIZE_BYTES: 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"; - -/// Default name for `send_bytes` byte-stream headers. -static BYTE_DEFAULT_NAME: &str = "unknown"; - #[cfg(test)] mod tests { use super::*; @@ -988,10 +717,10 @@ mod tests { 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(STREAM_CHUNK_SIZE_BYTES); + 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(), STREAM_CHUNK_SIZE_BYTES); // first chunk is full MTU + 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()); @@ -1193,7 +922,7 @@ mod tests { stream_id: "gc-test-stream".to_string(), timestamp: 0, topic: "gc-test-topic".to_string(), - mime_type: TEXT_MIME_TYPE.to_owned(), + mime_type: constants::TEXT_MIME_TYPE.to_owned(), total_length: None, encryption_type: proto::encryption::Type::None.into(), attributes: HashMap::new(), 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..6188eff19 --- /dev/null +++ b/livekit-data-stream/src/outgoing/raw_stream.rs @@ -0,0 +1,201 @@ +// 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 crate::utils::{StreamProgress, StreamResult, StreamError, SendError}; +use super::constants; + +pub(crate) struct RawStreamOpenOptions { + pub(crate) header: proto::data_stream::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, 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)), + // 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..6bb2968f2 --- /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::outgoing::{RawStream, constants::STREAM_CHUNK_SIZE_BYTES}; +use crate::utf8_chunk::Utf8AwareChunkExt; +use crate::info::{TextStreamInfo, ByteStreamInfo}; +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/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) + } +} From fda208b51d98f660e9c6042c10ce5ce7f2da8506 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 13:29:07 -0400 Subject: [PATCH 38/46] fix: run cargo fmt --- livekit-data-stream/src/incoming/mod.rs | 10 +++------- livekit-data-stream/src/incoming/stream_reader.rs | 4 +--- livekit-data-stream/src/lib.rs | 8 ++------ livekit-data-stream/src/outgoing/constants.rs | 1 - livekit-data-stream/src/outgoing/mod.rs | 14 +++++++++----- livekit-data-stream/src/outgoing/raw_stream.rs | 14 +++++++++++--- livekit-data-stream/src/outgoing/stream_writer.rs | 6 +++--- 7 files changed, 29 insertions(+), 28 deletions(-) diff --git a/livekit-data-stream/src/incoming/mod.rs b/livekit-data-stream/src/incoming/mod.rs index f508324c8..402d2582b 100644 --- a/livekit-data-stream/src/incoming/mod.rs +++ b/livekit-data-stream/src/incoming/mod.rs @@ -19,11 +19,11 @@ use parking_lot::Mutex; use std::{collections::HashMap, sync::Arc}; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; -use crate::utils::{StreamError, StreamProgress, StreamResult}; use crate::info::{AnyStreamInfo, ByteStreamInfo, TextStreamInfo}; +use crate::utils::{StreamError, StreamProgress, StreamResult}; mod stream_reader; -pub use stream_reader::{StreamReader, ByteStreamReader, TextStreamReader, AnyStreamReader}; +pub use stream_reader::{AnyStreamReader, ByteStreamReader, StreamReader, TextStreamReader}; struct Descriptor { progress: StreamProgress, @@ -123,11 +123,7 @@ impl IncomingStreamManager { ) -> (Self, UnboundedReceiver<(AnyStreamReader, String)>) { let (open_tx, open_rx) = mpsc::unbounded_channel(); ( - Self { - inner: Arc::new(Mutex::new(Default::default())), - open_tx, - reserved_topics, - }, + Self { inner: Arc::new(Mutex::new(Default::default())), open_tx, reserved_topics }, open_rx, ) } diff --git a/livekit-data-stream/src/incoming/stream_reader.rs b/livekit-data-stream/src/incoming/stream_reader.rs index 78341252b..d5afe7d24 100644 --- a/livekit-data-stream/src/incoming/stream_reader.rs +++ b/livekit-data-stream/src/incoming/stream_reader.rs @@ -12,9 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::{ - AnyStreamInfo, ByteStreamInfo, StreamError, StreamResult, TextStreamInfo, -}; +use super::{AnyStreamInfo, ByteStreamInfo, StreamError, StreamResult, TextStreamInfo}; use bytes::{Bytes, BytesMut}; use futures_util::{Stream, StreamExt}; use std::{ diff --git a/livekit-data-stream/src/lib.rs b/livekit-data-stream/src/lib.rs index 12460f64d..8e26338af 100644 --- a/livekit-data-stream/src/lib.rs +++ b/livekit-data-stream/src/lib.rs @@ -13,7 +13,7 @@ // limitations under the License. mod utils; -pub use utils::{StreamResult, StreamError, SendError}; +pub use utils::{SendError, StreamError, StreamResult}; mod info; pub use info::{ByteStreamInfo, TextStreamInfo}; @@ -22,11 +22,7 @@ mod utf8_chunk; mod incoming; pub use incoming::{ - StreamReader, - ByteStreamReader, - TextStreamReader, - AnyStreamReader, - IncomingStreamManager, + AnyStreamReader, ByteStreamReader, IncomingStreamManager, StreamReader, TextStreamReader, }; mod outgoing; diff --git a/livekit-data-stream/src/outgoing/constants.rs b/livekit-data-stream/src/outgoing/constants.rs index 89dec6ea4..3e551cc1d 100644 --- a/livekit-data-stream/src/outgoing/constants.rs +++ b/livekit-data-stream/src/outgoing/constants.rs @@ -24,4 +24,3 @@ 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 index 9267c1829..d35ad4b6f 100644 --- a/livekit-data-stream/src/outgoing/mod.rs +++ b/livekit-data-stream/src/outgoing/mod.rs @@ -23,12 +23,12 @@ use proto::data_stream::CompressionType; use std::{collections::HashMap, io::Write, path::Path, sync::Arc}; use tokio::sync::Mutex; +use crate::info::{ByteStreamInfo, OperationType, TextStreamInfo}; use crate::utf8_chunk::Utf8AwareChunkExt; -use crate::info::{OperationType, TextStreamInfo, ByteStreamInfo}; -use crate::utils::{StreamResult, StreamError, SendError}; +use crate::utils::{SendError, StreamError, StreamResult}; mod stream_writer; -pub use stream_writer::{StreamWriter, ByteStreamWriter, TextStreamWriter}; +pub use stream_writer::{ByteStreamWriter, StreamWriter, TextStreamWriter}; mod constants; @@ -190,7 +190,8 @@ impl OutgoingStreamManager { if should_compress { stream.write_raw_chunks(payload.as_compressed()?).await?; } else { - for chunk in payload.uncompressed.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES) { + for chunk in payload.uncompressed.utf8_aware_chunks(constants::STREAM_CHUNK_SIZE_BYTES) + { stream.write_chunk(chunk).await?; } } @@ -443,7 +444,10 @@ fn build_byte_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()), + mime_type: options + .mime_type + .clone() + .unwrap_or_else(|| constants::BYTE_MIME_TYPE.to_owned()), total_length, encryption_type: proto::encryption::Type::None.into(), attributes: options.attributes.clone(), diff --git a/livekit-data-stream/src/outgoing/raw_stream.rs b/livekit-data-stream/src/outgoing/raw_stream.rs index 6188eff19..5c9821b41 100644 --- a/livekit-data-stream/src/outgoing/raw_stream.rs +++ b/livekit-data-stream/src/outgoing/raw_stream.rs @@ -18,8 +18,8 @@ use livekit_protocol as proto; use std::{io::Write, path::Path}; use tokio::io::AsyncReadExt; -use crate::utils::{StreamProgress, StreamResult, StreamError, SendError}; use super::constants; +use crate::utils::{SendError, StreamError, StreamProgress, StreamResult}; pub(crate) struct RawStreamOpenOptions { pub(crate) header: proto::data_stream::Header, @@ -72,7 +72,11 @@ impl RawStream { /// 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<()> { + 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]; @@ -152,7 +156,11 @@ impl RawStream { } } - pub(crate) fn create_chunk_packet(id: &str, chunk_index: u64, content: &[u8]) -> proto::DataPacket { + 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, diff --git a/livekit-data-stream/src/outgoing/stream_writer.rs b/livekit-data-stream/src/outgoing/stream_writer.rs index 6bb2968f2..bac111022 100644 --- a/livekit-data-stream/src/outgoing/stream_writer.rs +++ b/livekit-data-stream/src/outgoing/stream_writer.rs @@ -12,12 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{sync::Arc}; +use std::sync::Arc; use tokio::sync::Mutex; -use crate::outgoing::{RawStream, constants::STREAM_CHUNK_SIZE_BYTES}; +use crate::info::{ByteStreamInfo, TextStreamInfo}; +use crate::outgoing::{constants::STREAM_CHUNK_SIZE_BYTES, RawStream}; use crate::utf8_chunk::Utf8AwareChunkExt; -use crate::info::{TextStreamInfo, ByteStreamInfo}; use crate::utils::StreamResult; /// Writer for an open data stream. From d9e9bb3178828c93ae7a63c7139c0454fe14efa7 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 15:31:32 -0400 Subject: [PATCH 39/46] fix: add missing OperationType reexport --- livekit-data-stream/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/livekit-data-stream/src/lib.rs b/livekit-data-stream/src/lib.rs index 8e26338af..00f723995 100644 --- a/livekit-data-stream/src/lib.rs +++ b/livekit-data-stream/src/lib.rs @@ -16,7 +16,7 @@ mod utils; pub use utils::{SendError, StreamError, StreamResult}; mod info; -pub use info::{ByteStreamInfo, TextStreamInfo}; +pub use info::{ByteStreamInfo, OperationType, TextStreamInfo}; mod utf8_chunk; From 5de595b8f40d7bb5b5023ae237fc5ca0fe582fa8 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 16:32:47 -0400 Subject: [PATCH 40/46] fix: adjust comments and docs --- livekit-api/src/signal_client/mod.rs | 3 --- livekit-common/README.md | 8 +++----- livekit-common/src/lib.rs | 11 +++++------ 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index feda646fb..badfb8690 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -65,9 +65,6 @@ const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = &[ proto::client_info::Capability::CapCompressionDeflateRaw, ]; -// The canonical `client_protocol` constants live in `livekit-common` (shared with the -// data-stream crate); re-exported here so existing `livekit_api::signal_client::CLIENT_PROTOCOL_*` -// references keep resolving. pub use livekit_common::{ CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DATA_STREAM_V2, CLIENT_PROTOCOL_DEFAULT, }; diff --git a/livekit-common/README.md b/livekit-common/README.md index 17f6cf712..cf5fce7a9 100644 --- a/livekit-common/README.md +++ b/livekit-common/README.md @@ -1,8 +1,6 @@ # LiveKit Common -**Important**: -This is an internal crate holding foundational types shared across the LiveKit client SDK crates -(identities, encryption/capability enums, client-protocol constants, and the remote-participant -registry trait). It is not intended to be used directly. +An internal crate which holds shared data structures that many downstream modules all use, like +`ParticipantIdentity` or `ClientCapability`. -To build applications with LiveKit, please use the public APIs provided by the client SDKs. +To build applications with LiveKit, please use the public APIs provided by the [livekit](../livekit) crate. diff --git a/livekit-common/src/lib.rs b/livekit-common/src/lib.rs index d2c1ac47a..9402ebcf4 100644 --- a/livekit-common/src/lib.rs +++ b/livekit-common/src/lib.rs @@ -116,9 +116,8 @@ impl From for i32 { // ClientCapability // ------------------------------------------------------------------------------------------------- -/// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). -/// -/// Stored typed rather than as the raw protobuf `i32` so accessors don't leak protobuf types. +/// A capability a participant's client advertises, mirroring the `ClientInfo.Capability` protobuf +/// enum. #[derive(Debug, Clone, Copy, Eq, PartialEq)] #[non_exhaustive] pub enum ClientCapability { @@ -162,9 +161,9 @@ impl From for i32 { /// Read access to remote participants' advertised protocol and capabilities. /// -/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send -/// path (inline / compression eligibility), so both consult a single abstraction over -/// the room's remote participants and both are unit-testable with a fake. +/// 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; From daa69d32ed1a9df00f49fbdf1ac98842ce34c9b5 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 16:39:29 -0400 Subject: [PATCH 41/46] refactor: make pseudo_random_text easier to understand The old version used a derivation of a c algorithm which very few people would have familiarly with. So adjust this to use `rand` instead. --- livekit-data-stream/Cargo.toml | 1 + livekit-data-stream/src/incoming/mod.rs | 17 ++++++++++------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/livekit-data-stream/Cargo.toml b/livekit-data-stream/Cargo.toml index 2ac95482f..f768d8ce7 100644 --- a/livekit-data-stream/Cargo.toml +++ b/livekit-data-stream/Cargo.toml @@ -31,3 +31,4 @@ uuid = { version = "1", features = ["v4"] } [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/mod.rs b/livekit-data-stream/src/incoming/mod.rs index 402d2582b..5b65e92f6 100644 --- a/livekit-data-stream/src/incoming/mod.rs +++ b/livekit-data-stream/src/incoming/mod.rs @@ -397,14 +397,17 @@ mod tests { } /// 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 { - let mut text = String::with_capacity(len); - let mut state: u64 = 0xdead_beef_cafe_babe; - for _ in 0..len { - state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - text.push((b'a' + ((state >> 33) % 26) as u8) as char); - } - text + 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)] From d6deda80b41dd1980515a45dc9f2df6f19d3c945 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 16:40:41 -0400 Subject: [PATCH 42/46] fix: run cargo fmt --- livekit/src/proto.rs | 1 - livekit/src/room/mod.rs | 5 ++--- livekit/src/room/rpc/mod.rs | 2 +- livekit/src/room/rpc/tests.rs | 2 +- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/livekit/src/proto.rs b/livekit/src/proto.rs index 443fac407..b8d937644 100644 --- a/livekit/src/proto.rs +++ b/livekit/src/proto.rs @@ -139,7 +139,6 @@ impl From for DataPacketKind { } } - impl From for participant::ParticipantState { fn from(value: participant_info::State) -> Self { match value { diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 1fdd3353f..8695b5780 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -680,9 +680,8 @@ 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.into() - ); + let (incoming_stream_manager, open_rx) = + IncomingStreamManager::new(INTERNAL_DATA_STREAM_TOPICS.into()); let (outgoing_stream_manager, packet_rx) = OutgoingStreamManager::new(); let room_info = join_response.room.unwrap(); diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 19c03433e..bc5f55bce 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -24,8 +24,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_protocol::RpcError as RpcError_Proto; use livekit_common::RemoteParticipantRegistry; +use livekit_protocol::RpcError as RpcError_Proto; use std::{error::Error, fmt::Display, future::Future, time::Duration}; // RPC protocol version constants (distinct from client_protocol; this is the diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index 096b963f5..8ba573c17 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -23,8 +23,8 @@ 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_protocol as proto; use livekit_common::RemoteParticipantRegistry; +use livekit_protocol as proto; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; use std::sync::Arc; From 042cb7e99f179bbfdb0cd07db04539bf9d498724 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 16:56:25 -0400 Subject: [PATCH 43/46] fix: drop recv_reader --- livekit-data-stream/src/incoming/mod.rs | 34 ++++++++++--------------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/livekit-data-stream/src/incoming/mod.rs b/livekit-data-stream/src/incoming/mod.rs index 5b65e92f6..dabcbf5d6 100644 --- a/livekit-data-stream/src/incoming/mod.rs +++ b/livekit-data-stream/src/incoming/mod.rs @@ -473,12 +473,6 @@ mod tests { proto::Trailer { stream_id: id.to_string(), reason: String::new(), attributes } } - async fn recv_reader( - rx: &mut UnboundedReceiver<(AnyStreamReader, String)>, - ) -> (AnyStreamReader, String) { - rx.recv().await.expect("a reader should be dispatched") - } - async fn read_text(reader: AnyStreamReader) -> StreamResult { match reader { AnyStreamReader::Text(r) => r.read_all().await, @@ -517,7 +511,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, identity) = recv_reader(&mut rx).await; + 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); @@ -533,7 +527,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + 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])); @@ -554,7 +548,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + 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", @@ -575,7 +569,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + 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))); @@ -589,7 +583,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + 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))); @@ -603,7 +597,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + 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))); } @@ -625,7 +619,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); 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); @@ -639,7 +633,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); } @@ -659,7 +653,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); assert_eq!(read_text(reader).await.unwrap(), text); } @@ -679,7 +673,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); } @@ -705,7 +699,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); for (i, piece) in chunk_pieces.iter().enumerate() { mgr.handle_chunk(chunk("s1", i as u64, piece.to_vec()), EncType::None); } @@ -721,7 +715,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); // Partial content, no trailer: the sender then drops. mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); mgr.abort_streams_from(SENDER); @@ -736,7 +730,7 @@ mod tests { "bob".to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); // A different participant disconnecting must not disturb bob's stream. mgr.abort_streams_from(SENDER); @@ -762,7 +756,7 @@ mod tests { SENDER.to_string(), EncType::None, ); - let (reader, _) = recv_reader(&mut rx).await; + let (reader, _) = rx.recv().await.expect("a reader should be dispatched"); mgr.handle_chunk(chunk("s1", 0, pieces[0].to_vec()), EncType::None); // Skip index 1 -> feed index 2: a gap is a hard error. mgr.handle_chunk(chunk("s1", 2, pieces[1].to_vec()), EncType::None); From cf353d4f5f10eeab2faf449f949ed98d5f660766 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 16:56:38 -0400 Subject: [PATCH 44/46] fix: add MaybeCompressed docstring --- livekit-data-stream/src/outgoing/mod.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/livekit-data-stream/src/outgoing/mod.rs b/livekit-data-stream/src/outgoing/mod.rs index d35ad4b6f..8c762d3d7 100644 --- a/livekit-data-stream/src/outgoing/mod.rs +++ b/livekit-data-stream/src/outgoing/mod.rs @@ -348,6 +348,12 @@ fn evaluate_eligibility( SendEligibility { inline, compression } } +/// A struct which manages the state of data which potentially may need to be compressed in the +/// future. +/// +/// By storing the compressed text optionally after performing compression, we can be sure that co +/// compression will only ever happen once, even if it must happen as part of speculative paths +/// (like checking whether compressed bytes are bigger than the literal bytes). struct MaybeCompressed<'a> { uncompressed: &'a [u8], compressed: Option>, From 18304933d7150f7578cd92d0558046841bb1eb95 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 17:05:38 -0400 Subject: [PATCH 45/46] refactor: reformat somewhat_compressible to use rand --- livekit-data-stream/src/outgoing/mod.rs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/livekit-data-stream/src/outgoing/mod.rs b/livekit-data-stream/src/outgoing/mod.rs index 8c762d3d7..4b362c344 100644 --- a/livekit-data-stream/src/outgoing/mod.rs +++ b/livekit-data-stream/src/outgoing/mod.rs @@ -603,14 +603,20 @@ mod tests { /// ~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(); - let mut state: u64 = 0x1234_5678_9abc_def0; for _ in 0..blocks { s.push_str("hello world"); for _ in 0..1000 { - state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); - s.push((b'a' + ((state >> 33) % 26) as u8) as char); + s.push(rng.random_range(b'a'..=b'z') as char); } } s From 15c2d7b340d95d5d60201298838b19fb29ef0c5c Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 6 Jul 2026 17:10:07 -0400 Subject: [PATCH 46/46] docs: update data streams readme --- livekit-data-stream/README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/livekit-data-stream/README.md b/livekit-data-stream/README.md index 781b5457d..2ff6b9376 100644 --- a/livekit-data-stream/README.md +++ b/livekit-data-stream/README.md @@ -1,6 +1,7 @@ -# LiveKit Data Track +# LiveKit Data Stream **Important**: -This is an internal crate that powers the data tracks feature in LiveKit client SDKs (including [Rust](https://crates.io/crates/livekit) and others) and is not usable directly. +This is an internal crate that powers the data streams feature in LiveKit client SDKs (including [Rust](https://crates.io/crates/livekit) and others) and is not usable directly. -To use data tracks in your application, please use the public APIs provided by the client SDKs. +To use data streams in your application, please use the public APIs provided by the +[livekit](../livekit) crate and other client sdks.