From 332e837d6a2c98896470f24c43aef8ab30527bc4 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 10 Apr 2026 17:04:50 -0400 Subject: [PATCH 01/18] feat: implement client protocol support in rust sdk --- livekit-api/src/signal_client/mod.rs | 1 + livekit/src/room/mod.rs | 5 +++++ livekit/src/room/participant/local_participant.rs | 6 ++++++ livekit/src/room/participant/mod.rs | 5 +++++ livekit/src/room/participant/remote_participant.rs | 6 ++++++ 5 files changed, 23 insertions(+) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 689621103..cc87837ee 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -571,6 +571,7 @@ fn create_join_request_param( os, os_version, device_model, + client_protocol: 1, // CLIENT_PROTOCOL_DATA_STREAM_RPC ..Default::default() }; diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 0035237b6..4353db676 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -554,6 +554,7 @@ impl Room { pi.joined_at_ms, e2ee_manager.encryption_type(), pi.permission, + pi.client_protocol, ); let dispatcher = Dispatcher::::default(); @@ -733,6 +734,7 @@ impl Room { pi.attributes, pi.joined_at_ms, pi.permission, + pi.client_protocol, ) }; participant.update_info(pi.clone()); @@ -1143,6 +1145,7 @@ impl RoomSession { pi.attributes, pi.joined_at_ms, pi.permission, + pi.client_protocol, ) }; @@ -1828,6 +1831,7 @@ impl RoomSession { attributes: HashMap, joined_at: i64, permission: Option, + client_protocol: i32, ) -> RemoteParticipant { let participant = RemoteParticipant::new( self.rtc_engine.clone(), @@ -1842,6 +1846,7 @@ impl RoomSession { joined_at, self.options.auto_subscribe, permission, + client_protocol, ); participant.on_track_published({ diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index 1053abde5..91658edc7 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -126,6 +126,7 @@ impl LocalParticipant { joined_at: i64, encryption_type: EncryptionType, permission: Option, + client_protocol: i32, ) -> Self { Self { inner: super::new_inner( @@ -140,6 +141,7 @@ impl LocalParticipant { kind_details, joined_at, permission, + client_protocol, ), local: Arc::new(LocalInfo { events: LocalEvents::default(), @@ -855,6 +857,10 @@ impl LocalParticipant { self.inner.info.read().permission.clone() } + pub fn client_protocol(&self) -> i32 { + self.inner.info.read().client_protocol + } + pub async fn perform_rpc(&self, data: PerformRpcData) -> Result { // Maximum amount of time it should ever take for an RPC request to reach the destination, and the ACK to come back // This is set to 7 seconds to account for various relay timeouts and retries in LiveKit Cloud that occur in rare cases diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index c3a52adfa..fea11090b 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -145,6 +145,7 @@ struct ParticipantInfo { pub disconnect_reason: DisconnectReason, pub joined_at: i64, pub permission: Option, + pub client_protocol: i32, } type TrackMutedHandler = Box; @@ -195,6 +196,7 @@ pub(super) fn new_inner( kind_details: Vec, joined_at: i64, permission: Option, + client_protocol: i32, ) -> Arc { Arc::new(ParticipantInner { rtc_engine, @@ -213,6 +215,7 @@ pub(super) fn new_inner( disconnect_reason: DisconnectReason::UnknownReason, joined_at, permission, + client_protocol, }), track_publications: Default::default(), events: Default::default(), @@ -264,6 +267,8 @@ pub(super) fn update_info( cb(participant.clone(), new_info.permission.clone()); } } + + info.client_protocol = new_info.client_protocol; } pub(super) fn set_speaking( diff --git a/livekit/src/room/participant/remote_participant.rs b/livekit/src/room/participant/remote_participant.rs index eea7e5672..b19882a57 100644 --- a/livekit/src/room/participant/remote_participant.rs +++ b/livekit/src/room/participant/remote_participant.rs @@ -89,6 +89,7 @@ impl RemoteParticipant { joined_at: i64, auto_subscribe: bool, permission: Option, + client_protocol: i32, ) -> Self { Self { inner: super::new_inner( @@ -103,6 +104,7 @@ impl RemoteParticipant { kind_details, joined_at, permission, + client_protocol, ), remote: Arc::new(RemoteInfo { events: Default::default(), auto_subscribe }), } @@ -575,6 +577,10 @@ impl RemoteParticipant { self.inner.info.read().permission.clone() } + pub fn client_protocol(&self) -> i32 { + self.inner.info.read().client_protocol + } + pub fn is_encrypted(&self) -> bool { *self.inner.is_encrypted.read() } From 74a8fcc127942fc7f6c3f053529c4486248dc34d Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 10 Apr 2026 17:05:41 -0400 Subject: [PATCH 02/18] feat: add new rpc v2 functionality (RpcClientManager / RpcServerManager) --- livekit/src/room/mod.rs | 97 ++-- .../src/room/participant/local_participant.rs | 300 +----------- livekit/src/room/participant/rpc.rs | 157 +------ livekit/src/room/rpc/caller.rs | 426 ++++++++++++++++++ livekit/src/room/rpc/handler.rs | 313 +++++++++++++ livekit/src/room/rpc/mod.rs | 189 ++++++++ livekit/tests/rpc_test.rs | 65 +++ 7 files changed, 1068 insertions(+), 479 deletions(-) create mode 100644 livekit/src/room/rpc/caller.rs create mode 100644 livekit/src/room/rpc/handler.rs create mode 100644 livekit/src/room/rpc/mod.rs diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 4353db676..fe5fad6a8 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -67,6 +67,7 @@ pub mod id; pub mod options; pub mod participant; pub mod publication; +pub mod rpc; pub mod track; pub(crate) mod utils; @@ -329,30 +330,6 @@ pub struct ChatMessage { pub generated: Option, } -#[derive(Debug, Clone)] -pub struct RpcRequest { - pub destination_identity: String, - pub id: String, - pub method: String, - pub payload: String, - pub response_timeout: Duration, - pub version: u32, -} - -#[derive(Debug, Clone)] -pub struct RpcResponse { - destination_identity: String, - request_id: String, - payload: Option, - error: Option, -} - -#[derive(Debug, Clone)] -pub struct RpcAck { - destination_identity: String, - request_id: String, -} - #[derive(Debug, Clone)] #[non_exhaustive] pub struct RoomSdkOptions { @@ -473,9 +450,11 @@ pub(crate) struct RoomSession { remote_participants: RwLock>, e2ee_manager: E2eeManager, incoming_stream_manager: IncomingStreamManager, - outgoing_stream_manager: OutgoingStreamManager, + pub(crate) outgoing_stream_manager: OutgoingStreamManager, local_dt_input: dt::local::ManagerInput, remote_dt_input: dt::remote::ManagerInput, + pub(crate) rpc_client: rpc::RpcClientManager, + pub(crate) rpc_server: rpc::RpcServerManager, handle: AsyncMutex>, } @@ -689,6 +668,8 @@ impl Room { outgoing_stream_manager, local_dt_input, remote_dt_input, + rpc_client: rpc::RpcClientManager::new(), + rpc_server: rpc::RpcServerManager::new(), handle: Default::default(), }); inner.local_participant.set_session(Arc::downgrade(&inner)); @@ -759,6 +740,7 @@ impl Room { open_rx, dispatcher.clone(), close_rx.resubscribe(), + inner.clone(), )); let outgoing_stream_handle = livekit_runtime::spawn(outgoing_data_stream_task( packet_rx, @@ -987,25 +969,29 @@ impl RoomSession { log::warn!("Received RPC request with null caller identity"); return Ok(()); } - let local_participant = self.local_participant.clone(); + let rtc_engine = self.rtc_engine.clone(); + let session = self.clone(); + let caller = caller_identity.unwrap(); livekit_runtime::spawn(async move { - local_participant - .handle_incoming_rpc_request( - caller_identity.unwrap(), + session + .rpc_server + .handle_request( + caller, request_id, method, payload, response_timeout, version, + &rtc_engine, ) .await; }); } EngineEvent::RpcResponse { request_id, payload, error } => { - self.local_participant.handle_incoming_rpc_response(request_id, payload, error); + self.rpc_client.handle_response(request_id, payload, error); } EngineEvent::RpcAck { request_id } => { - self.local_participant.handle_incoming_rpc_ack(request_id); + self.rpc_client.handle_ack(request_id); } EngineEvent::SpeakersChanged { speakers } => self.handle_speakers_changed(speakers), EngineEvent::ConnectionQuality { updates } => { @@ -1989,6 +1975,17 @@ impl RoomSession { self.remote_participants.read().get(identity).cloned() } + pub(crate) fn get_remote_client_protocol( + &self, + identity: &ParticipantIdentity, + ) -> i32 { + self.remote_participants + .read() + .get(identity) + .map(|p| p.client_protocol()) + .unwrap_or(rpc::CLIENT_PROTOCOL_DEFAULT) + } + fn get_local_or_remote_participant( &self, identity: &ParticipantIdentity, @@ -2058,10 +2055,14 @@ impl RoomSession { } /// Receives stream readers for newly-opened streams and dispatches room events. +/// +/// Intercepts text streams on RPC topics (`lk.rpc_request`, `lk.rpc_response`) +/// and routes them to the RPC managers instead of emitting them as room events. async fn incoming_data_stream_task( mut open_rx: UnboundedReceiver<(AnyStreamReader, String)>, dispatcher: Dispatcher, mut close_rx: broadcast::Receiver<()>, + session: Arc, ) { loop { tokio::select! { @@ -2072,11 +2073,35 @@ async fn incoming_data_stream_task( reader: TakeCell::new(reader), participant_identity: ParticipantIdentity(identity) }), - AnyStreamReader::Text(reader) => dispatcher.dispatch(&RoomEvent::TextStreamOpened { - topic: reader.info().topic.clone(), - reader: TakeCell::new(reader), - participant_identity: ParticipantIdentity(identity) - }), + AnyStreamReader::Text(reader) => { + let topic = reader.info().topic.clone(); + match topic.as_str() { + rpc::RPC_REQUEST_TOPIC => { + let caller_identity = ParticipantIdentity(identity); + let session = session.clone(); + livekit_runtime::spawn(async move { + session.rpc_server.handle_request_stream( + reader, + caller_identity, + &session, + ).await; + }); + } + rpc::RPC_RESPONSE_TOPIC => { + let session = session.clone(); + livekit_runtime::spawn(async move { + session.rpc_client.handle_response_stream(reader).await; + }); + } + _ => { + dispatcher.dispatch(&RoomEvent::TextStreamOpened { + topic, + reader: TakeCell::new(reader), + participant_identity: ParticipantIdentity(identity) + }); + } + } + } } }, _ = close_rx.recv() => { diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index 91658edc7..9e7fcc3f3 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -35,10 +35,10 @@ use crate::{ e2ee::EncryptionType, options::{self, compute_video_encodings, video_layers_from_encodings, TrackPublishOptions}, prelude::*, - room::participant::rpc::{RpcError, RpcErrorCode, RpcInvocationData, MAX_PAYLOAD_BYTES}, + room::rpc::{RpcError, RpcErrorCode, RpcInvocationData}, rtc_engine::lk_runtime::LkRuntime, rtc_engine::{EngineError, RtcEngine}, - ChatMessage, DataPacket, RoomSession, RpcAck, RpcRequest, RpcResponse, SipDTMF, Transcription, + ChatMessage, DataPacket, RoomSession, SipDTMF, Transcription, }; use chrono::Utc; use libwebrtc::{ @@ -51,14 +51,6 @@ use livekit_protocol as proto; use livekit_runtime::timeout; use parking_lot::{Mutex, RwLock}; use proto::request_response::Reason; -use semver::Version; -use tokio::sync::oneshot; - -type RpcHandler = Arc< - dyn Fn(RpcInvocationData) -> Pin> + Send>> - + Send - + Sync, ->; const REQUEST_TIMEOUT: Duration = Duration::from_secs(5); @@ -71,25 +63,9 @@ struct LocalEvents { local_track_unpublished: Mutex>, } -struct RpcState { - pending_acks: HashMap>, - pending_responses: HashMap>>, - handlers: HashMap, -} - -impl RpcState { - fn new() -> Self { - Self { - pending_acks: HashMap::new(), - pending_responses: HashMap::new(), - handlers: HashMap::new(), - } - } -} struct LocalInfo { events: LocalEvents, encryption_type: EncryptionType, - rpc_state: Mutex, all_participants_allowed: Mutex, track_permissions: Mutex>, session: RwLock>>, @@ -146,7 +122,6 @@ impl LocalParticipant { local: Arc::new(LocalInfo { events: LocalEvents::default(), encryption_type, - rpc_state: Mutex::new(RpcState::new()), all_participants_allowed: Mutex::new(true), track_permissions: Mutex::new(vec![]), session: Default::default(), @@ -684,76 +659,6 @@ impl LocalParticipant { .map_err(Into::into) } - async fn publish_rpc_request(&self, rpc_request: RpcRequest) -> RoomResult<()> { - let destination_identities = vec![rpc_request.destination_identity]; - let rpc_request_message = proto::RpcRequest { - id: rpc_request.id, - method: rpc_request.method, - payload: rpc_request.payload, - response_timeout_ms: rpc_request.response_timeout.as_millis() as u32, - version: rpc_request.version, - ..Default::default() - }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcRequest(rpc_request_message)), - destination_identities, - ..Default::default() - }; - - self.inner - .rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) - } - - async fn publish_rpc_response(&self, rpc_response: RpcResponse) -> RoomResult<()> { - let destination_identities = vec![rpc_response.destination_identity]; - let rpc_response_message = proto::RpcResponse { - request_id: rpc_response.request_id, - value: Some(match rpc_response.error { - Some(error) => proto::rpc_response::Value::Error(proto::RpcError { - code: error.code, - message: error.message, - data: error.data, - }), - None => proto::rpc_response::Value::Payload(rpc_response.payload.unwrap()), - }), - ..Default::default() - }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcResponse(rpc_response_message)), - destination_identities: destination_identities.clone(), - ..Default::default() - }; - - self.inner - .rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) - } - - async fn publish_rpc_ack(&self, rpc_ack: RpcAck) -> RoomResult<()> { - let destination_identities = vec![rpc_ack.destination_identity]; - let rpc_ack_message = - proto::RpcAck { request_id: rpc_ack.request_id, ..Default::default() }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcAck(rpc_ack_message)), - destination_identities: destination_identities.clone(), - ..Default::default() - }; - - self.inner - .rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) - } - pub(crate) async fn update_track_subscription_permissions(&self) { let all_participants_allowed = *self.local.all_participants_allowed.lock(); let track_permissions = self @@ -862,99 +767,10 @@ impl LocalParticipant { } pub async fn perform_rpc(&self, data: PerformRpcData) -> Result { - // Maximum amount of time it should ever take for an RPC request to reach the destination, and the ACK to come back - // This is set to 7 seconds to account for various relay timeouts and retries in LiveKit Cloud that occur in rare cases - - let max_round_trip_latency = Duration::from_millis(7000); - let min_effective_timeout = Duration::from_millis(1000); - - if data.payload.len() > MAX_PAYLOAD_BYTES { - return Err(RpcError::built_in(RpcErrorCode::RequestPayloadTooLarge, None)); - } - - if let Some(server_info) = - self.inner.rtc_engine.session().signal_client().join_response().server_info - { - if !server_info.version.is_empty() { - let server_version = Version::parse(&server_info.version).unwrap(); - let min_required_version = Version::parse("1.8.0").unwrap(); - if server_version < min_required_version { - return Err(RpcError::built_in(RpcErrorCode::UnsupportedServer, None)); - } - } - } - - let id = create_random_uuid(); - let (ack_tx, ack_rx) = oneshot::channel(); - let (response_tx, response_rx) = oneshot::channel(); - let effective_timeout = std::cmp::max( - data.response_timeout.saturating_sub(max_round_trip_latency), - min_effective_timeout, - ); - - // Register channels BEFORE sending the request to avoid race condition - // where the response arrives before we've registered the handlers - { - let mut rpc_state = self.local.rpc_state.lock(); - rpc_state.pending_acks.insert(id.clone(), ack_tx); - rpc_state.pending_responses.insert(id.clone(), response_tx); - } - - if let Err(e) = self - .publish_rpc_request(RpcRequest { - destination_identity: data.destination_identity.clone(), - id: id.clone(), - method: data.method.clone(), - payload: data.payload.clone(), - response_timeout: effective_timeout, - version: 1, - }) - .await - { - // Clean up on failure - let mut rpc_state = self.local.rpc_state.lock(); - rpc_state.pending_acks.remove(&id); - rpc_state.pending_responses.remove(&id); - log::error!("Failed to publish RPC request: {}", e); - return Err(RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string()))); - } - - // Wait for ack timeout - match tokio::time::timeout(max_round_trip_latency, ack_rx).await { - Err(_) => { - let mut rpc_state = self.local.rpc_state.lock(); - rpc_state.pending_acks.remove(&id); - rpc_state.pending_responses.remove(&id); - return Err(RpcError::built_in(RpcErrorCode::ConnectionTimeout, None)); - } - Ok(_) => { - // Ack received, continue to wait for response - } - } - - // Wait for response timout - let response = match tokio::time::timeout(data.response_timeout, response_rx).await { - Err(_) => { - self.local.rpc_state.lock().pending_responses.remove(&id); - return Err(RpcError::built_in(RpcErrorCode::ResponseTimeout, None)); - } - Ok(result) => result, - }; - - match response { - Err(_) => { - // Something went wrong locally - Err(RpcError::built_in(RpcErrorCode::RecipientDisconnected, None)) - } - Ok(Err(e)) => { - // RPC error from remote, forward it - Err(e) - } - Ok(Ok(payload)) => { - // Successful response - Ok(payload) - } - } + let session = self.session().ok_or_else(|| { + RpcError::built_in(RpcErrorCode::SendFailed, Some("Not connected".to_string())) + })?; + session.rpc_client.perform_rpc(data, &session).await } pub fn register_rpc_method( @@ -965,7 +781,9 @@ impl LocalParticipant { + Sync + 'static, ) { - self.local.rpc_state.lock().handlers.insert(method, Arc::new(handler)); + if let Some(session) = self.session() { + session.rpc_server.register_method(method, handler); + } // Pre-connect the publisher PC so ACKs can be sent immediately when requests arrive. // Without this, the first RPC request would trigger publisher negotiation, causing @@ -974,104 +792,8 @@ impl LocalParticipant { } pub fn unregister_rpc_method(&self, method: String) { - self.local.rpc_state.lock().handlers.remove(&method); - } - - pub(crate) fn handle_incoming_rpc_ack(&self, request_id: String) { - let mut rpc_state = self.local.rpc_state.lock(); - if let Some(tx) = rpc_state.pending_acks.remove(&request_id) { - let _ = tx.send(()); - } else { - log::error!("Ack received for unexpected RPC request: {}", request_id); - } - } - - pub(crate) fn handle_incoming_rpc_response( - &self, - request_id: String, - payload: Option, - error: Option, - ) { - let mut rpc_state = self.local.rpc_state.lock(); - if let Some(tx) = rpc_state.pending_responses.remove(&request_id) { - let _ = tx.send(match error { - Some(e) => Err(RpcError::from_proto(e)), - None => Ok(payload.unwrap_or_default()), - }); - } else { - log::error!("Response received for unexpected RPC request: {}", request_id); - } - } - - pub(crate) async fn handle_incoming_rpc_request( - &self, - caller_identity: ParticipantIdentity, - request_id: String, - method: String, - payload: String, - response_timeout: Duration, - version: u32, - ) { - if let Err(e) = self - .publish_rpc_ack(RpcAck { - destination_identity: caller_identity.to_string(), - request_id: request_id.clone(), - }) - .await - { - log::error!("Failed to publish RPC ACK: {:?}", e); - } - - let caller_identity_2 = caller_identity.clone(); - let request_id_2 = request_id.clone(); - - let response = if version != 1 { - Err(RpcError::built_in(RpcErrorCode::UnsupportedVersion, None)) - } else { - let handler = self.local.rpc_state.lock().handlers.get(&method).cloned(); - - match handler { - Some(handler) => { - match tokio::task::spawn(async move { - handler(RpcInvocationData { - request_id: request_id.clone(), - caller_identity: caller_identity.clone(), - payload: payload.clone(), - response_timeout, - }) - .await - }) - .await - { - Ok(result) => result, - Err(e) => { - log::error!("RPC method handler returned an error: {:?}", e); - Err(RpcError::built_in(RpcErrorCode::ApplicationError, None)) - } - } - } - None => Err(RpcError::built_in(RpcErrorCode::UnsupportedMethod, None)), - } - }; - - let (payload, error) = match response { - Ok(response_payload) if response_payload.len() <= MAX_PAYLOAD_BYTES => { - (Some(response_payload), None) - } - Ok(_) => (None, Some(RpcError::built_in(RpcErrorCode::ResponsePayloadTooLarge, None))), - Err(e) => (None, Some(e.into())), - }; - - if let Err(e) = self - .publish_rpc_response(RpcResponse { - destination_identity: caller_identity_2.to_string(), - request_id: request_id_2, - payload, - error: error.map(|e| e.to_proto()), - }) - .await - { - log::error!("Failed to publish RPC response: {:?}", e); + if let Some(session) = self.session() { + session.rpc_server.unregister_method(&method); } } diff --git a/livekit/src/room/participant/rpc.rs b/livekit/src/room/participant/rpc.rs index b04691dda..28c1d4463 100644 --- a/livekit/src/room/participant/rpc.rs +++ b/livekit/src/room/participant/rpc.rs @@ -12,157 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::room::participant::ParticipantIdentity; -use livekit_protocol::RpcError as RpcError_Proto; -use std::{error::Error, fmt::Display, time::Duration}; - -/// Parameters for performing an RPC call -#[derive(Debug, Clone)] -pub struct PerformRpcData { - pub destination_identity: String, - pub method: String, - pub payload: String, - pub response_timeout: Duration, -} - -impl Default for PerformRpcData { - fn default() -> Self { - Self { - destination_identity: Default::default(), - method: Default::default(), - payload: Default::default(), - response_timeout: Duration::from_secs(15), - } - } -} - -/// Data passed to method handler for incoming RPC invocations -/// -/// Attributes: -/// request_id (String): The unique request ID. Will match at both sides of the call, useful for debugging or logging. -/// caller_identity (ParticipantIdentity): The unique participant identity of the caller. -/// payload (String): The payload of the request. User-definable format, typically JSON. -/// response_timeout (Duration): The maximum time the caller will wait for a response. -#[derive(Debug, Clone)] -pub struct RpcInvocationData { - pub request_id: String, - pub caller_identity: ParticipantIdentity, - pub payload: String, - pub response_timeout: Duration, -} - -/// Specialized error handling for RPC methods. -/// -/// Instances of this type, when thrown in a method handler, will have their `message` -/// serialized and sent across the wire. The caller will receive an equivalent error on the other side. -/// -/// Build-in types are included but developers may use any string, with a max length of 256 bytes. -#[derive(Debug, Clone)] -pub struct RpcError { - pub code: u32, - pub message: String, - pub data: Option, -} - -impl RpcError { - pub const MAX_MESSAGE_BYTES: usize = 256; - pub const MAX_DATA_BYTES: usize = 15360; // 15 KB - - /// Creates an error object with the given code and message, plus an optional data payload. - /// - /// If thrown in an RPC method handler, the error will be sent back to the caller. - /// - /// Error codes 1001-1999 are reserved for built-in errors (see RpcErrorCode for their meanings). - pub fn new(code: u32, message: String, data: Option) -> Self { - Self { - code, - message: truncate_bytes(&message, Self::MAX_MESSAGE_BYTES), - data: data.map(|d| truncate_bytes(&d, Self::MAX_DATA_BYTES)), - } - } - - pub fn from_proto(proto: RpcError_Proto) -> Self { - Self::new(proto.code, proto.message, Some(proto.data)) - } - - pub fn to_proto(&self) -> RpcError_Proto { - RpcError_Proto { - code: self.code, - message: self.message.clone(), - data: self.data.clone().unwrap_or_default(), - } - } -} - -impl Display for RpcError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "RPC Error: {} ({})", self.message, self.code) - } -} -impl Error for RpcError {} - -#[derive(Debug, Clone, Copy)] -pub enum RpcErrorCode { - ApplicationError = 1500, - ConnectionTimeout = 1501, - ResponseTimeout = 1502, - RecipientDisconnected = 1503, - ResponsePayloadTooLarge = 1504, - SendFailed = 1505, - - UnsupportedMethod = 1400, - RecipientNotFound = 1401, - RequestPayloadTooLarge = 1402, - UnsupportedServer = 1403, - UnsupportedVersion = 1404, -} - -impl RpcErrorCode { - pub(crate) fn message(&self) -> &'static str { - match self { - Self::ApplicationError => "Application error in method handler", - Self::ConnectionTimeout => "Connection timeout", - Self::ResponseTimeout => "Response timeout", - Self::RecipientDisconnected => "Recipient disconnected", - Self::ResponsePayloadTooLarge => "Response payload too large", - Self::SendFailed => "Failed to send", - - Self::UnsupportedMethod => "Method not supported at destination", - Self::RecipientNotFound => "Recipient not found", - Self::RequestPayloadTooLarge => "Request payload too large", - Self::UnsupportedServer => "RPC not supported by server", - Self::UnsupportedVersion => "Unsupported RPC version", - } - } -} - -impl RpcError { - /// Creates an error object from the code, with an auto-populated message. - pub(crate) fn built_in(code: RpcErrorCode, data: Option) -> Self { - Self::new(code as u32, code.message().to_string(), data) - } -} - -/// Maximum payload size in bytes -pub const MAX_PAYLOAD_BYTES: usize = 15360; // 15 KB - -/// Calculate the byte length of a string -pub(crate) fn byte_length(s: &str) -> usize { - s.as_bytes().len() -} - -/// Truncate a string to a maximum number of bytes -pub(crate) fn truncate_bytes(s: &str, max_bytes: usize) -> String { - if byte_length(s) <= max_bytes { - return s.to_string(); - } - - let mut result = String::new(); - for c in s.chars() { - if byte_length(&(result.clone() + &c.to_string())) > max_bytes { - break; - } - result.push(c); - } - result -} +// Re-export all RPC types from the room::rpc module. +// This keeps existing imports from `room::participant::*` working. +pub use crate::room::rpc::*; diff --git a/livekit/src/room/rpc/caller.rs b/livekit/src/room/rpc/caller.rs new file mode 100644 index 000000000..1e4c653b2 --- /dev/null +++ b/livekit/src/room/rpc/caller.rs @@ -0,0 +1,426 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::{ + PerformRpcData, RpcError, RpcErrorCode, ATTR_METHOD, ATTR_REQUEST_ID, + ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, CLIENT_PROTOCOL_DATA_STREAM_RPC, + MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, +}; +use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; +use crate::room::id::ParticipantIdentity; +use crate::room::RoomSession; +use crate::rtc_engine::RtcEngine; +use crate::DataPacketKind; +use libwebrtc::native::create_random_uuid; +use livekit_protocol as proto; +use parking_lot::Mutex; +use semver::Version; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::oneshot; + +/// Manages outgoing RPC calls (caller/client side). +/// +/// Tracks pending ACKs and responses, handles v1 packet and v2 data stream +/// transport selection based on the remote participant's client protocol. +pub struct RpcClientManager { + pending_acks: Mutex>>, + pending_responses: + Mutex>>>, +} + +impl RpcClientManager { + pub fn new() -> Self { + Self { + pending_acks: Mutex::new(HashMap::new()), + pending_responses: Mutex::new(HashMap::new()), + } + } + + /// Perform an RPC call to a remote participant. + /// + /// Selects v1 (data packet) or v2 (data stream) transport based on + /// the remote participant's client_protocol. + pub(crate) async fn perform_rpc( + &self, + data: PerformRpcData, + session: &Arc, + ) -> Result { + let max_round_trip_latency = Duration::from_millis(7000); + let min_effective_timeout = Duration::from_millis(1000); + + let rtc_engine = &session.rtc_engine; + + if let Some(server_info) = rtc_engine + .session() + .signal_client() + .join_response() + .server_info + { + if !server_info.version.is_empty() { + let server_version = + Version::parse(&server_info.version).unwrap(); + let min_required_version = Version::parse("1.8.0").unwrap(); + if server_version < min_required_version { + return Err(RpcError::built_in( + RpcErrorCode::UnsupportedServer, + None, + )); + } + } + } + + // Determine transport version based on remote participant's client_protocol + let remote_protocol = session + .get_remote_client_protocol(&ParticipantIdentity( + data.destination_identity.clone(), + )); + let use_v2 = remote_protocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC; + + // Only enforce payload size limit for v1 transport + if !use_v2 && data.payload.len() > MAX_PAYLOAD_BYTES { + return Err(RpcError::built_in( + RpcErrorCode::RequestPayloadTooLarge, + None, + )); + } + + let id = create_random_uuid(); + let (ack_tx, ack_rx) = oneshot::channel(); + let (response_tx, response_rx) = oneshot::channel(); + let effective_timeout = std::cmp::max( + data.response_timeout.saturating_sub(max_round_trip_latency), + min_effective_timeout, + ); + + // Register channels BEFORE sending the request to avoid race condition + // where the response arrives before we've registered the handlers + { + let mut pending_acks = self.pending_acks.lock(); + let mut pending_responses = self.pending_responses.lock(); + pending_acks.insert(id.clone(), ack_tx); + pending_responses.insert(id.clone(), response_tx); + } + + let send_result = if use_v2 { + self.send_v2_request( + session, + &data.destination_identity, + &id, + &data.method, + &data.payload, + effective_timeout, + ) + .await + } else { + publish_rpc_request( + rtc_engine, + &data.destination_identity, + &id, + &data.method, + &data.payload, + effective_timeout, + 1, // version + ) + .await + .map_err(|e| { + RpcError::built_in( + RpcErrorCode::SendFailed, + Some(e.to_string()), + ) + }) + }; + + if let Err(e) = send_result { + // Clean up on failure + let mut pending_acks = self.pending_acks.lock(); + let mut pending_responses = self.pending_responses.lock(); + pending_acks.remove(&id); + pending_responses.remove(&id); + log::error!("Failed to publish RPC request: {}", e); + return Err(e); + } + + // Wait for ack timeout + match tokio::time::timeout(max_round_trip_latency, ack_rx).await { + Err(_) => { + let mut pending_acks = self.pending_acks.lock(); + let mut pending_responses = self.pending_responses.lock(); + pending_acks.remove(&id); + pending_responses.remove(&id); + return Err(RpcError::built_in( + RpcErrorCode::ConnectionTimeout, + None, + )); + } + Ok(_) => { + // Ack received, continue to wait for response + } + } + + // Wait for response timeout + let response = + match tokio::time::timeout(data.response_timeout, response_rx) + .await + { + Err(_) => { + self.pending_responses.lock().remove(&id); + return Err(RpcError::built_in( + RpcErrorCode::ResponseTimeout, + None, + )); + } + Ok(result) => result, + }; + + match response { + Err(_) => { + // Something went wrong locally + Err(RpcError::built_in( + RpcErrorCode::RecipientDisconnected, + None, + )) + } + Ok(Err(e)) => { + // RPC error from remote, forward it + Err(e) + } + Ok(Ok(payload)) => { + // Successful response + Ok(payload) + } + } + } + + /// Send an RPC request as a v2 text data stream. + async fn send_v2_request( + &self, + session: &Arc, + destination_identity: &str, + id: &str, + method: &str, + payload: &str, + response_timeout: Duration, + ) -> Result<(), RpcError> { + let mut attributes = HashMap::new(); + attributes + .insert(ATTR_REQUEST_ID.to_string(), id.to_string()); + attributes + .insert(ATTR_METHOD.to_string(), method.to_string()); + attributes.insert( + ATTR_RESPONSE_TIMEOUT_MS.to_string(), + response_timeout.as_millis().to_string(), + ); + attributes + .insert(ATTR_VERSION.to_string(), "2".to_string()); + + let options = StreamTextOptions { + topic: RPC_REQUEST_TOPIC.to_string(), + attributes, + destination_identities: vec![ParticipantIdentity( + destination_identity.to_string(), + )], + ..Default::default() + }; + + session + .outgoing_stream_manager + .send_text(payload, options) + .await + .map(|_| ()) + .map_err(|e| { + RpcError::built_in( + RpcErrorCode::SendFailed, + Some(e.to_string()), + ) + }) + } + + pub(crate) fn handle_ack(&self, request_id: String) { + let mut pending = self.pending_acks.lock(); + if let Some(tx) = pending.remove(&request_id) { + let _ = tx.send(()); + } else { + log::error!( + "Ack received for unexpected RPC request: {}", + request_id + ); + } + } + + /// Handle a v1 RPC response packet. + /// + /// Also handles error responses for v2 calls, since error responses + /// always use v1 packets regardless of transport version. + pub(crate) fn handle_response( + &self, + request_id: String, + payload: Option, + error: Option, + ) { + let mut pending = self.pending_responses.lock(); + if let Some(tx) = pending.remove(&request_id) { + let _ = tx.send(match error { + Some(e) => Err(RpcError::from_proto(e)), + None => Ok(payload.unwrap_or_default()), + }); + } else { + log::error!( + "Response received for unexpected RPC request: {}", + request_id + ); + } + } + + /// Handle a v2 RPC success response received as a data stream. + /// + /// Success responses between v2 clients arrive as text data streams + /// on the `lk.rpc_response` topic. Error responses always arrive + /// as v1 packets and are handled by `handle_response`. + pub(crate) async fn handle_response_stream( + &self, + reader: TextStreamReader, + ) { + let request_id = reader + .info() + .attributes + .get(ATTR_REQUEST_ID) + .cloned() + .unwrap_or_default(); + + if request_id.is_empty() { + log::error!( + "RPC v2 response stream missing request_id attribute" + ); + return; + } + + let payload = match reader.read_all().await { + Ok(payload) => payload, + Err(e) => { + log::error!( + "Failed to read RPC v2 response stream: {:?}", + e + ); + // Resolve with error so the caller doesn't hang + let mut pending = self.pending_responses.lock(); + if let Some(tx) = pending.remove(&request_id) { + let _ = tx.send(Err(RpcError::built_in( + RpcErrorCode::ApplicationError, + Some(format!("Failed to read response stream: {}", e)), + ))); + } + return; + } + }; + + let mut pending = self.pending_responses.lock(); + if let Some(tx) = pending.remove(&request_id) { + let _ = tx.send(Ok(payload)); + } else { + log::error!( + "Response stream received for unexpected RPC request: {}", + request_id + ); + } + } +} + +/// Publish a v1 RPC request data packet. +pub(crate) async fn publish_rpc_request( + rtc_engine: &Arc, + destination_identity: &str, + id: &str, + method: &str, + payload: &str, + response_timeout: Duration, + version: u32, +) -> Result<(), crate::room::RoomError> { + let rpc_request_message = proto::RpcRequest { + id: id.to_string(), + method: method.to_string(), + payload: payload.to_string(), + response_timeout_ms: response_timeout.as_millis() as u32, + version, + ..Default::default() + }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcRequest( + rpc_request_message, + )), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + rtc_engine + .publish_data(data, DataPacketKind::Reliable, false) + .await + .map_err(Into::into) +} + +/// Publish a v1 RPC response data packet. +pub(crate) async fn publish_rpc_response( + rtc_engine: &Arc, + destination_identity: &str, + request_id: &str, + payload: Option, + error: Option, +) -> Result<(), crate::room::RoomError> { + let rpc_response_message = proto::RpcResponse { + request_id: request_id.to_string(), + value: Some(match error { + Some(error) => proto::rpc_response::Value::Error(error), + None => proto::rpc_response::Value::Payload(payload.unwrap()), + }), + ..Default::default() + }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcResponse( + rpc_response_message, + )), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + rtc_engine + .publish_data(data, DataPacketKind::Reliable, false) + .await + .map_err(Into::into) +} + +/// Publish a v1 RPC ack data packet. +pub(crate) async fn publish_rpc_ack( + rtc_engine: &Arc, + destination_identity: &str, + request_id: &str, +) -> Result<(), crate::room::RoomError> { + let rpc_ack_message = proto::RpcAck { + request_id: request_id.to_string(), + ..Default::default() + }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcAck(rpc_ack_message)), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + rtc_engine + .publish_data(data, DataPacketKind::Reliable, false) + .await + .map_err(Into::into) +} diff --git a/livekit/src/room/rpc/handler.rs b/livekit/src/room/rpc/handler.rs new file mode 100644 index 000000000..daa4575b7 --- /dev/null +++ b/livekit/src/room/rpc/handler.rs @@ -0,0 +1,313 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::caller::{publish_rpc_ack, publish_rpc_response}; +use super::{ + RpcError, RpcErrorCode, RpcInvocationData, ATTR_METHOD, ATTR_REQUEST_ID, + ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, MAX_PAYLOAD_BYTES, + RPC_RESPONSE_TOPIC, +}; +use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; +use crate::room::id::ParticipantIdentity; +use crate::room::RoomSession; +use crate::rtc_engine::RtcEngine; +use parking_lot::Mutex; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; + +pub(crate) type RpcHandlerFn = Arc< + dyn Fn(RpcInvocationData) -> Pin> + Send>> + + Send + + Sync, +>; + +/// Manages incoming RPC requests (handler/server side). +/// +/// Stores registered method handlers and dispatches incoming requests +/// to the appropriate handler. Handles both v1 packet and v2 data stream +/// request formats. +pub struct RpcServerManager { + handlers: Mutex>, +} + +impl RpcServerManager { + pub fn new() -> Self { + Self { handlers: Mutex::new(HashMap::new()) } + } + + pub fn register_method( + &self, + method: String, + handler: impl Fn(RpcInvocationData) -> Pin> + Send>> + + Send + + Sync + + 'static, + ) { + self.handlers.lock().insert(method, Arc::new(handler)); + } + + pub fn unregister_method(&self, method: &str) { + self.handlers.lock().remove(method); + } + + pub(crate) fn get_handler(&self, method: &str) -> Option { + self.handlers.lock().get(method).cloned() + } + + /// Handle an incoming v1 RPC request (received as a DataPacket). + /// + /// Sends ACK, invokes the registered handler, and sends the response + /// as a v1 RPC response packet. + pub(crate) async fn handle_request( + &self, + caller_identity: ParticipantIdentity, + request_id: String, + method: String, + payload: String, + response_timeout: Duration, + version: u32, + rtc_engine: &Arc, + ) { + // Send ACK immediately + if let Err(e) = + publish_rpc_ack(rtc_engine, &caller_identity.0, &request_id).await + { + log::error!("Failed to publish RPC ACK: {:?}", e); + } + + let response = if version != 1 { + Err(RpcError::built_in(RpcErrorCode::UnsupportedVersion, None)) + } else { + self.invoke_handler(&caller_identity, &request_id, &method, &payload, response_timeout).await + }; + + let (resp_payload, error) = match response { + Ok(response_payload) + if response_payload.len() <= MAX_PAYLOAD_BYTES => + { + (Some(response_payload), None) + } + Ok(_) => ( + None, + Some( + RpcError::built_in( + RpcErrorCode::ResponsePayloadTooLarge, + None, + ) + .to_proto(), + ), + ), + Err(e) => (None, Some(e.to_proto())), + }; + + if let Err(e) = publish_rpc_response( + rtc_engine, + &caller_identity.0, + &request_id, + resp_payload, + error, + ) + .await + { + log::error!("Failed to publish RPC response: {:?}", e); + } + } + + /// Handle an incoming v2 RPC request (received as a data stream). + /// + /// Parses request metadata from stream attributes, sends ACK, + /// invokes the handler, and sends the response. Success responses + /// use a v2 data stream; error responses always use v1 packets. + pub(crate) async fn handle_request_stream( + &self, + reader: TextStreamReader, + caller_identity: ParticipantIdentity, + session: &Arc, + ) { + let attrs = &reader.info().attributes; + + let request_id = attrs + .get(ATTR_REQUEST_ID) + .cloned() + .unwrap_or_default(); + let method = attrs + .get(ATTR_METHOD) + .cloned() + .unwrap_or_default(); + let response_timeout_ms: u64 = attrs + .get(ATTR_RESPONSE_TIMEOUT_MS) + .and_then(|v| v.parse().ok()) + .unwrap_or(15000); + let version: u32 = attrs + .get(ATTR_VERSION) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + + let response_timeout = Duration::from_millis(response_timeout_ms); + let rtc_engine = &session.rtc_engine; + + // Send ACK immediately (always v1 packet) + if let Err(e) = + publish_rpc_ack(rtc_engine, &caller_identity.0, &request_id).await + { + log::error!("Failed to publish RPC ACK: {:?}", e); + } + + if version != 2 { + let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); + let _ = publish_rpc_response( + rtc_engine, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; + return; + } + + // Read the full payload from the stream + let payload = match reader.read_all().await { + Ok(payload) => payload, + Err(e) => { + log::error!("Failed to read RPC v2 request stream: {:?}", e); + let error = RpcError::built_in( + RpcErrorCode::ApplicationError, + Some(format!("Failed to read request stream: {}", e)), + ); + let _ = publish_rpc_response( + rtc_engine, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; + return; + } + }; + + let response = self.invoke_handler( + &caller_identity, + &request_id, + &method, + &payload, + response_timeout, + ).await; + + match response { + Ok(response_payload) => { + // Success: send response as v2 data stream + let mut attributes = HashMap::new(); + attributes.insert( + ATTR_REQUEST_ID.to_string(), + request_id.clone(), + ); + + let options = StreamTextOptions { + topic: RPC_RESPONSE_TOPIC.to_string(), + attributes, + destination_identities: vec![caller_identity.clone()], + ..Default::default() + }; + + if let Err(e) = session + .outgoing_stream_manager + .send_text(&response_payload, options) + .await + { + log::error!( + "Failed to send RPC v2 response stream: {:?}", + e + ); + // Fall back to error via v1 packet + let error = RpcError::built_in( + RpcErrorCode::SendFailed, + Some(e.to_string()), + ); + let _ = publish_rpc_response( + rtc_engine, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; + } + } + Err(e) => { + // Error: always send as v1 packet + if let Err(send_err) = publish_rpc_response( + rtc_engine, + &caller_identity.0, + &request_id, + None, + Some(e.to_proto()), + ) + .await + { + log::error!( + "Failed to publish RPC error response: {:?}", + send_err + ); + } + } + } + } + + /// Invoke a registered handler for an RPC method, with error handling. + async fn invoke_handler( + &self, + caller_identity: &ParticipantIdentity, + request_id: &str, + method: &str, + payload: &str, + response_timeout: Duration, + ) -> Result { + let handler = self.get_handler(method); + + match handler { + Some(handler) => { + let caller_id = caller_identity.clone(); + let req_id = request_id.to_string(); + let req_payload = payload.to_string(); + match tokio::task::spawn(async move { + handler(RpcInvocationData { + request_id: req_id, + caller_identity: caller_id, + payload: req_payload, + response_timeout, + }) + .await + }) + .await + { + Ok(result) => result, + Err(e) => { + log::error!( + "RPC method handler returned an error: {:?}", + e + ); + Err(RpcError::built_in( + RpcErrorCode::ApplicationError, + None, + )) + } + } + } + None => { + Err(RpcError::built_in(RpcErrorCode::UnsupportedMethod, None)) + } + } + } +} diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs new file mode 100644 index 000000000..d4f1c3e88 --- /dev/null +++ b/livekit/src/room/rpc/mod.rs @@ -0,0 +1,189 @@ +// Copyright 2025 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +mod caller; +mod handler; + +pub use caller::RpcClientManager; +pub use handler::RpcServerManager; + +use crate::room::id::ParticipantIdentity; +use livekit_protocol::RpcError as RpcError_Proto; +use std::{error::Error, fmt::Display, time::Duration}; + +// Client protocol version constants +pub(crate) const CLIENT_PROTOCOL_DEFAULT: i32 = 0; +pub(crate) const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; + +// Data stream topic constants for RPC v2 +pub(crate) const RPC_REQUEST_TOPIC: &str = "lk.rpc_request"; +pub(crate) const RPC_RESPONSE_TOPIC: &str = "lk.rpc_response"; + +// Stream attribute keys for RPC v2 +pub(crate) const ATTR_REQUEST_ID: &str = "lk.rpc_request_id"; +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"; + +/// Parameters for performing an RPC call +#[derive(Debug, Clone)] +pub struct PerformRpcData { + pub destination_identity: String, + pub method: String, + pub payload: String, + pub response_timeout: Duration, +} + +impl Default for PerformRpcData { + fn default() -> Self { + Self { + destination_identity: Default::default(), + method: Default::default(), + payload: Default::default(), + response_timeout: Duration::from_secs(15), + } + } +} + +/// Data passed to method handler for incoming RPC invocations +/// +/// Attributes: +/// request_id (String): The unique request ID. Will match at both sides of the call, useful for debugging or logging. +/// caller_identity (ParticipantIdentity): The unique participant identity of the caller. +/// payload (String): The payload of the request. User-definable format, typically JSON. +/// response_timeout (Duration): The maximum time the caller will wait for a response. +#[derive(Debug, Clone)] +pub struct RpcInvocationData { + pub request_id: String, + pub caller_identity: ParticipantIdentity, + pub payload: String, + pub response_timeout: Duration, +} + +/// Specialized error handling for RPC methods. +/// +/// Instances of this type, when thrown in a method handler, will have their `message` +/// serialized and sent across the wire. The caller will receive an equivalent error on the other side. +/// +/// Build-in types are included but developers may use any string, with a max length of 256 bytes. +#[derive(Debug, Clone)] +pub struct RpcError { + pub code: u32, + pub message: String, + pub data: Option, +} + +impl RpcError { + pub const MAX_MESSAGE_BYTES: usize = 256; + pub const MAX_DATA_BYTES: usize = 15360; // 15 KB + + /// Creates an error object with the given code and message, plus an optional data payload. + /// + /// If thrown in an RPC method handler, the error will be sent back to the caller. + /// + /// Error codes 1001-1999 are reserved for built-in errors (see RpcErrorCode for their meanings). + pub fn new(code: u32, message: String, data: Option) -> Self { + Self { + code, + message: truncate_bytes(&message, Self::MAX_MESSAGE_BYTES), + data: data.map(|d| truncate_bytes(&d, Self::MAX_DATA_BYTES)), + } + } + + pub fn from_proto(proto: RpcError_Proto) -> Self { + Self::new(proto.code, proto.message, Some(proto.data)) + } + + pub fn to_proto(&self) -> RpcError_Proto { + RpcError_Proto { + code: self.code, + message: self.message.clone(), + data: self.data.clone().unwrap_or_default(), + } + } +} + +impl Display for RpcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "RPC Error: {} ({})", self.message, self.code) + } +} +impl Error for RpcError {} + +#[derive(Debug, Clone, Copy)] +pub enum RpcErrorCode { + ApplicationError = 1500, + ConnectionTimeout = 1501, + ResponseTimeout = 1502, + RecipientDisconnected = 1503, + ResponsePayloadTooLarge = 1504, + SendFailed = 1505, + + UnsupportedMethod = 1400, + RecipientNotFound = 1401, + RequestPayloadTooLarge = 1402, + UnsupportedServer = 1403, + UnsupportedVersion = 1404, +} + +impl RpcErrorCode { + pub(crate) fn message(&self) -> &'static str { + match self { + Self::ApplicationError => "Application error in method handler", + Self::ConnectionTimeout => "Connection timeout", + Self::ResponseTimeout => "Response timeout", + Self::RecipientDisconnected => "Recipient disconnected", + Self::ResponsePayloadTooLarge => "Response payload too large", + Self::SendFailed => "Failed to send", + + Self::UnsupportedMethod => "Method not supported at destination", + Self::RecipientNotFound => "Recipient not found", + Self::RequestPayloadTooLarge => "Request payload too large", + Self::UnsupportedServer => "RPC not supported by server", + Self::UnsupportedVersion => "Unsupported RPC version", + } + } +} + +impl RpcError { + /// Creates an error object from the code, with an auto-populated message. + pub(crate) fn built_in(code: RpcErrorCode, data: Option) -> Self { + Self::new(code as u32, code.message().to_string(), data) + } +} + +/// Maximum payload size in bytes for RPC v1 +pub const MAX_PAYLOAD_BYTES: usize = 15360; // 15 KB + +/// Calculate the byte length of a string +pub(crate) fn byte_length(s: &str) -> usize { + s.as_bytes().len() +} + +/// Truncate a string to a maximum number of bytes +pub(crate) fn truncate_bytes(s: &str, max_bytes: usize) -> String { + if byte_length(s) <= max_bytes { + return s.to_string(); + } + + let mut result = String::new(); + for c in s.chars() { + if byte_length(&(result.clone() + &c.to_string())) > max_bytes { + break; + } + result.push(c); + } + result +} diff --git a/livekit/tests/rpc_test.rs b/livekit/tests/rpc_test.rs index edee6063d..ba7fd88fa 100644 --- a/livekit/tests/rpc_test.rs +++ b/livekit/tests/rpc_test.rs @@ -77,6 +77,71 @@ pub async fn test_rpc_unregistered() -> Result<()> { Ok(()) } +#[cfg(feature = "__lk-e2e-test")] +#[test_log::test(tokio::test)] +pub async fn test_rpc_large_payload() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (caller_room, _) = rooms.pop().unwrap(); + let (callee_room, _) = rooms.pop().unwrap(); + let callee_identity = callee_room.local_participant().identity(); + + const METHOD_NAME: &str = "large-payload-method"; + // 20KB payload - exceeds 15KB v1 limit but works with v2 data streams + let large_payload: String = "x".repeat(20_000); + + callee_room.local_participant().register_rpc_method(METHOD_NAME.to_string(), |data| { + Box::pin(async move { Ok(data.payload.to_string()) }) + }); + + let perform_data = PerformRpcData { + method: METHOD_NAME.to_string(), + destination_identity: callee_identity.to_string(), + payload: large_payload.clone(), + response_timeout: Duration::from_secs(5), + ..Default::default() + }; + let return_payload = caller_room + .local_participant() + .perform_rpc(perform_data) + .await + .context("Large payload invocation failed")?; + assert_eq!(return_payload, large_payload, "Large payload mismatch"); + Ok(()) +} + +#[cfg(feature = "__lk-e2e-test")] +#[test_log::test(tokio::test)] +pub async fn test_rpc_error_response() -> Result<()> { + use livekit::prelude::{RpcError, RpcErrorCode}; + + let mut rooms = test_rooms(2).await?; + let (caller_room, _) = rooms.pop().unwrap(); + let (callee_room, _) = rooms.pop().unwrap(); + let callee_identity = callee_room.local_participant().identity(); + + const METHOD_NAME: &str = "error-method"; + + callee_room.local_participant().register_rpc_method(METHOD_NAME.to_string(), |_data| { + Box::pin(async move { + Err(RpcError::new(42, "custom error".to_string(), Some("error data".to_string()))) + }) + }); + + let perform_data = PerformRpcData { + method: METHOD_NAME.to_string(), + destination_identity: callee_identity.to_string(), + payload: "test".to_string(), + response_timeout: Duration::from_secs(5), + ..Default::default() + }; + let result = caller_room.local_participant().perform_rpc(perform_data).await; + assert!(result.is_err(), "Expected error response"); + let err = result.unwrap_err(); + assert_eq!(err.code, 42, "Error code mismatch"); + assert_eq!(err.message, "custom error", "Error message mismatch"); + Ok(()) +} + #[cfg(feature = "__lk-e2e-test")] #[test_log::test(tokio::test)] pub async fn test_rpc_unknown_destination() -> Result<()> { From a53e78a4455b5ab2bb9980052dd60c2408b3d61e Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 11:29:17 -0400 Subject: [PATCH 03/18] feat: add rpc tests for v1 / v2 rpc protocol types --- livekit-api/src/signal_client/mod.rs | 7 +- livekit/src/room/data_stream/incoming.rs | 11 + livekit/src/room/mod.rs | 13 +- .../src/room/participant/local_participant.rs | 3 +- livekit/src/room/rpc/caller.rs | 98 +- livekit/src/room/rpc/handler.rs | 115 ++- livekit/src/room/rpc/mod.rs | 75 +- livekit/src/room/rpc/tests.rs | 872 ++++++++++++++++++ 8 files changed, 1087 insertions(+), 107 deletions(-) create mode 100644 livekit/src/room/rpc/tests.rs diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index cc87837ee..0d05195da 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -83,11 +83,14 @@ pub enum SignalError { pub struct SignalSdkOptions { pub sdk: String, pub sdk_version: Option, + /// Override the client_protocol advertised during join. + /// If None, uses the default (currently 1 = data stream RPC support). + pub client_protocol: Option, } impl Default for SignalSdkOptions { fn default() -> Self { - Self { sdk: "rust".to_string(), sdk_version: None } + Self { sdk: "rust".to_string(), sdk_version: None, client_protocol: None } } } @@ -571,7 +574,7 @@ fn create_join_request_param( os, os_version, device_model, - client_protocol: 1, // CLIENT_PROTOCOL_DATA_STREAM_RPC + client_protocol: options.sdk_options.client_protocol.unwrap_or(1), ..Default::default() }; diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index d9b9ecf3b..e0782f96a 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -148,6 +148,17 @@ impl Stream for ByteStreamReader { } } +#[cfg(test)] +impl TextStreamReader { + /// Create a TextStreamReader for testing purposes. + pub(crate) fn new_for_test( + info: TextStreamInfo, + chunk_rx: UnboundedReceiver>, + ) -> Self { + Self { info, chunk_rx } + } +} + impl StreamReader for TextStreamReader { type Output = String; type Info = TextStreamInfo; diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index fe5fad6a8..53fc7f3d2 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -335,11 +335,14 @@ pub struct ChatMessage { pub struct RoomSdkOptions { pub sdk: String, pub sdk_version: String, + /// Override the client_protocol advertised during join. + /// If None, uses the default (currently 1 = data stream RPC support). + pub client_protocol: Option, } impl Default for RoomSdkOptions { fn default() -> Self { - Self { sdk: "rust".to_string(), sdk_version: SDK_VERSION.to_string() } + Self { sdk: "rust".to_string(), sdk_version: SDK_VERSION.to_string(), client_protocol: None } } } @@ -348,6 +351,7 @@ impl From for SignalSdkOptions { let mut sdk_options = SignalSdkOptions::default(); sdk_options.sdk = options.sdk; sdk_options.sdk_version = Some(options.sdk_version); + sdk_options.client_protocol = options.client_protocol; sdk_options } } @@ -969,10 +973,10 @@ impl RoomSession { log::warn!("Received RPC request with null caller identity"); return Ok(()); } - let rtc_engine = self.rtc_engine.clone(); let session = self.clone(); let caller = caller_identity.unwrap(); livekit_runtime::spawn(async move { + let transport = rpc::SessionTransport(session.clone()); session .rpc_server .handle_request( @@ -982,7 +986,7 @@ impl RoomSession { payload, response_timeout, version, - &rtc_engine, + &transport, ) .await; }); @@ -2080,10 +2084,11 @@ async fn incoming_data_stream_task( let caller_identity = ParticipantIdentity(identity); let session = session.clone(); livekit_runtime::spawn(async move { + let transport = rpc::SessionTransport(session.clone()); session.rpc_server.handle_request_stream( reader, caller_identity, - &session, + &transport, ).await; }); } diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index 9e7fcc3f3..28e21acf0 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -770,7 +770,8 @@ impl LocalParticipant { let session = self.session().ok_or_else(|| { RpcError::built_in(RpcErrorCode::SendFailed, Some("Not connected".to_string())) })?; - session.rpc_client.perform_rpc(data, &session).await + let transport = crate::room::rpc::SessionTransport(session.clone()); + session.rpc_client.perform_rpc(data, &transport).await } pub fn register_rpc_method( diff --git a/livekit/src/room/rpc/caller.rs b/livekit/src/room/rpc/caller.rs index 1e4c653b2..9ee7a22b1 100644 --- a/livekit/src/room/rpc/caller.rs +++ b/livekit/src/room/rpc/caller.rs @@ -13,21 +13,17 @@ // limitations under the License. use super::{ - PerformRpcData, RpcError, RpcErrorCode, ATTR_METHOD, ATTR_REQUEST_ID, - ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, CLIENT_PROTOCOL_DATA_STREAM_RPC, - MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, + PerformRpcData, RpcError, RpcErrorCode, RpcTransport, ATTR_METHOD, + ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, + CLIENT_PROTOCOL_DATA_STREAM_RPC, MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; -use crate::room::RoomSession; -use crate::rtc_engine::RtcEngine; -use crate::DataPacketKind; use libwebrtc::native::create_random_uuid; use livekit_protocol as proto; use parking_lot::Mutex; use semver::Version; use std::collections::HashMap; -use std::sync::Arc; use std::time::Duration; use tokio::sync::oneshot; @@ -56,37 +52,26 @@ impl RpcClientManager { pub(crate) async fn perform_rpc( &self, data: PerformRpcData, - session: &Arc, + transport: &(impl RpcTransport + 'static), ) -> Result { let max_round_trip_latency = Duration::from_millis(7000); let min_effective_timeout = Duration::from_millis(1000); - let rtc_engine = &session.rtc_engine; - - if let Some(server_info) = rtc_engine - .session() - .signal_client() - .join_response() - .server_info - { - if !server_info.version.is_empty() { - let server_version = - Version::parse(&server_info.version).unwrap(); - let min_required_version = Version::parse("1.8.0").unwrap(); - if server_version < min_required_version { - return Err(RpcError::built_in( - RpcErrorCode::UnsupportedServer, - None, - )); - } + if let Some(version_str) = transport.server_version() { + let server_version = Version::parse(&version_str).unwrap(); + let min_required_version = Version::parse("1.8.0").unwrap(); + if server_version < min_required_version { + return Err(RpcError::built_in( + RpcErrorCode::UnsupportedServer, + None, + )); } } // Determine transport version based on remote participant's client_protocol - let remote_protocol = session - .get_remote_client_protocol(&ParticipantIdentity( - data.destination_identity.clone(), - )); + let remote_protocol = transport.remote_client_protocol( + &ParticipantIdentity(data.destination_identity.clone()), + ); let use_v2 = remote_protocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC; // Only enforce payload size limit for v1 transport @@ -116,7 +101,7 @@ impl RpcClientManager { let send_result = if use_v2 { self.send_v2_request( - session, + transport, &data.destination_identity, &id, &data.method, @@ -126,7 +111,7 @@ impl RpcClientManager { .await } else { publish_rpc_request( - rtc_engine, + transport, &data.destination_identity, &id, &data.method, @@ -187,7 +172,7 @@ impl RpcClientManager { match response { Err(_) => { - // Something went wrong locally + // Channel closed — sender dropped (e.g. disconnect) Err(RpcError::built_in( RpcErrorCode::RecipientDisconnected, None, @@ -207,7 +192,7 @@ impl RpcClientManager { /// Send an RPC request as a v2 text data stream. async fn send_v2_request( &self, - session: &Arc, + transport: &impl RpcTransport, destination_identity: &str, id: &str, method: &str, @@ -235,8 +220,7 @@ impl RpcClientManager { ..Default::default() }; - session - .outgoing_stream_manager + transport .send_text(payload, options) .await .map(|_| ()) @@ -248,6 +232,22 @@ impl RpcClientManager { }) } + /// Drop the pending response sender for a request, simulating a disconnect. + #[cfg(test)] + pub(crate) fn drop_pending_response(&self, request_id: &str) { + self.pending_responses.lock().remove(request_id); + } + + /// Register a pending response channel for testing. + #[cfg(test)] + pub(crate) fn insert_pending_response( + &self, + request_id: String, + tx: tokio::sync::oneshot::Sender>, + ) { + self.pending_responses.lock().insert(request_id, tx); + } + pub(crate) fn handle_ack(&self, request_id: String) { let mut pending = self.pending_acks.lock(); if let Some(tx) = pending.remove(&request_id) { @@ -319,7 +319,10 @@ impl RpcClientManager { if let Some(tx) = pending.remove(&request_id) { let _ = tx.send(Err(RpcError::built_in( RpcErrorCode::ApplicationError, - Some(format!("Failed to read response stream: {}", e)), + Some(format!( + "Failed to read response stream: {}", + e + )), ))); } return; @@ -340,7 +343,7 @@ impl RpcClientManager { /// Publish a v1 RPC request data packet. pub(crate) async fn publish_rpc_request( - rtc_engine: &Arc, + transport: &impl RpcTransport, destination_identity: &str, id: &str, method: &str, @@ -365,15 +368,12 @@ pub(crate) async fn publish_rpc_request( ..Default::default() }; - rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) + transport.publish_data(data).await } /// Publish a v1 RPC response data packet. pub(crate) async fn publish_rpc_response( - rtc_engine: &Arc, + transport: &impl RpcTransport, destination_identity: &str, request_id: &str, payload: Option, @@ -396,15 +396,12 @@ pub(crate) async fn publish_rpc_response( ..Default::default() }; - rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) + transport.publish_data(data).await } /// Publish a v1 RPC ack data packet. pub(crate) async fn publish_rpc_ack( - rtc_engine: &Arc, + transport: &impl RpcTransport, destination_identity: &str, request_id: &str, ) -> Result<(), crate::room::RoomError> { @@ -419,8 +416,5 @@ pub(crate) async fn publish_rpc_ack( ..Default::default() }; - rtc_engine - .publish_data(data, DataPacketKind::Reliable, false) - .await - .map_err(Into::into) + transport.publish_data(data).await } diff --git a/livekit/src/room/rpc/handler.rs b/livekit/src/room/rpc/handler.rs index daa4575b7..6e94a420c 100644 --- a/livekit/src/room/rpc/handler.rs +++ b/livekit/src/room/rpc/handler.rs @@ -14,19 +14,23 @@ use super::caller::{publish_rpc_ack, publish_rpc_response}; use super::{ - RpcError, RpcErrorCode, RpcInvocationData, ATTR_METHOD, ATTR_REQUEST_ID, - ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, MAX_PAYLOAD_BYTES, - RPC_RESPONSE_TOPIC, + RpcError, RpcErrorCode, RpcInvocationData, RpcTransport, ATTR_METHOD, + ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, + MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; -use crate::room::RoomSession; -use crate::rtc_engine::RtcEngine; use parking_lot::Mutex; -use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; +use std::{ + collections::HashMap, future::Future, pin::Pin, sync::Arc, + time::Duration, +}; pub(crate) type RpcHandlerFn = Arc< - dyn Fn(RpcInvocationData) -> Pin> + Send>> + dyn Fn( + RpcInvocationData, + ) + -> Pin> + Send>> + Send + Sync, >; @@ -48,8 +52,11 @@ impl RpcServerManager { pub fn register_method( &self, method: String, - handler: impl Fn(RpcInvocationData) -> Pin> + Send>> - + Send + handler: impl Fn( + RpcInvocationData, + ) -> Pin< + Box> + Send>, + > + Send + Sync + 'static, ) { @@ -76,19 +83,29 @@ impl RpcServerManager { payload: String, response_timeout: Duration, version: u32, - rtc_engine: &Arc, + transport: &(impl RpcTransport + 'static), ) { // Send ACK immediately if let Err(e) = - publish_rpc_ack(rtc_engine, &caller_identity.0, &request_id).await + publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } let response = if version != 1 { - Err(RpcError::built_in(RpcErrorCode::UnsupportedVersion, None)) + Err(RpcError::built_in( + RpcErrorCode::UnsupportedVersion, + None, + )) } else { - self.invoke_handler(&caller_identity, &request_id, &method, &payload, response_timeout).await + self.invoke_handler( + &caller_identity, + &request_id, + &method, + &payload, + response_timeout, + ) + .await }; let (resp_payload, error) = match response { @@ -111,7 +128,7 @@ impl RpcServerManager { }; if let Err(e) = publish_rpc_response( - rtc_engine, + transport, &caller_identity.0, &request_id, resp_payload, @@ -132,18 +149,13 @@ impl RpcServerManager { &self, reader: TextStreamReader, caller_identity: ParticipantIdentity, - session: &Arc, + transport: &(impl RpcTransport + 'static), ) { let attrs = &reader.info().attributes; - let request_id = attrs - .get(ATTR_REQUEST_ID) - .cloned() - .unwrap_or_default(); - let method = attrs - .get(ATTR_METHOD) - .cloned() - .unwrap_or_default(); + let request_id = + attrs.get(ATTR_REQUEST_ID).cloned().unwrap_or_default(); + let method = attrs.get(ATTR_METHOD).cloned().unwrap_or_default(); let response_timeout_ms: u64 = attrs .get(ATTR_RESPONSE_TIMEOUT_MS) .and_then(|v| v.parse().ok()) @@ -154,19 +166,19 @@ impl RpcServerManager { .unwrap_or(0); let response_timeout = Duration::from_millis(response_timeout_ms); - let rtc_engine = &session.rtc_engine; // Send ACK immediately (always v1 packet) if let Err(e) = - publish_rpc_ack(rtc_engine, &caller_identity.0, &request_id).await + publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } if version != 2 { - let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); + let error = + RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); let _ = publish_rpc_response( - rtc_engine, + transport, &caller_identity.0, &request_id, None, @@ -180,13 +192,19 @@ impl RpcServerManager { let payload = match reader.read_all().await { Ok(payload) => payload, Err(e) => { - log::error!("Failed to read RPC v2 request stream: {:?}", e); + log::error!( + "Failed to read RPC v2 request stream: {:?}", + e + ); let error = RpcError::built_in( RpcErrorCode::ApplicationError, - Some(format!("Failed to read request stream: {}", e)), + Some(format!( + "Failed to read request stream: {}", + e + )), ); let _ = publish_rpc_response( - rtc_engine, + transport, &caller_identity.0, &request_id, None, @@ -197,13 +215,15 @@ impl RpcServerManager { } }; - let response = self.invoke_handler( - &caller_identity, - &request_id, - &method, - &payload, - response_timeout, - ).await; + let response = self + .invoke_handler( + &caller_identity, + &request_id, + &method, + &payload, + response_timeout, + ) + .await; match response { Ok(response_payload) => { @@ -217,14 +237,14 @@ impl RpcServerManager { let options = StreamTextOptions { topic: RPC_RESPONSE_TOPIC.to_string(), attributes, - destination_identities: vec![caller_identity.clone()], + destination_identities: vec![ + caller_identity.clone(), + ], ..Default::default() }; - if let Err(e) = session - .outgoing_stream_manager - .send_text(&response_payload, options) - .await + if let Err(e) = + transport.send_text(&response_payload, options).await { log::error!( "Failed to send RPC v2 response stream: {:?}", @@ -236,7 +256,7 @@ impl RpcServerManager { Some(e.to_string()), ); let _ = publish_rpc_response( - rtc_engine, + transport, &caller_identity.0, &request_id, None, @@ -248,7 +268,7 @@ impl RpcServerManager { Err(e) => { // Error: always send as v1 packet if let Err(send_err) = publish_rpc_response( - rtc_engine, + transport, &caller_identity.0, &request_id, None, @@ -305,9 +325,10 @@ impl RpcServerManager { } } } - None => { - Err(RpcError::built_in(RpcErrorCode::UnsupportedMethod, None)) - } + None => Err(RpcError::built_in( + RpcErrorCode::UnsupportedMethod, + None, + )), } } } diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index d4f1c3e88..1a8166282 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -15,12 +15,16 @@ mod caller; mod handler; +#[cfg(test)] +mod tests; + pub use caller::RpcClientManager; pub use handler::RpcServerManager; +use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; use livekit_protocol::RpcError as RpcError_Proto; -use std::{error::Error, fmt::Display, time::Duration}; +use std::{error::Error, fmt::Display, future::Future, time::Duration}; // Client protocol version constants pub(crate) const CLIENT_PROTOCOL_DEFAULT: i32 = 0; @@ -37,6 +41,75 @@ pub(crate) const ATTR_RESPONSE_TIMEOUT_MS: &str = "lk.rpc_request_response_timeout_ms"; pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; +/// 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 { + /// Send a data packet (used for v1 RPC packets and ACKs). + fn publish_data( + &self, + data: livekit_protocol::DataPacket, + ) -> impl Future> + Send; + + /// Send text as a data stream (used for v2 RPC requests and responses). + fn send_text( + &self, + text: &str, + 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; +} + +/// Production implementation of `RpcTransport` backed by a `RoomSession`. +pub(crate) struct SessionTransport(pub(crate) std::sync::Arc); + +impl RpcTransport for SessionTransport { + async fn publish_data( + &self, + data: livekit_protocol::DataPacket, + ) -> Result<(), crate::room::RoomError> { + self.0 + .rtc_engine + .publish_data(data, crate::DataPacketKind::Reliable, false) + .await + .map_err(Into::into) + } + + async fn send_text( + &self, + 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) + } + + fn server_version(&self) -> Option { + self.0 + .rtc_engine + .session() + .signal_client() + .join_response() + .server_info + .and_then(|info| { + if info.version.is_empty() { + None + } else { + Some(info.version) + } + }) + } +} + /// Parameters for performing an RPC call #[derive(Debug, Clone)] pub struct PerformRpcData { diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs new file mode 100644 index 000000000..eb4ae2276 --- /dev/null +++ b/livekit/src/room/rpc/tests.rs @@ -0,0 +1,872 @@ +use super::*; +use crate::data_stream::{ + OperationType, StreamResult, StreamTextOptions, TextStreamInfo, + TextStreamReader, +}; +use crate::e2ee::EncryptionType; +use crate::room::id::ParticipantIdentity; +use crate::room::RoomError; +use bytes::Bytes; +use chrono::Utc; +use livekit_protocol as proto; +use parking_lot::Mutex as ParkingMutex; +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::{mpsc, Notify}; + +// --------------------------------------------------------------------------- +// Mock transport +// --------------------------------------------------------------------------- + +/// Captures all outgoing packets and text streams for assertion. +struct MockTransport { + sent_packets: Arc>>, + sent_texts: Arc>>, + packet_sent: Arc, + text_sent: Arc, + remote_protocols: HashMap, + server_ver: Option, +} + +impl MockTransport { + fn new() -> Self { + Self { + sent_packets: Default::default(), + sent_texts: Default::default(), + packet_sent: Arc::new(Notify::new()), + text_sent: Arc::new(Notify::new()), + remote_protocols: HashMap::new(), + server_ver: Some("1.8.0".to_string()), + } + } + + fn with_remote_protocol(mut self, identity: &str, protocol: i32) -> Self { + self.remote_protocols.insert(identity.to_string(), protocol); + self + } + + /// Wait until at least one packet has been sent. + async fn wait_for_packet(&self) { + self.packet_sent.notified().await; + } + + /// Wait until at least one text stream has been sent. + async fn wait_for_text(&self) { + self.text_sent.notified().await; + } + + /// Return all sent packets. + fn packets(&self) -> Vec { + self.sent_packets.lock().clone() + } + + /// Return all sent text streams as (body, options). + fn texts(&self) -> Vec<(String, StreamTextOptions)> { + self.sent_texts.lock().clone() + } + + /// Count packets matching a predicate on their `value`. + fn count_packets bool>( + &self, + f: F, + ) -> usize { + self.packets() + .iter() + .filter(|p| p.value.as_ref().map_or(false, &f)) + .count() + } + + /// Extract the request ID from the first RPC request packet or text stream. + fn extract_request_id(&self) -> String { + // Try v1 packets first + for p in self.packets() { + if let Some(proto::data_packet::Value::RpcRequest(req)) = &p.value + { + return req.id.clone(); + } + } + // Try v2 text streams + for (_, opts) in self.texts() { + if opts.topic == RPC_REQUEST_TOPIC { + if let Some(id) = opts.attributes.get(ATTR_REQUEST_ID) { + return id.clone(); + } + } + } + panic!("No RPC request found in mock transport"); + } +} + +impl RpcTransport for MockTransport { + async fn publish_data( + &self, + data: proto::DataPacket, + ) -> Result<(), RoomError> { + self.sent_packets.lock().push(data); + self.packet_sent.notify_waiters(); + Ok(()) + } + + async fn send_text( + &self, + text: &str, + options: StreamTextOptions, + ) -> StreamResult { + self.sent_texts + .lock() + .push((text.to_string(), options.clone())); + self.text_sent.notify_waiters(); + Ok(TextStreamInfo { + id: "mock-stream-id".to_string(), + topic: options.topic, + timestamp: Utc::now(), + total_length: Some(text.len() as u64), + attributes: options.attributes, + mime_type: "text/plain".to_string(), + operation_type: OperationType::Create, + version: 0, + reply_to_stream_id: None, + attached_stream_ids: vec![], + generated: false, + encryption_type: EncryptionType::None, + }) + } + + 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() + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn make_text_reader( + text: &str, + attributes: HashMap, + topic: &str, +) -> TextStreamReader { + let (tx, rx) = mpsc::unbounded_channel(); + tx.send(Ok(Bytes::from(text.to_string()))).unwrap(); + drop(tx); // close the stream + TextStreamReader::new_for_test( + TextStreamInfo { + id: "test-stream".to_string(), + topic: topic.to_string(), + timestamp: Utc::now(), + total_length: Some(text.len() as u64), + attributes, + mime_type: "text/plain".to_string(), + operation_type: OperationType::Create, + version: 0, + reply_to_stream_id: None, + attached_stream_ids: vec![], + generated: false, + encryption_type: EncryptionType::None, + }, + rx, + ) +} + +fn v2_request_attrs( + request_id: &str, + method: &str, + timeout_ms: u64, +) -> HashMap { + let mut attrs = HashMap::new(); + attrs.insert(ATTR_REQUEST_ID.to_string(), request_id.to_string()); + attrs.insert(ATTR_METHOD.to_string(), method.to_string()); + attrs.insert( + ATTR_RESPONSE_TIMEOUT_MS.to_string(), + timeout_ms.to_string(), + ); + attrs.insert(ATTR_VERSION.to_string(), "2".to_string()); + attrs +} + +fn v2_response_attrs(request_id: &str) -> HashMap { + let mut attrs = HashMap::new(); + attrs.insert(ATTR_REQUEST_ID.to_string(), request_id.to_string()); + attrs +} + +fn is_rpc_request_packet(v: &proto::data_packet::Value) -> bool { + matches!(v, proto::data_packet::Value::RpcRequest(_)) +} + +fn is_rpc_response_packet(v: &proto::data_packet::Value) -> bool { + matches!(v, proto::data_packet::Value::RpcResponse(_)) +} + +fn is_rpc_ack_packet(v: &proto::data_packet::Value) -> bool { + matches!(v, proto::data_packet::Value::RpcAck(_)) +} + +fn extract_response_error( + transport: &MockTransport, +) -> Option { + for p in transport.packets() { + if let Some(proto::data_packet::Value::RpcResponse(resp)) = &p.value { + if let Some(proto::rpc_response::Value::Error(e)) = &resp.value { + return Some(e.clone()); + } + } + } + None +} + +/// Run `perform_rpc` in a background task and return a handle. +/// +/// Uses `Arc` to share the client and transport safely across the spawn boundary. +async fn spawn_perform_rpc( + client: Arc, + transport: Arc, + data: PerformRpcData, +) -> tokio::task::JoinHandle> { + tokio::spawn(async move { client.perform_rpc(data, &*transport).await }) +} + +// ========================================================================= +// v2 -> v2 tests (both sides support data streams) +// ========================================================================= + +/// Spec #1: Caller happy path (short payload) — v2 data stream used. +#[tokio::test] +async fn test_v2_v2_caller_happy_path_short() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "greet".into(), + payload: "hello".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + // Wait for the request to be sent + transport.wait_for_text().await; + + // Verify: sent as v2 data stream, NOT a v1 packet + assert_eq!(transport.count_packets(is_rpc_request_packet), 0); + assert_eq!(transport.texts().len(), 1); + let (body, opts) = &transport.texts()[0]; + assert_eq!(opts.topic, RPC_REQUEST_TOPIC); + assert_eq!(body, "hello"); + assert_eq!(opts.attributes.get(ATTR_VERSION).unwrap(), "2"); + + let request_id = transport.extract_request_id(); + + // Simulate ACK + response + client.handle_ack(request_id.clone()); + client.handle_response(request_id, Some("world".into()), None); + + let result = handle.await.unwrap(); + assert_eq!(result.unwrap(), "world"); +} + +/// Spec #2: Caller happy path (large payload > 15 KB) — no size error. +#[tokio::test] +async fn test_v2_v2_caller_happy_path_large_payload() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + ); + + let large_payload = "x".repeat(20_000); + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "big".into(), + payload: large_payload, + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_text().await; + + let (body, _) = &transport.texts()[0]; + assert_eq!(body.len(), 20_000); + + let request_id = transport.extract_request_id(); + client.handle_ack(request_id.clone()); + client.handle_response(request_id, Some("ok".into()), None); + + let result = handle.await.unwrap(); + assert_eq!(result.unwrap(), "ok"); +} + +/// Spec #3: Handler happy path — response sent via v2 data stream. +#[tokio::test] +async fn test_v2_v2_handler_happy_path() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("echo".to_string(), |data| { + Box::pin(async move { Ok(data.payload) }) + }); + + let reader = make_text_reader( + "request-body", + v2_request_attrs("req-1", "echo", 5000), + RPC_REQUEST_TOPIC, + ); + + server + .handle_request_stream( + reader, + ParticipantIdentity("caller".into()), + &transport, + ) + .await; + + // ACK should be sent as v1 packet + assert_eq!(transport.count_packets(is_rpc_ack_packet), 1); + + // Success response should be sent as v2 data stream, NOT a v1 packet + assert_eq!(transport.count_packets(is_rpc_response_packet), 0); + assert_eq!(transport.texts().len(), 1); + let (body, opts) = &transport.texts()[0]; + assert_eq!(opts.topic, RPC_RESPONSE_TOPIC); + assert_eq!(body, "request-body"); // echo + assert_eq!(opts.attributes.get(ATTR_REQUEST_ID).unwrap(), "req-1"); +} + +/// Spec #4: Unhandled error in handler — error sent via v1 packet. +#[tokio::test] +async fn test_v2_v2_handler_unhandled_error() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("crash".to_string(), |_data| { + Box::pin(async move { + panic!("handler panic"); + }) + }); + + let reader = make_text_reader( + "payload", + v2_request_attrs("req-2", "crash", 5000), + RPC_REQUEST_TOPIC, + ); + + server + .handle_request_stream( + reader, + ParticipantIdentity("caller".into()), + &transport, + ) + .await; + + // Error responses always use v1 packets, even between v2 clients + assert_eq!(transport.count_packets(is_rpc_response_packet), 1); + assert_eq!(transport.texts().len(), 0); // no data stream response + + let err = extract_response_error(&transport).unwrap(); + assert_eq!(err.code, RpcErrorCode::ApplicationError as u32); +} + +/// Spec #5: RpcError passthrough in handler — custom error code preserved. +#[tokio::test] +async fn test_v2_v2_handler_rpc_error_passthrough() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("fail".to_string(), |_data| { + Box::pin(async move { + Err(RpcError::new(101, "custom".into(), Some("data".into()))) + }) + }); + + let reader = make_text_reader( + "payload", + v2_request_attrs("req-3", "fail", 5000), + RPC_REQUEST_TOPIC, + ); + + server + .handle_request_stream( + reader, + ParticipantIdentity("caller".into()), + &transport, + ) + .await; + + // Error sent as v1 packet + let err = extract_response_error(&transport).unwrap(); + assert_eq!(err.code, 101); + assert_eq!(err.message, "custom"); +} + +/// Spec #6: Response timeout — caller gives up after timeout. +#[tokio::test] +async fn test_v2_v2_response_timeout() { + let client = RpcClientManager::new(); + let transport = MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC); + + // Very short timeout — no ack or response will arrive. + // The ack timeout (7s) is larger than response_timeout (50ms), + // so connection timeout fires. + let result = client + .perform_rpc( + PerformRpcData { + destination_identity: "dest".into(), + method: "slow".into(), + payload: "x".into(), + response_timeout: Duration::from_millis(50), + }, + &transport, + ) + .await; + + let err = result.unwrap_err(); + assert_eq!(err.code, RpcErrorCode::ConnectionTimeout as u32); +} + +/// Spec #7: Error response — v1 error packet received by v2 caller. +#[tokio::test] +async fn test_v2_v2_error_response() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "err".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_text().await; + let request_id = transport.extract_request_id(); + + client.handle_ack(request_id.clone()); + // Error response arrives as v1 packet (per spec) + client.handle_response( + request_id, + None, + Some(proto::RpcError { + code: 101, + message: "nope".into(), + data: "details".into(), + }), + ); + + let result = handle.await.unwrap(); + let err = result.unwrap_err(); + assert_eq!(err.code, 101); + assert_eq!(err.message, "nope"); +} + +/// Spec #8: Participant disconnection — channel dropped before response. +#[tokio::test] +async fn test_v2_v2_participant_disconnection() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "dc".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_text().await; + let request_id = transport.extract_request_id(); + + // ACK arrives, then the responder disconnects (response channel dropped) + client.handle_ack(request_id.clone()); + // Simulate disconnect by dropping the pending response sender + client.drop_pending_response(&request_id); + + let result = handle.await.unwrap(); + let err = result.unwrap_err(); + assert_eq!(err.code, RpcErrorCode::RecipientDisconnected as u32); +} + +// ========================================================================= +// v2 -> v1 tests (v2 caller, v1 handler) +// ========================================================================= + +/// Spec #10: Caller falls back to v1 packet when remote is v1. +#[tokio::test] +async fn test_v2_v1_caller_request_fallback() { + let client = Arc::new(RpcClientManager::new()); + // Remote has client_protocol = 0 (v1 only) + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "greet".into(), + payload: "hi".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_packet().await; + + // Verify: sent as v1 packet, NOT a data stream + assert_eq!(transport.count_packets(is_rpc_request_packet), 1); + assert_eq!( + transport + .texts() + .iter() + .filter(|(_, o)| o.topic == RPC_REQUEST_TOPIC) + .count(), + 0 + ); + + let request_id = transport.extract_request_id(); + client.handle_ack(request_id.clone()); + client.handle_response(request_id, Some("yo".into()), None); + + let result = handle.await.unwrap(); + assert_eq!(result.unwrap(), "yo"); +} + +/// Spec #11: v1 handler receives v1 request and responds with v1 packet. +#[tokio::test] +async fn test_v2_v1_handler_v1_request() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("echo".to_string(), |data| { + Box::pin(async move { Ok(data.payload) }) + }); + + server + .handle_request( + ParticipantIdentity("caller".into()), + "req-v1".into(), + "echo".into(), + "v1-body".into(), + Duration::from_secs(5), + RPC_VERSION_V1, + &transport, + ) + .await; + + // ACK sent + assert_eq!(transport.count_packets(is_rpc_ack_packet), 1); + // Response sent as v1 packet (not data stream) + assert_eq!(transport.count_packets(is_rpc_response_packet), 1); + assert_eq!(transport.texts().len(), 0); + + // Verify response payload + for p in transport.packets() { + if let Some(proto::data_packet::Value::RpcResponse(resp)) = &p.value { + if let Some(proto::rpc_response::Value::Payload(payload)) = + &resp.value + { + assert_eq!(payload, "v1-body"); + } + } + } +} + +/// Spec #12: Payload too large rejected for v1 remote. +#[tokio::test] +async fn test_v2_v1_payload_too_large() { + let client = RpcClientManager::new(); + let transport = MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); + + let large_payload = "x".repeat(MAX_PAYLOAD_BYTES + 1); + let result = client + .perform_rpc( + PerformRpcData { + destination_identity: "dest".into(), + method: "big".into(), + payload: large_payload, + response_timeout: Duration::from_secs(5), + }, + &transport, + ) + .await; + + let err = result.unwrap_err(); + assert_eq!(err.code, RpcErrorCode::RequestPayloadTooLarge as u32); +} + +/// Spec #13: Response timeout with v1 remote. +#[tokio::test] +async fn test_v2_v1_response_timeout() { + let client = RpcClientManager::new(); + let transport = MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); + + let result = client + .perform_rpc( + PerformRpcData { + destination_identity: "dest".into(), + method: "slow".into(), + payload: "x".into(), + response_timeout: Duration::from_millis(50), + }, + &transport, + ) + .await; + + let err = result.unwrap_err(); + assert_eq!(err.code, RpcErrorCode::ConnectionTimeout as u32); +} + +/// Spec #14: Error response from v1 handler. +#[tokio::test] +async fn test_v2_v1_error_response() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "err".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_packet().await; + let request_id = transport.extract_request_id(); + + client.handle_ack(request_id.clone()); + client.handle_response( + request_id, + None, + Some(proto::RpcError { + code: 101, + message: "v1-err".into(), + data: String::new(), + }), + ); + + let result = handle.await.unwrap(); + let err = result.unwrap_err(); + assert_eq!(err.code, 101); + assert_eq!(err.message, "v1-err"); +} + +/// Spec #15: Participant disconnection with v1 remote. +#[tokio::test] +async fn test_v2_v1_participant_disconnection() { + let client = Arc::new(RpcClientManager::new()); + let transport = Arc::new( + MockTransport::new() + .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), + ); + + let handle = spawn_perform_rpc( + client.clone(), + transport.clone(), + PerformRpcData { + destination_identity: "dest".into(), + method: "dc".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + }, + ) + .await; + + transport.wait_for_packet().await; + let request_id = transport.extract_request_id(); + + client.handle_ack(request_id.clone()); + // Simulate disconnect by dropping the pending response sender + client.drop_pending_response(&request_id); + + let result = handle.await.unwrap(); + let err = result.unwrap_err(); + assert_eq!(err.code, RpcErrorCode::RecipientDisconnected as u32); +} + +// ========================================================================= +// v1 -> v2 tests (v1 caller, v2 handler) +// ========================================================================= + +/// Spec #16: v2 handler responds with v1 packet when request was v1. +#[tokio::test] +async fn test_v1_v2_handler_response_fallback() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("echo".to_string(), |data| { + Box::pin(async move { Ok(data.payload) }) + }); + + // v1 caller sends a v1 packet request to our v2 handler + server + .handle_request( + ParticipantIdentity("v1-caller".into()), + "req-v1-to-v2".into(), + "echo".into(), + "hello-from-v1".into(), + Duration::from_secs(5), + RPC_VERSION_V1, + &transport, + ) + .await; + + // ACK via v1 packet + assert_eq!(transport.count_packets(is_rpc_ack_packet), 1); + // Response via v1 packet (not data stream), even though handler supports v2 + assert_eq!(transport.count_packets(is_rpc_response_packet), 1); + assert_eq!(transport.texts().len(), 0); +} + +/// Spec #17: Unhandled error in v2 handler for v1 caller — APPLICATION_ERROR. +#[tokio::test] +async fn test_v1_v2_handler_unhandled_error() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("crash".to_string(), |_data| { + Box::pin(async move { + panic!("boom"); + }) + }); + + server + .handle_request( + ParticipantIdentity("v1-caller".into()), + "req-crash".into(), + "crash".into(), + "x".into(), + Duration::from_secs(5), + RPC_VERSION_V1, + &transport, + ) + .await; + + let err = extract_response_error(&transport).unwrap(); + assert_eq!(err.code, RpcErrorCode::ApplicationError as u32); +} + +/// Spec #18: RpcError passthrough in v2 handler for v1 caller. +#[tokio::test] +async fn test_v1_v2_handler_rpc_error_passthrough() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + server.register_method("fail".to_string(), |_data| { + Box::pin(async move { + Err(RpcError::new( + 101, + "custom-err".into(), + Some("extra".into()), + )) + }) + }); + + server + .handle_request( + ParticipantIdentity("v1-caller".into()), + "req-fail".into(), + "fail".into(), + "x".into(), + Duration::from_secs(5), + 1, + &transport, + ) + .await; + + let err = extract_response_error(&transport).unwrap(); + assert_eq!(err.code, 101); + assert_eq!(err.message, "custom-err"); +} + +// ========================================================================= +// Additional tests +// ========================================================================= + +/// Verify handle_response_stream resolves the pending caller correctly. +#[tokio::test] +async fn test_v2_response_stream_resolves_caller() { + let client = RpcClientManager::new(); + + // Manually register a pending response + let (tx, rx) = tokio::sync::oneshot::channel(); + client.insert_pending_response("req-stream".to_string(), tx); + + let reader = make_text_reader( + "stream-result", + v2_response_attrs("req-stream"), + RPC_RESPONSE_TOPIC, + ); + + client.handle_response_stream(reader).await; + + let result: Result = rx.await.unwrap(); + assert_eq!(result.unwrap(), "stream-result"); +} + +/// Verify unregistered method returns UNSUPPORTED_METHOD error via v2 path. +#[tokio::test] +async fn test_v2_handler_unsupported_method() { + let server = RpcServerManager::new(); + let transport = MockTransport::new(); + + let reader = make_text_reader( + "payload", + v2_request_attrs("req-unsup", "nonexistent", 5000), + RPC_REQUEST_TOPIC, + ); + + server + .handle_request_stream( + reader, + ParticipantIdentity("caller".into()), + &transport, + ) + .await; + + let err = extract_response_error(&transport).unwrap(); + assert_eq!(err.code, RpcErrorCode::UnsupportedMethod as u32); +} From 254692005e859823c2ee592cff68b9b2981eac62 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 11:44:49 -0400 Subject: [PATCH 04/18] feat: rename files and abstract magic numbers into constants --- livekit/src/room/rpc/{caller.rs => client.rs} | 5 +++-- livekit/src/room/rpc/mod.rs | 13 +++++++++---- livekit/src/room/rpc/{handler.rs => server.rs} | 8 ++++---- 3 files changed, 16 insertions(+), 10 deletions(-) rename livekit/src/room/rpc/{caller.rs => client.rs} (98%) rename livekit/src/room/rpc/{handler.rs => server.rs} (97%) diff --git a/livekit/src/room/rpc/caller.rs b/livekit/src/room/rpc/client.rs similarity index 98% rename from livekit/src/room/rpc/caller.rs rename to livekit/src/room/rpc/client.rs index 9ee7a22b1..0a7ebcd26 100644 --- a/livekit/src/room/rpc/caller.rs +++ b/livekit/src/room/rpc/client.rs @@ -16,6 +16,7 @@ use super::{ PerformRpcData, RpcError, RpcErrorCode, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, CLIENT_PROTOCOL_DATA_STREAM_RPC, MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, + RPC_VERSION_V1, RPC_VERSION_V2, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; @@ -117,7 +118,7 @@ impl RpcClientManager { &data.method, &data.payload, effective_timeout, - 1, // version + RPC_VERSION_V1, ) .await .map_err(|e| { @@ -209,7 +210,7 @@ impl RpcClientManager { response_timeout.as_millis().to_string(), ); attributes - .insert(ATTR_VERSION.to_string(), "2".to_string()); + .insert(ATTR_VERSION.to_string(), RPC_VERSION_V2.to_string()); let options = StreamTextOptions { topic: RPC_REQUEST_TOPIC.to_string(), diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 1a8166282..cb1af7db4 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -12,14 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -mod caller; -mod handler; +mod client; +mod server; #[cfg(test)] mod tests; -pub use caller::RpcClientManager; -pub use handler::RpcServerManager; +pub use client::RpcClientManager; +pub use server::RpcServerManager; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; @@ -30,6 +30,11 @@ use std::{error::Error, fmt::Display, future::Future, time::Duration}; pub(crate) const CLIENT_PROTOCOL_DEFAULT: i32 = 0; pub(crate) const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; +// RPC protocol version constants (distinct from client_protocol; this is the +// version field on RpcRequest / v2 stream attributes). +pub(crate) const RPC_VERSION_V1: u32 = 1; +pub(crate) const RPC_VERSION_V2: u32 = 2; + // Data stream topic constants for RPC v2 pub(crate) const RPC_REQUEST_TOPIC: &str = "lk.rpc_request"; pub(crate) const RPC_RESPONSE_TOPIC: &str = "lk.rpc_response"; diff --git a/livekit/src/room/rpc/handler.rs b/livekit/src/room/rpc/server.rs similarity index 97% rename from livekit/src/room/rpc/handler.rs rename to livekit/src/room/rpc/server.rs index 6e94a420c..1ca852859 100644 --- a/livekit/src/room/rpc/handler.rs +++ b/livekit/src/room/rpc/server.rs @@ -12,11 +12,11 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::caller::{publish_rpc_ack, publish_rpc_response}; +use super::client::{publish_rpc_ack, publish_rpc_response}; use super::{ RpcError, RpcErrorCode, RpcInvocationData, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, - MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, + MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, RPC_VERSION_V1, RPC_VERSION_V2, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; @@ -92,7 +92,7 @@ impl RpcServerManager { log::error!("Failed to publish RPC ACK: {:?}", e); } - let response = if version != 1 { + let response = if version != RPC_VERSION_V1 { Err(RpcError::built_in( RpcErrorCode::UnsupportedVersion, None, @@ -174,7 +174,7 @@ impl RpcServerManager { log::error!("Failed to publish RPC ACK: {:?}", e); } - if version != 2 { + if version != RPC_VERSION_V2 { let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); let _ = publish_rpc_response( From d7879c2dba3ee08a70e44a76550a85249592fe77 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 14:31:16 -0400 Subject: [PATCH 05/18] fix: move client protocol definitions to livekit-api --- livekit-api/src/signal_client/mod.rs | 22 ++++++++++++++++++---- livekit/src/room/mod.rs | 4 ++-- livekit/src/room/rpc/mod.rs | 4 ---- livekit/src/room/rpc/tests.rs | 1 + 4 files changed, 21 insertions(+), 10 deletions(-) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 0d05195da..591f24077 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -54,6 +54,12 @@ const REGION_FETCH_TIMEOUT: Duration = Duration::from_secs(3); const VALIDATE_TIMEOUT: Duration = Duration::from_secs(3); pub const PROTOCOL_VERSION: u32 = 17; +/// 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; + #[derive(Error, Debug)] pub enum SignalError { #[error("ws failure: {0}")] @@ -83,14 +89,19 @@ pub enum SignalError { pub struct SignalSdkOptions { pub sdk: String, pub sdk_version: Option, - /// Override the client_protocol advertised during join. - /// If None, uses the default (currently 1 = data stream RPC support). + /// Override the client_protocol advertised during join. If `None`, falls back + /// to `CLIENT_PROTOCOL_DEFAULT` (0). The SDK's default constructor sets this + /// to `CLIENT_PROTOCOL_DATA_STREAM_RPC` (1) to advertise data-stream RPC support. pub client_protocol: Option, } impl Default for SignalSdkOptions { fn default() -> Self { - Self { sdk: "rust".to_string(), sdk_version: None, client_protocol: None } + Self { + sdk: "rust".to_string(), + sdk_version: None, + client_protocol: Some(CLIENT_PROTOCOL_DATA_STREAM_RPC), + } } } @@ -574,7 +585,7 @@ fn create_join_request_param( os, os_version, device_model, - client_protocol: options.sdk_options.client_protocol.unwrap_or(1), + client_protocol: options.sdk_options.client_protocol.unwrap_or(CLIENT_PROTOCOL_DEFAULT), ..Default::default() }; @@ -664,6 +675,8 @@ fn get_livekit_url( lk_url.query_pairs_mut().append_pair("join_request", &join_request_param); } else { // For v0 path (dual PC mode): use URL query parameters + let client_protocol = + options.sdk_options.client_protocol.unwrap_or(CLIENT_PROTOCOL_DEFAULT); lk_url .query_pairs_mut() .append_pair("sdk", options.sdk_options.sdk.as_str()) @@ -671,6 +684,7 @@ fn get_livekit_url( .append_pair("os_version", os_info.version().to_string().as_str()) .append_pair("device_model", device_model.to_string().as_str()) .append_pair("protocol", PROTOCOL_VERSION.to_string().as_str()) + .append_pair("client_protocol", client_protocol.to_string().as_str()) .append_pair("auto_subscribe", if options.auto_subscribe { "1" } else { "0" }) .append_pair("adaptive_stream", if options.adaptive_stream { "1" } else { "0" }); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 53fc7f3d2..2683a8365 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -23,7 +23,7 @@ use libwebrtc::{ rtp_transceiver::RtpTransceiver, RtcError, }; -use livekit_api::signal_client::{SignalOptions, SignalSdkOptions, SIGNAL_CONNECT_TIMEOUT}; +use livekit_api::signal_client::{SignalOptions, SignalSdkOptions, SIGNAL_CONNECT_TIMEOUT, CLIENT_PROTOCOL_DEFAULT}; use livekit_datatrack::{ api::{DataTrackSid, RemoteDataTrack}, backend as dt, @@ -1987,7 +1987,7 @@ impl RoomSession { .read() .get(identity) .map(|p| p.client_protocol()) - .unwrap_or(rpc::CLIENT_PROTOCOL_DEFAULT) + .unwrap_or(CLIENT_PROTOCOL_DEFAULT) } fn get_local_or_remote_participant( diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index cb1af7db4..8c6d7f97e 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -26,10 +26,6 @@ use crate::room::id::ParticipantIdentity; use livekit_protocol::RpcError as RpcError_Proto; use std::{error::Error, fmt::Display, future::Future, time::Duration}; -// Client protocol version constants -pub(crate) const CLIENT_PROTOCOL_DEFAULT: i32 = 0; -pub(crate) const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; - // RPC protocol version constants (distinct from client_protocol; this is the // version field on RpcRequest / v2 stream attributes). pub(crate) const RPC_VERSION_V1: u32 = 1; diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index eb4ae2276..6138e2ad3 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -9,6 +9,7 @@ use crate::room::RoomError; use bytes::Bytes; use chrono::Utc; use livekit_protocol as proto; +use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; use std::sync::Arc; From 1713fd1e1747e77a5d4d56905c8c153dcc4391a2 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 15:57:59 -0400 Subject: [PATCH 06/18] fix: ensure CLIENT_PROTOCOL_DATA_STREAM_RPC is set in RoomSdkOptions::default() --- livekit/src/room/mod.rs | 16 ++++++++++++---- livekit/src/room/rpc/client.rs | 3 ++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 2683a8365..0ff599a35 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -23,7 +23,10 @@ use libwebrtc::{ rtp_transceiver::RtpTransceiver, RtcError, }; -use livekit_api::signal_client::{SignalOptions, SignalSdkOptions, SIGNAL_CONNECT_TIMEOUT, CLIENT_PROTOCOL_DEFAULT}; +use livekit_api::signal_client::{ + SignalOptions, SignalSdkOptions, CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT, + SIGNAL_CONNECT_TIMEOUT, +}; use livekit_datatrack::{ api::{DataTrackSid, RemoteDataTrack}, backend as dt, @@ -335,14 +338,19 @@ pub struct ChatMessage { pub struct RoomSdkOptions { pub sdk: String, pub sdk_version: String, - /// Override the client_protocol advertised during join. - /// If None, uses the default (currently 1 = data stream RPC support). + /// Override the client_protocol advertised during join. If `None`, falls back + /// to `CLIENT_PROTOCOL_DEFAULT` (0). The default constructor sets this to + /// `CLIENT_PROTOCOL_DATA_STREAM_RPC` (1) to advertise data-stream RPC support. pub client_protocol: Option, } impl Default for RoomSdkOptions { fn default() -> Self { - Self { sdk: "rust".to_string(), sdk_version: SDK_VERSION.to_string(), client_protocol: None } + Self { + sdk: "rust".to_string(), + sdk_version: SDK_VERSION.to_string(), + client_protocol: Some(CLIENT_PROTOCOL_DATA_STREAM_RPC), + } } } diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index 0a7ebcd26..31689cbe1 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.rs @@ -15,13 +15,14 @@ use super::{ PerformRpcData, RpcError, RpcErrorCode, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, - CLIENT_PROTOCOL_DATA_STREAM_RPC, MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, + MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, RPC_VERSION_V1, RPC_VERSION_V2, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; use libwebrtc::native::create_random_uuid; use livekit_protocol as proto; +use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_RPC; use parking_lot::Mutex; use semver::Version; use std::collections::HashMap; From d97de53ea311a8ec7776422f38730854e077af07 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 16:16:49 -0400 Subject: [PATCH 07/18] fix: add missing license header --- livekit/src/room/rpc/tests.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index 6138e2ad3..b04a29a44 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -1,3 +1,17 @@ +// 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::*; use crate::data_stream::{ OperationType, StreamResult, StreamTextOptions, TextStreamInfo, From 3980b104bef9cb40e75ffaa2628884b311e3788a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 13 Apr 2026 16:19:25 -0400 Subject: [PATCH 08/18] Create wip_support_for_large_rpc_messages_using_data_streams.md --- ...ip_support_for_large_rpc_messages_using_data_streams.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/wip_support_for_large_rpc_messages_using_data_streams.md diff --git a/.changeset/wip_support_for_large_rpc_messages_using_data_streams.md b/.changeset/wip_support_for_large_rpc_messages_using_data_streams.md new file mode 100644 index 000000000..64807aeee --- /dev/null +++ b/.changeset/wip_support_for_large_rpc_messages_using_data_streams.md @@ -0,0 +1,7 @@ +--- +livekit: patch +livekit-api: patch +livekit-ffi: patch +--- + +Support for large RPC messages using data streams - #1013 (@1egoman) From 7fc7b17f279e0dabf21ce56c78aa77b8307ef28f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:05:21 -0400 Subject: [PATCH 09/18] fix: address incorrect license header years --- livekit/src/room/rpc/client.rs | 2 +- livekit/src/room/rpc/mod.rs | 2 +- livekit/src/room/rpc/server.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index 31689cbe1..b8ce78bb0 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.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. diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 8c6d7f97e..a11f4f214 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/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. diff --git a/livekit/src/room/rpc/server.rs b/livekit/src/room/rpc/server.rs index 1ca852859..ba9887df1 100644 --- a/livekit/src/room/rpc/server.rs +++ b/livekit/src/room/rpc/server.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. From 4e638072c60186ec8f85d96d5742317f9f7606b3 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:21:51 -0400 Subject: [PATCH 10/18] refactor: move advertised client protocol into constant It doesn't need to be drilled through as an option, it's fundamentally tied to the broader code context it is part of. --- livekit-api/src/signal_client/mod.rs | 15 ++++++--------- livekit/src/room/mod.rs | 6 ------ 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 591f24077..d7820150a 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -60,6 +60,10 @@ 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; +/// The client protocol which is sent to other clients and indicates the set of apis that other +/// clients should assume this client supports. +const ADVERTISED_CLIENT_PROTOCOL: i32 = CLIENT_PROTOCOL_DATA_STREAM_RPC; + #[derive(Error, Debug)] pub enum SignalError { #[error("ws failure: {0}")] @@ -89,10 +93,6 @@ pub enum SignalError { pub struct SignalSdkOptions { pub sdk: String, pub sdk_version: Option, - /// Override the client_protocol advertised during join. If `None`, falls back - /// to `CLIENT_PROTOCOL_DEFAULT` (0). The SDK's default constructor sets this - /// to `CLIENT_PROTOCOL_DATA_STREAM_RPC` (1) to advertise data-stream RPC support. - pub client_protocol: Option, } impl Default for SignalSdkOptions { @@ -100,7 +100,6 @@ impl Default for SignalSdkOptions { Self { sdk: "rust".to_string(), sdk_version: None, - client_protocol: Some(CLIENT_PROTOCOL_DATA_STREAM_RPC), } } } @@ -585,7 +584,7 @@ fn create_join_request_param( os, os_version, device_model, - client_protocol: options.sdk_options.client_protocol.unwrap_or(CLIENT_PROTOCOL_DEFAULT), + client_protocol: ADVERTISED_CLIENT_PROTOCOL, ..Default::default() }; @@ -675,8 +674,6 @@ fn get_livekit_url( lk_url.query_pairs_mut().append_pair("join_request", &join_request_param); } else { // For v0 path (dual PC mode): use URL query parameters - let client_protocol = - options.sdk_options.client_protocol.unwrap_or(CLIENT_PROTOCOL_DEFAULT); lk_url .query_pairs_mut() .append_pair("sdk", options.sdk_options.sdk.as_str()) @@ -684,7 +681,7 @@ fn get_livekit_url( .append_pair("os_version", os_info.version().to_string().as_str()) .append_pair("device_model", device_model.to_string().as_str()) .append_pair("protocol", PROTOCOL_VERSION.to_string().as_str()) - .append_pair("client_protocol", client_protocol.to_string().as_str()) + .append_pair("client_protocol", ADVERTISED_CLIENT_PROTOCOL.to_string().as_str()) .append_pair("auto_subscribe", if options.auto_subscribe { "1" } else { "0" }) .append_pair("adaptive_stream", if options.adaptive_stream { "1" } else { "0" }); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 0ff599a35..8e2eac860 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -338,10 +338,6 @@ pub struct ChatMessage { pub struct RoomSdkOptions { pub sdk: String, pub sdk_version: String, - /// Override the client_protocol advertised during join. If `None`, falls back - /// to `CLIENT_PROTOCOL_DEFAULT` (0). The default constructor sets this to - /// `CLIENT_PROTOCOL_DATA_STREAM_RPC` (1) to advertise data-stream RPC support. - pub client_protocol: Option, } impl Default for RoomSdkOptions { @@ -349,7 +345,6 @@ impl Default for RoomSdkOptions { Self { sdk: "rust".to_string(), sdk_version: SDK_VERSION.to_string(), - client_protocol: Some(CLIENT_PROTOCOL_DATA_STREAM_RPC), } } } @@ -359,7 +354,6 @@ impl From for SignalSdkOptions { let mut sdk_options = SignalSdkOptions::default(); sdk_options.sdk = options.sdk; sdk_options.sdk_version = Some(options.sdk_version); - sdk_options.client_protocol = options.client_protocol; sdk_options } } From 702ca0ccd7cff18dc08db5c46d9dc83588c08d65 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:26:39 -0400 Subject: [PATCH 11/18] fix: run cargo fmt --- livekit/src/room/mod.rs | 10 +- livekit/src/room/rpc/client.rs | 143 +++++++------------------ livekit/src/room/rpc/mod.rs | 23 ++-- livekit/src/room/rpc/server.rs | 147 ++++++------------------- livekit/src/room/rpc/tests.rs | 189 ++++++++------------------------- 5 files changed, 125 insertions(+), 387 deletions(-) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 8e2eac860..25e978731 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -342,10 +342,7 @@ pub struct RoomSdkOptions { impl Default for RoomSdkOptions { fn default() -> Self { - Self { - sdk: "rust".to_string(), - sdk_version: SDK_VERSION.to_string(), - } + Self { sdk: "rust".to_string(), sdk_version: SDK_VERSION.to_string() } } } @@ -1981,10 +1978,7 @@ impl RoomSession { self.remote_participants.read().get(identity).cloned() } - pub(crate) fn get_remote_client_protocol( - &self, - identity: &ParticipantIdentity, - ) -> i32 { + pub(crate) fn get_remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { self.remote_participants .read() .get(identity) diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index b8ce78bb0..fb82355d3 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.rs @@ -13,16 +13,15 @@ // limitations under the License. use super::{ - PerformRpcData, RpcError, RpcErrorCode, RpcTransport, ATTR_METHOD, - ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, - MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, - RPC_VERSION_V1, RPC_VERSION_V2, + PerformRpcData, RpcError, RpcErrorCode, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, + ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, MAX_PAYLOAD_BYTES, RPC_REQUEST_TOPIC, RPC_VERSION_V1, + RPC_VERSION_V2, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; use libwebrtc::native::create_random_uuid; -use livekit_protocol as proto; use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_RPC; +use livekit_protocol as proto; use parking_lot::Mutex; use semver::Version; use std::collections::HashMap; @@ -35,8 +34,7 @@ use tokio::sync::oneshot; /// transport selection based on the remote participant's client protocol. pub struct RpcClientManager { pending_acks: Mutex>>, - pending_responses: - Mutex>>>, + pending_responses: Mutex>>>, } impl RpcClientManager { @@ -63,25 +61,18 @@ impl RpcClientManager { let server_version = Version::parse(&version_str).unwrap(); let min_required_version = Version::parse("1.8.0").unwrap(); if server_version < min_required_version { - return Err(RpcError::built_in( - RpcErrorCode::UnsupportedServer, - None, - )); + return Err(RpcError::built_in(RpcErrorCode::UnsupportedServer, None)); } } // Determine transport version based on remote participant's client_protocol - let remote_protocol = transport.remote_client_protocol( - &ParticipantIdentity(data.destination_identity.clone()), - ); + let remote_protocol = transport + .remote_client_protocol(&ParticipantIdentity(data.destination_identity.clone())); let use_v2 = remote_protocol >= CLIENT_PROTOCOL_DATA_STREAM_RPC; // Only enforce payload size limit for v1 transport if !use_v2 && data.payload.len() > MAX_PAYLOAD_BYTES { - return Err(RpcError::built_in( - RpcErrorCode::RequestPayloadTooLarge, - None, - )); + return Err(RpcError::built_in(RpcErrorCode::RequestPayloadTooLarge, None)); } let id = create_random_uuid(); @@ -122,12 +113,7 @@ impl RpcClientManager { RPC_VERSION_V1, ) .await - .map_err(|e| { - RpcError::built_in( - RpcErrorCode::SendFailed, - Some(e.to_string()), - ) - }) + .map_err(|e| RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string()))) }; if let Err(e) = send_result { @@ -147,10 +133,7 @@ impl RpcClientManager { let mut pending_responses = self.pending_responses.lock(); pending_acks.remove(&id); pending_responses.remove(&id); - return Err(RpcError::built_in( - RpcErrorCode::ConnectionTimeout, - None, - )); + return Err(RpcError::built_in(RpcErrorCode::ConnectionTimeout, None)); } Ok(_) => { // Ack received, continue to wait for response @@ -158,27 +141,18 @@ impl RpcClientManager { } // Wait for response timeout - let response = - match tokio::time::timeout(data.response_timeout, response_rx) - .await - { - Err(_) => { - self.pending_responses.lock().remove(&id); - return Err(RpcError::built_in( - RpcErrorCode::ResponseTimeout, - None, - )); - } - Ok(result) => result, - }; + let response = match tokio::time::timeout(data.response_timeout, response_rx).await { + Err(_) => { + self.pending_responses.lock().remove(&id); + return Err(RpcError::built_in(RpcErrorCode::ResponseTimeout, None)); + } + Ok(result) => result, + }; match response { Err(_) => { // Channel closed — sender dropped (e.g. disconnect) - Err(RpcError::built_in( - RpcErrorCode::RecipientDisconnected, - None, - )) + Err(RpcError::built_in(RpcErrorCode::RecipientDisconnected, None)) } Ok(Err(e)) => { // RPC error from remote, forward it @@ -202,23 +176,16 @@ impl RpcClientManager { response_timeout: Duration, ) -> Result<(), RpcError> { let mut attributes = HashMap::new(); + attributes.insert(ATTR_REQUEST_ID.to_string(), id.to_string()); + attributes.insert(ATTR_METHOD.to_string(), method.to_string()); attributes - .insert(ATTR_REQUEST_ID.to_string(), id.to_string()); - attributes - .insert(ATTR_METHOD.to_string(), method.to_string()); - attributes.insert( - ATTR_RESPONSE_TIMEOUT_MS.to_string(), - response_timeout.as_millis().to_string(), - ); - attributes - .insert(ATTR_VERSION.to_string(), RPC_VERSION_V2.to_string()); + .insert(ATTR_RESPONSE_TIMEOUT_MS.to_string(), response_timeout.as_millis().to_string()); + attributes.insert(ATTR_VERSION.to_string(), RPC_VERSION_V2.to_string()); let options = StreamTextOptions { topic: RPC_REQUEST_TOPIC.to_string(), attributes, - destination_identities: vec![ParticipantIdentity( - destination_identity.to_string(), - )], + destination_identities: vec![ParticipantIdentity(destination_identity.to_string())], ..Default::default() }; @@ -226,12 +193,7 @@ impl RpcClientManager { .send_text(payload, options) .await .map(|_| ()) - .map_err(|e| { - RpcError::built_in( - RpcErrorCode::SendFailed, - Some(e.to_string()), - ) - }) + .map_err(|e| RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string()))) } /// Drop the pending response sender for a request, simulating a disconnect. @@ -255,10 +217,7 @@ impl RpcClientManager { if let Some(tx) = pending.remove(&request_id) { let _ = tx.send(()); } else { - log::error!( - "Ack received for unexpected RPC request: {}", - request_id - ); + log::error!("Ack received for unexpected RPC request: {}", request_id); } } @@ -279,10 +238,7 @@ impl RpcClientManager { None => Ok(payload.unwrap_or_default()), }); } else { - log::error!( - "Response received for unexpected RPC request: {}", - request_id - ); + log::error!("Response received for unexpected RPC request: {}", request_id); } } @@ -291,40 +247,24 @@ impl RpcClientManager { /// Success responses between v2 clients arrive as text data streams /// on the `lk.rpc_response` topic. Error responses always arrive /// as v1 packets and are handled by `handle_response`. - pub(crate) async fn handle_response_stream( - &self, - reader: TextStreamReader, - ) { - let request_id = reader - .info() - .attributes - .get(ATTR_REQUEST_ID) - .cloned() - .unwrap_or_default(); + pub(crate) async fn handle_response_stream(&self, reader: TextStreamReader) { + let request_id = reader.info().attributes.get(ATTR_REQUEST_ID).cloned().unwrap_or_default(); if request_id.is_empty() { - log::error!( - "RPC v2 response stream missing request_id attribute" - ); + log::error!("RPC v2 response stream missing request_id attribute"); return; } let payload = match reader.read_all().await { Ok(payload) => payload, Err(e) => { - log::error!( - "Failed to read RPC v2 response stream: {:?}", - e - ); + log::error!("Failed to read RPC v2 response stream: {:?}", e); // Resolve with error so the caller doesn't hang let mut pending = self.pending_responses.lock(); if let Some(tx) = pending.remove(&request_id) { let _ = tx.send(Err(RpcError::built_in( RpcErrorCode::ApplicationError, - Some(format!( - "Failed to read response stream: {}", - e - )), + Some(format!("Failed to read response stream: {}", e)), ))); } return; @@ -335,10 +275,7 @@ impl RpcClientManager { if let Some(tx) = pending.remove(&request_id) { let _ = tx.send(Ok(payload)); } else { - log::error!( - "Response stream received for unexpected RPC request: {}", - request_id - ); + log::error!("Response stream received for unexpected RPC request: {}", request_id); } } } @@ -363,9 +300,7 @@ pub(crate) async fn publish_rpc_request( }; let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcRequest( - rpc_request_message, - )), + value: Some(proto::data_packet::Value::RpcRequest(rpc_request_message)), destination_identities: vec![destination_identity.to_string()], ..Default::default() }; @@ -391,9 +326,7 @@ pub(crate) async fn publish_rpc_response( }; let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcResponse( - rpc_response_message, - )), + value: Some(proto::data_packet::Value::RpcResponse(rpc_response_message)), destination_identities: vec![destination_identity.to_string()], ..Default::default() }; @@ -407,10 +340,8 @@ pub(crate) async fn publish_rpc_ack( destination_identity: &str, request_id: &str, ) -> Result<(), crate::room::RoomError> { - let rpc_ack_message = proto::RpcAck { - request_id: request_id.to_string(), - ..Default::default() - }; + let rpc_ack_message = + proto::RpcAck { request_id: request_id.to_string(), ..Default::default() }; let data = proto::DataPacket { value: Some(proto::data_packet::Value::RpcAck(rpc_ack_message)), diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index a11f4f214..215bfaa56 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -38,8 +38,7 @@ pub(crate) const RPC_RESPONSE_TOPIC: &str = "lk.rpc_response"; // Stream attribute keys for RPC v2 pub(crate) const ATTR_REQUEST_ID: &str = "lk.rpc_request_id"; 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_RESPONSE_TIMEOUT_MS: &str = "lk.rpc_request_response_timeout_ms"; pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; /// Transport abstraction for RPC operations. @@ -95,19 +94,13 @@ impl RpcTransport for SessionTransport { } fn server_version(&self) -> Option { - self.0 - .rtc_engine - .session() - .signal_client() - .join_response() - .server_info - .and_then(|info| { - if info.version.is_empty() { - None - } else { - Some(info.version) - } - }) + self.0.rtc_engine.session().signal_client().join_response().server_info.and_then(|info| { + if info.version.is_empty() { + None + } else { + Some(info.version) + } + }) } } diff --git a/livekit/src/room/rpc/server.rs b/livekit/src/room/rpc/server.rs index ba9887df1..149019346 100644 --- a/livekit/src/room/rpc/server.rs +++ b/livekit/src/room/rpc/server.rs @@ -14,23 +14,17 @@ use super::client::{publish_rpc_ack, publish_rpc_response}; use super::{ - RpcError, RpcErrorCode, RpcInvocationData, RpcTransport, ATTR_METHOD, - ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, - MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, RPC_VERSION_V1, RPC_VERSION_V2, + RpcError, RpcErrorCode, RpcInvocationData, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, + ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, RPC_VERSION_V1, + RPC_VERSION_V2, }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; use parking_lot::Mutex; -use std::{ - collections::HashMap, future::Future, pin::Pin, sync::Arc, - time::Duration, -}; +use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; pub(crate) type RpcHandlerFn = Arc< - dyn Fn( - RpcInvocationData, - ) - -> Pin> + Send>> + dyn Fn(RpcInvocationData) -> Pin> + Send>> + Send + Sync, >; @@ -52,11 +46,8 @@ impl RpcServerManager { pub fn register_method( &self, method: String, - handler: impl Fn( - RpcInvocationData, - ) -> Pin< - Box> + Send>, - > + Send + handler: impl Fn(RpcInvocationData) -> Pin> + Send>> + + Send + Sync + 'static, ) { @@ -86,55 +77,31 @@ impl RpcServerManager { transport: &(impl RpcTransport + 'static), ) { // Send ACK immediately - if let Err(e) = - publish_rpc_ack(transport, &caller_identity.0, &request_id).await - { + if let Err(e) = publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } let response = if version != RPC_VERSION_V1 { - Err(RpcError::built_in( - RpcErrorCode::UnsupportedVersion, - None, - )) + Err(RpcError::built_in(RpcErrorCode::UnsupportedVersion, None)) } else { - self.invoke_handler( - &caller_identity, - &request_id, - &method, - &payload, - response_timeout, - ) - .await + self.invoke_handler(&caller_identity, &request_id, &method, &payload, response_timeout) + .await }; let (resp_payload, error) = match response { - Ok(response_payload) - if response_payload.len() <= MAX_PAYLOAD_BYTES => - { + Ok(response_payload) if response_payload.len() <= MAX_PAYLOAD_BYTES => { (Some(response_payload), None) } Ok(_) => ( None, - Some( - RpcError::built_in( - RpcErrorCode::ResponsePayloadTooLarge, - None, - ) - .to_proto(), - ), + Some(RpcError::built_in(RpcErrorCode::ResponsePayloadTooLarge, None).to_proto()), ), Err(e) => (None, Some(e.to_proto())), }; - if let Err(e) = publish_rpc_response( - transport, - &caller_identity.0, - &request_id, - resp_payload, - error, - ) - .await + if let Err(e) = + publish_rpc_response(transport, &caller_identity.0, &request_id, resp_payload, error) + .await { log::error!("Failed to publish RPC response: {:?}", e); } @@ -153,30 +120,21 @@ impl RpcServerManager { ) { let attrs = &reader.info().attributes; - let request_id = - attrs.get(ATTR_REQUEST_ID).cloned().unwrap_or_default(); + let request_id = attrs.get(ATTR_REQUEST_ID).cloned().unwrap_or_default(); let method = attrs.get(ATTR_METHOD).cloned().unwrap_or_default(); - let response_timeout_ms: u64 = attrs - .get(ATTR_RESPONSE_TIMEOUT_MS) - .and_then(|v| v.parse().ok()) - .unwrap_or(15000); - let version: u32 = attrs - .get(ATTR_VERSION) - .and_then(|v| v.parse().ok()) - .unwrap_or(0); + let response_timeout_ms: u64 = + attrs.get(ATTR_RESPONSE_TIMEOUT_MS).and_then(|v| v.parse().ok()).unwrap_or(15000); + let version: u32 = attrs.get(ATTR_VERSION).and_then(|v| v.parse().ok()).unwrap_or(0); let response_timeout = Duration::from_millis(response_timeout_ms); // Send ACK immediately (always v1 packet) - if let Err(e) = - publish_rpc_ack(transport, &caller_identity.0, &request_id).await - { + if let Err(e) = publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } if version != RPC_VERSION_V2 { - let error = - RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); + let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); let _ = publish_rpc_response( transport, &caller_identity.0, @@ -192,16 +150,10 @@ impl RpcServerManager { let payload = match reader.read_all().await { Ok(payload) => payload, Err(e) => { - log::error!( - "Failed to read RPC v2 request stream: {:?}", - e - ); + log::error!("Failed to read RPC v2 request stream: {:?}", e); let error = RpcError::built_in( RpcErrorCode::ApplicationError, - Some(format!( - "Failed to read request stream: {}", - e - )), + Some(format!("Failed to read request stream: {}", e)), ); let _ = publish_rpc_response( transport, @@ -216,45 +168,26 @@ impl RpcServerManager { }; let response = self - .invoke_handler( - &caller_identity, - &request_id, - &method, - &payload, - response_timeout, - ) + .invoke_handler(&caller_identity, &request_id, &method, &payload, response_timeout) .await; match response { Ok(response_payload) => { // Success: send response as v2 data stream let mut attributes = HashMap::new(); - attributes.insert( - ATTR_REQUEST_ID.to_string(), - request_id.clone(), - ); + attributes.insert(ATTR_REQUEST_ID.to_string(), request_id.clone()); let options = StreamTextOptions { topic: RPC_RESPONSE_TOPIC.to_string(), attributes, - destination_identities: vec![ - caller_identity.clone(), - ], + destination_identities: vec![caller_identity.clone()], ..Default::default() }; - if let Err(e) = - transport.send_text(&response_payload, options).await - { - log::error!( - "Failed to send RPC v2 response stream: {:?}", - e - ); + if let Err(e) = transport.send_text(&response_payload, options).await { + log::error!("Failed to send RPC v2 response stream: {:?}", e); // Fall back to error via v1 packet - let error = RpcError::built_in( - RpcErrorCode::SendFailed, - Some(e.to_string()), - ); + let error = RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string())); let _ = publish_rpc_response( transport, &caller_identity.0, @@ -276,10 +209,7 @@ impl RpcServerManager { ) .await { - log::error!( - "Failed to publish RPC error response: {:?}", - send_err - ); + log::error!("Failed to publish RPC error response: {:?}", send_err); } } } @@ -314,21 +244,12 @@ impl RpcServerManager { { Ok(result) => result, Err(e) => { - log::error!( - "RPC method handler returned an error: {:?}", - e - ); - Err(RpcError::built_in( - RpcErrorCode::ApplicationError, - None, - )) + log::error!("RPC method handler returned an error: {:?}", e); + Err(RpcError::built_in(RpcErrorCode::ApplicationError, None)) } } } - None => Err(RpcError::built_in( - RpcErrorCode::UnsupportedMethod, - None, - )), + None => Err(RpcError::built_in(RpcErrorCode::UnsupportedMethod, None)), } } } diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index b04a29a44..f4e5db2a0 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -14,16 +14,15 @@ use super::*; use crate::data_stream::{ - OperationType, StreamResult, StreamTextOptions, TextStreamInfo, - TextStreamReader, + OperationType, StreamResult, StreamTextOptions, TextStreamInfo, TextStreamReader, }; use crate::e2ee::EncryptionType; use crate::room::id::ParticipantIdentity; use crate::room::RoomError; use bytes::Bytes; use chrono::Utc; -use livekit_protocol as proto; use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; +use livekit_protocol as proto; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; use std::sync::Arc; @@ -82,22 +81,15 @@ impl MockTransport { } /// Count packets matching a predicate on their `value`. - fn count_packets bool>( - &self, - f: F, - ) -> usize { - self.packets() - .iter() - .filter(|p| p.value.as_ref().map_or(false, &f)) - .count() + fn count_packets bool>(&self, f: F) -> usize { + self.packets().iter().filter(|p| p.value.as_ref().map_or(false, &f)).count() } /// Extract the request ID from the first RPC request packet or text stream. fn extract_request_id(&self) -> String { // Try v1 packets first for p in self.packets() { - if let Some(proto::data_packet::Value::RpcRequest(req)) = &p.value - { + if let Some(proto::data_packet::Value::RpcRequest(req)) = &p.value { return req.id.clone(); } } @@ -114,10 +106,7 @@ impl MockTransport { } impl RpcTransport for MockTransport { - async fn publish_data( - &self, - data: proto::DataPacket, - ) -> Result<(), RoomError> { + async fn publish_data(&self, data: proto::DataPacket) -> Result<(), RoomError> { self.sent_packets.lock().push(data); self.packet_sent.notify_waiters(); Ok(()) @@ -128,9 +117,7 @@ impl RpcTransport for MockTransport { text: &str, options: StreamTextOptions, ) -> StreamResult { - self.sent_texts - .lock() - .push((text.to_string(), options.clone())); + self.sent_texts.lock().push((text.to_string(), options.clone())); self.text_sent.notify_waiters(); Ok(TextStreamInfo { id: "mock-stream-id".to_string(), @@ -149,10 +136,7 @@ impl RpcTransport for MockTransport { } fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { - self.remote_protocols - .get(&identity.0) - .copied() - .unwrap_or(CLIENT_PROTOCOL_DEFAULT) + self.remote_protocols.get(&identity.0).copied().unwrap_or(CLIENT_PROTOCOL_DEFAULT) } fn server_version(&self) -> Option { @@ -191,18 +175,11 @@ fn make_text_reader( ) } -fn v2_request_attrs( - request_id: &str, - method: &str, - timeout_ms: u64, -) -> HashMap { +fn v2_request_attrs(request_id: &str, method: &str, timeout_ms: u64) -> HashMap { let mut attrs = HashMap::new(); attrs.insert(ATTR_REQUEST_ID.to_string(), request_id.to_string()); attrs.insert(ATTR_METHOD.to_string(), method.to_string()); - attrs.insert( - ATTR_RESPONSE_TIMEOUT_MS.to_string(), - timeout_ms.to_string(), - ); + attrs.insert(ATTR_RESPONSE_TIMEOUT_MS.to_string(), timeout_ms.to_string()); attrs.insert(ATTR_VERSION.to_string(), "2".to_string()); attrs } @@ -225,9 +202,7 @@ fn is_rpc_ack_packet(v: &proto::data_packet::Value) -> bool { matches!(v, proto::data_packet::Value::RpcAck(_)) } -fn extract_response_error( - transport: &MockTransport, -) -> Option { +fn extract_response_error(transport: &MockTransport) -> Option { for p in transport.packets() { if let Some(proto::data_packet::Value::RpcResponse(resp)) = &p.value { if let Some(proto::rpc_response::Value::Error(e)) = &resp.value { @@ -258,8 +233,7 @@ async fn spawn_perform_rpc( async fn test_v2_v2_caller_happy_path_short() { let client = Arc::new(RpcClientManager::new()); let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), ); let handle = spawn_perform_rpc( @@ -300,8 +274,7 @@ async fn test_v2_v2_caller_happy_path_short() { async fn test_v2_v2_caller_happy_path_large_payload() { let client = Arc::new(RpcClientManager::new()); let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), ); let large_payload = "x".repeat(20_000); @@ -336,9 +309,7 @@ async fn test_v2_v2_handler_happy_path() { let server = RpcServerManager::new(); let transport = MockTransport::new(); - server.register_method("echo".to_string(), |data| { - Box::pin(async move { Ok(data.payload) }) - }); + server.register_method("echo".to_string(), |data| Box::pin(async move { Ok(data.payload) })); let reader = make_text_reader( "request-body", @@ -346,13 +317,7 @@ async fn test_v2_v2_handler_happy_path() { RPC_REQUEST_TOPIC, ); - server - .handle_request_stream( - reader, - ParticipantIdentity("caller".into()), - &transport, - ) - .await; + server.handle_request_stream(reader, ParticipantIdentity("caller".into()), &transport).await; // ACK should be sent as v1 packet assert_eq!(transport.count_packets(is_rpc_ack_packet), 1); @@ -378,19 +343,10 @@ async fn test_v2_v2_handler_unhandled_error() { }) }); - let reader = make_text_reader( - "payload", - v2_request_attrs("req-2", "crash", 5000), - RPC_REQUEST_TOPIC, - ); + let reader = + make_text_reader("payload", v2_request_attrs("req-2", "crash", 5000), RPC_REQUEST_TOPIC); - server - .handle_request_stream( - reader, - ParticipantIdentity("caller".into()), - &transport, - ) - .await; + server.handle_request_stream(reader, ParticipantIdentity("caller".into()), &transport).await; // Error responses always use v1 packets, even between v2 clients assert_eq!(transport.count_packets(is_rpc_response_packet), 1); @@ -407,24 +363,13 @@ async fn test_v2_v2_handler_rpc_error_passthrough() { let transport = MockTransport::new(); server.register_method("fail".to_string(), |_data| { - Box::pin(async move { - Err(RpcError::new(101, "custom".into(), Some("data".into()))) - }) + Box::pin(async move { Err(RpcError::new(101, "custom".into(), Some("data".into()))) }) }); - let reader = make_text_reader( - "payload", - v2_request_attrs("req-3", "fail", 5000), - RPC_REQUEST_TOPIC, - ); + let reader = + make_text_reader("payload", v2_request_attrs("req-3", "fail", 5000), RPC_REQUEST_TOPIC); - server - .handle_request_stream( - reader, - ParticipantIdentity("caller".into()), - &transport, - ) - .await; + server.handle_request_stream(reader, ParticipantIdentity("caller".into()), &transport).await; // Error sent as v1 packet let err = extract_response_error(&transport).unwrap(); @@ -436,8 +381,8 @@ async fn test_v2_v2_handler_rpc_error_passthrough() { #[tokio::test] async fn test_v2_v2_response_timeout() { let client = RpcClientManager::new(); - let transport = MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC); + let transport = + MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC); // Very short timeout — no ack or response will arrive. // The ack timeout (7s) is larger than response_timeout (50ms), @@ -463,8 +408,7 @@ async fn test_v2_v2_response_timeout() { async fn test_v2_v2_error_response() { let client = Arc::new(RpcClientManager::new()); let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), ); let handle = spawn_perform_rpc( @@ -487,11 +431,7 @@ async fn test_v2_v2_error_response() { client.handle_response( request_id, None, - Some(proto::RpcError { - code: 101, - message: "nope".into(), - data: "details".into(), - }), + Some(proto::RpcError { code: 101, message: "nope".into(), data: "details".into() }), ); let result = handle.await.unwrap(); @@ -505,8 +445,7 @@ async fn test_v2_v2_error_response() { async fn test_v2_v2_participant_disconnection() { let client = Arc::new(RpcClientManager::new()); let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), + MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DATA_STREAM_RPC), ); let handle = spawn_perform_rpc( @@ -543,10 +482,8 @@ async fn test_v2_v2_participant_disconnection() { async fn test_v2_v1_caller_request_fallback() { let client = Arc::new(RpcClientManager::new()); // Remote has client_protocol = 0 (v1 only) - let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), - ); + let transport = + Arc::new(MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT)); let handle = spawn_perform_rpc( client.clone(), @@ -564,14 +501,7 @@ async fn test_v2_v1_caller_request_fallback() { // Verify: sent as v1 packet, NOT a data stream assert_eq!(transport.count_packets(is_rpc_request_packet), 1); - assert_eq!( - transport - .texts() - .iter() - .filter(|(_, o)| o.topic == RPC_REQUEST_TOPIC) - .count(), - 0 - ); + assert_eq!(transport.texts().iter().filter(|(_, o)| o.topic == RPC_REQUEST_TOPIC).count(), 0); let request_id = transport.extract_request_id(); client.handle_ack(request_id.clone()); @@ -587,9 +517,7 @@ async fn test_v2_v1_handler_v1_request() { let server = RpcServerManager::new(); let transport = MockTransport::new(); - server.register_method("echo".to_string(), |data| { - Box::pin(async move { Ok(data.payload) }) - }); + server.register_method("echo".to_string(), |data| Box::pin(async move { Ok(data.payload) })); server .handle_request( @@ -612,9 +540,7 @@ async fn test_v2_v1_handler_v1_request() { // Verify response payload for p in transport.packets() { if let Some(proto::data_packet::Value::RpcResponse(resp)) = &p.value { - if let Some(proto::rpc_response::Value::Payload(payload)) = - &resp.value - { + if let Some(proto::rpc_response::Value::Payload(payload)) = &resp.value { assert_eq!(payload, "v1-body"); } } @@ -625,8 +551,7 @@ async fn test_v2_v1_handler_v1_request() { #[tokio::test] async fn test_v2_v1_payload_too_large() { let client = RpcClientManager::new(); - let transport = MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); + let transport = MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); let large_payload = "x".repeat(MAX_PAYLOAD_BYTES + 1); let result = client @@ -649,8 +574,7 @@ async fn test_v2_v1_payload_too_large() { #[tokio::test] async fn test_v2_v1_response_timeout() { let client = RpcClientManager::new(); - let transport = MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); + let transport = MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT); let result = client .perform_rpc( @@ -672,10 +596,8 @@ async fn test_v2_v1_response_timeout() { #[tokio::test] async fn test_v2_v1_error_response() { let client = Arc::new(RpcClientManager::new()); - let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), - ); + let transport = + Arc::new(MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT)); let handle = spawn_perform_rpc( client.clone(), @@ -696,11 +618,7 @@ async fn test_v2_v1_error_response() { client.handle_response( request_id, None, - Some(proto::RpcError { - code: 101, - message: "v1-err".into(), - data: String::new(), - }), + Some(proto::RpcError { code: 101, message: "v1-err".into(), data: String::new() }), ); let result = handle.await.unwrap(); @@ -713,10 +631,8 @@ async fn test_v2_v1_error_response() { #[tokio::test] async fn test_v2_v1_participant_disconnection() { let client = Arc::new(RpcClientManager::new()); - let transport = Arc::new( - MockTransport::new() - .with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT), - ); + let transport = + Arc::new(MockTransport::new().with_remote_protocol("dest", CLIENT_PROTOCOL_DEFAULT)); let handle = spawn_perform_rpc( client.clone(), @@ -752,9 +668,7 @@ async fn test_v1_v2_handler_response_fallback() { let server = RpcServerManager::new(); let transport = MockTransport::new(); - server.register_method("echo".to_string(), |data| { - Box::pin(async move { Ok(data.payload) }) - }); + server.register_method("echo".to_string(), |data| Box::pin(async move { Ok(data.payload) })); // v1 caller sends a v1 packet request to our v2 handler server @@ -811,13 +725,7 @@ async fn test_v1_v2_handler_rpc_error_passthrough() { let transport = MockTransport::new(); server.register_method("fail".to_string(), |_data| { - Box::pin(async move { - Err(RpcError::new( - 101, - "custom-err".into(), - Some("extra".into()), - )) - }) + Box::pin(async move { Err(RpcError::new(101, "custom-err".into(), Some("extra".into()))) }) }); server @@ -850,11 +758,8 @@ async fn test_v2_response_stream_resolves_caller() { let (tx, rx) = tokio::sync::oneshot::channel(); client.insert_pending_response("req-stream".to_string(), tx); - let reader = make_text_reader( - "stream-result", - v2_response_attrs("req-stream"), - RPC_RESPONSE_TOPIC, - ); + let reader = + make_text_reader("stream-result", v2_response_attrs("req-stream"), RPC_RESPONSE_TOPIC); client.handle_response_stream(reader).await; @@ -874,13 +779,7 @@ async fn test_v2_handler_unsupported_method() { RPC_REQUEST_TOPIC, ); - server - .handle_request_stream( - reader, - ParticipantIdentity("caller".into()), - &transport, - ) - .await; + server.handle_request_stream(reader, ParticipantIdentity("caller".into()), &transport).await; let err = extract_response_error(&transport).unwrap(); assert_eq!(err.code, RpcErrorCode::UnsupportedMethod as u32); From 29fe346baecee1604024ef4259dea9408f37645b Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:28:01 -0400 Subject: [PATCH 12/18] refactor: rename ADVERTISED_CLIENT_PROTOCOL -> CLIENT_PROTOCOL_VERSION This should match PROTOCOL_VERSION. --- livekit-api/src/signal_client/mod.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index d7820150a..2a4d153e3 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -62,7 +62,7 @@ pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; /// The client protocol which is sent to other clients and indicates the set of apis that other /// clients should assume this client supports. -const ADVERTISED_CLIENT_PROTOCOL: i32 = CLIENT_PROTOCOL_DATA_STREAM_RPC; +const CLIENT_PROTOCOL_VERSION: i32 = CLIENT_PROTOCOL_DATA_STREAM_RPC; #[derive(Error, Debug)] pub enum SignalError { @@ -584,7 +584,7 @@ fn create_join_request_param( os, os_version, device_model, - client_protocol: ADVERTISED_CLIENT_PROTOCOL, + client_protocol: CLIENT_PROTOCOL_VERSION, ..Default::default() }; @@ -681,7 +681,7 @@ fn get_livekit_url( .append_pair("os_version", os_info.version().to_string().as_str()) .append_pair("device_model", device_model.to_string().as_str()) .append_pair("protocol", PROTOCOL_VERSION.to_string().as_str()) - .append_pair("client_protocol", ADVERTISED_CLIENT_PROTOCOL.to_string().as_str()) + .append_pair("client_protocol", CLIENT_PROTOCOL_VERSION.to_string().as_str()) .append_pair("auto_subscribe", if options.auto_subscribe { "1" } else { "0" }) .append_pair("adaptive_stream", if options.adaptive_stream { "1" } else { "0" }); From 85c55e7f46a6fd3ac34613fea6ab91ea20d0ea01 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:44:19 -0400 Subject: [PATCH 13/18] refactor: switch from if block -> .then --- livekit/src/room/rpc/mod.rs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 215bfaa56..494fbcc63 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -94,13 +94,13 @@ impl RpcTransport for SessionTransport { } fn server_version(&self) -> Option { - self.0.rtc_engine.session().signal_client().join_response().server_info.and_then(|info| { - if info.version.is_empty() { - None - } else { - Some(info.version) - } - }) + self.0 + .rtc_engine + .session() + .signal_client() + .join_response() + .server_info + .and_then(|info| info.version.is_empty().then(|| info.version)) } } From cc75a94f4e11141a2dca1a5997dcb94b5811ed73 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:56:53 -0400 Subject: [PATCH 14/18] refactor: make handle_request take HandleRequestOptions --- livekit/src/room/mod.rs | 14 +++++---- livekit/src/room/rpc/mod.rs | 2 +- livekit/src/room/rpc/server.rs | 26 ++++++++++++---- livekit/src/room/rpc/tests.rs | 56 +++++++++++++++++++--------------- 4 files changed, 61 insertions(+), 37 deletions(-) diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index 25e978731..419e1c43f 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -979,12 +979,14 @@ impl RoomSession { session .rpc_server .handle_request( - caller, - request_id, - method, - payload, - response_timeout, - version, + rpc::HandleRequestOptions { + caller_identity: caller, + request_id, + method, + payload, + response_timeout, + version, + }, &transport, ) .await; diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 494fbcc63..4dc1aa95a 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -19,7 +19,7 @@ mod server; mod tests; pub use client::RpcClientManager; -pub use server::RpcServerManager; +pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; diff --git a/livekit/src/room/rpc/server.rs b/livekit/src/room/rpc/server.rs index 149019346..762039490 100644 --- a/livekit/src/room/rpc/server.rs +++ b/livekit/src/room/rpc/server.rs @@ -29,6 +29,16 @@ pub(crate) type RpcHandlerFn = Arc< + Sync, >; +/// Parameters for [`RpcServerManager::handle_request`]. +pub struct HandleRequestOptions { + pub caller_identity: ParticipantIdentity, + pub request_id: String, + pub method: String, + pub payload: String, + pub response_timeout: Duration, + pub version: u32, +} + /// Manages incoming RPC requests (handler/server side). /// /// Stores registered method handlers and dispatches incoming requests @@ -68,14 +78,18 @@ impl RpcServerManager { /// as a v1 RPC response packet. pub(crate) async fn handle_request( &self, - caller_identity: ParticipantIdentity, - request_id: String, - method: String, - payload: String, - response_timeout: Duration, - version: u32, + options: HandleRequestOptions, transport: &(impl RpcTransport + 'static), ) { + let HandleRequestOptions { + caller_identity, + request_id, + method, + payload, + response_timeout, + version, + } = options; + // Send ACK immediately if let Err(e) = publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index f4e5db2a0..f366a0b57 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -521,12 +521,14 @@ async fn test_v2_v1_handler_v1_request() { server .handle_request( - ParticipantIdentity("caller".into()), - "req-v1".into(), - "echo".into(), - "v1-body".into(), - Duration::from_secs(5), - RPC_VERSION_V1, + HandleRequestOptions { + caller_identity: ParticipantIdentity("caller".into()), + request_id: "req-v1".into(), + method: "echo".into(), + payload: "v1-body".into(), + response_timeout: Duration::from_secs(5), + version: RPC_VERSION_V1, + }, &transport, ) .await; @@ -673,12 +675,14 @@ async fn test_v1_v2_handler_response_fallback() { // v1 caller sends a v1 packet request to our v2 handler server .handle_request( - ParticipantIdentity("v1-caller".into()), - "req-v1-to-v2".into(), - "echo".into(), - "hello-from-v1".into(), - Duration::from_secs(5), - RPC_VERSION_V1, + HandleRequestOptions { + caller_identity: ParticipantIdentity("v1-caller".into()), + request_id: "req-v1-to-v2".into(), + method: "echo".into(), + payload: "hello-from-v1".into(), + response_timeout: Duration::from_secs(5), + version: RPC_VERSION_V1, + }, &transport, ) .await; @@ -704,12 +708,14 @@ async fn test_v1_v2_handler_unhandled_error() { server .handle_request( - ParticipantIdentity("v1-caller".into()), - "req-crash".into(), - "crash".into(), - "x".into(), - Duration::from_secs(5), - RPC_VERSION_V1, + HandleRequestOptions { + caller_identity: ParticipantIdentity("v1-caller".into()), + request_id: "req-crash".into(), + method: "crash".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + version: RPC_VERSION_V1, + }, &transport, ) .await; @@ -730,12 +736,14 @@ async fn test_v1_v2_handler_rpc_error_passthrough() { server .handle_request( - ParticipantIdentity("v1-caller".into()), - "req-fail".into(), - "fail".into(), - "x".into(), - Duration::from_secs(5), - 1, + HandleRequestOptions { + caller_identity: ParticipantIdentity("v1-caller".into()), + request_id: "req-fail".into(), + method: "fail".into(), + payload: "x".into(), + response_timeout: Duration::from_secs(5), + version: RPC_VERSION_V1, + }, &transport, ) .await; From c82affe86cf1537301122bfba8cff6965ebf3bb9 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Fri, 24 Apr 2026 16:59:00 -0400 Subject: [PATCH 15/18] fix: run cargo fmt --- livekit-api/src/signal_client/mod.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index 2a4d153e3..024f33fba 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -97,10 +97,7 @@ pub struct SignalSdkOptions { impl Default for SignalSdkOptions { fn default() -> Self { - Self { - sdk: "rust".to_string(), - sdk_version: None, - } + Self { sdk: "rust".to_string(), sdk_version: None } } } From e98ab99a53a116699402ccf93a1265e02a10578a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 27 Apr 2026 12:51:41 -0400 Subject: [PATCH 16/18] fix: move publish_rpc_request into self.send_v1_request method This is easier to follow and matches better with how the v2 version works --- livekit/src/room/rpc/client.rs | 59 +++++++++++++++++----------------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index fb82355d3..1a3ec4e3a 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.rs @@ -103,14 +103,13 @@ impl RpcClientManager { ) .await } else { - publish_rpc_request( + self.send_v1_request( transport, &data.destination_identity, &id, &data.method, &data.payload, effective_timeout, - RPC_VERSION_V1, ) .await .map_err(|e| RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string()))) @@ -165,6 +164,34 @@ impl RpcClientManager { } } + /// Publish a v1 RPC request data packet. + pub(crate) async fn send_v1_request( + &self, + transport: &impl RpcTransport, + destination_identity: &str, + id: &str, + method: &str, + payload: &str, + response_timeout: Duration, + ) -> Result<(), crate::room::RoomError> { + let rpc_request_message = proto::RpcRequest { + id: id.to_string(), + method: method.to_string(), + payload: payload.to_string(), + response_timeout_ms: response_timeout.as_millis() as u32, + version: RPC_VERSION_V1, + ..Default::default() + }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcRequest(rpc_request_message)), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + transport.publish_data(data).await + } + /// Send an RPC request as a v2 text data stream. async fn send_v2_request( &self, @@ -280,34 +307,6 @@ impl RpcClientManager { } } -/// Publish a v1 RPC request data packet. -pub(crate) async fn publish_rpc_request( - transport: &impl RpcTransport, - destination_identity: &str, - id: &str, - method: &str, - payload: &str, - response_timeout: Duration, - version: u32, -) -> Result<(), crate::room::RoomError> { - let rpc_request_message = proto::RpcRequest { - id: id.to_string(), - method: method.to_string(), - payload: payload.to_string(), - response_timeout_ms: response_timeout.as_millis() as u32, - version, - ..Default::default() - }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcRequest(rpc_request_message)), - destination_identities: vec![destination_identity.to_string()], - ..Default::default() - }; - - transport.publish_data(data).await -} - /// Publish a v1 RPC response data packet. pub(crate) async fn publish_rpc_response( transport: &impl RpcTransport, From f1c4081c517b7ae3f45ea31b6b0cbbd82656402a Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 27 Apr 2026 13:00:33 -0400 Subject: [PATCH 17/18] refactor: move publish_rpc_response / publish_rpc_ack into RpcServerManager as methods --- livekit/src/room/rpc/client.rs | 43 ----------------------- livekit/src/room/rpc/server.rs | 62 +++++++++++++++++++++++++++++----- 2 files changed, 54 insertions(+), 51 deletions(-) diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index 1a3ec4e3a..99f7f2077 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.rs @@ -307,46 +307,3 @@ impl RpcClientManager { } } -/// Publish a v1 RPC response data packet. -pub(crate) async fn publish_rpc_response( - transport: &impl RpcTransport, - destination_identity: &str, - request_id: &str, - payload: Option, - error: Option, -) -> Result<(), crate::room::RoomError> { - let rpc_response_message = proto::RpcResponse { - request_id: request_id.to_string(), - value: Some(match error { - Some(error) => proto::rpc_response::Value::Error(error), - None => proto::rpc_response::Value::Payload(payload.unwrap()), - }), - ..Default::default() - }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcResponse(rpc_response_message)), - destination_identities: vec![destination_identity.to_string()], - ..Default::default() - }; - - transport.publish_data(data).await -} - -/// Publish a v1 RPC ack data packet. -pub(crate) async fn publish_rpc_ack( - transport: &impl RpcTransport, - destination_identity: &str, - request_id: &str, -) -> Result<(), crate::room::RoomError> { - let rpc_ack_message = - proto::RpcAck { request_id: request_id.to_string(), ..Default::default() }; - - let data = proto::DataPacket { - value: Some(proto::data_packet::Value::RpcAck(rpc_ack_message)), - destination_identities: vec![destination_identity.to_string()], - ..Default::default() - }; - - transport.publish_data(data).await -} diff --git a/livekit/src/room/rpc/server.rs b/livekit/src/room/rpc/server.rs index 762039490..58c85fa53 100644 --- a/livekit/src/room/rpc/server.rs +++ b/livekit/src/room/rpc/server.rs @@ -12,7 +12,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use super::client::{publish_rpc_ack, publish_rpc_response}; use super::{ RpcError, RpcErrorCode, RpcInvocationData, RpcTransport, ATTR_METHOD, ATTR_REQUEST_ID, ATTR_RESPONSE_TIMEOUT_MS, ATTR_VERSION, MAX_PAYLOAD_BYTES, RPC_RESPONSE_TOPIC, RPC_VERSION_V1, @@ -20,6 +19,7 @@ use super::{ }; use crate::data_stream::{StreamReader, StreamTextOptions, TextStreamReader}; use crate::room::id::ParticipantIdentity; +use livekit_protocol as proto; use parking_lot::Mutex; use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc, time::Duration}; @@ -91,7 +91,7 @@ impl RpcServerManager { } = options; // Send ACK immediately - if let Err(e) = publish_rpc_ack(transport, &caller_identity.0, &request_id).await { + if let Err(e) = self.publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } @@ -114,7 +114,7 @@ impl RpcServerManager { }; if let Err(e) = - publish_rpc_response(transport, &caller_identity.0, &request_id, resp_payload, error) + self.publish_rpc_response_packet(transport, &caller_identity.0, &request_id, resp_payload, error) .await { log::error!("Failed to publish RPC response: {:?}", e); @@ -143,13 +143,13 @@ impl RpcServerManager { let response_timeout = Duration::from_millis(response_timeout_ms); // Send ACK immediately (always v1 packet) - if let Err(e) = publish_rpc_ack(transport, &caller_identity.0, &request_id).await { + if let Err(e) = self.publish_rpc_ack(transport, &caller_identity.0, &request_id).await { log::error!("Failed to publish RPC ACK: {:?}", e); } if version != RPC_VERSION_V2 { let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); - let _ = publish_rpc_response( + let _ = self.publish_rpc_response_packet( transport, &caller_identity.0, &request_id, @@ -169,7 +169,7 @@ impl RpcServerManager { RpcErrorCode::ApplicationError, Some(format!("Failed to read request stream: {}", e)), ); - let _ = publish_rpc_response( + let _ = self.publish_rpc_response_packet( transport, &caller_identity.0, &request_id, @@ -202,7 +202,7 @@ impl RpcServerManager { log::error!("Failed to send RPC v2 response stream: {:?}", e); // Fall back to error via v1 packet let error = RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string())); - let _ = publish_rpc_response( + let _ = self.publish_rpc_response_packet( transport, &caller_identity.0, &request_id, @@ -214,7 +214,7 @@ impl RpcServerManager { } Err(e) => { // Error: always send as v1 packet - if let Err(send_err) = publish_rpc_response( + if let Err(send_err) = self.publish_rpc_response_packet( transport, &caller_identity.0, &request_id, @@ -266,4 +266,50 @@ impl RpcServerManager { None => Err(RpcError::built_in(RpcErrorCode::UnsupportedMethod, None)), } } + + /// Publish a v1 RPC response data packet. + async fn publish_rpc_response_packet( + &self, + transport: &impl RpcTransport, + destination_identity: &str, + request_id: &str, + payload: Option, + error: Option, + ) -> Result<(), crate::room::RoomError> { + let rpc_response_message = proto::RpcResponse { + request_id: request_id.to_string(), + value: Some(match error { + Some(error) => proto::rpc_response::Value::Error(error), + None => proto::rpc_response::Value::Payload(payload.unwrap()), + }), + ..Default::default() + }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcResponse(rpc_response_message)), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + transport.publish_data(data).await + } + + /// Publish a v1 RPC ack data packet. + async fn publish_rpc_ack( + &self, + transport: &impl RpcTransport, + destination_identity: &str, + request_id: &str, + ) -> Result<(), crate::room::RoomError> { + let rpc_ack_message = + proto::RpcAck { request_id: request_id.to_string(), ..Default::default() }; + + let data = proto::DataPacket { + value: Some(proto::data_packet::Value::RpcAck(rpc_ack_message)), + destination_identities: vec![destination_identity.to_string()], + ..Default::default() + }; + + transport.publish_data(data).await + } } From a16a4e6e2127da6d9c548e740720da85d87a6b0f Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Mon, 27 Apr 2026 13:06:52 -0400 Subject: [PATCH 18/18] fix: run cargo fmt --- livekit/src/room/rpc/client.rs | 1 - livekit/src/room/rpc/server.rs | 78 +++++++++++++++++++--------------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/livekit/src/room/rpc/client.rs b/livekit/src/room/rpc/client.rs index 99f7f2077..4c35ca0f8 100644 --- a/livekit/src/room/rpc/client.rs +++ b/livekit/src/room/rpc/client.rs @@ -306,4 +306,3 @@ impl RpcClientManager { } } } - diff --git a/livekit/src/room/rpc/server.rs b/livekit/src/room/rpc/server.rs index 58c85fa53..578d66b14 100644 --- a/livekit/src/room/rpc/server.rs +++ b/livekit/src/room/rpc/server.rs @@ -113,9 +113,15 @@ impl RpcServerManager { Err(e) => (None, Some(e.to_proto())), }; - if let Err(e) = - self.publish_rpc_response_packet(transport, &caller_identity.0, &request_id, resp_payload, error) - .await + if let Err(e) = self + .publish_rpc_response_packet( + transport, + &caller_identity.0, + &request_id, + resp_payload, + error, + ) + .await { log::error!("Failed to publish RPC response: {:?}", e); } @@ -149,14 +155,15 @@ impl RpcServerManager { if version != RPC_VERSION_V2 { let error = RpcError::built_in(RpcErrorCode::UnsupportedVersion, None); - let _ = self.publish_rpc_response_packet( - transport, - &caller_identity.0, - &request_id, - None, - Some(error.to_proto()), - ) - .await; + let _ = self + .publish_rpc_response_packet( + transport, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; return; } @@ -169,14 +176,15 @@ impl RpcServerManager { RpcErrorCode::ApplicationError, Some(format!("Failed to read request stream: {}", e)), ); - let _ = self.publish_rpc_response_packet( - transport, - &caller_identity.0, - &request_id, - None, - Some(error.to_proto()), - ) - .await; + let _ = self + .publish_rpc_response_packet( + transport, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; return; } }; @@ -202,26 +210,28 @@ impl RpcServerManager { log::error!("Failed to send RPC v2 response stream: {:?}", e); // Fall back to error via v1 packet let error = RpcError::built_in(RpcErrorCode::SendFailed, Some(e.to_string())); - let _ = self.publish_rpc_response_packet( + let _ = self + .publish_rpc_response_packet( + transport, + &caller_identity.0, + &request_id, + None, + Some(error.to_proto()), + ) + .await; + } + } + Err(e) => { + // Error: always send as v1 packet + if let Err(send_err) = self + .publish_rpc_response_packet( transport, &caller_identity.0, &request_id, None, - Some(error.to_proto()), + Some(e.to_proto()), ) - .await; - } - } - Err(e) => { - // Error: always send as v1 packet - if let Err(send_err) = self.publish_rpc_response_packet( - transport, - &caller_identity.0, - &request_id, - None, - Some(e.to_proto()), - ) - .await + .await { log::error!("Failed to publish RPC error response: {:?}", send_err); }