Skip to main content

bitwarden_ipc/
ipc_client.rs

1use std::sync::{Arc, Mutex};
2
3use bitwarden_threading::cancellation_token::CancellationToken;
4use serde::de::DeserializeOwned;
5use thiserror::Error;
6use tokio::select;
7
8use crate::{
9    constants::CHANNEL_BUFFER_CAPACITY,
10    error::{
11        AlreadyRunningError, ErrorKind, IpcErrorKind, ReceiveError, SendError, SubscribeError,
12        TypedReceiveError,
13    },
14    message::{IncomingMessage, OutgoingMessage, PayloadTypeName, TypedIncomingMessage},
15    rpc::{
16        exec::{handler::ErasedRpcHandler, handler_registry::RpcHandlerRegistry},
17        request_message::{RPC_REQUEST_PAYLOAD_TYPE_NAME, RpcRequestPayload},
18        response_message::OutgoingRpcResponseMessage,
19    },
20    serde_utils,
21    traits::{CommunicationBackend, CryptoProvider, SessionRepository},
22};
23
24/// A subscription to receive messages over IPC.
25/// The subcription will start buffering messages after its creation and return them
26/// when receive() is called. Messages received before the subscription was created will not be
27/// returned.
28pub struct IpcClientSubscription {
29    pub(crate) receiver: tokio::sync::broadcast::Receiver<IncomingMessage>,
30    pub(crate) topic: Option<String>,
31}
32
33/// A subscription to receive messages over IPC.
34/// The subcription will start buffering messages after its creation and return them
35/// when receive() is called. Messages received before the subscription was created will not be
36/// returned.
37pub struct IpcClientTypedSubscription<Payload: DeserializeOwned + PayloadTypeName>(
38    IpcClientSubscription,
39    std::marker::PhantomData<Payload>,
40);
41
42/// Internal shared state for the IPC client.
43struct IpcClientInner<Crypto, Com, Ses>
44where
45    Crypto: CryptoProvider<Com, Ses>,
46    Com: CommunicationBackend,
47    Ses: SessionRepository<Crypto::Session>,
48{
49    crypto: Crypto,
50    communication: Com,
51    sessions: Ses,
52
53    handlers: RpcHandlerRegistry,
54    incoming: Mutex<Option<tokio::sync::broadcast::Receiver<IncomingMessage>>>,
55    cancellation_token: Mutex<Option<CancellationToken>>,
56}
57
58/// An IPC client that handles communication between different components and clients.
59/// It uses a crypto provider to encrypt and decrypt messages, a communication backend to send and
60/// receive messages, and a session repository to persist sessions.
61///
62/// This is the concrete implementation of the [`IpcClient`](crate::IpcClient) trait.
63pub struct IpcClientImpl<Crypto, Com, Ses>
64where
65    Crypto: CryptoProvider<Com, Ses>,
66    Com: CommunicationBackend,
67    Ses: SessionRepository<Crypto::Session>,
68{
69    inner: Arc<IpcClientInner<Crypto, Com, Ses>>,
70}
71
72impl<Crypto, Com, Ses> Clone for IpcClientImpl<Crypto, Com, Ses>
73where
74    Crypto: CryptoProvider<Com, Ses>,
75    Com: CommunicationBackend,
76    Ses: SessionRepository<Crypto::Session>,
77{
78    fn clone(&self) -> Self {
79        Self {
80            inner: self.inner.clone(),
81        }
82    }
83}
84
85impl<Crypto, Com, Ses> IpcClientImpl<Crypto, Com, Ses>
86where
87    Crypto: CryptoProvider<Com, Ses>,
88    Com: CommunicationBackend,
89    Ses: SessionRepository<Crypto::Session>,
90{
91    /// Create a new IPC client with the provided crypto provider, communication backend, and
92    /// session repository.
93    pub fn new(crypto: Crypto, communication: Com, sessions: Ses) -> Self {
94        Self {
95            inner: Arc::new(IpcClientInner {
96                crypto,
97                communication,
98                sessions,
99
100                handlers: RpcHandlerRegistry::new(),
101                incoming: Mutex::new(None),
102                cancellation_token: Mutex::new(None),
103            }),
104        }
105    }
106}
107
108#[async_trait::async_trait]
109impl<Crypto, Com, Ses> crate::ipc_client_trait::IpcClient for IpcClientImpl<Crypto, Com, Ses>
110where
111    Crypto: CryptoProvider<Com, Ses>,
112    Com: CommunicationBackend,
113    Ses: SessionRepository<Crypto::Session>,
114{
115    async fn start(
116        &self,
117        cancellation_token: Option<CancellationToken>,
118    ) -> Result<(), AlreadyRunningError> {
119        if self.is_running() {
120            return Err(AlreadyRunningError);
121        }
122
123        let cancellation_token = cancellation_token.unwrap_or_default();
124        self.inner
125            .cancellation_token
126            .lock()
127            .expect("Failed to lock cancellation token mutex")
128            .replace(cancellation_token.clone());
129
130        let com_receiver = self.inner.communication.subscribe().await;
131        let (client_tx, client_rx) = tokio::sync::broadcast::channel(CHANNEL_BUFFER_CAPACITY);
132
133        self.inner
134            .incoming
135            .lock()
136            .expect("Failed to lock incoming mutex")
137            .replace(client_rx);
138
139        let inner = self.inner.clone();
140        let future = async move {
141            loop {
142                let rpc_topic = RPC_REQUEST_PAYLOAD_TYPE_NAME.to_owned();
143                select! {
144                    _ = cancellation_token.cancelled() => {
145                        tracing::debug!("Cancellation signal received, stopping IPC client");
146                        break;
147                    }
148                    received = inner.crypto.receive(&com_receiver, &inner.communication, &inner.sessions) => {
149                        match received {
150                            Ok(message) if message.topic == Some(rpc_topic) => {
151                                handle_rpc_request(&inner, message)
152                            }
153                            Ok(message) => {
154                                if client_tx.send(message).is_err() {
155                                    tracing::error!("Failed to save incoming message");
156                                    break;
157                                };
158                            }
159                            Err(error) if matches!(error.kind(), ErrorKind::Fatal) => {
160                                tracing::error!(?error, "Fatal error receiving message, stopping IPC client");
161                                break;
162                            }
163                            Err(error) => {
164                                tracing::warn!(?error, "Recoverable error receiving message, continuing");
165                            }
166                        }
167                    }
168                }
169            }
170            tracing::debug!("IPC client shutting down");
171            stop_inner(&inner);
172        };
173
174        #[cfg(not(target_arch = "wasm32"))]
175        tokio::spawn(future);
176
177        #[cfg(target_arch = "wasm32")]
178        wasm_bindgen_futures::spawn_local(future);
179
180        Ok(())
181    }
182
183    fn is_running(&self) -> bool {
184        let has_incoming = self
185            .inner
186            .incoming
187            .lock()
188            .expect("Failed to lock incoming mutex")
189            .as_ref()
190            .map(|receiver| !receiver.is_closed())
191            .unwrap_or(false);
192        let has_cancellation_token = self
193            .inner
194            .cancellation_token
195            .lock()
196            .expect("Failed to lock cancellation token mutex")
197            .is_some();
198        has_incoming && has_cancellation_token
199    }
200
201    async fn send(&self, message: OutgoingMessage) -> Result<(), SendError> {
202        let result = self
203            .inner
204            .crypto
205            .send(&self.inner.communication, &self.inner.sessions, message)
206            .await;
207
208        if let Err(ref error) = result {
209            match error.kind() {
210                ErrorKind::Fatal => {
211                    tracing::error!(?error, "Fatal error sending message, stopping IPC client");
212                    stop_inner(&self.inner);
213                }
214                // An unreachable destination is an expected condition and not logged
215                ErrorKind::Unreachable => {}
216                // Every other recoverable send failure is still surfaced.
217                ErrorKind::Other => {
218                    tracing::debug!(
219                        ?error,
220                        "Recoverable error sending message, IPC client will continue running"
221                    );
222                }
223            }
224        }
225
226        result.map_err(|e| match e.kind() {
227            ErrorKind::Unreachable => SendError::Unreachable,
228            _ => SendError::Other(format!("{e:?}")),
229        })
230    }
231
232    async fn subscribe(
233        &self,
234        topic: Option<String>,
235    ) -> Result<IpcClientSubscription, SubscribeError> {
236        Ok(IpcClientSubscription {
237            receiver: self
238                .inner
239                .incoming
240                .lock()
241                .expect("Failed to lock incoming mutex")
242                .as_ref()
243                .ok_or(SubscribeError::NotStarted)?
244                .resubscribe(),
245            topic,
246        })
247    }
248
249    async fn register_rpc_handler_erased(&self, name: &str, handler: Box<dyn ErasedRpcHandler>) {
250        self.inner
251            .handlers
252            .register_erased(name.to_owned(), handler)
253            .await;
254    }
255}
256
257fn stop_inner<Crypto, Com, Ses>(inner: &IpcClientInner<Crypto, Com, Ses>)
258where
259    Crypto: CryptoProvider<Com, Ses>,
260    Com: CommunicationBackend,
261    Ses: SessionRepository<Crypto::Session>,
262{
263    let mut cancellation_token = inner
264        .cancellation_token
265        .lock()
266        .expect("Failed to lock cancellation token mutex");
267
268    if let Some(cancellation_token) = cancellation_token.take() {
269        cancellation_token.cancel();
270    }
271}
272
273fn handle_rpc_request<Crypto, Com, Ses>(
274    inner: &Arc<IpcClientInner<Crypto, Com, Ses>>,
275    incoming_message: IncomingMessage,
276) where
277    Crypto: CryptoProvider<Com, Ses>,
278    Com: CommunicationBackend,
279    Ses: SessionRepository<Crypto::Session>,
280{
281    let inner = inner.clone();
282    let future = async move {
283        #[derive(Debug, Error)]
284        enum HandleError {
285            #[error("Failed to deserialize request message: {0}")]
286            Deserialize(String),
287
288            #[error("Failed to serialize response message: {0}")]
289            Serialize(String),
290        }
291
292        async fn handle(
293            incoming_message: IncomingMessage,
294            handlers: &RpcHandlerRegistry,
295        ) -> Result<OutgoingMessage, HandleError> {
296            let request = RpcRequestPayload::from_slice(incoming_message.payload.clone()).map_err(
297                |e: serde_utils::DeserializeError| HandleError::Deserialize(e.to_string()),
298            )?;
299
300            let response = handlers.handle(&request).await;
301
302            let response_message = OutgoingRpcResponseMessage {
303                request_id: request.request_id(),
304                request_type: request.request_type(),
305                result: response,
306            };
307
308            // Publish the response on the dedicated topic the requester is subscribed to.
309            let payload = serde_utils::to_vec(&response_message)
310                .map_err(|e: serde_utils::SerializeError| HandleError::Serialize(e.to_string()))?;
311
312            Ok(OutgoingMessage {
313                payload,
314                destination: incoming_message.source.into(),
315                topic: Some(request.response_topic().to_owned()),
316            })
317        }
318
319        match handle(incoming_message, &inner.handlers).await {
320            Ok(outgoing_message) => {
321                // Send response directly through the crypto provider (not through the trait)
322                // since we're inside the background task and don't have a trait object.
323                let result = inner
324                    .crypto
325                    .send(&inner.communication, &inner.sessions, outgoing_message)
326                    .await;
327                if result.is_err() {
328                    tracing::error!("Failed to send response message");
329                }
330            }
331            Err(error) => {
332                tracing::error!(%error, "Error handling RPC request");
333            }
334        }
335    };
336
337    #[cfg(not(target_arch = "wasm32"))]
338    tokio::spawn(future);
339
340    #[cfg(target_arch = "wasm32")]
341    wasm_bindgen_futures::spawn_local(future);
342}
343
344impl IpcClientSubscription {
345    /// Receive a message, optionally filtering by topic.
346    /// Setting the cancellation_token to `None` will wait indefinitely.
347    pub async fn receive(
348        &mut self,
349        cancellation_token: Option<CancellationToken>,
350    ) -> Result<IncomingMessage, ReceiveError> {
351        let cancellation_token = cancellation_token.unwrap_or_default();
352
353        loop {
354            select! {
355                _ = cancellation_token.cancelled() => {
356                    return Err(ReceiveError::Cancelled)
357                }
358                result = self.receiver.recv() => {
359                    let received = result?;
360                    if self.topic.is_none() || received.topic == self.topic {
361                        return Ok::<IncomingMessage, ReceiveError>(received);
362                    }
363                }
364            }
365        }
366    }
367}
368
369impl<Payload> IpcClientTypedSubscription<Payload>
370where
371    Payload: DeserializeOwned + PayloadTypeName,
372{
373    pub(crate) fn new(subscription: IpcClientSubscription) -> Self {
374        Self(subscription, std::marker::PhantomData)
375    }
376
377    /// Receive a message.
378    /// Setting the cancellation_token to `None` will wait indefinitely.
379    pub async fn receive(
380        &mut self,
381        cancellation_token: Option<CancellationToken>,
382    ) -> Result<TypedIncomingMessage<Payload>, TypedReceiveError> {
383        let received = self.0.receive(cancellation_token).await?;
384        received
385            .try_into()
386            .map_err(|e: serde_utils::DeserializeError| TypedReceiveError::Typing(e.to_string()))
387    }
388}
389
390#[cfg(test)]
391mod tests {
392    use std::{collections::HashMap, time::Duration};
393
394    use bitwarden_threading::time::sleep;
395    use serde::{Deserialize, Serialize};
396
397    use super::*;
398    use crate::{
399        IpcClientExt,
400        endpoint::{Endpoint, HostId, Source},
401        ipc_client_trait::IpcClient,
402        message::PayloadTypeName,
403        rpc::{
404            request::RpcRequest,
405            request_message::{RPC_REQUEST_PAYLOAD_TYPE_NAME, RpcRequestMessage},
406            response_message::IncomingRpcResponseMessage,
407        },
408        traits::{InMemorySessionRepository, NoEncryptionCryptoProvider, TestCommunicationBackend},
409    };
410
411    /// Error type for [`TestCryptoProvider`] that carries an explicit fatal/recoverable
412    /// classification so tests can exercise both control-flow paths.
413    #[derive(Debug, Clone)]
414    struct TestCryptoError {
415        // Read only through the derived `Debug` impl (the outer `SendError` wraps it via
416        // `{e:?}`), which dead-code analysis does not count as a use.
417        #[allow(dead_code)]
418        message: String,
419        fatal: bool,
420    }
421
422    impl IpcErrorKind for TestCryptoError {
423        fn kind(&self) -> ErrorKind {
424            if self.fatal {
425                ErrorKind::Fatal
426            } else {
427                ErrorKind::Other
428            }
429        }
430    }
431
432    struct TestCryptoProvider {
433        /// Simulate a send result. Set to `None` wait indefinitely
434        send_result: Option<Result<(), TestCryptoError>>,
435        /// Simulate a receive result. Set to `None` wait indefinitely
436        receive_result: Option<Result<IncomingMessage, TestCryptoError>>,
437    }
438
439    type TestSessionRepository = InMemorySessionRepository<String>;
440    impl CryptoProvider<TestCommunicationBackend, TestSessionRepository> for TestCryptoProvider {
441        type Session = String;
442        type SendError = TestCryptoError;
443        type ReceiveError = TestCryptoError;
444
445        async fn receive(
446            &self,
447            _receiver: &<TestCommunicationBackend as CommunicationBackend>::Receiver,
448            _communication: &TestCommunicationBackend,
449            _sessions: &TestSessionRepository,
450        ) -> Result<IncomingMessage, Self::ReceiveError> {
451            match &self.receive_result {
452                Some(result) => {
453                    // Yield (and throttle) so a recoverable error that makes the processing loop
454                    // `continue` doesn't busy-spin and starve the single-threaded test runtime.
455                    // Real backends await their underlying transport here, which has the same
456                    // effect.
457                    sleep(Duration::from_millis(5)).await;
458                    result.clone()
459                }
460                None => {
461                    // Simulate waiting for a message but never returning
462                    sleep(Duration::from_secs(600)).await;
463                    Err(TestCryptoError {
464                        message: "Simulated timeout".to_string(),
465                        fatal: true,
466                    })
467                }
468            }
469        }
470
471        async fn send(
472            &self,
473            _communication: &TestCommunicationBackend,
474            _sessions: &TestSessionRepository,
475            _message: OutgoingMessage,
476        ) -> Result<(), Self::SendError> {
477            match &self.send_result {
478                Some(result) => result.clone(),
479                None => {
480                    // Simulate waiting for a message to be send but never returning
481                    sleep(Duration::from_secs(600)).await;
482                    Err(TestCryptoError {
483                        message: "Simulated timeout".to_string(),
484                        fatal: true,
485                    })
486                }
487            }
488        }
489    }
490
491    #[tokio::test]
492    async fn returns_send_error_when_crypto_provider_returns_error() {
493        let message = OutgoingMessage {
494            payload: vec![],
495            destination: Endpoint::BrowserBackground { id: HostId::Own },
496            topic: None,
497        };
498        let crypto_provider = TestCryptoProvider {
499            send_result: Some(Err(TestCryptoError {
500                message: "Crypto error".to_string(),
501                fatal: false,
502            })),
503            receive_result: Some(Err(TestCryptoError {
504                message: "Should not have be called".to_string(),
505                fatal: false,
506            })),
507        };
508        let communication_provider = TestCommunicationBackend::new();
509        let session_map = TestSessionRepository::new(HashMap::new());
510        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
511        let _ = client.start(None).await;
512
513        let error = client.send(message).await.unwrap_err();
514
515        assert!(error.to_string().contains("Crypto error"));
516    }
517
518    #[tokio::test]
519    async fn communication_provider_has_outgoing_message_when_sending_through_ipc_client() {
520        let message = OutgoingMessage {
521            payload: vec![],
522            destination: Endpoint::BrowserBackground { id: HostId::Own },
523            topic: None,
524        };
525        let crypto_provider = NoEncryptionCryptoProvider;
526        let communication_provider = TestCommunicationBackend::new();
527        let session_map = InMemorySessionRepository::new(HashMap::new());
528        let client =
529            IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
530        let _ = client.start(None).await;
531
532        client.send(message.clone()).await.unwrap();
533
534        let outgoing_messages = communication_provider.outgoing().await;
535        assert_eq!(outgoing_messages, vec![message]);
536    }
537
538    #[tokio::test]
539    async fn returns_received_message_when_received_from_backend() {
540        let message = IncomingMessage {
541            payload: vec![],
542            source: Source::Web {
543                tab_id: 9001,
544                document_id: "doc-1".to_string(),
545                origin: "https://example.com".to_string(),
546            },
547            destination: Endpoint::BrowserBackground { id: HostId::Own },
548            topic: None,
549        };
550        let crypto_provider = NoEncryptionCryptoProvider;
551        let communication_provider = TestCommunicationBackend::new();
552        let session_map = InMemorySessionRepository::new(HashMap::new());
553        let client =
554            IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
555        let _ = client.start(None).await;
556
557        let mut subscription = client
558            .subscribe(None)
559            .await
560            .expect("Subscribing should not fail");
561        communication_provider.push_incoming(message.clone());
562        let received_message = subscription.receive(None).await.unwrap();
563
564        assert_eq!(received_message, message);
565    }
566
567    #[tokio::test]
568    async fn skips_non_matching_topics_and_returns_first_matching_message() {
569        let non_matching_message = IncomingMessage {
570            payload: vec![],
571            source: Source::Web {
572                tab_id: 9001,
573                document_id: "doc-1".to_string(),
574                origin: "https://example.com".to_string(),
575            },
576            destination: Endpoint::BrowserBackground { id: HostId::Own },
577            topic: Some("non_matching_topic".to_owned()),
578        };
579        let matching_message = IncomingMessage {
580            payload: vec![109],
581            source: Source::Web {
582                tab_id: 9001,
583                document_id: "doc-1".to_string(),
584                origin: "https://example.com".to_string(),
585            },
586            destination: Endpoint::BrowserBackground { id: HostId::Own },
587            topic: Some("matching_topic".to_owned()),
588        };
589
590        let crypto_provider = NoEncryptionCryptoProvider;
591        let communication_provider = TestCommunicationBackend::new();
592        let session_map = InMemorySessionRepository::new(HashMap::new());
593        let client =
594            IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
595        let _ = client.start(None).await;
596        let mut subscription = client
597            .subscribe(Some("matching_topic".to_owned()))
598            .await
599            .expect("Subscribing should not fail");
600        communication_provider.push_incoming(non_matching_message.clone());
601        communication_provider.push_incoming(non_matching_message.clone());
602        communication_provider.push_incoming(matching_message.clone());
603
604        let received_message: IncomingMessage = subscription.receive(None).await.unwrap();
605
606        assert_eq!(received_message, matching_message);
607    }
608
609    #[tokio::test]
610    async fn skips_unrelated_messages_and_returns_typed_message() {
611        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
612        struct TestPayload {
613            some_data: String,
614        }
615
616        impl PayloadTypeName for TestPayload {
617            const PAYLOAD_TYPE_NAME: &str = "TestPayload";
618        }
619
620        let unrelated = IncomingMessage {
621            payload: vec![],
622            source: Source::Web {
623                tab_id: 9001,
624                document_id: "doc-1".to_string(),
625                origin: "https://example.com".to_string(),
626            },
627            destination: Endpoint::BrowserBackground { id: HostId::Own },
628            topic: None,
629        };
630        let typed_message = crate::message::TypedIncomingMessage {
631            payload: TestPayload {
632                some_data: "Hello, world!".to_string(),
633            },
634            source: Source::Web {
635                tab_id: 9001,
636                document_id: "doc-1".to_string(),
637                origin: "https://example.com".to_string(),
638            },
639            destination: Endpoint::BrowserBackground { id: HostId::Own },
640        };
641
642        let crypto_provider = NoEncryptionCryptoProvider;
643        let communication_provider = TestCommunicationBackend::new();
644        let session_map = InMemorySessionRepository::new(HashMap::new());
645        let client =
646            IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
647        let _ = client.start(None).await;
648        let mut subscription = client
649            .subscribe_typed::<TestPayload>()
650            .await
651            .expect("Subscribing should not fail");
652        communication_provider.push_incoming(unrelated.clone());
653        communication_provider.push_incoming(unrelated.clone());
654        communication_provider.push_incoming(
655            typed_message
656                .clone()
657                .try_into()
658                .expect("Serialization should not fail"),
659        );
660
661        let received_message = subscription.receive(None).await.unwrap();
662
663        assert_eq!(received_message, typed_message);
664    }
665
666    #[tokio::test]
667    async fn returns_error_if_related_message_was_not_deserializable() {
668        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
669        struct TestPayload {
670            some_data: String,
671        }
672
673        impl PayloadTypeName for TestPayload {
674            const PAYLOAD_TYPE_NAME: &str = "TestPayload";
675        }
676
677        let non_deserializable_message = IncomingMessage {
678            payload: vec![],
679            source: Source::Web {
680                tab_id: 9001,
681                document_id: "doc-1".to_string(),
682                origin: "https://example.com".to_string(),
683            },
684            destination: Endpoint::BrowserBackground { id: HostId::Own },
685            topic: Some("TestPayload".to_owned()),
686        };
687
688        let crypto_provider = NoEncryptionCryptoProvider;
689        let communication_provider = TestCommunicationBackend::new();
690        let session_map = InMemorySessionRepository::new(HashMap::new());
691        let client =
692            IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
693        let _ = client.start(None).await;
694        let mut subscription = client
695            .subscribe_typed::<TestPayload>()
696            .await
697            .expect("Subscribing should not fail");
698        communication_provider.push_incoming(non_deserializable_message.clone());
699
700        let result = subscription.receive(None).await;
701        assert!(matches!(result, Err(TypedReceiveError::Typing(_))));
702    }
703
704    #[tokio::test]
705    async fn ipc_client_stops_if_crypto_returns_fatal_send_error() {
706        let message = OutgoingMessage {
707            payload: vec![],
708            destination: Endpoint::BrowserBackground { id: HostId::Own },
709            topic: None,
710        };
711        let crypto_provider = TestCryptoProvider {
712            send_result: Some(Err(TestCryptoError {
713                message: "Crypto error".to_string(),
714                fatal: true,
715            })),
716            receive_result: None,
717        };
718        let communication_provider = TestCommunicationBackend::new();
719        let session_map = TestSessionRepository::new(HashMap::new());
720        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
721        let _ = client.start(None).await;
722
723        let error = client.send(message).await.unwrap_err();
724        let is_running = client.is_running();
725
726        assert!(error.to_string().contains("Crypto error"));
727        assert!(!is_running);
728    }
729
730    #[tokio::test]
731    async fn ipc_client_keeps_running_if_crypto_returns_recoverable_send_error() {
732        let message = OutgoingMessage {
733            payload: vec![],
734            destination: Endpoint::BrowserBackground { id: HostId::Own },
735            topic: None,
736        };
737        let crypto_provider = TestCryptoProvider {
738            // A recoverable send error (e.g. a handshake timeout because the peer is down) must
739            // not tear down the shared client.
740            send_result: Some(Err(TestCryptoError {
741                message: "Crypto error".to_string(),
742                fatal: false,
743            })),
744            // Block forever on receive so the loop stays alive while we inspect it.
745            receive_result: None,
746        };
747        let communication_provider = TestCommunicationBackend::new();
748        let session_map = TestSessionRepository::new(HashMap::new());
749        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
750        let _ = client.start(None).await;
751
752        let error = client.send(message).await.unwrap_err();
753        let is_running = client.is_running();
754
755        // The error is still surfaced to the caller...
756        assert!(error.to_string().contains("Crypto error"));
757        // ...but the client keeps running so future sends/requests can succeed.
758        assert!(is_running);
759    }
760
761    #[tokio::test]
762    async fn ipc_client_stops_if_crypto_returns_fatal_receive_error() {
763        let crypto_provider = TestCryptoProvider {
764            send_result: None,
765            receive_result: Some(Err(TestCryptoError {
766                message: "Crypto error".to_string(),
767                fatal: true,
768            })),
769        };
770        let communication_provider = TestCommunicationBackend::new();
771        let session_map = TestSessionRepository::new(HashMap::new());
772        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
773        let cancellation_token = CancellationToken::new();
774        let _ = client.start(Some(cancellation_token.clone())).await;
775
776        // Give the client some time to process the error
777        tokio::time::sleep(Duration::from_millis(100)).await;
778        let is_running = client.is_running();
779
780        assert!(!is_running);
781        assert!(cancellation_token.is_cancelled());
782    }
783
784    #[tokio::test]
785    async fn ipc_client_keeps_running_if_crypto_returns_recoverable_receive_error() {
786        let crypto_provider = TestCryptoProvider {
787            send_result: None,
788            // A recoverable receive error must not stop the processing loop.
789            receive_result: Some(Err(TestCryptoError {
790                message: "Crypto error".to_string(),
791                fatal: false,
792            })),
793        };
794        let communication_provider = TestCommunicationBackend::new();
795        let session_map = TestSessionRepository::new(HashMap::new());
796        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
797        let cancellation_token = CancellationToken::new();
798        let _ = client.start(Some(cancellation_token.clone())).await;
799
800        // Give the client time to hit the recoverable receive error (repeatedly).
801        tokio::time::sleep(Duration::from_millis(100)).await;
802        let is_running = client.is_running();
803
804        assert!(is_running);
805        assert!(!cancellation_token.is_cancelled());
806    }
807
808    #[tokio::test]
809    async fn ipc_client_is_not_running_if_cancellation_token_is_cancelled() {
810        let crypto_provider = TestCryptoProvider {
811            send_result: None,
812            receive_result: None,
813        };
814        let communication_provider = TestCommunicationBackend::new();
815        let session_map = TestSessionRepository::new(HashMap::new());
816        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
817        let cancellation_token = CancellationToken::new();
818        let _ = client.start(Some(cancellation_token.clone())).await;
819
820        // Give the client some time to process
821        tokio::time::sleep(Duration::from_millis(100)).await;
822
823        // Cancel the token and give the client some time to process the cancellation
824        cancellation_token.cancel();
825        tokio::time::sleep(Duration::from_millis(100)).await;
826        let is_running = client.is_running();
827
828        assert!(!is_running);
829    }
830
831    #[tokio::test]
832    async fn ipc_client_is_running_if_no_errors_are_encountered() {
833        let crypto_provider = TestCryptoProvider {
834            send_result: None,
835            receive_result: None,
836        };
837        let communication_provider = TestCommunicationBackend::new();
838        let session_map = TestSessionRepository::new(HashMap::new());
839        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
840        let cancellation_token = CancellationToken::new();
841        let _ = client.start(Some(cancellation_token.clone())).await;
842
843        // Give the client some time to process
844        tokio::time::sleep(Duration::from_millis(100)).await;
845        let is_running = client.is_running();
846
847        assert!(is_running);
848        assert!(!cancellation_token.is_cancelled());
849    }
850
851    #[tokio::test]
852    async fn ipc_client_is_not_running_if_not_started() {
853        let crypto_provider = TestCryptoProvider {
854            send_result: None,
855            receive_result: None,
856        };
857        let communication_provider = TestCommunicationBackend::new();
858        let session_map = TestSessionRepository::new(HashMap::new());
859        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
860
861        // Give the client some time to process
862        tokio::time::sleep(Duration::from_millis(100)).await;
863        let is_running = client.is_running();
864
865        assert!(!is_running);
866    }
867
868    #[tokio::test]
869    async fn ipc_client_start_returns_error_if_already_running() {
870        let crypto_provider = TestCryptoProvider {
871            send_result: None,
872            receive_result: None,
873        };
874        let communication_provider = TestCommunicationBackend::new();
875        let session_map = TestSessionRepository::new(HashMap::new());
876        let client = IpcClientImpl::new(crypto_provider, communication_provider, session_map);
877        let cancellation_token = CancellationToken::new();
878        let first_result = client.start(Some(cancellation_token.clone())).await;
879        assert_eq!(first_result, Ok(()));
880
881        // Give the client some time to process
882        tokio::time::sleep(Duration::from_millis(100)).await;
883        assert!(client.is_running());
884
885        let second_result = client.start(Some(cancellation_token.clone())).await;
886        assert_eq!(second_result, Err(AlreadyRunningError));
887    }
888
889    mod request {
890        use super::*;
891        use crate::RpcHandler;
892
893        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
894        struct TestRequest {
895            a: i32,
896            b: i32,
897        }
898
899        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
900        struct TestResponse {
901            result: i32,
902        }
903
904        impl RpcRequest for TestRequest {
905            type Response = TestResponse;
906
907            const NAME: &str = "TestRequest";
908        }
909
910        struct TestHandler;
911
912        impl RpcHandler for TestHandler {
913            type Request = TestRequest;
914
915            async fn handle(&self, request: Self::Request) -> TestResponse {
916                TestResponse {
917                    result: request.a + request.b,
918                }
919            }
920        }
921
922        /// A second request type whose response is a unit enum, which serde serializes to a bare
923        /// JSON string. Deserializing it as [`TestResponse`] fails, which is what makes it a
924        /// faithful stand-in for the real `GetBiometricsStatus` / `UnlockBiometrics` pair.
925        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
926        struct OtherRequest;
927
928        #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
929        enum OtherResponse {
930            Available,
931        }
932
933        impl RpcRequest for OtherRequest {
934            type Response = OtherResponse;
935
936            const NAME: &str = "OtherRequest";
937        }
938
939        #[tokio::test]
940        async fn request_sends_message_and_returns_response() {
941            let crypto_provider = NoEncryptionCryptoProvider;
942            let communication_provider = TestCommunicationBackend::new();
943            let session_map = InMemorySessionRepository::default();
944            let client =
945                IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
946            let _ = client.start(None).await;
947            let request = TestRequest { a: 1, b: 2 };
948            let response = TestResponse { result: 3 };
949
950            // Send the request
951            let request_clone = request.clone();
952            let client_clone = client.clone();
953            let result_handle = tokio::spawn(async move {
954                client_clone
955                    .request::<TestRequest>(
956                        request_clone,
957                        Endpoint::BrowserBackground { id: HostId::Own },
958                        None,
959                    )
960                    .await
961            });
962            tokio::time::sleep(Duration::from_millis(100)).await;
963
964            // Read and verify the outgoing message
965            let outgoing_messages = communication_provider.outgoing().await;
966            let outgoing_request: RpcRequestMessage<TestRequest> =
967                serde_utils::from_slice(&outgoing_messages[0].payload)
968                    .expect("Deserialization should not fail");
969            assert_eq!(outgoing_request.request_type, "TestRequest");
970            assert_eq!(outgoing_request.request, request);
971
972            // Simulate receiving a response
973            let simulated_response = IncomingRpcResponseMessage {
974                result: Ok(response),
975                request_id: outgoing_request.request_id.clone(),
976                request_type: outgoing_request.request_type.clone(),
977            };
978            let simulated_response = IncomingMessage {
979                payload: serde_utils::to_vec(&simulated_response)
980                    .expect("Serialization should not fail"),
981                source: Source::BrowserBackground { id: HostId::Own },
982                destination: Endpoint::Web {
983                    tab_id: 9001,
984                    document_id: "doc-1".to_string(),
985                },
986                // The requester subscribes to the dedicated topic it minted, so the response must
987                // be published there.
988                topic: Some(outgoing_request.response_topic.clone()),
989            };
990            communication_provider.push_incoming(simulated_response);
991
992            // Wait for the response
993            let result = result_handle.await.unwrap();
994            assert_eq!(result.unwrap().result, 3);
995        }
996
997        /// Two requests in flight at once must each receive their own response. Each request mints
998        /// its own response topic, so `TestRequest` and `OtherRequest` never see each other's
999        /// responses even though their response types differ (`OtherResponse::Available` is a bare
1000        /// JSON string that would not deserialize into `TestResponse`).
1001        #[tokio::test]
1002        async fn concurrent_request_of_a_different_type_does_not_disturb_a_pending_request() {
1003            let crypto_provider = NoEncryptionCryptoProvider;
1004            let communication_provider = TestCommunicationBackend::new();
1005            let session_map = InMemorySessionRepository::default();
1006            let client =
1007                IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
1008            let _ = client.start(None).await;
1009
1010            // Put both requests in flight at the same time.
1011            let test_handle = {
1012                let client = client.clone();
1013                tokio::spawn(async move {
1014                    client
1015                        .request::<TestRequest>(
1016                            TestRequest { a: 1, b: 2 },
1017                            Endpoint::BrowserBackground { id: HostId::Own },
1018                            None,
1019                        )
1020                        .await
1021                })
1022            };
1023            let other_handle = {
1024                let client = client.clone();
1025                tokio::spawn(async move {
1026                    client
1027                        .request::<OtherRequest>(
1028                            OtherRequest,
1029                            Endpoint::BrowserBackground { id: HostId::Own },
1030                            None,
1031                        )
1032                        .await
1033                })
1034            };
1035            tokio::time::sleep(Duration::from_millis(100)).await;
1036
1037            // Recover both requests from the wire, keyed by type, so we can answer each on the
1038            // dedicated response topic it minted.
1039            let outgoing = communication_provider.outgoing().await;
1040            let requests: HashMap<String, RpcRequestMessage<serde_json::Value>> = outgoing
1041                .iter()
1042                .map(|message| {
1043                    let partial: RpcRequestMessage<serde_json::Value> =
1044                        serde_utils::from_slice(&message.payload)
1045                            .expect("Deserialization should not fail");
1046                    (partial.request_type.clone(), partial)
1047                })
1048                .collect();
1049
1050            let respond = |topic: String, payload: Vec<u8>| {
1051                communication_provider.push_incoming(IncomingMessage {
1052                    payload,
1053                    source: Source::BrowserBackground { id: HostId::Own },
1054                    destination: Endpoint::Web {
1055                        tab_id: 9001,
1056                        document_id: "doc-1".to_string(),
1057                    },
1058                    topic: Some(topic),
1059                });
1060            };
1061
1062            // Answer `OtherRequest` first, while `TestRequest` is still waiting. Each response goes
1063            // to its own dedicated topic, so the two can never be confused for one another.
1064            respond(
1065                requests["OtherRequest"].response_topic.clone(),
1066                serde_utils::to_vec(&IncomingRpcResponseMessage {
1067                    result: Ok(OtherResponse::Available),
1068                    request_id: requests["OtherRequest"].request_id.clone(),
1069                    request_type: "OtherRequest".to_string(),
1070                })
1071                .expect("Serialization should not fail"),
1072            );
1073
1074            respond(
1075                requests["TestRequest"].response_topic.clone(),
1076                serde_utils::to_vec(&IncomingRpcResponseMessage {
1077                    result: Ok(TestResponse { result: 3 }),
1078                    request_id: requests["TestRequest"].request_id.clone(),
1079                    request_type: "TestRequest".to_string(),
1080                })
1081                .expect("Serialization should not fail"),
1082            );
1083
1084            assert_eq!(
1085                other_handle
1086                    .await
1087                    .unwrap()
1088                    .expect("OtherRequest should succeed"),
1089                OtherResponse::Available
1090            );
1091            assert_eq!(
1092                test_handle
1093                    .await
1094                    .unwrap()
1095                    .expect("TestRequest should succeed"),
1096                TestResponse { result: 3 }
1097            );
1098        }
1099
1100        /// A response that reaches this request's dedicated topic but fails to deserialize into the
1101        /// expected response type is a genuine error for *this* request: because the topic is
1102        /// dedicated, there is no other request it could belong to, so the failure is surfaced
1103        /// immediately.
1104        #[tokio::test]
1105        async fn malformed_response_surfaces_error_instead_of_hanging() {
1106            let crypto_provider = NoEncryptionCryptoProvider;
1107            let communication_provider = TestCommunicationBackend::new();
1108            let session_map = InMemorySessionRepository::default();
1109            let client =
1110                IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
1111            let _ = client.start(None).await;
1112
1113            let result_handle = {
1114                let client = client.clone();
1115                tokio::spawn(async move {
1116                    client
1117                        .request::<TestRequest>(
1118                            TestRequest { a: 1, b: 2 },
1119                            Endpoint::BrowserBackground { id: HostId::Own },
1120                            None,
1121                        )
1122                        .await
1123                })
1124            };
1125            tokio::time::sleep(Duration::from_millis(100)).await;
1126
1127            // Recover the dedicated response topic and answer it with a body that cannot be
1128            // deserialized into `TestResponse` (`OtherResponse::Available` serializes to a bare
1129            // string).
1130            let outgoing = communication_provider.outgoing().await;
1131            let request: RpcRequestMessage<TestRequest> =
1132                serde_utils::from_slice(&outgoing[0].payload)
1133                    .expect("Deserialization should not fail");
1134            let malformed = IncomingRpcResponseMessage {
1135                result: Ok(OtherResponse::Available),
1136                request_id: request.request_id.clone(),
1137                request_type: request.request_type.clone(),
1138            };
1139            communication_provider.push_incoming(IncomingMessage {
1140                payload: serde_utils::to_vec(&malformed).expect("Serialization should not fail"),
1141                source: Source::BrowserBackground { id: HostId::Own },
1142                destination: Endpoint::Web {
1143                    tab_id: 9001,
1144                    document_id: "doc-1".to_string(),
1145                },
1146                topic: Some(request.response_topic.clone()),
1147            });
1148
1149            let result = tokio::time::timeout(Duration::from_secs(5), result_handle)
1150                .await
1151                .expect("request must not hang on a malformed response")
1152                .unwrap();
1153            assert!(matches!(
1154                result,
1155                Err(crate::error::RequestError::Rpc(
1156                    crate::rpc::error::RpcError::ResponseDeserialization(_)
1157                ))
1158            ));
1159        }
1160
1161        #[tokio::test]
1162        async fn incoming_rpc_message_handles_request_and_returns_response() {
1163            let crypto_provider = NoEncryptionCryptoProvider;
1164            let communication_provider = TestCommunicationBackend::new();
1165            let session_map = InMemorySessionRepository::default();
1166            let client =
1167                IpcClientImpl::new(crypto_provider, communication_provider.clone(), session_map);
1168            let _ = client.start(None).await;
1169            let request_id = uuid::Uuid::new_v4().to_string();
1170            let request = TestRequest { a: 1, b: 2 };
1171            let response = TestResponse { result: 3 };
1172
1173            // Register the handler
1174            client.register_rpc_handler(TestHandler).await;
1175
1176            // Simulate receiving a request
1177            let response_topic = format!("RpcResponseMessage:{request_id}");
1178            let simulated_request = RpcRequestMessage {
1179                request,
1180                request_id: request_id.clone(),
1181                request_type: "TestRequest".to_string(),
1182                response_topic: response_topic.clone(),
1183            };
1184            let simulated_request_message = IncomingMessage {
1185                payload: serde_utils::to_vec(&simulated_request)
1186                    .expect("Serialization should not fail"),
1187                source: Source::Web {
1188                    tab_id: 9001,
1189                    document_id: "doc-1".to_string(),
1190                    origin: "https://example.com".to_string(),
1191                },
1192                destination: Endpoint::BrowserBackground { id: HostId::Own },
1193                topic: Some(RPC_REQUEST_PAYLOAD_TYPE_NAME.to_owned()),
1194            };
1195            communication_provider.push_incoming(simulated_request_message);
1196
1197            // Give the client some time to process the request
1198            tokio::time::sleep(Duration::from_millis(100)).await;
1199
1200            // Read and verify the outgoing message
1201            let outgoing_messages = communication_provider.outgoing().await;
1202            let outgoing_response: IncomingRpcResponseMessage<TestResponse> =
1203                serde_utils::from_slice(&outgoing_messages[0].payload)
1204                    .expect("Deserialization should not fail");
1205
1206            // The response must be published on the dedicated topic carried by the request.
1207            assert_eq!(outgoing_messages[0].topic, Some(response_topic));
1208            assert_eq!(outgoing_response.request_type, "TestRequest");
1209            assert_eq!(outgoing_response.result, Ok(response));
1210        }
1211    }
1212}