xmtp_api_backend/queries/bidi.rs
1//! The native bidirectional connection actor owns both stream halves.
2//!
3//! It sends all request frames, answers server pings, and matches probe pongs.
4//! It forwards subscription events in wire order. The binding supplies frame
5//! types and splits envelope batches. The actor also checks silence, bounds
6//! outbound backlog, and drains after request half-close.
7//!
8//! When the response stream ends, the actor releases both halves. Later sends
9//! fail through the closed command channel. The lease ledger owns reconnect.
10
11use parking_lot::Mutex;
12use std::collections::{HashMap, VecDeque};
13use std::sync::Arc;
14use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
15use std::time::Duration;
16
17use futures::StreamExt;
18use futures::stream::BoxStream;
19use tokio::sync::{mpsc, oneshot};
20use xmtp_common::{AbortHandle, MaybeSend, MaybeSync, RetryableError, StreamHandle};
21use xmtp_proto::types::Topic;
22
23/// Wire-outbound depth. The actor is the sole writer; a transport that stops
24/// draining backs frames up here first, then in the actor's `pending` queue,
25/// until [`MAX_PENDING_FRAMES`] declares the wire wedged and the actor gives up.
26pub(crate) const WIRE_BUFFER: usize = 64;
27/// Caller→actor command depth.
28pub(crate) const COMMAND_BUFFER: usize = 64;
29/// Actor→caller event depth; large enough that a brief consumer stall doesn't
30/// stall wire reads (and thus pong liveness).
31pub(crate) const EVENT_BUFFER: usize = 1024;
32/// Default keepalive until Started supplies the server interval.
33pub(crate) const DEFAULT_KEEPALIVE_MS: u32 = 30_000;
34/// The default probe deadline multiplier
35/// is this many keepalive intervals — generous enough not to false-positive a
36/// slow-but-live link. Latency-sensitive callers (e.g. a notification handler)
37/// pass a much smaller bound to [`Connection::probe_within`].
38///
39/// It is also the actor's total silence budget: after `N - 1` intervals with no
40/// inbound frame the actor sends a watchdog ping, and after the full `N` it
41/// tears down (see [`watchdog_deadline`]).
42pub(crate) const PROBE_TIMEOUT_MULTIPLIER: u32 = 3;
43/// Nonce for the actor's own watchdog pings. Client probes mint their nonces
44/// via [`next_probe_nonce`], which skips this value, so they can never collide
45/// with it — and the watchdog doesn't correlate the pong anyway: *any* inbound
46/// frame proves the link and resets the window.
47const WATCHDOG_NONCE: u64 = u64::MAX;
48
49/// Mint a client probe nonce: counts up from 1, skipping `0` and the reserved
50/// [`WATCHDOG_NONCE`] when the counter wraps. The loop settles within three
51/// steps — there are only two reserved values.
52fn next_probe_nonce(counter: &AtomicU64) -> u64 {
53 loop {
54 let nonce = counter.fetch_add(1, Ordering::Relaxed).wrapping_add(1);
55 if nonce != WATCHDOG_NONCE && nonce != 0 {
56 return nonce;
57 }
58 }
59}
60/// Hard cap on the outbound backlog. Past this, the wire has been wedged long
61/// enough that buffering more is pointless — the transport isn't draining the
62/// request half, so the link is effectively dead — and the actor gives up,
63/// tearing down so the consumer re-opens from cursors on a fresh stream.
64/// Reached only under a sustained stall while commands or auto-pongs keep
65/// arriving; tearing down here (rather than parking callers forever) is also
66/// what keeps a queued `Finish` from starving behind a wedged wire.
67pub(crate) const MAX_PENDING_FRAMES: usize = WIRE_BUFFER * 2;
68/// Total time the post-`finish` drain will wait for wire capacity while flushing
69/// already-accepted frames. Generous for a transient stall (a draining transport
70/// frees a slot in milliseconds) yet bounded, so a wedged transport can't hold
71/// the half-close hostage — see [`drain_after_finish`].
72const DRAIN_FLUSH_BUDGET: Duration = Duration::from_secs(1);
73
74/// Backend-specific wire vocabulary for a bidi subscription. The control core is
75/// generic over this: a binding names the wire request/response types and the
76/// per-backend `Mutate` and message types, builds outbound frames, and — via
77/// [`BidiBinding::handle`] — classifies an inbound response into an [`Inbound`]
78/// instruction.
79pub trait BidiBinding: Send + 'static {
80 /// Outbound wire frame (client → server).
81 type Request: Send + 'static;
82 /// Inbound wire frame (server → client).
83 type Response: Send + 'static;
84 /// The backend update payload.
85 type Mutate: Send;
86 /// Encrypted group envelope for the consumer.
87 type GroupMessage: MaybeSend + MaybeSync;
88 /// Encrypted welcome envelope for the consumer.
89 type WelcomeMessage: MaybeSend + MaybeSync;
90
91 /// Wrap a `Mutate` as an outbound request frame.
92 fn mutate_frame(mutate: Self::Mutate) -> Self::Request;
93 /// A client `Ping` request frame (liveness probe).
94 fn ping_frame(nonce: u64) -> Self::Request;
95 /// A `Pong` request frame answering a server `Ping`.
96 fn pong_frame(nonce: u64) -> Self::Request;
97
98 /// Classify one inbound response into an actor instruction. An associated
99 /// fn, like the frame constructors: bindings are stateless by design — any
100 /// stateful ordering or cursor tracking belongs to the consumer, not a
101 /// single connection actor.
102 fn handle(response: Self::Response) -> Inbound<Self::GroupMessage, Self::WelcomeMessage>;
103}
104
105/// What an inbound frame means to the control core, after the binding classifies
106/// it. Liveness (`Ping`/`Pong`) is handled by the actor and never surfaced;
107/// everything else becomes a consumer [`Event`].
108pub enum Inbound<G, W> {
109 /// Server ping — the actor auto-pongs this nonce.
110 Ping(u64),
111 /// Server pong — the actor resolves the matching client probe.
112 Pong(u64),
113 /// A single consumer event to surface (handshake / markers).
114 Emit(Event<G, W>),
115 /// A delivery batch — the actor emits `GroupMessages` then
116 /// `WelcomeMessages`. Empty batches produce no event.
117 Messages { group: Vec<G>, welcome: Vec<W> },
118 /// Nothing to do — unknown version or an informational/undecodable frame.
119 Skip,
120 /// The frame cannot establish an ordered delivery prefix.
121 Invalid(&'static str),
122}
123
124#[derive(Debug, thiserror::Error)]
125pub enum ConnectionFailure {
126 #[error("subscription stream failed: {0}")]
127 Wire(#[source] xmtp_proto::api::NetworkError),
128 #[error("invalid subscription frame: {0}")]
129 Protocol(&'static str),
130}
131
132impl RetryableError for ConnectionFailure {
133 fn is_retryable(&self) -> bool {
134 match self {
135 Self::Wire(error) => error.is_retryable(),
136 Self::Protocol(_) => false,
137 }
138 }
139}
140
141type FailureSlot = Arc<Mutex<Option<Arc<ConnectionFailure>>>>;
142
143/// Events surfaced to the consumer, in wire order. `Ping`/`Pong` never appear —
144/// liveness lives entirely inside the actor. Generic over the backend's group
145/// and welcome message types.
146#[derive(Debug, Clone, PartialEq)]
147pub enum Event<G, W> {
148 /// The first response supplies the server keepalive interval.
149 Started { keepalive_interval_ms: u32 },
150 /// An accepted update supplies fixed targets for newly added topics.
151 Applied { id: u64, targets: Vec<(Topic, u64)> },
152 /// Group envelopes in wire order.
153 GroupMessages { messages: Vec<G> },
154 /// Welcome envelopes in wire order.
155 WelcomeMessages { messages: Vec<W> },
156}
157
158#[derive(Debug, thiserror::Error)]
159pub enum BidiError {
160 #[error("the bidi connection is closed; re-open and resume from durable cursors")]
161 Closed,
162 #[error("liveness probe timed out; treat the link as dead, drop it, and re-open")]
163 ProbeTimedOut,
164}
165
166impl RetryableError for BidiError {
167 fn is_retryable(&self) -> bool {
168 true
169 }
170}
171
172/// Outcome of a [`Connection::try_mutate`] that could not be accepted; both
173/// variants hand the mutate back to the caller.
174#[derive(Debug)]
175pub enum TryMutateError<M> {
176 /// The command buffer is momentarily full — retry once the actor has
177 /// made progress (e.g. after the caller drains more events).
178 Full(M),
179 /// The connection is finished or its actor is gone; this update can only be
180 /// re-issued on a fresh connection.
181 Closed(M),
182}
183
184/// Submitted by the handle, performed by the actor (the sole wire writer).
185enum Command<B: BidiBinding> {
186 Mutate(B::Mutate),
187 /// A client liveness probe. The actor sends the `Ping` and fires `ack` when
188 /// the matching `Pong` returns; if the actor exits first, `ack` is dropped
189 /// and the waiting [`Connection::probe`] resolves to `Closed`.
190 Probe {
191 nonce: u64,
192 ack: oneshot::Sender<()>,
193 },
194 /// Half-close the request half. The actor drops its wire sender so the
195 /// outbound stream ends (the server sees the half-close), then drains inbound
196 /// to completion. No further outbound frames are sent after this.
197 Finish,
198}
199
200/// A handle to one open bidirectional subscription. Writing to the wire is the
201/// actor's job; this only submits commands and reads events. Generic over the
202/// backend [`BidiBinding`]. The backend module supplies the concrete alias.
203pub struct Connection<B: BidiBinding> {
204 commands: mpsc::Sender<Command<B>>,
205 events: mpsc::Receiver<Event<B::GroupMessage, B::WelcomeMessage>>,
206 probe_nonce: AtomicU64,
207 /// Latched `true` once a `finish` has *delivered* `Command::Finish` to the
208 /// actor; monotonic — set once on delivery, never cleared. Lets `mutate`/
209 /// `probe` observe `Closed` without racing the actor's teardown of the
210 /// command channel: after `finish` returns, `Command::Finish` is still in
211 /// flight and the receiver lives until the actor drains it, so a
212 /// FIFO-following `mutate` would otherwise be accepted into the buffer and
213 /// then silently dropped. Latching only on delivery (not before the send) is
214 /// what makes a cancelled `finish` harmless and concurrent `finish` calls
215 /// race-free — there is no reset to race.
216 finished: AtomicBool,
217 /// The server's advertised keepalive cadence (ms), recorded when
218 /// [`Self::next`] surfaces `Started`; `0` until then. Drives the default
219 /// probe deadline so `probe` can self-bound without the caller re-deriving
220 /// it. A probe issued before `Started` has been consumed falls back to the
221 /// 30s-derived default, which the probe docs already sanction as safe.
222 keepalive_ms: u32,
223 actor: Box<dyn AbortHandle>,
224 failure: FailureSlot,
225}
226
227impl<B: BidiBinding> Connection<B> {
228 /// Open the stream: seed `initial` as the first request frame (it names the
229 /// initial topic set with per-topic resume cursors),
230 /// then hand the outbound frame stream to `transport` to obtain the inbound
231 /// frame stream, and spawn the actor. The backend modules wrap this with an
232 /// ergonomic `open(api, initial)`.
233 pub(crate) async fn start<T, Fut, S, E>(initial: B::Mutate, transport: T) -> Result<Self, E>
234 where
235 T: FnOnce(BoxStream<'static, B::Request>) -> Fut,
236 Fut: Future<Output = Result<S, E>>,
237 S: futures::Stream<Item = Result<B::Response, E>> + Send + 'static,
238 E: RetryableError + Send + 'static,
239 {
240 let (wire_out, mut wire_out_rx) = mpsc::channel(WIRE_BUFFER);
241 let (commands_tx, commands_rx) = mpsc::channel(COMMAND_BUFFER);
242 let (event_tx, events) = mpsc::channel(EVENT_BUFFER);
243 let failure = FailureSlot::default();
244
245 // The first request frame carries the initial update. Seed it into
246 // the fresh, empty wire channel before the transport or the actor can
247 // write anything else; the receiver is still held here, so a fresh,
248 // empty, bounded channel can neither be full nor closed — `try_send`
249 // makes that "cannot block" invariant structural.
250 wire_out
251 .try_send(B::mutate_frame(initial))
252 .unwrap_or_else(|_| {
253 unreachable!("send into a fresh, empty, owned channel cannot fail")
254 });
255
256 // Wrap the receiver as a `Stream` without pulling in `tokio-stream`:
257 // `poll_recv` is exactly the poll fn a `Stream` needs.
258 let outbound = futures::stream::poll_fn(move |cx| wire_out_rx.poll_recv(cx));
259 let inbound = transport(Box::pin(outbound)).await?;
260
261 let actor = xmtp_common::spawn(
262 None,
263 // Box::pin makes the inbound stream `Unpin` for the select loop without
264 // requiring the transport's stream type to be `Unpin` itself.
265 run_actor::<B, _, _>(
266 Box::pin(inbound),
267 wire_out,
268 commands_rx,
269 event_tx,
270 failure.clone(),
271 ),
272 );
273
274 Ok(Self {
275 commands: commands_tx,
276 events,
277 probe_nonce: AtomicU64::new(0),
278 finished: AtomicBool::new(false),
279 keepalive_ms: 0,
280 actor: actor.abort_handle(),
281 failure,
282 })
283 }
284
285 /// Add/remove subscriptions in place. Awaits a free command slot
286 /// (backpressure is right for a state change); returns `Closed` once the
287 /// actor has stopped — the command receiver dies with it.
288 pub async fn mutate(&self, mutate: B::Mutate) -> Result<(), BidiError> {
289 if self.finished.load(Ordering::Acquire) {
290 return Err(BidiError::Closed);
291 }
292 self.commands
293 .send(Command::Mutate(mutate))
294 .await
295 .map_err(|_| BidiError::Closed)
296 }
297
298 /// Non-blocking [`Self::mutate`]: accept the update into the command buffer or
299 /// hand it straight back. Exists for callers that must never park on this
300 /// handle — a consumer that is also the sole drainer of [`Self::next`] and
301 /// awaits `mutate` while events back up can deadlock against the actor
302 /// (each side blocked on the channel only the other drains). Such callers
303 /// keep their own retry queue and stay free to drain.
304 ///
305 /// `Full` returns the mutate for the caller to retry; `Closed` returns it
306 /// for a post-mortem (re-open and re-subscribe from durable cursors).
307 pub fn try_mutate(&self, mutate: B::Mutate) -> Result<(), TryMutateError<B::Mutate>> {
308 if self.finished.load(Ordering::Acquire) {
309 return Err(TryMutateError::Closed(mutate));
310 }
311 self.commands
312 .try_send(Command::Mutate(mutate))
313 .map_err(|e| {
314 let (closed, cmd) = match e {
315 mpsc::error::TrySendError::Full(cmd) => (false, cmd),
316 mpsc::error::TrySendError::Closed(cmd) => (true, cmd),
317 };
318 let Command::Mutate(mutate) = cmd else {
319 unreachable!("try_mutate only submits Command::Mutate")
320 };
321 if closed {
322 TryMutateError::Closed(mutate)
323 } else {
324 TryMutateError::Full(mutate)
325 }
326 })
327 }
328
329 /// Half-close the request half — signal that we are done sending. The
330 /// outbound stream ends, so `mutate` and `probe` thereafter return `Closed`;
331 /// any live delivery already in flight keeps arriving until the server closes
332 /// its side. Half-close ends the session. It does not wait for catch-up
333 /// targets. Use the returned targets to decide when to cancel a bounded sync.
334 pub async fn finish(&self) -> Result<(), BidiError> {
335 // Latch closed only *after* the send is delivered. A cancelled `finish`
336 // (future dropped mid-send) never delivered `Finish`, so it must not
337 // wedge the handle — leaving the latch untouched is exactly right. And
338 // because the latch is monotonic (set on delivery, never cleared), two
339 // concurrent `finish` calls can't race a reset. The same-caller contract
340 // still holds: a FIFO-following `mutate`/`probe` runs after this store.
341 self.commands
342 .send(Command::Finish)
343 .await
344 .map_err(|_| BidiError::Closed)?;
345 self.finished.store(true, Ordering::Release);
346 Ok(())
347 }
348
349 /// Probe the link (e.g. right after the process resumes) with the default
350 /// deadline: `N ×` the server's advertised keepalive interval (a 30s-derived
351 /// fallback until `Started` arrives). Resolves `Ok` on the matching `Pong`,
352 /// `Closed` if the link is already torn down, or `ProbeTimedOut` if no pong
353 /// arrives in time — the half-open case this whole mechanism exists to catch.
354 pub async fn probe(&self) -> Result<(), BidiError> {
355 self.probe_within(self.default_probe_timeout()).await
356 }
357
358 /// Probe with an explicit deadline. A latency-sensitive caller — say a push
359 /// notification handler that must decide in a couple of seconds whether to
360 /// reuse the connection or re-open — passes a bound far below the default.
361 /// A tight bound trades occasional false `ProbeTimedOut`s for speed, which is
362 /// safe: re-opening replays from durable cursors and discards duplicates.
363 ///
364 /// The timeout covers the whole probe — submitting the ping (so a stalled
365 /// wire backpressuring the command queue can't park us) and awaiting the
366 /// pong. We deliberately don't fail-fast on a full command queue: a transient
367 /// burst of `mutate`s is "busy," not "dead."
368 pub async fn probe_within(&self, timeout: Duration) -> Result<(), BidiError> {
369 match xmtp_common::time::timeout(timeout, self.probe_inner()).await {
370 Ok(result) => result,
371 Err(_elapsed) => Err(BidiError::ProbeTimedOut),
372 }
373 }
374
375 async fn probe_inner(&self) -> Result<(), BidiError> {
376 if self.finished.load(Ordering::Acquire) {
377 return Err(BidiError::Closed);
378 }
379 let nonce = next_probe_nonce(&self.probe_nonce);
380 let (ack, ack_rx) = oneshot::channel();
381 self.commands
382 .send(Command::Probe { nonce, ack })
383 .await
384 .map_err(|_| BidiError::Closed)?;
385 ack_rx.await.map_err(|_| BidiError::Closed)
386 }
387
388 pub(crate) fn default_probe_timeout(&self) -> Duration {
389 let keepalive = match self.keepalive_ms {
390 0 => DEFAULT_KEEPALIVE_MS,
391 ms => ms,
392 };
393 Duration::from_millis(u64::from(keepalive) * u64::from(PROBE_TIMEOUT_MULTIPLIER))
394 }
395
396 /// Next event, in wire order. `None` means the connection ended (server
397 /// close, network death, watchdog teardown, or reap) — resume from durable
398 /// cursors on a fresh connection.
399 ///
400 /// Only resolves on an event or end-of-stream, but a half-open link cannot
401 /// leave it pending forever: the actor's silence watchdog tears the
402 /// connection down after [`PROBE_TIMEOUT_MULTIPLIER`] keepalive intervals
403 /// with no inbound frame (one watchdog ping in between), and that teardown
404 /// surfaces here as `None`. [`Self::probe`] remains for callers that need
405 /// an answer *faster* than the watchdog budget — e.g. a notification
406 /// handler deciding within seconds whether to reuse the connection.
407 pub async fn next(&mut self) -> Option<Event<B::GroupMessage, B::WelcomeMessage>> {
408 let event = self.events.recv().await;
409 // Record the server's cadence as `Started` passes through, so a probe
410 // issued in reaction to it already gets the keepalive-derived deadline.
411 if let Some(Event::Started {
412 keepalive_interval_ms,
413 ..
414 }) = &event
415 {
416 self.keepalive_ms = *keepalive_interval_ms;
417 }
418 event
419 }
420
421 pub fn failure(&self) -> Option<Arc<ConnectionFailure>> {
422 self.failure.lock().clone()
423 }
424}
425
426impl<B: BidiBinding> Drop for Connection<B> {
427 fn drop(&mut self) {
428 // Abort the actor so it cannot keep auto-ponging — a zombie keepalive
429 // would hold the server-side subscription open forever. The abort drops
430 // the actor's wire-outbound and inbound, cancelling the underlying
431 // request and tearing the stream down in both directions.
432 self.actor.end();
433 }
434}
435
436/// When the silence watchdog next fires: after `N - 1` keepalive intervals
437/// without an inbound frame it probes, and a probed connection gets the one
438/// remaining interval — the full `N ×` budget of [`PROBE_TIMEOUT_MULTIPLIER`] —
439/// before teardown. Recomputed every `select!` iteration, so any inbound frame
440/// pushes both deadlines out.
441fn watchdog_deadline(
442 last_inbound: tokio::time::Instant,
443 keepalive: Duration,
444 probed: bool,
445) -> tokio::time::Instant {
446 let intervals = if probed {
447 PROBE_TIMEOUT_MULTIPLIER
448 } else {
449 PROBE_TIMEOUT_MULTIPLIER - 1
450 };
451 last_inbound + keepalive * intervals
452}
453
454/// Hand `frame` to the wire if it has room right now, else queue it for the
455/// reserve branch to flush. Returns `false` when the actor should give up and tear
456/// down: either the wire is already closed (this frame can't be delivered), or the
457/// backlog has grown past [`MAX_PENDING_FRAMES`] — a wedged wire we won't buffer
458/// behind forever.
459///
460/// Reaching the queue means the transport isn't draining the request half —
461/// which on a healthy link should essentially never happen — so we warn the
462/// first time we fall back to it (not on every frame: an existing backlog skips
463/// straight to the queue without re-probing the wire).
464#[must_use]
465fn enqueue<R>(wire_out: &mpsc::Sender<R>, pending: &mut VecDeque<R>, frame: R) -> bool {
466 if pending.is_empty() {
467 match wire_out.try_send(frame) {
468 Ok(()) => return true,
469 Err(mpsc::error::TrySendError::Full(frame)) => {
470 tracing::warn!(
471 "bidi wire is backpressured (transport not draining the request half); \
472 queuing outbound frames"
473 );
474 pending.push_back(frame);
475 }
476 // Transport gone. Signal teardown now rather than queue an undeliverable
477 // frame and lean on the reserve branch to notice on a later `select!`
478 // iteration — which could accept a few more doomed frames first, since
479 // `select!` picks ready branches at random. The frame is dropped; a wire
480 // this dead will never send it, and the consumer re-syncs from durable
481 // cursors on re-open.
482 Err(mpsc::error::TrySendError::Closed(_frame)) => return false,
483 }
484 } else {
485 pending.push_back(frame);
486 }
487 if pending.len() > MAX_PENDING_FRAMES {
488 tracing::warn!(
489 backlog = pending.len(),
490 "bidi wire wedged past the outbound backlog cap; giving up so the consumer re-opens"
491 );
492 return false;
493 }
494 true
495}
496
497/// The single owner of the wire: sole reader of inbound, sole writer of
498/// outbound, sole producer of events. Caller commands and server frames are
499/// multiplexed onto one outbound FIFO via an internal `pending` queue, drained
500/// to the wire by a `reserve()` branch — so no send ever blocks the loop and a
501/// busy wire can't delay an auto-pong. (Event sends to the *consumer* do still
502/// await: a hopelessly slow consumer backpressures here, the intended path to
503/// reap-then-resume — distinct from outbound/wire pressure, which the queue
504/// handles.) It ends when the wire ends/errors or the handle goes away; ending
505/// drops `wire_out` (the request half closes, tearing the stream down), the
506/// `commands` receiver (so `mutate`/`probe` see `Closed`), the `events` sender
507/// (so `next` ends), and any outstanding probe acks (so pending `probe`s see
508/// `Closed`). One place, every teardown. It also gives up the same way if the
509/// outbound backlog blows past [`MAX_PENDING_FRAMES`] — a wedged wire that will
510/// never drain, so we tear down and let the consumer re-open — or if the wire
511/// goes silent past the watchdog budget (see [`watchdog_deadline`]): a
512/// conformant server keeps pinging, so sustained silence, with one watchdog
513/// ping of grace, means a half-open link that will never speak again.
514async fn run_actor<B, S, E>(
515 mut inbound: S,
516 wire_out: mpsc::Sender<B::Request>,
517 mut commands: mpsc::Receiver<Command<B>>,
518 events: mpsc::Sender<Event<B::GroupMessage, B::WelcomeMessage>>,
519 failure: FailureSlot,
520) where
521 B: BidiBinding,
522 S: futures::Stream<Item = Result<B::Response, E>> + Unpin,
523 E: RetryableError + 'static,
524{
525 // Outstanding client probes awaiting their `Pong`, keyed by nonce.
526 let mut probes: HashMap<u64, oneshot::Sender<()>> = HashMap::new();
527 // Outbound frames awaiting wire capacity. We drain these through a `reserve()`
528 // branch below rather than `send().await` in a branch body — an `.await` in a
529 // `select!` arm blocks the whole loop, so a backed-up wire would stall inbound
530 // reads and delay auto-pongs (getting us reaped on an otherwise healthy link).
531 let mut pending: VecDeque<B::Request> = VecDeque::new();
532 // Set by `Command::Finish`: leave the main loop and drain inbound to close,
533 // rather than tear everything down. Distinguishes a deliberate half-close
534 // from the wire/handle-gone breaks, which fall straight through to teardown.
535 let mut finished = false;
536 // Silence-watchdog state. A conformant server pings at `keepalive` cadence,
537 // so a healthy wire always shows *some* inbound frame well inside the
538 // budget; sustained silence is a half-open link. The cadence starts at the
539 // default and updates when `Started` advertises the real one.
540 let mut keepalive = Duration::from_millis(DEFAULT_KEEPALIVE_MS.into());
541 let mut last_inbound = tokio::time::Instant::now();
542 let mut watchdog_probed = false;
543
544 loop {
545 tokio::select! {
546 // Polled in order, so teardown is truly last-resort: the watchdog
547 // arm at the bottom is only reached when no flush, command, or
548 // inbound frame is ready — a pong landing in the same poll as the
549 // deadline always wins the tie and restamps the window. (A command
550 // flood can starve the deadline check, but a silent wire under a
551 // command flood hits `enqueue`'s give-up cap and tears down there.)
552 biased;
553 // Hand one queued frame to the wire the moment it has capacity,
554 // concurrently with everything else. This is what keeps a busy wire
555 // from blocking the loop. Gated on a non-empty queue so we only
556 // contend for a permit when there's something to flush.
557 permit = wire_out.reserve(), if !pending.is_empty() => {
558 match permit {
559 Ok(permit) => {
560 permit.send(pending.pop_front().expect("queue is non-empty"));
561 // Drain as much of the backlog as the wire will take right
562 // now, so a cleared stall flushes promptly.
563 while !pending.is_empty() {
564 match wire_out.try_reserve() {
565 Ok(permit) => {
566 permit.send(pending.pop_front().expect("queue is non-empty"));
567 }
568 Err(_) => break, // wire full again, or closed
569 }
570 }
571 }
572 Err(_) => break, // transport gone
573 }
574 }
575 // A command from the handle: mutate, probe, or finish. Never gated:
576 // a backlog-room gate would starve a queued `Finish` forever on a
577 // wedged wire (the give-up cap is only checked when a frame is
578 // enqueued, and a closed gate means nothing is). Backlog growth from
579 // accepted mutates/probes is bounded by `enqueue`'s
580 // [`MAX_PENDING_FRAMES`] give-up instead — a wedged wire tears down
581 // rather than parking callers forever.
582 cmd = commands.recv() => {
583 let Some(cmd) = cmd else {
584 // Every handle dropped — nothing more will be sent.
585 break;
586 };
587 let queued = match cmd {
588 Command::Mutate(mutate) => {
589 enqueue(&wire_out, &mut pending, B::mutate_frame(mutate))
590 }
591 Command::Probe { nonce, ack } => {
592 // Sweep entries whose probe already timed out client-side
593 // (their receiver is gone), so a scheduled prober against
594 // a pong-less peer can't grow the map without bound.
595 probes.retain(|_, ack| !ack.is_closed());
596 probes.insert(nonce, ack);
597 enqueue(&wire_out, &mut pending, B::ping_frame(nonce))
598 }
599 Command::Finish => {
600 // Half-close: stop accepting new commands, then flush the
601 // backlog and drain inbound to close (in `drain_after_finish`).
602 finished = true;
603 break;
604 }
605 };
606 if !queued {
607 break; // wire wedged past the backlog cap — give up
608 }
609 }
610 // A frame from the server. Always read, so liveness never stalls behind
611 // outbound pressure.
612 frame = inbound.next() => {
613 let Some(frame) = frame else {
614 break; // inbound ended — the stream is closed
615 };
616 let response = match frame {
617 Ok(r) => r,
618 Err(e) => {
619 tracing::warn!("bidi subscription stream errored: {e}");
620 *failure.lock() = Some(Arc::new(ConnectionFailure::Wire(xmtp_proto::api::NetworkError::new(e))));
621 break;
622 }
623 };
624 match B::handle(response) {
625 Inbound::Invalid(reason) => {
626 *failure.lock() = Some(Arc::new(ConnectionFailure::Protocol(reason)));
627 break;
628 }
629 // Liveness is internal: auto-pong / probe-correlate here, never
630 // surface it to the consumer.
631 Inbound::Ping(nonce) => {
632 // Auto-pong: hand it to the wire (or queue it FIFO behind a
633 // backlog). A busy wire delays but never drops the pong, and
634 // never blocks us from reading the next frame — but if the
635 // backlog has blown past the cap, the wire is wedged and we
636 // give up rather than keep piling pongs into a dead stream.
637 if !enqueue(&wire_out, &mut pending, B::pong_frame(nonce)) {
638 break;
639 }
640 }
641 Inbound::Pong(nonce) => {
642 // Resolve the matching client probe. Unmatched pongs are
643 // ignored — never surfaced to the consumer.
644 if let Some(ack) = probes.remove(&nonce) {
645 let _ = ack.send(());
646 }
647 }
648 // Consumer-facing frames. The same emit path feeds the live loop
649 // and the post-`finish` drain, so their event semantics match.
650 instruction => {
651 // Adopt the server's advertised cadence for the watchdog as
652 // `Started` passes through (0 keeps the fallback).
653 if let Inbound::Emit(Event::Started {
654 keepalive_interval_ms,
655 ..
656 }) = &instruction
657 && *keepalive_interval_ms > 0
658 {
659 keepalive = Duration::from_millis((*keepalive_interval_ms).into());
660 }
661 if emit_instruction(&events, instruction).await {
662 break;
663 }
664 }
665 }
666 // Stamp *after* the frame is fully handled, not on receipt: the
667 // emit above can park on consumer backpressure for a long time,
668 // and that stall is the consumer's, not the wire's — inbound
669 // frames may be sitting unread behind it. Restarting the window
670 // when we resume listening keeps a slow consumer from reading
671 // as wire silence and tearing down a healthy link.
672 last_inbound = tokio::time::Instant::now();
673 watchdog_probed = false;
674 }
675 // The silence watchdog. Fires only while the arms above are idle
676 // (enforced by `biased`) — exactly the "nothing inbound" condition
677 // it exists to detect. One
678 // unanswered ping stands between silence and teardown, so a quiet
679 // but live link (a server between keepalives) is never reaped: any
680 // inbound frame — the pong included — resets the window above.
681 _ = tokio::time::sleep_until(watchdog_deadline(last_inbound, keepalive, watchdog_probed)) => {
682 if watchdog_probed {
683 tracing::warn!(
684 silent_for_ms = last_inbound.elapsed().as_millis() as u64,
685 "bidi wire silent past the watchdog budget (probe unanswered); \
686 tearing down so the consumer re-opens from cursors"
687 );
688 break;
689 }
690 watchdog_probed = true;
691 if !enqueue(&wire_out, &mut pending, B::ping_frame(WATCHDOG_NONCE)) {
692 break;
693 }
694 }
695 }
696 }
697 // Resolve any in-flight probes now: dropping the probe-ack senders makes each
698 // waiting `probe()` see `Closed`. Must happen *before* `drain_after_finish`,
699 // which can block on `inbound` for a while on a half-open link — a probe must
700 // never hang on a connection that has already left the live loop.
701 drop(probes);
702 // A deliberate half-close drains inbound to close rather than tearing down;
703 // every other exit reason falls straight through to teardown.
704 if finished {
705 drain_after_finish::<B, _, _>(inbound, wire_out, pending, commands, &events).await;
706 }
707 // One exit log for every reason (wire ended/errored, handle gone, or finished
708 // draining). The error a caller sees is just `Closed`; the "why" lives here.
709 tracing::debug!("bidi subscription actor stopped");
710 // `wire_out`, `commands`, `events`, and the `pending` queue drop here — that
711 // *is* the teardown in both directions.
712}
713
714/// Surface the consumer events for one classified inbound frame, returning true
715/// if the consumer is gone (the actor should stop). `Ping`/`Pong` must already
716/// have been handled by the caller — passing them here is a no-op. Shared by the
717/// live loop and the post-`finish` drain so both deliver identical events.
718async fn emit_instruction<G, W>(
719 events: &mpsc::Sender<Event<G, W>>,
720 instruction: Inbound<G, W>,
721) -> bool {
722 match instruction {
723 Inbound::Emit(event) => emit(events, event).await,
724 Inbound::Messages { group, welcome } => {
725 if !group.is_empty() && emit(events, Event::GroupMessages { messages: group }).await {
726 return true;
727 }
728 if !welcome.is_empty()
729 && emit(events, Event::WelcomeMessages { messages: welcome }).await
730 {
731 return true;
732 }
733 false
734 }
735 // Ping/Pong are the caller's job; Skip is a no-op.
736 Inbound::Ping(_) | Inbound::Pong(_) | Inbound::Skip => false,
737 Inbound::Invalid(_) => true,
738 }
739}
740
741/// Drain inbound to completion after a half-close. First flush any frames the
742/// caller already had accepted (a `mutate` it got `Ok` for, an auto-pong, a
743/// probe ping queued under backpressure) so half-close doesn't silently discard
744/// them. Then dropping `wire_out` ends the outbound stream (the server sees the
745/// half-close, finishes the update, and closes its side); dropping `commands`
746/// makes any late `mutate`/`probe` see `Closed`. We keep surfacing events until
747/// the server closes inbound (`next` -> `None`) or the consumer goes away.
748/// Liveness frames can't be answered (the wire is gone), so they are ignored;
749/// outstanding probes drop with the actor and resolve to `Closed`.
750async fn drain_after_finish<B, S, E>(
751 mut inbound: S,
752 wire_out: mpsc::Sender<B::Request>,
753 mut pending: VecDeque<B::Request>,
754 commands: mpsc::Receiver<Command<B>>,
755 events: &mpsc::Sender<Event<B::GroupMessage, B::WelcomeMessage>>,
756) where
757 B: BidiBinding,
758 S: futures::Stream<Item = Result<B::Response, E>> + Unpin,
759 E: std::fmt::Display,
760{
761 // Flush the accepted-but-unsent backlog before closing the request half,
762 // waiting for wire capacity under one shared budget. Bounded on purpose: an
763 // unbounded `reserve().await` on a stalled transport (wire full, server no
764 // longer reading the request stream) would block forever, so `drop(wire_out)`
765 // would never run and the half-close would never reach the server — but a
766 // purely non-blocking flush drops accepted frames on a *transient* stall a
767 // healthy transport clears in milliseconds. Frames still unplaced when the
768 // budget runs out are lost; the consumer re-syncs from durable cursors on
769 // re-open, and a wedged actor is the strictly worse outcome.
770 let flush_deadline = tokio::time::Instant::now() + DRAIN_FLUSH_BUDGET;
771 while let Some(frame) = pending.pop_front() {
772 match tokio::time::timeout_at(flush_deadline, wire_out.reserve()).await {
773 Ok(Ok(permit)) => permit.send(frame),
774 Ok(Err(_)) => break, // wire gone — close rather than block
775 Err(_elapsed) => {
776 tracing::warn!(
777 dropped = pending.len() + 1,
778 "bidi half-close flush budget exhausted on a stalled wire; \
779 dropping the remaining backlog (re-open re-syncs from cursors)"
780 );
781 break;
782 }
783 }
784 }
785 drop(wire_out);
786 drop(commands);
787 while let Some(frame) = inbound.next().await {
788 let response = match frame {
789 Ok(r) => r,
790 Err(e) => {
791 tracing::warn!("bidi stream errored during finish drain: {e}");
792 break;
793 }
794 };
795 if emit_instruction(events, B::handle(response)).await {
796 break; // consumer gone
797 }
798 }
799}
800
801/// Send one event to the consumer, awaiting a free slot (the backpressure that
802/// stalls wire reads once [`EVENT_BUFFER`] fills). Returns true when the
803/// consumer is gone and the actor should shut down. (The server's keepalive
804/// cadence is recorded handle-side, when [`Connection::next`] surfaces
805/// `Started` — every event reaches the consumer through there.)
806async fn emit<G, W>(events: &mpsc::Sender<Event<G, W>>, event: Event<G, W>) -> bool {
807 events.send(event).await.is_err()
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813
814 /// A trivial binding: wire frames are bare `u64`s and nothing is ever
815 /// surfaced. Enough to exercise the backend-agnostic control logic directly.
816 struct TestBinding;
817
818 impl BidiBinding for TestBinding {
819 type Request = u64;
820 type Response = u64;
821 type Mutate = u64;
822 type GroupMessage = ();
823 type WelcomeMessage = ();
824
825 fn mutate_frame(mutate: u64) -> u64 {
826 mutate
827 }
828 fn ping_frame(nonce: u64) -> u64 {
829 nonce
830 }
831 fn pong_frame(nonce: u64) -> u64 {
832 nonce
833 }
834 fn handle(_response: u64) -> Inbound<(), ()> {
835 Inbound::Skip
836 }
837 }
838
839 /// Probe nonces skip the reserved values across the wrap: the counter
840 /// steps over [`WATCHDOG_NONCE`] and `0`, then resumes counting from 1.
841 #[xmtp_common::test]
842 fn probe_nonces_never_mint_the_watchdog_nonce() {
843 let counter = AtomicU64::new(u64::MAX - 2);
844 let minted: Vec<u64> = (0..4).map(|_| next_probe_nonce(&counter)).collect();
845 assert_eq!(minted, vec![u64::MAX - 1, 1, 2, 3]);
846 }
847
848 /// `finish` is a half-close, not an abort: frames the caller already had
849 /// accepted under wire backpressure (parked in the actor's `pending` queue,
850 /// not yet on the wire) must be flushed before the request half closes. Drive
851 /// `drain_after_finish` directly with a pre-seeded `pending` and an
852 /// already-closed inbound, so the flush is the only behaviour under test.
853 #[xmtp_common::test(unwrap_try = true)]
854 async fn drain_after_finish_flushes_pending_before_closing() {
855 let (wire_out, mut wire_rx) = mpsc::channel::<u64>(WIRE_BUFFER);
856 let (_commands_tx, commands_rx) = mpsc::channel::<Command<TestBinding>>(COMMAND_BUFFER);
857 let (events, _events_rx) = mpsc::channel::<Event<(), ()>>(EVENT_BUFFER);
858
859 // A distinctively-tagged frame queued as if wire backpressure had parked
860 // it before the caller half-closed.
861 let mut pending = VecDeque::new();
862 pending.push_back(7_777_u64);
863
864 // Inbound is already closed, so the drain has nothing to read and returns
865 // as soon as the pending flush completes.
866 let inbound = futures::stream::empty::<Result<u64, BidiError>>();
867 drain_after_finish::<TestBinding, _, _>(inbound, wire_out, pending, commands_rx, &events)
868 .await;
869
870 // The queued frame reached the wire instead of being dropped on close,
871 // and the wire then closes (the actor dropped its sender).
872 assert_eq!(
873 wire_rx.recv().await,
874 Some(7_777),
875 "the pending frame must be flushed to the wire"
876 );
877 assert_eq!(
878 wire_rx.recv().await,
879 None,
880 "the wire closes once the flush is done"
881 );
882 }
883
884 /// A *wedged* wire must not hold the half-close hostage: the flush waits out
885 /// [`DRAIN_FLUSH_BUDGET`] for capacity, then drops the backlog and still
886 /// closes the request half so the server sees the half-close.
887 #[xmtp_common::test(unwrap_try = true)]
888 async fn drain_after_finish_bounds_the_flush_on_a_wedged_wire() {
889 // Capacity 1, pre-filled, receiver never polled: reserve() can never
890 // succeed, so only the budget can end the flush.
891 let (wire_out, mut wire_rx) = mpsc::channel::<u64>(1);
892 let (_commands_tx, commands_rx) = mpsc::channel::<Command<TestBinding>>(COMMAND_BUFFER);
893 let (events, _events_rx) = mpsc::channel::<Event<(), ()>>(EVENT_BUFFER);
894 wire_out.try_send(1_u64).unwrap();
895
896 let mut pending = VecDeque::new();
897 pending.push_back(7_777_u64);
898
899 let inbound = futures::stream::empty::<Result<u64, BidiError>>();
900 drain_after_finish::<TestBinding, _, _>(inbound, wire_out, pending, commands_rx, &events)
901 .await;
902
903 // The pre-wedged frame is still there; the un-flushable backlog was
904 // dropped; and crucially the request half closed anyway.
905 assert_eq!(wire_rx.recv().await, Some(1));
906 assert_eq!(
907 wire_rx.recv().await,
908 None,
909 "the request half must close even when the flush budget expires"
910 );
911 }
912
913 /// Binding for the watchdog tests. A frame under `100_000` is a `Started`
914 /// advertising that many milliseconds of keepalive — so each test picks a
915 /// cadence fast enough to run in real time. [`MSG_FRAME`] delivers one
916 /// group message (to park the actor on consumer backpressure); anything
917 /// else is a no-op frame that still counts as inbound activity.
918 struct WatchdogBinding;
919 const MSG_FRAME: u64 = 1_000_000;
920 const ACTIVITY_FRAME: u64 = 2_000_000;
921 /// Generous ceiling for CI scheduling noise; every wait in these tests
922 /// resolves in well under a second on a healthy run.
923 const WAIT: Duration = Duration::from_secs(5);
924
925 impl BidiBinding for WatchdogBinding {
926 type Request = u64;
927 type Response = u64;
928 type Mutate = u64;
929 type GroupMessage = ();
930 type WelcomeMessage = ();
931
932 fn mutate_frame(mutate: u64) -> u64 {
933 mutate
934 }
935 fn ping_frame(nonce: u64) -> u64 {
936 nonce
937 }
938 fn pong_frame(nonce: u64) -> u64 {
939 nonce
940 }
941 fn handle(response: u64) -> Inbound<(), ()> {
942 match response {
943 ms if ms < 100_000 => Inbound::Emit(Event::Started {
944 keepalive_interval_ms: ms as u32,
945 }),
946 MSG_FRAME => Inbound::Messages {
947 group: vec![()],
948 welcome: vec![],
949 },
950 _ => Inbound::Skip,
951 }
952 }
953 }
954
955 struct ActorHarness {
956 inbound: mpsc::Sender<Result<u64, BidiError>>,
957 wire: mpsc::Receiver<u64>,
958 events: mpsc::Receiver<Event<(), ()>>,
959 _commands: mpsc::Sender<Command<WatchdogBinding>>,
960 }
961
962 /// Spawn `run_actor` over harness-controlled channels, mirroring
963 /// [`Connection::start`]'s wiring (`poll_fn` stream + `Box::pin`).
964 fn spawn_actor() -> ActorHarness {
965 let (inbound_tx, mut inbound_rx) = mpsc::channel::<Result<u64, BidiError>>(2048);
966 let (wire_out, wire_rx) = mpsc::channel::<u64>(WIRE_BUFFER);
967 let (commands_tx, commands_rx) = mpsc::channel::<Command<WatchdogBinding>>(COMMAND_BUFFER);
968 let (events_tx, events_rx) = mpsc::channel::<Event<(), ()>>(EVENT_BUFFER);
969 let inbound = futures::stream::poll_fn(move |cx| inbound_rx.poll_recv(cx));
970 xmtp_common::spawn(
971 None,
972 run_actor::<WatchdogBinding, _, _>(
973 Box::pin(inbound),
974 wire_out,
975 commands_rx,
976 events_tx,
977 FailureSlot::default(),
978 ),
979 );
980 ActorHarness {
981 inbound: inbound_tx,
982 wire: wire_rx,
983 events: events_rx,
984 _commands: commands_tx,
985 }
986 }
987
988 /// Total silence earns exactly one watchdog ping, then teardown: the
989 /// half-open link surfaces as end-of-events instead of hanging forever.
990 #[xmtp_common::test(unwrap_try = true)]
991 async fn watchdog_probes_then_tears_down_a_silent_wire() {
992 let mut actor = spawn_actor();
993 actor.inbound.send(Ok(150)).await?; // Started: 150ms keepalive
994 assert!(matches!(
995 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?,
996 Some(Event::Started { .. })
997 ));
998
999 let ping = xmtp_common::time::timeout(WAIT, actor.wire.recv()).await?;
1000 assert_eq!(
1001 ping,
1002 Some(WATCHDOG_NONCE),
1003 "the watchdog must probe before giving up"
1004 );
1005 assert_eq!(
1006 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?,
1007 None,
1008 "an unanswered watchdog ping must tear the connection down"
1009 );
1010 }
1011
1012 /// A quiet-but-live link is never reaped: any inbound frame — a pong or
1013 /// otherwise — answers the watchdog ping and resets the window.
1014 #[xmtp_common::test(unwrap_try = true)]
1015 async fn an_answered_watchdog_probe_keeps_the_wire_alive() {
1016 let mut actor = spawn_actor();
1017 actor.inbound.send(Ok(150)).await?;
1018 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?;
1019
1020 // Ride out three whole silence windows, answering each probe.
1021 for _ in 0..3 {
1022 let ping = xmtp_common::time::timeout(WAIT, actor.wire.recv()).await?;
1023 assert_eq!(ping, Some(WATCHDOG_NONCE));
1024 actor.inbound.send(Ok(ACTIVITY_FRAME)).await?;
1025 }
1026 assert!(
1027 matches!(
1028 actor.events.try_recv(),
1029 Err(mpsc::error::TryRecvError::Empty)
1030 ),
1031 "an answered probe must keep the connection alive"
1032 );
1033
1034 // Stop answering: the next unanswered ping tears it down.
1035 assert_eq!(
1036 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?,
1037 None
1038 );
1039 }
1040
1041 /// A steady trickle of frames well inside the probe threshold never
1042 /// triggers a probe at all.
1043 #[xmtp_common::test(unwrap_try = true)]
1044 async fn inbound_activity_resets_the_watchdog() {
1045 let mut actor = spawn_actor();
1046 actor.inbound.send(Ok(200)).await?; // probe would fire at 400ms silent
1047 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?;
1048
1049 for _ in 0..20 {
1050 actor.inbound.send(Ok(ACTIVITY_FRAME)).await?;
1051 xmtp_common::time::sleep(Duration::from_millis(50)).await;
1052 }
1053 assert!(
1054 matches!(actor.wire.try_recv(), Err(mpsc::error::TryRecvError::Empty)),
1055 "no watchdog ping may fire while frames keep arriving"
1056 );
1057 assert!(matches!(
1058 actor.events.try_recv(),
1059 Err(mpsc::error::TryRecvError::Empty)
1060 ));
1061 }
1062
1063 /// Consumer backpressure must not read as wire silence: while the actor is
1064 /// parked emitting into a full event channel, inbound frames sit unread
1065 /// behind it — the stall is the consumer's, not the wire's. The silence
1066 /// window restarts when the actor resumes listening, so a slow consumer
1067 /// never gets a healthy link torn down.
1068 #[xmtp_common::test(unwrap_try = true)]
1069 async fn consumer_backpressure_is_not_wire_silence() {
1070 let mut actor = spawn_actor();
1071 actor.inbound.send(Ok(200)).await?; // probe at 400ms, teardown at 600ms
1072 xmtp_common::time::timeout(WAIT, actor.events.recv()).await?;
1073
1074 // Fill the event buffer plus one: the actor parks mid-emit.
1075 for _ in 0..(EVENT_BUFFER + 1) {
1076 actor.inbound.send(Ok(MSG_FRAME)).await?;
1077 }
1078 // Hold the park well past the full teardown budget, draining nothing.
1079 xmtp_common::time::sleep(Duration::from_millis(800)).await;
1080
1081 // Drain; the actor unparks with a fresh silence window. If the park
1082 // had counted as silence, both watchdog deadlines are long past and
1083 // teardown would be immediate.
1084 let mut delivered = 0;
1085 while delivered < EVENT_BUFFER + 1 {
1086 match xmtp_common::time::timeout(WAIT, actor.events.recv()).await? {
1087 Some(Event::GroupMessages { .. }) => delivered += 1,
1088 other => panic!("unexpected event while draining: {other:?}"),
1089 }
1090 }
1091 xmtp_common::time::sleep(Duration::from_millis(150)).await;
1092 assert!(
1093 matches!(
1094 actor.events.try_recv(),
1095 Err(mpsc::error::TryRecvError::Empty)
1096 ),
1097 "a consumer stall must not be read as wire silence"
1098 );
1099 }
1100}