Skip to main content

xmtp_mls/subscriptions/
router_callbacks.rs

1//! Native callback streams over the backend subscription router.
2//!
3//! Clients share a wire only when they share an API client Arc and host.
4//! The registry retains the API client, so its address cannot be reused.
5//! Retryable wire failures keep leases alive and reconnect with backoff.
6//! Each callback stream reports close on terminal failure or explicit close.
7
8use std::collections::HashMap;
9use std::sync::{Arc, LazyLock};
10
11use parking_lot::Mutex;
12
13use tokio::sync::oneshot;
14use tokio_util::sync::CancellationToken;
15
16use xmtp_api_backend::{BackendBinding, BidiConnection, BidiTransport, OpenError, TransportError};
17use xmtp_common::{MaybeSend, StreamHandle};
18use xmtp_db::consent_record::ConsentState;
19use xmtp_db::group::ConversationType;
20use xmtp_db::group_message::StoredGroupMessage;
21use xmtp_proto::api_client::{XmtpMlsBidiStreams, XmtpMlsStreams};
22use xmtp_proto::types::{GroupId, InstallationId};
23
24use xmtp_common::Event;
25use xmtp_macro::log_event;
26
27use super::{Result, StreamKind, SubscribeError};
28use crate::Client;
29use crate::context::XmtpSharedContext;
30use crate::groups::MlsGroup;
31
32/// Stable identity of the Arc that owns a backend API client.
33pub trait ApiClientIdentity {
34    fn api_client_identity(&self) -> usize;
35}
36
37impl<C: ?Sized> ApiClientIdentity for Arc<C> {
38    fn api_client_identity(&self) -> usize {
39        Arc::as_ptr(self).cast::<()>() as usize
40    }
41}
42
43type WireKey = (String, usize);
44
45struct SharedWires {
46    transports: HashMap<WireKey, BidiTransport<BackendBinding>>,
47    suspend_requested: bool,
48}
49
50static SHARED_WIRES: LazyLock<Mutex<SharedWires>> = LazyLock::new(|| {
51    Mutex::new(SharedWires {
52        transports: HashMap::new(),
53        suspend_requested: false,
54    })
55});
56
57/// Prevent automatic unary receipt from bypassing native stream suspension.
58pub(crate) fn bidi_streams_suspended() -> bool {
59    SHARED_WIRES.lock().suspend_requested
60}
61
62#[cfg(test)]
63pub(crate) fn shared_transport_count() -> usize {
64    SHARED_WIRES.lock().transports.len()
65}
66
67/// The shared transport for the destination `api` dials, created at that
68/// destination's first stream (see the module docs and [`SHARED_WIRES`]).
69///
70/// A transport's ledger task is bound to the async runtime alive at its
71/// first use — the one-runtime-per-process reality of mobile, node, and
72/// agents. A process that tears its runtime down and starts another (some
73/// non-`nextest` test harnesses) would find the cached transport dead;
74/// `nextest`'s process-per-test model keeps tests clear of that.
75pub(crate) fn shared_transport<C>(api: C) -> BidiTransport<BackendBinding>
76where
77    C: XmtpMlsBidiStreams + ApiClientIdentity + Clone + Send + Sync + 'static,
78    C::SubscribeStream: 'static,
79{
80    let key = (api.host().to_owned(), api.api_client_identity());
81    // CFG-064: interest-update frames are chunked here, below
82    // `ApiClientWrapper`, so the caps come from the transport's own copy of the
83    // snapshot. Whoever creates the transport donates them along with the API
84    // client, and a transport that was never told keeps the compiled defaults.
85    let mutate = xmtp_api_backend::MutateLimits::from_limits(&api.bidi_limits());
86    let mut wires = SHARED_WIRES.lock();
87    // A transport created while the app is backgrounded is born suspended —
88    // its first lease parks instead of dialing (see [`SharedWires`]).
89    let born_suspended = wires.suspend_requested;
90    wires
91        .transports
92        .entry(key)
93        .or_insert_with_key(|(host, _)| {
94            // Whoever streams to this destination first donates their api
95            // client for the life of the process.
96            tracing::info!(
97                %host,
98                suspended = born_suspended,
99                "bidi: initializing the shared transport for a destination"
100            );
101            BidiTransport::new_within(
102                move |initial| {
103                    let api = api.clone();
104                    async move {
105                        BidiConnection::open(&api, initial)
106                            .await
107                            .map_err(OpenError::new)
108                    }
109                },
110                born_suspended,
111                mutate,
112            )
113        })
114        .clone()
115}
116
117/// Suspend every shared wire and remember the intent for new wires.
118pub async fn suspend_bidi_streams() -> Result<()> {
119    // Enqueue each transport's command UNDER the same lock that flips the
120    // process flag, so a concurrent resume can't interleave its command send
121    // between our flag flip and ours — the actor then sees Suspend/Resume in
122    // the order the lock ordered the flags, not in scheduler order. The push is
123    // synchronous; only the reply await below runs outside the lock.
124    let (wires, pending): (Vec<_>, Vec<_>) = {
125        let mut shared = SHARED_WIRES.lock();
126        shared.suspend_requested = true;
127        shared
128            .transports
129            .iter()
130            .map(|(host, t)| ((host.clone(), t.clone()), t.enqueue_suspend()))
131            .unzip()
132    };
133    settle_lifecycle(&wires, await_lifecycle_acks(pending).await)
134}
135
136/// Resume shared wires. Reconnect and target processing continue in their tasks.
137pub async fn resume_bidi_streams() -> Result<()> {
138    // Enqueue under the flag lock, same as [`suspend_bidi_streams`]: the
139    // command order must match the flag order under all interleavings. The
140    // reply receivers are dropped — fire-and-forget — and an `Err` from a
141    // closed (tombstoned) transport is dropped with them: a destination
142    // permanently off the network is vacuously resumed.
143    let mut shared = SHARED_WIRES.lock();
144    shared.suspend_requested = false;
145    for t in shared.transports.values() {
146        let _ = t.enqueue_resume();
147    }
148    Ok(())
149}
150
151/// Await every enqueued lifecycle reply, mapping a dropped sender (a closed
152/// transport) to `Closed`. The command pushes already happened under the
153/// registry lock ([`suspend_bidi_streams`]); only this await runs outside it,
154/// so a slow catch-up on one destination never delays another's command.
155async fn await_lifecycle_acks(
156    pending: Vec<std::result::Result<tokio::sync::oneshot::Receiver<()>, TransportError>>,
157) -> Vec<std::result::Result<(), TransportError>> {
158    futures::future::join_all(pending.into_iter().map(|reply| async move {
159        match reply {
160            Ok(rx) => rx.await.map_err(|_| TransportError::Closed),
161            Err(e) => Err(e),
162        }
163    }))
164    .await
165}
166
167/// Wait for every wire before reporting a lifecycle failure.
168fn settle_lifecycle(
169    wires: &[(WireKey, BidiTransport<BackendBinding>)],
170    results: Vec<std::result::Result<(), TransportError>>,
171) -> Result<()> {
172    let mut first_error = None;
173    for (_, result) in wires.iter().zip(results) {
174        match result {
175            Ok(()) => {}
176            Err(TransportError::Closed) => {}
177            Err(e) => first_error = first_error.or(Some(e)),
178        }
179    }
180    match first_error {
181        None => Ok(()),
182        Some(e) => Err(SubscribeError::Transport(e)),
183    }
184}
185
186/// Close telemetry and callback also run when the stream task is aborted.
187struct StreamClosedGuard<F: FnOnce()> {
188    kind: StreamKind,
189    installation: InstallationId,
190    on_close: Option<F>,
191}
192
193impl<F: FnOnce()> Drop for StreamClosedGuard<F> {
194    fn drop(&mut self) {
195        log_event!(Event::StreamClosed, self.installation, kind = ?self.kind);
196        if let Some(on_close) = self.on_close.take() {
197            on_close();
198        }
199    }
200}
201
202pub(crate) struct StreamOrigin {
203    pub(crate) kind: StreamKind,
204    pub(crate) installation: InstallationId,
205    pub(crate) cancel: CancellationToken,
206}
207
208impl StreamOrigin {
209    fn new<Context: XmtpSharedContext>(kind: StreamKind, context: &Context) -> Self {
210        Self {
211            kind,
212            installation: context.installation_id(),
213            cancel: context.cancellation_token().clone(),
214        }
215    }
216}
217
218/// Deliver local stream items. The next poll acknowledges the preceding callback return.
219pub(crate) fn pump_stream<T, S, St>(
220    origin: StreamOrigin,
221    subscribe: S,
222    mut callback: impl FnMut(Result<T>) + MaybeSend + 'static,
223    on_close: impl FnOnce() + MaybeSend + 'static,
224) -> impl StreamHandle<StreamOutput = Result<()>>
225where
226    T: MaybeSend + 'static,
227    St: futures::Stream<Item = Result<T>> + MaybeSend + Unpin + 'static,
228    S: Future<Output = Result<St>> + MaybeSend + 'static,
229{
230    use futures::StreamExt;
231    let (tx, rx) = oneshot::channel();
232    let task = async move {
233        let StreamOrigin {
234            kind,
235            installation,
236            cancel,
237        } = origin;
238        log_event!(Event::StreamOpened, installation, kind = ?kind);
239        let _closed = StreamClosedGuard {
240            kind,
241            installation,
242            on_close: Some(on_close),
243        };
244        let mut stream = tokio::select! {
245            _ = cancel.cancelled() => return Ok(()),
246            result = subscribe => result?,
247        };
248        let _ = tx.send(());
249        loop {
250            tokio::select! {
251                _ = cancel.cancelled() => break,
252                next = stream.next() => match next {
253                    Some(item) => callback(item),
254                    None => break,
255                }
256            }
257        }
258        Ok(())
259    };
260    xmtp_common::spawn(Some(rx), xmtp_common::bind_task_hub(task))
261}
262
263impl<C> Client<C>
264where
265    C: XmtpSharedContext + 'static,
266    C::ApiClient:
267        XmtpMlsBidiStreams + XmtpMlsStreams + ApiClientIdentity + Clone + Send + Sync + 'static,
268    <C::ApiClient as XmtpMlsBidiStreams>::SubscribeStream: 'static,
269{
270    pub fn stream_all_messages_with_callback_dispatch(
271        client: Arc<Client<C>>,
272        conversation_type: Option<ConversationType>,
273        consent_states: Option<Vec<ConsentState>>,
274        callback: impl FnMut(Result<StoredGroupMessage>) + MaybeSend + 'static,
275        on_close: impl FnOnce() + MaybeSend + 'static,
276    ) -> impl StreamHandle<StreamOutput = Result<()>> {
277        let origin = StreamOrigin::new(StreamKind::All, &client.context);
278        let subscribe = async move {
279            super::stream_all::StreamAllMessages::new_owned(
280                client.context.clone(),
281                conversation_type,
282                consent_states,
283            )
284            .await
285        };
286        pump_stream(origin, subscribe, callback, on_close)
287    }
288
289    pub fn stream_conversations_with_callback_dispatch(
290        client: Arc<Client<C>>,
291        conversation_type: Option<ConversationType>,
292        include_duplicate_dms: bool,
293        callback: impl FnMut(Result<MlsGroup<C>>) + MaybeSend + 'static,
294        on_close: impl FnOnce() + MaybeSend + 'static,
295    ) -> impl StreamHandle<StreamOutput = Result<()>> {
296        let origin = StreamOrigin::new(StreamKind::Conversations, &client.context);
297        let subscribe = async move {
298            super::stream_conversations::StreamConversations::new_owned(
299                client.context.clone(),
300                conversation_type,
301                include_duplicate_dms,
302                None,
303            )
304            .await
305        };
306        pump_stream(origin, subscribe, callback, on_close)
307    }
308}
309
310pub fn stream_conversation_messages_with_callback_dispatch<C>(
311    context: C,
312    group_id: GroupId,
313    callback: impl FnMut(Result<StoredGroupMessage>) + MaybeSend + 'static,
314    on_close: impl FnOnce() + MaybeSend + 'static,
315) -> impl StreamHandle<StreamOutput = Result<()>>
316where
317    C: XmtpSharedContext + 'static,
318    C::ApiClient:
319        XmtpMlsBidiStreams + XmtpMlsStreams + ApiClientIdentity + Clone + Send + Sync + 'static,
320    <C::ApiClient as XmtpMlsBidiStreams>::SubscribeStream: 'static,
321{
322    let origin = StreamOrigin::new(StreamKind::Messages, &context);
323    let subscribe = async move {
324        super::stream_messages::StreamGroupMessages::new_owned(context, vec![group_id]).await
325    };
326    pump_stream(origin, subscribe, callback, on_close)
327}