Skip to main content

xmtp_api_backend/queries/backend/
connection.rs

1//! Backend subscription frames for the connection actor and topic ledger.
2
3use crate::queries::bidi::{BidiBinding, Connection, Event, Inbound};
4use crate::queries::bidi_transport::TransportBinding;
5use xmtp_proto::api_client::XmtpMlsBidiStreams;
6use xmtp_proto::backend_v1::{
7    self, Ping, Pong, ServerEnvelope, SubscribeRequest, SubscribeResponse, subscribe_request,
8    subscribe_request::Update, subscribe_response,
9};
10use xmtp_proto::types::{Topic, TopicKind};
11
12/// The backend uses one scalar cursor per topic.
13pub struct BackendBinding;
14
15pub type BidiConnection = Connection<BackendBinding>;
16pub type BidiEvent = Event<ServerEnvelope, ServerEnvelope>;
17
18fn request_frame(request: subscribe_request::Request) -> SubscribeRequest {
19    SubscribeRequest {
20        request: Some(request),
21    }
22}
23
24fn topic_from_wire(topic: &backend_v1::Topic) -> Option<Topic> {
25    // Topic::try_from checks length. Check the kind before calling Topic::kind.
26    TopicKind::try_from(*topic.topic.first()?).ok()?;
27    Topic::try_from(topic.topic.clone()).ok()
28}
29
30fn envelope_topic(envelope: &ServerEnvelope) -> Option<Topic> {
31    topic_from_wire(envelope.meta.as_ref()?.topic.as_ref()?)
32}
33
34impl BidiBinding for BackendBinding {
35    type Request = SubscribeRequest;
36    type Response = SubscribeResponse;
37    type Mutate = Update;
38    type GroupMessage = ServerEnvelope;
39    type WelcomeMessage = ServerEnvelope;
40
41    fn mutate_frame(update: Update) -> SubscribeRequest {
42        request_frame(subscribe_request::Request::Update(update))
43    }
44
45    fn ping_frame(nonce: u64) -> SubscribeRequest {
46        request_frame(subscribe_request::Request::Ping(Ping { nonce }))
47    }
48
49    fn pong_frame(nonce: u64) -> SubscribeRequest {
50        request_frame(subscribe_request::Request::Pong(Pong { nonce }))
51    }
52
53    fn handle(response: SubscribeResponse) -> Inbound<ServerEnvelope, ServerEnvelope> {
54        use subscribe_response::Response;
55        match response.response {
56            Some(Response::Started(started)) => Inbound::Emit(Event::Started {
57                keepalive_interval_ms: started.keepalive_interval_ms,
58            }),
59            Some(Response::Applied(applied)) => {
60                let targets: Option<Vec<_>> = applied
61                    .added_targets
62                    .into_iter()
63                    .map(|target| {
64                        Some((
65                            topic_from_wire(target.topic.as_ref()?)?,
66                            target.through_sequence_id,
67                        ))
68                    })
69                    .collect();
70                match targets {
71                    Some(targets) => Inbound::Emit(Event::Applied {
72                        id: applied.id,
73                        targets,
74                    }),
75                    None => Inbound::Invalid("Applied topic"),
76                }
77            }
78            Some(Response::Messages(messages)) => {
79                let mut group = Vec::new();
80                let mut welcome = Vec::new();
81                for envelope in messages.envelopes {
82                    match envelope_topic(&envelope).map(|topic| topic.kind()) {
83                        Some(TopicKind::GroupMessagesV1 | TopicKind::IdentityUpdatesV1) => {
84                            group.push(envelope)
85                        }
86                        Some(TopicKind::WelcomeMessagesV1) => welcome.push(envelope),
87                        _ => return Inbound::Invalid("envelope topic"),
88                    }
89                }
90                Inbound::Messages { group, welcome }
91            }
92            Some(Response::Ping(ping)) => Inbound::Ping(ping.nonce),
93            Some(Response::Pong(pong)) => Inbound::Pong(pong.nonce),
94            None => Inbound::Invalid("response"),
95        }
96    }
97}
98
99impl TransportBinding for BackendBinding {
100    type Cursor = u64;
101
102    fn build_mutate(
103        adds: impl IntoIterator<Item = (Topic, u64)>,
104        removes: impl IntoIterator<Item = Topic>,
105        id: u64,
106    ) -> Update {
107        Update {
108            id,
109            adds: adds
110                .into_iter()
111                .map(|(topic, sequence_id)| backend_v1::TopicQuery {
112                    topic: Some(backend_v1::Topic {
113                        topic: topic.to_bytes().into_vec(),
114                    }),
115                    cursor: Some(backend_v1::Cursor { sequence_id }),
116                })
117                .collect(),
118            removes: removes
119                .into_iter()
120                .map(|topic| backend_v1::Topic {
121                    topic: topic.to_bytes().into_vec(),
122                })
123                .collect(),
124        }
125    }
126
127    fn group_topic(envelope: &ServerEnvelope) -> Option<Topic> {
128        envelope_topic(envelope)
129    }
130
131    fn welcome_topic(envelope: &ServerEnvelope) -> Option<Topic> {
132        envelope_topic(envelope)
133    }
134
135    fn group_cursor(envelope: &ServerEnvelope) -> Option<u64> {
136        Some(envelope.meta.as_ref()?.cursor.as_ref()?.sequence_id)
137    }
138
139    fn welcome_cursor(envelope: &ServerEnvelope) -> Option<u64> {
140        Self::group_cursor(envelope)
141    }
142
143    fn group_envelope(envelope: &ServerEnvelope) -> &ServerEnvelope {
144        envelope
145    }
146    fn welcome_envelope(envelope: &ServerEnvelope) -> &ServerEnvelope {
147        envelope
148    }
149
150    fn advance(position: &mut u64, delivered: u64) {
151        *position = (*position).max(delivered);
152    }
153
154    fn covers(position: &u64, delivered: &u64) -> bool {
155        delivered <= position
156    }
157
158    fn meet(a: u64, b: u64) -> u64 {
159        a.min(b)
160    }
161}
162
163impl BidiConnection {
164    /// Open the backend subscription with the initial topic update.
165    pub async fn open<A>(api: &A, initial: Update) -> Result<Self, A::Error>
166    where
167        A: XmtpMlsBidiStreams,
168        A::SubscribeStream: 'static,
169    {
170        Self::start(initial, |outbound| api.subscribe_bidi(outbound)).await
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177    use crate::queries::bidi::{
178        BidiError, COMMAND_BUFFER, DEFAULT_KEEPALIVE_MS, EVENT_BUFFER, MAX_PENDING_FRAMES,
179        PROBE_TIMEOUT_MULTIPLIER, TryMutateError, WIRE_BUFFER,
180    };
181    use futures::StreamExt;
182    use futures::stream::BoxStream;
183    use std::sync::Mutex;
184    use std::time::Duration;
185    use tokio::sync::mpsc;
186    use xmtp_proto::api::ApiClientError;
187    use xmtp_proto::backend_v1::subscribe_request::Update as Mutate;
188    use xmtp_proto::types::TopicKind;
189
190    use crate::test::bidi::mock_pair;
191
192    #[derive(Debug, thiserror::Error)]
193    #[error("boom")]
194    struct Boom;
195    impl xmtp_common::RetryableError for Boom {
196        fn is_retryable(&self) -> bool {
197            false
198        }
199    }
200
201    fn started(keepalive: u32) -> subscribe_response::Response {
202        subscribe_response::Response::Started(subscribe_response::Started {
203            keepalive_interval_ms: keepalive,
204        })
205    }
206
207    fn applied(id: u64) -> subscribe_response::Response {
208        subscribe_response::Response::Applied(subscribe_response::Applied {
209            id,
210            added_targets: vec![],
211        })
212    }
213
214    fn wire_topic(kind: TopicKind, identifier: &[u8]) -> backend_v1::Topic {
215        backend_v1::Topic {
216            topic: kind.create(identifier).to_bytes().into_vec(),
217        }
218    }
219
220    fn initial_mutate() -> Mutate {
221        BackendBinding::build_mutate(
222            [
223                (TopicKind::GroupMessagesV1.create(b"group"), 5),
224                (TopicKind::WelcomeMessagesV1.create(b"installation"), 0),
225            ],
226            [],
227            11,
228        )
229    }
230
231    #[xmtp_common::test(unwrap_try = true)]
232    async fn open_sends_initial_mutate_and_emits_started() {
233        let (api, mut server) = mock_pair();
234        let mut conn = BidiConnection::open(&api, initial_mutate()).await?;
235
236        let subscribe_request::Request::Update(sent) = server.next_request().await else {
237            panic!("first frame must be the initial Mutate");
238        };
239        assert_eq!(sent, initial_mutate());
240
241        server.send(started(30_000));
242        assert_eq!(
243            conn.next().await,
244            Some(BidiEvent::Started {
245                keepalive_interval_ms: 30_000,
246            })
247        );
248    }
249
250    #[xmtp_common::test(unwrap_try = true)]
251    async fn auto_pongs_server_ping_without_surfacing_it() {
252        let (api, mut server) = mock_pair();
253        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
254        server.next_request().await; // initial mutate
255
256        server.send(subscribe_response::Response::Ping(Ping { nonce: 42 }));
257        let subscribe_request::Request::Pong(pong) = server.next_request().await else {
258            panic!("server ping must be answered with a pong");
259        };
260        assert_eq!(pong.nonce, 42);
261
262        // The ping/pong never reaches the consumer: the next event is the Started.
263        server.send(started(15_000));
264        assert_eq!(
265            conn.next().await,
266            Some(BidiEvent::Started {
267                keepalive_interval_ms: 15_000,
268            })
269        );
270    }
271
272    #[xmtp_common::test(unwrap_try = true)]
273    async fn probe_round_trips_and_pong_is_not_an_event() {
274        let (api, mut server) = mock_pair();
275        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
276        server.next_request().await; // initial mutate
277
278        // `probe` sends a client Ping and awaits its Pong; drive the server side
279        // concurrently to answer it.
280        let server_side = async {
281            let subscribe_request::Request::Ping(ping) = server.next_request().await else {
282                panic!("probe must send a Ping");
283            };
284            server.send(subscribe_response::Response::Pong(Pong {
285                nonce: ping.nonce,
286            }));
287        };
288        let (result, ()) = futures::join!(conn.probe(), server_side);
289        assert!(matches!(result, Ok(())), "probe should resolve on its pong");
290
291        // The correlating pong was consumed internally, never surfaced.
292        server.send(started(10_000));
293        assert_eq!(
294            conn.next().await,
295            Some(BidiEvent::Started {
296                keepalive_interval_ms: 10_000,
297            })
298        );
299    }
300
301    #[xmtp_common::test(unwrap_try = true)]
302    async fn mutate_is_forwarded_to_the_wire() {
303        let (api, mut server) = mock_pair();
304        let conn = BidiConnection::open(&api, Mutate::default()).await?;
305        server.next_request().await; // initial mutate
306
307        let m = Mutate {
308            removes: vec![wire_topic(TopicKind::GroupMessagesV1, b"group")],
309            ..Default::default()
310        };
311        conn.mutate(m.clone()).await?;
312        let subscribe_request::Request::Update(sent) = server.next_request().await else {
313            panic!("mutate must reach the wire");
314        };
315        assert_eq!(sent, m);
316    }
317
318    #[xmtp_common::test(unwrap_try = true)]
319    async fn unknown_frames_close_with_a_protocol_error() {
320        let (api, mut server) = mock_pair();
321        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
322        server.next_request().await; // initial mutate
323
324        // Prost maps an unknown response kind to an absent oneof.
325        server.send_raw(SubscribeResponse { response: None });
326        assert!(conn.next().await.is_none());
327        assert!(matches!(
328            conn.failure().as_deref(),
329            Some(crate::queries::bidi::ConnectionFailure::Protocol(
330                "response"
331            ))
332        ));
333    }
334
335    #[xmtp_common::test(unwrap_try = true)]
336    async fn inbound_error_closes_the_connection() {
337        let (api, mut server) = mock_pair();
338        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
339        server.next_request().await;
340
341        server
342            .to_client
343            .send(Err(ApiClientError::client(Boom)))
344            .unwrap();
345        assert_eq!(conn.next().await, None);
346    }
347
348    /// The whole point of the push-down: when the server closes the response
349    /// side, the actor (which owns *both* halves) tears everything down — so a
350    /// later `mutate`/`probe` reports `Closed` by ownership, never silently
351    /// enqueueing into a dead stream. The transport's drainer keeps the wire
352    /// channel itself open, so only the actor's exit can produce this.
353    #[xmtp_common::test(unwrap_try = true)]
354    async fn closing_inbound_tears_down_sends() {
355        let (api, mut server) = mock_pair();
356        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
357        server.next_request().await; // initial mutate
358
359        // Drop the server: its `to_client` closes (inbound ends → actor exits),
360        // while the transport's outbound drainer survives and keeps the wire
361        // channel open.
362        drop(server);
363        assert_eq!(conn.next().await, None, "actor observed end-of-stream");
364
365        assert!(
366            matches!(conn.mutate(Mutate::default()).await, Err(BidiError::Closed)),
367            "mutate after teardown must report Closed, not silently enqueue"
368        );
369        assert!(
370            matches!(conn.probe().await, Err(BidiError::Closed)),
371            "probe after teardown must report Closed"
372        );
373    }
374
375    #[xmtp_common::test(unwrap_try = true)]
376    async fn concurrent_mutate_and_probe_both_reach_the_wire() {
377        let (api, mut server) = mock_pair();
378        let conn = BidiConnection::open(&api, Mutate::default()).await?;
379        server.next_request().await; // initial mutate
380
381        let m = Mutate {
382            removes: vec![wire_topic(TopicKind::GroupMessagesV1, b"group")],
383            ..Default::default()
384        };
385
386        // Fire a mutate and a probe concurrently; both must reach the wire (in
387        // some order), and the probe resolves once its pong returns.
388        let expected = m.clone();
389        let server_side = async {
390            let mut saw_mutate = false;
391            let mut pong_nonce = None;
392            for _ in 0..2 {
393                match server.next_request().await {
394                    subscribe_request::Request::Update(sent) => {
395                        assert_eq!(sent, expected);
396                        saw_mutate = true;
397                    }
398                    subscribe_request::Request::Ping(ping) => pong_nonce = Some(ping.nonce),
399                    other => panic!("unexpected frame: {other:?}"),
400                }
401            }
402            server.send(subscribe_response::Response::Pong(Pong {
403                nonce: pong_nonce.expect("probe must send a ping"),
404            }));
405            saw_mutate
406        };
407
408        let (mutate_res, probe_res, saw_mutate) =
409            futures::join!(conn.mutate(m), conn.probe(), server_side);
410        assert!(mutate_res.is_ok());
411        assert!(matches!(probe_res, Ok(())));
412        assert!(saw_mutate, "the mutate must reach the wire");
413    }
414
415    #[xmtp_common::test(unwrap_try = true)]
416    async fn probe_within_times_out_when_no_pong() {
417        let (api, mut server) = mock_pair();
418        let conn = BidiConnection::open(&api, Mutate::default()).await?;
419        server.next_request().await; // initial mutate
420
421        // The server never answers the probe's ping. With a tight bound a caller
422        // (e.g. a notification handler) gets a prompt `ProbeTimedOut` rather than
423        // waiting out the keepalive-derived default.
424        let result = conn.probe_within(Duration::from_millis(100)).await;
425        assert!(matches!(result, Err(BidiError::ProbeTimedOut)));
426    }
427
428    #[xmtp_common::test(unwrap_try = true)]
429    async fn default_probe_timeout_tracks_server_keepalive() {
430        let (api, mut server) = mock_pair();
431        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
432        server.next_request().await; // initial mutate
433
434        // Before `Started`: the 30s fallback × N.
435        assert_eq!(
436            conn.default_probe_timeout(),
437            Duration::from_millis(
438                u64::from(DEFAULT_KEEPALIVE_MS) * u64::from(PROBE_TIMEOUT_MULTIPLIER)
439            )
440        );
441
442        // After `Started` advertises a cadence, the default tracks it.
443        server.send(started(5_000));
444        assert_eq!(
445            conn.next().await,
446            Some(BidiEvent::Started {
447                keepalive_interval_ms: 5_000,
448            })
449        );
450        assert_eq!(
451            conn.default_probe_timeout(),
452            Duration::from_millis(5_000 * u64::from(PROBE_TIMEOUT_MULTIPLIER))
453        );
454    }
455
456    /// A transport that delivers inbound but never drains the wire (it holds the
457    /// outbound stream unpolled), so a test can wedge the request half.
458    struct WedgedWireApi {
459        inbound: Mutex<Option<mpsc::UnboundedReceiver<Result<SubscribeResponse, ApiClientError>>>>,
460        held: Mutex<Option<BoxStream<'static, SubscribeRequest>>>,
461    }
462
463    #[xmtp_common::async_trait]
464    impl XmtpMlsBidiStreams for WedgedWireApi {
465        type SubscribeStream = BoxStream<'static, Result<SubscribeResponse, ApiClientError>>;
466        type Error = ApiClientError;
467
468        fn host(&self) -> &str {
469            "mock://bidi"
470        }
471
472        async fn subscribe_bidi(
473            &self,
474            requests: BoxStream<'static, SubscribeRequest>,
475        ) -> Result<Self::SubscribeStream, Self::Error> {
476            // Hold the outbound stream and never poll it: the wire fills and stays
477            // full.
478            *self.held.lock().unwrap() = Some(requests);
479            let mut inbound = self
480                .inbound
481                .lock()
482                .unwrap()
483                .take()
484                .expect("subscribe_bidi called twice");
485            Ok(Box::pin(futures::stream::poll_fn(move |cx| {
486                inbound.poll_recv(cx)
487            })))
488        }
489    }
490
491    /// With the old `send().await`-in-`select!` design, a full wire parked the
492    /// actor and it stopped reading inbound — so an auto-pong (or any event)
493    /// behind the wedge was lost. The queue + `reserve` branch must keep inbound
494    /// flowing while the wire is backed up.
495    #[xmtp_common::test(unwrap_try = true)]
496    async fn busy_wire_does_not_stall_inbound() {
497        let (to_client, inbound) = mpsc::unbounded_channel();
498        let api = WedgedWireApi {
499            inbound: Mutex::new(Some(inbound)),
500            held: Mutex::new(None),
501        };
502        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
503
504        // Saturate the wire: the transport never drains it, so the pre-seeded
505        // initial Mutate plus a batch of mutates fill the wire and back up the
506        // queue. None of these block the caller (command channel + queue have
507        // room), and crucially none park the actor.
508        for _ in 0..(WIRE_BUFFER * 2) {
509            conn.mutate(Mutate::default()).await?;
510        }
511
512        // Despite the wedged wire, a server frame is still read and surfaced.
513        to_client
514            .send(Ok(SubscribeResponse {
515                response: Some(started(7_000)),
516            }))
517            .unwrap();
518        assert_eq!(
519            conn.next().await,
520            Some(BidiEvent::Started {
521                keepalive_interval_ms: 7_000,
522            })
523        );
524    }
525
526    /// A permanently wedged wire must not buffer forever: once the backlog blows
527    /// past the cap the actor gives up and tears down, so the consumer re-opens
528    /// rather than the queue growing without bound.
529    #[xmtp_common::test(unwrap_try = true)]
530    async fn gives_up_when_wire_wedged_past_backlog_cap() {
531        let (to_client, inbound) = mpsc::unbounded_channel();
532        let api = WedgedWireApi {
533            inbound: Mutex::new(Some(inbound)),
534            held: Mutex::new(None),
535        };
536        let mut conn = BidiConnection::open(&api, Mutate::default()).await?;
537
538        // Flood server pings: the auto-pongs fill the never-draining wire, then
539        // pile in the queue until it exceeds the cap and the actor gives up.
540        for _ in 0..(MAX_PENDING_FRAMES + WIRE_BUFFER + 2) {
541            to_client
542                .send(Ok(SubscribeResponse {
543                    response: Some(subscribe_response::Response::Ping(Ping { nonce: 1 })),
544                }))
545                .unwrap();
546        }
547
548        // The connection tears down instead of buffering without bound.
549        assert_eq!(conn.next().await, None);
550        assert!(matches!(
551            conn.mutate(Mutate::default()).await,
552            Err(BidiError::Closed)
553        ));
554    }
555
556    /// A `Finish` queued behind a backlogged wire must still be processed — the
557    /// commands branch is deliberately ungated so a wedged wire can't starve it
558    /// (with a `pending`-room gate, the half-close would never reach the
559    /// transport and `next()` would hang forever). Regression test for that gate:
560    /// after `finish`, the flush budget expires, the un-flushable backlog is
561    /// dropped, and the request half closes — so draining the held outbound
562    /// stream yields exactly the frames the wire had already accepted, then ends.
563    #[xmtp_common::test(unwrap_try = true)]
564    async fn finish_is_processed_under_wire_backpressure() {
565        let (_to_client, inbound) = mpsc::unbounded_channel();
566        let api = WedgedWireApi {
567            inbound: Mutex::new(Some(inbound)),
568            held: Mutex::new(None),
569        };
570        let conn = BidiConnection::open(&api, Mutate::default()).await?;
571
572        // Back the wire up well past its depth: the initial Mutate plus the
573        // first WIRE_BUFFER - 1 mutates fill the wire; the rest park in the
574        // actor's pending queue.
575        for _ in 0..(WIRE_BUFFER * 2) {
576            conn.mutate(Mutate::default()).await?;
577        }
578
579        // The half-close must be accepted AND processed despite the backlog.
580        conn.finish().await?;
581
582        // Give the drain's flush budget time to expire (the wire never drains,
583        // so only the budget can end the flush), then take the held outbound
584        // stream: it must yield the WIRE_BUFFER frames the wire had accepted and
585        // then END — proof the actor dropped the request half rather than
586        // parking forever with Finish stuck behind the backlog.
587        xmtp_common::time::sleep(Duration::from_secs(2)).await;
588        let mut outbound = api.held.lock().unwrap().take().expect("wire was opened");
589        let mut flushed = 0usize;
590        while let Some(_frame) = outbound.next().await {
591            flushed += 1;
592        }
593        assert_eq!(
594            flushed, WIRE_BUFFER,
595            "only the already-accepted wire frames flush; the backlog is dropped \
596             and the request half closes"
597        );
598    }
599
600    /// After `finish`, the request half is half-closed: `mutate`/`probe` must
601    /// report `Closed` synchronously. Without the handle-side flag they would
602    /// race the actor — `Command::Finish` is still in flight, the command receiver
603    /// is briefly alive, and a FIFO-following `mutate` would be accepted into the
604    /// buffer (returning `Ok`) only to be dropped unread when the actor drains.
605    #[xmtp_common::test(unwrap_try = true)]
606    async fn mutate_and_probe_report_closed_after_finish() {
607        let (api, mut server) = mock_pair();
608        let conn = BidiConnection::open(&api, Mutate::default()).await?;
609        server.next_request().await; // initial mutate
610
611        conn.finish().await?;
612
613        assert!(
614            matches!(conn.mutate(Mutate::default()).await, Err(BidiError::Closed)),
615            "mutate after finish must report Closed, not land in the buffer"
616        );
617        assert!(
618            matches!(conn.probe().await, Err(BidiError::Closed)),
619            "probe after finish must report Closed"
620        );
621    }
622
623    /// A `probe` in flight when `finish` half-closes must resolve to `Closed`, not
624    /// hang: the actor drops the probe-ack senders before the (possibly long)
625    /// inbound drain. Put the probe's ping on the wire, then half-close, and assert
626    /// the probe resolves `Closed` rather than waiting out its timeout.
627    #[xmtp_common::test(unwrap_try = true)]
628    async fn finish_resolves_in_flight_probe_to_closed() {
629        let (api, mut server) = mock_pair();
630        let conn = BidiConnection::open(&api, Mutate::default()).await?;
631        server.next_request().await; // initial mutate
632
633        // Once the probe's ping reaches the actor (registered, awaiting a pong that
634        // never comes), half-close. A generous bound keeps a regression — a probe
635        // that hangs through the drain — from waiting out the keepalive default.
636        let driver = async {
637            let req = server.next_request().await;
638            assert!(
639                matches!(req, subscribe_request::Request::Ping(_)),
640                "probe must put a ping on the wire"
641            );
642            conn.finish().await
643        };
644        let (probe_res, finish_res) =
645            futures::join!(conn.probe_within(Duration::from_secs(5)), driver);
646        assert!(
647            matches!(probe_res, Err(BidiError::Closed)),
648            "an in-flight probe must resolve Closed when finish half-closes, got {probe_res:?}"
649        );
650        assert!(finish_res.is_ok());
651    }
652
653    /// `try_mutate` hands the wave back with `Full` once the command buffer
654    /// saturates behind an actor parked on a full event channel, and accepts
655    /// again after the consumer drains. This is the primitive that lets a
656    /// consumer that is also the sole event drainer stay non-blocking on the
657    /// send side (the transport's deadlock discipline).
658    #[xmtp_common::test(unwrap_try = true)]
659    async fn try_mutate_reports_full_and_recovers_after_drain() {
660        let (api, mut server) = mock_pair();
661        let mut conn = BidiConnection::open(&api, initial_mutate()).await?;
662        server.next_request().await; // initial mutate
663
664        // Park the actor: overfill the event buffer so its emit blocks, which
665        // stops command intake.
666        for nonce in 0..(EVENT_BUFFER as u64 + 2) {
667            server.send(applied(nonce));
668        }
669        // Saturate the command buffer. The actor drains a bounded handful of
670        // these before it parks, so the loop terminates well under the bound.
671        let mut accepted = 0usize;
672        loop {
673            match conn.try_mutate(initial_mutate()) {
674                Ok(()) => {
675                    accepted += 1;
676                    assert!(
677                        accepted <= EVENT_BUFFER + COMMAND_BUFFER + 8,
678                        "actor never parked; try_mutate never reported Full"
679                    );
680                }
681                Err(TryMutateError::Full(_)) => break,
682                Err(TryMutateError::Closed(_)) => panic!("connection died under the flood"),
683            }
684            // Give the actor its chance to make progress between attempts.
685            tokio::task::yield_now().await;
686        }
687
688        // Draining events unparks the actor, which resumes command intake.
689        let mut recovered = false;
690        for _ in 0..(EVENT_BUFFER + COMMAND_BUFFER) {
691            assert!(
692                conn.next().await.is_some(),
693                "flooded events must all arrive"
694            );
695            if conn.try_mutate(initial_mutate()).is_ok() {
696                recovered = true;
697                break;
698            }
699        }
700        assert!(recovered, "try_mutate must accept again after a drain");
701    }
702
703    /// After `finish`, `try_mutate` reports `Closed` and returns the wave.
704    #[xmtp_common::test(unwrap_try = true)]
705    async fn try_mutate_reports_closed_after_finish() {
706        let (api, mut server) = mock_pair();
707        let conn = BidiConnection::open(&api, initial_mutate()).await?;
708        server.next_request().await; // initial mutate
709
710        conn.finish().await?;
711        assert!(matches!(
712            conn.try_mutate(initial_mutate()),
713            Err(TryMutateError::Closed(_))
714        ));
715    }
716}