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