Skip to main content

xmtp_api_backend/queries/
bidi_transport.rs

1//! One backend subscription connection serves multiple topic leases.
2//!
3//! Each registration has a fixed catch-up target from Applied. Each lease
4//! completes after its targets are met. Updates that lack acknowledgements
5//! keep resume callers waiting, including updates that only remove topics.
6//!
7//! Messages carry no registration tag. Route them by their metadata topic.
8//! Each lease keeps a monotonic delivery position above its requested floor.
9//! A lease that needs older messages removes and re-adds the held topic.
10//! Until the remove acknowledgement, only old holders receive queued messages.
11//! At that boundary, current interest determines the re-add cursor and holders.
12//! After the add acknowledgement, those holders receive the new registration.
13//! The delivery positions discard overlap. Ordered leases retain at most one
14//! wire frame while a full receive queue pauses wire reads for the connection.
15//! This global gate affects every lease on the connection. A progressing
16//! backlog can therefore delay reads for other leases longer than the pause
17//! timeout.
18//!
19//! A connection failure keeps leases alive. Reconnect uses the current topic
20//! set and the minimum durable receipt position of its leases. Suspend releases
21//! the connection and keeps this state. Resume waits for all acknowledgements
22//! and targets. A lease with no successful handoff for the pause deadline is
23//! closed so its consumer can recover from storage.
24//!
25//! Ordered leases raise their floors only after durable receipt. A reconnect
26//! resets delivery positions to these floors so uncommitted batches replay.
27//! The consumer owns durable progress. This module does not decode MLS data
28//! or promise exactly-once callbacks across a process crash.
29
30use std::collections::{HashMap, HashSet, VecDeque};
31use std::sync::{
32    Arc,
33    atomic::{AtomicBool, Ordering},
34};
35
36use prost::Message;
37use tokio::sync::{mpsc, oneshot};
38use xmtp_common::rate_limit::Bucket;
39use xmtp_common::time::Instant;
40use xmtp_common::{BoxDynFuture, MaybeSend, MaybeSync, RetryableError};
41#[cfg(not(test))]
42use xmtp_configuration::AUTH_LOCKOUT_COOLDOWN;
43use xmtp_proto::api::ApiClientError;
44use xmtp_proto::{
45    backend_v1::ServerEnvelope,
46    types::{
47        Cursor, IncomingBatchLimits, IncomingEvent, IncomingSubscription, OrderedEnvelopeBatch,
48        Topic, TopicCursor,
49    },
50};
51
52/// Keep the cool-down wait short in tests. Match the middleware's test value,
53/// so the wire waits exactly one cool-down and no longer.
54#[cfg(test)]
55const AUTH_LOCKOUT_COOLDOWN: std::time::Duration = std::time::Duration::from_millis(500);
56
57use super::bidi::{BidiBinding, Connection, Event, TryMutateError};
58use super::chunk_by_budget;
59
60/// Default event capacity for one lease. Slow consumers must recover from storage.
61pub const DEFAULT_LEASE_DEPTH: usize = 64;
62/// Retry a queued update when the connection is quiet and capacity may be free.
63const OUTBOX_RETRY_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
64/// Bound a continuous stall while a consumer accepts no handoff. Thirty seconds
65/// leaves half a 60-second sync budget for recovery from durable receipts. A
66/// progressing backlog can keep the global wire gate active beyond this period.
67const INCOMING_PAUSE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
68
69fn update_budget() -> Bucket {
70    Bucket::new(
71        xmtp_configuration::BACKEND_DEFAULT_MAX_UPDATE_FRAMES_PER_SECOND,
72        xmtp_configuration::BACKEND_DEFAULT_MAX_UPDATE_BURST,
73    )
74}
75
76/// Maximum adds per backend update. The wire also has a separate topic cap.
77pub(crate) const MAX_MUTATE_TOPICS: usize = xmtp_configuration::BACKEND_DEFAULT_MAX_UPDATE_ADDS;
78/// Leave space for the request wrapper within the backend byte limit.
79pub(crate) const MAX_MUTATE_BYTES: usize =
80    xmtp_configuration::BACKEND_DEFAULT_MAX_REQUEST_BYTES - PER_ENTRY_OVERHEAD;
81/// Conservative protobuf overhead per topic, including cursor and length fields.
82const PER_ENTRY_OVERHEAD: usize = 64;
83
84/// The interest-update frame shapes one deployment accepts. Adds and
85/// removes have separate caps because the backend enforces them separately.
86#[derive(Clone, Copy, Debug, PartialEq, Eq)]
87pub struct MutateLimits {
88    pub add_cap: usize,
89    pub remove_cap: usize,
90    pub byte_cap: usize,
91}
92
93impl Default for MutateLimits {
94    fn default() -> Self {
95        Self {
96            add_cap: MAX_MUTATE_TOPICS,
97            remove_cap: xmtp_configuration::BACKEND_DEFAULT_MAX_UPDATE_REMOVES,
98            byte_cap: MAX_MUTATE_BYTES,
99        }
100    }
101}
102
103impl MutateLimits {
104    /// Take the caps a deployment published, leaving the same room for the
105    /// request wrapper the compiled default leaves.
106    pub fn from_limits(limits: &xmtp_configuration::LimitsConfiguration) -> Self {
107        Self {
108            add_cap: limits.max_update_adds.max(1),
109            remove_cap: limits.max_update_removes.max(1),
110            byte_cap: limits
111                .max_request_bytes
112                .saturating_sub(PER_ENTRY_OVERHEAD)
113                .max(1),
114        }
115    }
116}
117
118fn topic_wire_cost(topic: &Topic) -> usize {
119    1 + topic.identifier().len() + PER_ENTRY_OVERHEAD
120}
121
122/// Split an add set into updates within the backend count and byte limits.
123/// The caller must also enforce the total topic limit for its connection.
124pub fn chunk_mutate_adds<C>(adds: Vec<(Topic, C)>) -> Vec<Vec<(Topic, C)>> {
125    chunk_by_budget(adds, MAX_MUTATE_TOPICS, MAX_MUTATE_BYTES, |(topic, _)| {
126        topic_wire_cost(topic)
127    })
128}
129
130const RECONNECT_INITIAL_DELAY: std::time::Duration = std::time::Duration::from_millis(100);
131const RECONNECT_MAX_DELAY: std::time::Duration = std::time::Duration::from_secs(30);
132const MIN_STABLE_UPTIME: std::time::Duration = std::time::Duration::from_secs(10);
133/// Bound the background drain after request half-close.
134const GRACEFUL_CLOSE_BUDGET: std::time::Duration = std::time::Duration::from_secs(5);
135
136/// Keep the source error and its retry decision after type erasure.
137#[derive(Debug)]
138pub struct OpenError {
139    retryable: bool,
140    locked_out: bool,
141    attempted: bool,
142    source: Box<dyn std::error::Error + Send + Sync + 'static>,
143}
144
145impl OpenError {
146    pub fn new<E>(e: E) -> Self
147    where
148        E: std::error::Error + xmtp_common::RetryableError + Send + Sync + 'static,
149    {
150        Self {
151            retryable: e.is_retryable(),
152            locked_out: Self::locked_out_of(&e),
153            attempted: !Self::cooldown_refusal(&e),
154            source: Box::new(e),
155        }
156    }
157
158    /// True while an authentication cool-down runs. The open cannot succeed now
159    /// but will become possible again without any caller action.
160    fn is_locked_out(&self) -> bool {
161        self.locked_out
162    }
163
164    fn attempted(&self) -> bool {
165        self.attempted
166    }
167
168    #[cfg(test)]
169    pub fn retryable(e: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
170        Self {
171            retryable: true,
172            locked_out: false,
173            attempted: true,
174            source: e.into(),
175        }
176    }
177
178    #[cfg(test)]
179    pub fn unretryable(e: impl Into<Box<dyn std::error::Error + Send + Sync + 'static>>) -> Self {
180        Self {
181            retryable: false,
182            locked_out: false,
183            attempted: true,
184            source: e.into(),
185        }
186    }
187}
188
189impl OpenError {
190    fn cooldown_refusal<E: 'static>(error: &E) -> bool {
191        (error as &dyn std::any::Any)
192            .downcast_ref::<ApiClientError>()
193            .is_some_and(|error| {
194                matches!(
195                    error,
196                    ApiClientError::Auth(xmtp_proto::api::AuthError::Exhausted)
197                )
198            })
199    }
200
201    /// Capture the lockout state of a concrete error before it is erased.
202    fn locked_out_of<E: 'static>(error: &E) -> bool {
203        (error as &dyn std::any::Any)
204            .downcast_ref::<ApiClientError>()
205            .is_some_and(ApiClientError::is_locked_out)
206    }
207}
208
209impl std::fmt::Display for OpenError {
210    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
211        self.source.fmt(f)
212    }
213}
214
215impl std::error::Error for OpenError {
216    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
217        Some(&*self.source)
218    }
219}
220
221impl xmtp_common::RetryableError for OpenError {
222    fn is_retryable(&self) -> bool {
223        self.retryable
224    }
225}
226
227/// Map backend envelopes to topics and positions for the lease ledger.
228pub trait TransportBinding: BidiBinding
229where
230    Self::GroupMessage: Clone,
231    Self::WelcomeMessage: Clone,
232{
233    /// A position that can represent each fixed backend target.
234    type Cursor: Copy + Send + std::fmt::Debug + From<u64> + Into<u64> + 'static;
235
236    /// Build an update with a nonzero ID. IDs increase on each connection.
237    fn build_mutate(
238        adds: impl IntoIterator<Item = (Topic, Self::Cursor)>,
239        removes: impl IntoIterator<Item = Topic>,
240        mutate_id: u64,
241    ) -> Self::Mutate;
242
243    fn group_topic(msg: &Self::GroupMessage) -> Option<Topic>;
244    fn welcome_topic(msg: &Self::WelcomeMessage) -> Option<Topic>;
245
246    fn group_cursor(msg: &Self::GroupMessage) -> Option<Self::Cursor>;
247    fn welcome_cursor(msg: &Self::WelcomeMessage) -> Option<Self::Cursor>;
248    fn group_envelope(msg: &Self::GroupMessage) -> &ServerEnvelope;
249    fn welcome_envelope(msg: &Self::WelcomeMessage) -> &ServerEnvelope;
250    fn advance(position: &mut Self::Cursor, delivered: Self::Cursor);
251    /// Return true when the position is at or above the delivered cursor.
252    fn covers(position: &Self::Cursor, delivered: &Self::Cursor) -> bool;
253    /// Return the greatest cursor covered by both positions.
254    fn meet(a: Self::Cursor, b: Self::Cursor) -> Self::Cursor;
255}
256
257#[derive(Debug, thiserror::Error)]
258pub enum TransportError {
259    #[error("the bidi transport is closed")]
260    Closed,
261    #[error("opening the bidi wire failed: {0}")]
262    Open(#[source] OpenError),
263    #[error("a lease must name at least one topic")]
264    Empty,
265    /// The lease exceeds the topic limit. This error is not retryable.
266    #[error("the bidi wire topic limit would be exceeded")]
267    TooManyTopics,
268    #[error("invalid subscription frame: {0}")]
269    Protocol(&'static str),
270    /// One envelope cannot fit. Reopen from durable receipt positions.
271    #[error("incoming subscription envelope exceeds its receive limit")]
272    Capacity,
273    /// The receive queue is full. Reopen from durable receipt positions.
274    #[error("incoming subscription delivery queue is full")]
275    Backpressure,
276    #[error(transparent)]
277    Wire(#[from] std::sync::Arc<super::bidi::ConnectionFailure>),
278}
279
280impl TransportError {
281    /// A cool-down refused this open before auth refresh or a network request.
282    pub fn is_cooldown_refusal(&self) -> bool {
283        matches!(self, Self::Open(error) if !error.attempted())
284    }
285}
286
287type IncomingFailure = std::sync::Arc<parking_lot::Mutex<Option<TransportError>>>;
288/// One delivery fits the lease's row and byte limits, across all its topics.
289type IncomingFrame = Result<Vec<IncomingEvent>, TransportError>;
290
291impl xmtp_common::RetryableError for TransportError {
292    fn is_retryable(&self) -> bool {
293        match self {
294            Self::Backpressure | Self::Capacity => true,
295            Self::Open(e) => e.is_retryable(),
296            Self::Wire(e) => e.is_retryable(),
297            Self::Closed | Self::Empty | Self::TooManyTopics | Self::Protocol(_) => false,
298        }
299    }
300}
301
302/// Encrypted envelopes and initial catch-up completion for one lease.
303pub enum LeaseEvent<B: TransportBinding>
304where
305    B::GroupMessage: Clone,
306    B::WelcomeMessage: Clone,
307{
308    /// Every topic has met its initial target. This is emitted once per lease.
309    CatchUpComplete,
310    GroupMessages(Vec<B::GroupMessage>),
311    WelcomeMessages(Vec<B::WelcomeMessage>),
312}
313
314#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
315struct LeaseId(u64);
316
317/// A cloneable handle to the connection and topic ledger task.
318pub struct BidiTransport<B: TransportBinding>
319where
320    B::GroupMessage: Clone,
321    B::WelcomeMessage: Clone,
322{
323    cmds: mpsc::UnboundedSender<Cmd<B>>,
324}
325
326impl<B: TransportBinding> Clone for BidiTransport<B>
327where
328    B::GroupMessage: Clone,
329    B::WelcomeMessage: Clone,
330{
331    fn clone(&self) -> Self {
332        Self {
333            cmds: self.cmds.clone(),
334        }
335    }
336}
337
338/// Local topic interest. Dropping the handle removes its interest immediately.
339pub struct TopicLease<B: TransportBinding>
340where
341    B::GroupMessage: Clone,
342    B::WelcomeMessage: Clone,
343{
344    id: LeaseId,
345    topics: Vec<Topic>,
346    events: mpsc::Receiver<LeaseEvent<B>>,
347    incoming: Option<mpsc::Receiver<IncomingFrame>>,
348    incoming_pending: std::vec::IntoIter<IncomingEvent>,
349    incoming_failure: IncomingFailure,
350    connected: Arc<AtomicBool>,
351    cmds: mpsc::UnboundedSender<Cmd<B>>,
352}
353
354impl<B: TransportBinding> TopicLease<B>
355where
356    B::GroupMessage: Clone,
357    B::WelcomeMessage: Clone,
358{
359    /// Read the next event. A closed channel requires recovery from storage.
360    pub async fn next(&mut self) -> Option<LeaseEvent<B>> {
361        self.events.recv().await
362    }
363
364    pub fn topics(&self) -> &[Topic] {
365        &self.topics
366    }
367
368    /// Read raw ordered events, including a terminal receive error.
369    pub async fn next_incoming(&mut self) -> Option<Result<IncomingEvent, TransportError>> {
370        loop {
371            if let Some(event) = self.incoming_pending.next() {
372                return Some(Ok(event));
373            }
374            match self.incoming.as_mut()?.recv().await {
375                Some(Ok(events)) => self.incoming_pending = events.into_iter(),
376                Some(Err(error)) => return Some(Err(error)),
377                None => return self.incoming_failure.lock().take().map(Err),
378            }
379        }
380    }
381
382    /// Call this only after the supplied positions commit to local storage.
383    pub fn acknowledge_received(&self, cursors: TopicCursor) {
384        let _ = self.cmds.send(Cmd::Received {
385            id: self.id,
386            cursors,
387        });
388    }
389
390    /// Keep this lease alive until its owned event stream is dropped.
391    pub fn into_incoming_subscription(self) -> IncomingSubscription<TransportError> {
392        let cmds = self.cmds.clone();
393        let id = self.id;
394        let connected = self.connected.clone();
395        let events = futures::stream::unfold(self, |mut lease| async move {
396            lease.next_incoming().await.map(|event| (event, lease))
397        });
398        IncomingSubscription::new(Box::pin(events), move |cursors| {
399            let _ = cmds.send(Cmd::Received { id, cursors });
400        })
401        .with_connection_source(move || connected.load(Ordering::Acquire))
402    }
403}
404
405impl<B: TransportBinding> Drop for TopicLease<B>
406where
407    B::GroupMessage: Clone,
408    B::WelcomeMessage: Clone,
409{
410    fn drop(&mut self) {
411        let _ = self.cmds.send(Cmd::Deref(self.id));
412    }
413}
414
415impl<B: TransportBinding> BidiTransport<B>
416where
417    B::GroupMessage: Clone,
418    B::WelcomeMessage: Clone,
419{
420    /// Open lazily through `opener`. A suspended transport waits for resume.
421    pub fn new<O, Fut>(opener: O, initially_suspended: bool) -> Self
422    where
423        O: Fn(B::Mutate) -> Fut + MaybeSend + MaybeSync + 'static,
424        Fut: Future<Output = Result<Connection<B>, OpenError>> + MaybeSend + 'static,
425    {
426        Self::spawn(opener, initially_suspended, MutateLimits::default())
427    }
428
429    /// Open lazily, chunking interest updates to what the deployment published.
430    pub fn new_within<O, Fut>(opener: O, initially_suspended: bool, mutate: MutateLimits) -> Self
431    where
432        O: Fn(B::Mutate) -> Fut + MaybeSend + MaybeSync + 'static,
433        Fut: Future<Output = Result<Connection<B>, OpenError>> + MaybeSend + 'static,
434    {
435        Self::spawn(opener, initially_suspended, mutate)
436    }
437
438    #[cfg(test)]
439    pub(crate) fn new_with_chunk_limits<O, Fut>(
440        opener: O,
441        initially_suspended: bool,
442        chunk_cap: usize,
443        chunk_bytes: usize,
444    ) -> Self
445    where
446        O: Fn(B::Mutate) -> Fut + MaybeSend + MaybeSync + 'static,
447        Fut: Future<Output = Result<Connection<B>, OpenError>> + MaybeSend + 'static,
448    {
449        Self::spawn(
450            opener,
451            initially_suspended,
452            MutateLimits {
453                add_cap: chunk_cap,
454                remove_cap: chunk_cap,
455                byte_cap: chunk_bytes,
456            },
457        )
458    }
459
460    fn spawn<O, Fut>(opener: O, initially_suspended: bool, mutate: MutateLimits) -> Self
461    where
462        O: Fn(B::Mutate) -> Fut + MaybeSend + MaybeSync + 'static,
463        Fut: Future<Output = Result<Connection<B>, OpenError>> + MaybeSend + 'static,
464    {
465        let (cmds, cmds_rx) = mpsc::unbounded_channel();
466        let opener: Opener<B> = Box::new(
467            move |initial| -> BoxDynFuture<'static, Result<Connection<B>, OpenError>> {
468                Box::pin(opener(initial))
469            },
470        );
471        xmtp_common::spawn(
472            None,
473            run_ledger::<B>(
474                opener,
475                cmds_rx,
476                cmds.clone().downgrade(),
477                initially_suspended,
478                mutate,
479            ),
480        );
481        Self { cmds }
482    }
483
484    /// Register topics with exclusive floors and a bounded event channel.
485    /// Refuse empty leases and leases that would exceed the wire topic limit.
486    /// While suspended, register locally without opening a connection.
487    pub async fn lease(
488        &self,
489        subs: Vec<(Topic, B::Cursor)>,
490        depth: usize,
491    ) -> Result<TopicLease<B>, TransportError> {
492        if subs.is_empty() {
493            return Err(TransportError::Empty);
494        }
495        let (reply, response) = oneshot::channel();
496        self.cmds
497            .send(Cmd::Lease { subs, depth, reply })
498            .map_err(|_| TransportError::Closed)?;
499        response.await.map_err(|_| TransportError::Closed)?
500    }
501
502    /// Register an ordered raw receiver. Receipt acknowledgements raise its resume floors.
503    pub async fn lease_ordered(
504        &self,
505        subs: Vec<(Topic, B::Cursor)>,
506        depth: usize,
507        limits: IncomingBatchLimits,
508    ) -> Result<TopicLease<B>, TransportError> {
509        if subs.is_empty() {
510            return Err(TransportError::Empty);
511        }
512        if limits.max_rows == 0 || limits.max_bytes == 0 {
513            return Err(TransportError::Capacity);
514        }
515        let (reply, response) = oneshot::channel();
516        self.cmds
517            .send(Cmd::LeaseOrdered {
518                subs,
519                depth,
520                limits,
521                reply,
522            })
523            .map_err(|_| TransportError::Closed)?;
524        response.await.map_err(|_| TransportError::Closed)?
525    }
526
527    /// Half-close the connection. Keep leases and positions for resume.
528    #[xmtp_common::span(prefix = "bidi")]
529    pub async fn suspend(&self) -> Result<(), TransportError> {
530        self.enqueue_suspend()?
531            .await
532            .map_err(|_| TransportError::Closed)
533    }
534
535    /// Queue suspend synchronously so callers can order lifecycle commands.
536    pub fn enqueue_suspend(&self) -> Result<oneshot::Receiver<()>, TransportError> {
537        let (reply, response) = oneshot::channel();
538        self.cmds
539            .send(Cmd::Suspend { reply })
540            .map_err(|_| TransportError::Closed)?;
541        Ok(response)
542    }
543
544    /// Reopen and wait until every update is acknowledged and every target is met.
545    /// A later suspend or removal of the last lease also releases this waiter.
546    #[xmtp_common::span(prefix = "bidi")]
547    pub async fn resume(&self) -> Result<(), TransportError> {
548        self.enqueue_resume()?
549            .await
550            .map_err(|_| TransportError::Closed)
551    }
552
553    /// Queue resume without waiting for catch-up. Return its completion receiver.
554    pub fn enqueue_resume(&self) -> Result<oneshot::Receiver<()>, TransportError> {
555        let (reply, response) = oneshot::channel();
556        self.cmds
557            .send(Cmd::Resume { reply })
558            .map_err(|_| TransportError::Closed)?;
559        Ok(response)
560    }
561}
562
563enum Cmd<B: TransportBinding>
564where
565    B::GroupMessage: Clone,
566    B::WelcomeMessage: Clone,
567{
568    Lease {
569        subs: Vec<(Topic, B::Cursor)>,
570        depth: usize,
571        reply: oneshot::Sender<Result<TopicLease<B>, TransportError>>,
572    },
573    LeaseOrdered {
574        subs: Vec<(Topic, B::Cursor)>,
575        depth: usize,
576        limits: IncomingBatchLimits,
577        reply: oneshot::Sender<Result<TopicLease<B>, TransportError>>,
578    },
579    Received {
580        id: LeaseId,
581        cursors: TopicCursor,
582    },
583    Deref(LeaseId),
584    Suspend {
585        reply: oneshot::Sender<()>,
586    },
587    Resume {
588        reply: oneshot::Sender<()>,
589    },
590}
591
592trait OpenWire<B: BidiBinding>: MaybeSend + MaybeSync {
593    fn open(&self, initial: B::Mutate) -> BoxDynFuture<'static, Result<Connection<B>, OpenError>>;
594}
595
596impl<B: BidiBinding, F> OpenWire<B> for F
597where
598    F: Fn(B::Mutate) -> BoxDynFuture<'static, Result<Connection<B>, OpenError>>
599        + MaybeSend
600        + MaybeSync,
601{
602    fn open(&self, initial: B::Mutate) -> BoxDynFuture<'static, Result<Connection<B>, OpenError>> {
603        self(initial)
604    }
605}
606
607type Opener<B> = Box<dyn OpenWire<B>>;
608#[derive(Debug, Clone, Copy)]
609enum DeliveryKind {
610    Group,
611    Welcome,
612}
613
614/// An update that still needs its acknowledgement.
615struct PendingUpdate<C> {
616    adds: Vec<(Topic, C)>,
617    removes: Vec<Topic>,
618}
619
620/// One topic registration on the current connection.
621struct TopicRegistration<C> {
622    target: Option<u64>,
623    delivered: C,
624    holders: HashSet<LeaseId>,
625    state: RegistrationState,
626}
627
628enum RegistrationState {
629    Adding,
630    Active,
631    /// Old holders receive queued frames until the remove acknowledgement.
632    Removing,
633}
634
635/// The enclosing lease and topic map identify this obligation.
636struct LeaseObligation {
637    satisfied: bool,
638}
639
640struct LeaseState<B: TransportBinding>
641where
642    B::GroupMessage: Clone,
643    B::WelcomeMessage: Clone,
644{
645    floors: HashMap<Topic, B::Cursor>,
646    delivered: HashMap<Topic, B::Cursor>,
647    obligations: HashMap<Topic, LeaseObligation>,
648    unmet: usize,
649    notified: bool,
650    events: mpsc::Sender<LeaseEvent<B>>,
651    incoming: Option<(mpsc::Sender<IncomingFrame>, IncomingBatchLimits)>,
652    incoming_failure: IncomingFailure,
653    connected: Arc<AtomicBool>,
654    incoming_pending: VecDeque<Vec<IncomingEvent>>,
655    paused_at: Option<Instant>,
656    pause_entries: u64,
657}
658
659impl<B: TransportBinding> LeaseState<B>
660where
661    B::GroupMessage: Clone,
662    B::WelcomeMessage: Clone,
663{
664    fn finish_pause(&mut self, now: Instant, reason: &'static str) {
665        if let Some(started) = self.paused_at.take() {
666            tracing::info!(
667                pause_entries = self.pause_entries,
668                pause_duration_ms = now.saturating_duration_since(started).as_millis() as u64,
669                backlog_depth = self.incoming_pending.len(),
670                reason,
671                "bidi incoming: pause ended"
672            );
673        }
674    }
675
676    /// Return true only when the consumer must reopen from durable receipts.
677    fn flush_incoming(&mut self, now: Instant) -> bool {
678        let Some((sender, _)) = &self.incoming else {
679            return false;
680        };
681        let sender = sender.clone();
682        while !self.incoming_pending.is_empty() {
683            let permit = match sender.try_reserve() {
684                Ok(permit) => permit,
685                Err(mpsc::error::TrySendError::Full(_)) => {
686                    if self.paused_at.is_some_and(|started| {
687                        now.saturating_duration_since(started) >= INCOMING_PAUSE_TIMEOUT
688                    }) {
689                        tracing::warn!(
690                            backlog_depth = self.incoming_pending.len(),
691                            "bidi incoming: pause deadline exceeded; closing lease"
692                        );
693                        *self.incoming_failure.lock() = Some(TransportError::Backpressure);
694                        self.finish_pause(now, "deadline");
695                        return true;
696                    }
697                    if self.paused_at.is_none() {
698                        self.paused_at = Some(now);
699                        self.pause_entries += 1;
700                        tracing::info!(
701                            pause_entries = self.pause_entries,
702                            backlog_depth = self.incoming_pending.len(),
703                            "bidi incoming: paused"
704                        );
705                    }
706                    return false;
707                }
708                Err(mpsc::error::TrySendError::Closed(_)) => {
709                    *self.incoming_failure.lock() = Some(TransportError::Backpressure);
710                    self.finish_pause(now, "consumer_closed");
711                    return true;
712                }
713            };
714            let Some(events) = self.incoming_pending.pop_front() else {
715                unreachable!()
716            };
717            let positions: Vec<_> = events
718                .iter()
719                .filter_map(|event| {
720                    let IncomingEvent::OrderedBatch(batch) = event else {
721                        return None;
722                    };
723                    let last = batch
724                        .envelopes
725                        .last()?
726                        .meta
727                        .as_ref()?
728                        .cursor
729                        .as_ref()?
730                        .sequence_id;
731                    Some((batch.topic.clone(), last))
732                })
733                .collect();
734            // No await or fallible operation separates handoff and cursor update.
735            // Pending chunks never change delivered. Floors change only on receipt.
736            permit.send(Ok(events));
737            for (topic, last) in positions {
738                if let Some(delivered) = self.delivered.get_mut(&topic) {
739                    B::advance(delivered, last.into());
740                }
741            }
742            // A successful handoff ends the stall, even with chunks pending.
743            self.finish_pause(now, "progress");
744        }
745        self.finish_pause(now, "drained");
746        false
747    }
748}
749
750struct Ledger<B: TransportBinding>
751where
752    B::GroupMessage: Clone,
753    B::WelcomeMessage: Clone,
754{
755    leases: HashMap<LeaseId, LeaseState<B>>,
756    by_topic: HashMap<Topic, HashSet<LeaseId>>,
757    last_seen: HashMap<Topic, B::Cursor>,
758    registrations: HashMap<Topic, TopicRegistration<B::Cursor>>,
759    pending_updates: HashMap<u64, PendingUpdate<B::Cursor>>,
760    dirty_topics: HashSet<Topic>,
761    failed_incoming: HashSet<LeaseId>,
762    next_lease: u64,
763    next_update: u64,
764    mutate: MutateLimits,
765}
766
767impl<B: TransportBinding> Default for Ledger<B>
768where
769    B::GroupMessage: Clone,
770    B::WelcomeMessage: Clone,
771{
772    fn default() -> Self {
773        Self {
774            leases: HashMap::new(),
775            by_topic: HashMap::new(),
776            last_seen: HashMap::new(),
777            registrations: HashMap::new(),
778            pending_updates: HashMap::new(),
779            dirty_topics: HashSet::new(),
780            failed_incoming: HashSet::new(),
781            next_lease: 0,
782            next_update: 0,
783            mutate: MutateLimits::default(),
784        }
785    }
786}
787
788impl<B: TransportBinding> Ledger<B>
789where
790    B::GroupMessage: Clone,
791    B::WelcomeMessage: Clone,
792{
793    fn has_pending_incoming(&self) -> bool {
794        self.leases
795            .values()
796            .any(|lease| !lease.incoming_pending.is_empty())
797    }
798
799    fn flush_incoming(&mut self, now: Instant) -> Vec<LeaseId> {
800        self.leases
801            .iter_mut()
802            .filter_map(|(id, lease)| {
803                if !lease.incoming_pending.is_empty() {
804                    self.dirty_topics.extend(lease.floors.keys().cloned());
805                }
806                lease.flush_incoming(now).then_some(*id)
807            })
808            .collect()
809    }
810
811    /// Raise reconnect floors from committed receipt, never from stream delivery.
812    fn received(&mut self, id: LeaseId, cursors: TopicCursor) {
813        let Some(lease) = self.leases.get_mut(&id) else {
814            return;
815        };
816        if lease.incoming.is_none() {
817            return;
818        }
819        for (topic, cursor) in cursors {
820            if cursor.0 > i64::MAX as u64 {
821                continue;
822            }
823            if let Some(floor) = lease.floors.get_mut(&topic) {
824                B::advance(floor, cursor.0.into());
825                if let Some(delivered) = lease.delivered.get_mut(&topic) {
826                    B::advance(delivered, cursor.0.into());
827                }
828                self.dirty_topics.insert(topic);
829            }
830        }
831    }
832
833    /// Emit the accepted read positions and fixed targets before raw data.
834    fn incoming_registered(&mut self, id: LeaseId, topics: impl IntoIterator<Item = Topic>) {
835        let Some(lease) = self.leases.get_mut(&id) else {
836            return;
837        };
838        let Some(_) = &lease.incoming else {
839            return;
840        };
841        // Only this lease's accepted topic registrations restore its source.
842        lease.connected.store(
843            lease.floors.keys().all(|topic| {
844                self.registrations.get(topic).is_some_and(|registration| {
845                    matches!(registration.state, RegistrationState::Active)
846                })
847            }),
848            Ordering::Release,
849        );
850        let mut starts = TopicCursor::new();
851        let mut targets = TopicCursor::new();
852        for topic in topics {
853            let Some(registration) = self.registrations.get(&topic) else {
854                continue;
855            };
856            let Some(target) = registration.target else {
857                continue;
858            };
859            let Some(start) = lease.delivered.get(&topic) else {
860                continue;
861            };
862            starts.insert(topic.clone(), Cursor((*start).into()));
863            targets.insert(topic, Cursor(target));
864        }
865        if !starts.is_empty() {
866            lease
867                .incoming_pending
868                .push_back(vec![IncomingEvent::Registered { starts, targets }]);
869            if lease.flush_incoming(Instant::now()) {
870                self.failed_incoming.insert(id);
871            }
872        }
873    }
874
875    /// Require exactly one fixed target for each topic added by this update.
876    fn valid_targets(&self, id: u64, targets: &[(Topic, u64)]) -> bool {
877        let Some(update) = self.pending_updates.get(&id) else {
878            return false;
879        };
880        let unique: HashSet<_> = targets.iter().map(|(topic, _)| topic).collect();
881        targets.len() == update.adds.len()
882            && unique.len() == targets.len()
883            && targets.iter().all(|(topic, target)| {
884                *target <= i64::MAX as u64 && update.adds.iter().any(|(added, _)| added == topic)
885            })
886    }
887
888    /// Validate the full frame before copying it into any raw receive channel.
889    fn demux_incoming<M>(
890        &mut self,
891        messages: &[M],
892        envelope_of: impl Fn(&M) -> &ServerEnvelope,
893    ) -> Result<Vec<LeaseId>, &'static str> {
894        let mut positions = HashMap::new();
895        for message in messages {
896            let envelope = envelope_of(message);
897            let meta = envelope.meta.as_ref().ok_or("metadata")?;
898            let topic =
899                Topic::parse(&meta.topic.as_ref().ok_or("topic")?.topic).map_err(|_| "topic")?;
900            let (_, cursor, _) =
901                crate::envelope::metadata(meta, topic.kind()).map_err(|_| "metadata")?;
902            let Some(registration) = self.registrations.get(&topic) else {
903                continue;
904            };
905            if registration.target.is_none() {
906                return Err("messages before Applied");
907            }
908            let previous = positions
909                .entry(topic)
910                .or_insert_with(|| Cursor(registration.delivered.into()));
911            if cursor <= *previous {
912                return Err("cursor order");
913            }
914            *previous = cursor;
915        }
916        let mut dropped = Vec::new();
917        for (id, lease) in &mut self.leases {
918            let Some((_, limits)) = &lease.incoming else {
919                continue;
920            };
921            let mut selected = Vec::new();
922            let mut impossible = false;
923            for message in messages {
924                let envelope = envelope_of(message);
925                let meta = envelope.meta.as_ref().ok_or("metadata")?;
926                let topic = Topic::parse(&meta.topic.as_ref().ok_or("topic")?.topic)
927                    .map_err(|_| "topic")?;
928                let Some(registration) = self.registrations.get(&topic) else {
929                    continue;
930                };
931                if !registration.holders.contains(id) {
932                    continue;
933                }
934                let Some(delivered) = lease.delivered.get(&topic) else {
935                    continue;
936                };
937                if meta.cursor.as_ref().ok_or("cursor")?.sequence_id <= (*delivered).into() {
938                    continue;
939                }
940                // Validate every selected envelope before sending any part of the frame.
941                // A zero row budget cannot admit even one envelope either.
942                impossible |= limits.max_rows == 0 || envelope.encoded_len() > limits.max_bytes;
943                selected.push((topic, envelope));
944            }
945            if impossible {
946                tracing::error!(
947                    max_rows = limits.max_rows,
948                    max_bytes = limits.max_bytes,
949                    "bidi incoming: envelope cannot fit receive limits; closing lease"
950                );
951                *lease.incoming_failure.lock() = Some(TransportError::Capacity);
952                dropped.push(*id);
953                continue;
954            }
955            if selected.is_empty() {
956                continue;
957            }
958            // The wire is gated while pending work exists. This retains at most
959            // one validated wire frame per lease, split across bounded deliveries.
960            // Project batch boundaries separately: these are NOT delivery cursors.
961            let mut projected = lease.delivered.clone();
962            for chunk in chunk_by_budget(
963                selected,
964                limits.max_rows,
965                limits.max_bytes,
966                |(_, envelope)| envelope.encoded_len(),
967            ) {
968                let mut by_topic: HashMap<Topic, Vec<ServerEnvelope>> = HashMap::new();
969                for (topic, envelope) in chunk {
970                    by_topic.entry(topic).or_default().push(envelope.clone());
971                }
972                let mut events = Vec::with_capacity(by_topic.len());
973                for (topic, envelopes) in by_topic {
974                    let position = projected.get_mut(&topic).ok_or("lease topic")?;
975                    let after = Cursor((*position).into());
976                    let last = envelopes
977                        .last()
978                        .and_then(|envelope| envelope.meta.as_ref()?.cursor.as_ref())
979                        .ok_or("cursor")?
980                        .sequence_id;
981                    B::advance(position, last.into());
982                    events.push(IncomingEvent::OrderedBatch(OrderedEnvelopeBatch {
983                        topic,
984                        after,
985                        envelopes,
986                    }));
987                }
988                lease.incoming_pending.push_back(events);
989            }
990            if lease.flush_incoming(Instant::now()) {
991                dropped.push(*id);
992            }
993        }
994        Ok(dropped)
995    }
996
997    fn next_update_id(&mut self) -> u64 {
998        self.next_update += 1;
999        self.next_update
1000    }
1001
1002    /// Create local interest before opening or changing a connection.
1003    fn register(
1004        &mut self,
1005        subs: &[(Topic, B::Cursor)],
1006        events: mpsc::Sender<LeaseEvent<B>>,
1007    ) -> LeaseId {
1008        self.next_lease += 1;
1009        let id = LeaseId(self.next_lease);
1010        let floors: HashMap<_, _> = subs.iter().cloned().collect();
1011        for topic in floors.keys() {
1012            self.by_topic.entry(topic.clone()).or_default().insert(id);
1013        }
1014        self.leases.insert(
1015            id,
1016            LeaseState {
1017                delivered: floors.clone(),
1018                obligations: floors
1019                    .keys()
1020                    .map(|topic| (topic.clone(), LeaseObligation { satisfied: false }))
1021                    .collect(),
1022                unmet: floors.len(),
1023                floors,
1024                notified: false,
1025                events,
1026                incoming: None,
1027                incoming_failure: IncomingFailure::default(),
1028                connected: Arc::new(AtomicBool::new(false)),
1029                incoming_pending: VecDeque::new(),
1030                paused_at: None,
1031                pause_entries: 0,
1032            },
1033        );
1034        id
1035    }
1036
1037    /// Forget connection state. Keep each lease's delivery guard.
1038    fn reset_wire(&mut self) {
1039        self.registrations.clear();
1040        self.pending_updates.clear();
1041        self.dirty_topics.clear();
1042        for lease in self.leases.values_mut() {
1043            lease.connected.store(false, Ordering::Release);
1044            lease.obligations.clear();
1045            lease.unmet = lease.floors.len();
1046            if lease.incoming.is_some() {
1047                lease.finish_pause(Instant::now(), "wire_reset");
1048                lease.incoming_pending.clear();
1049                lease.delivered.clone_from(&lease.floors);
1050            }
1051        }
1052    }
1053
1054    fn reset_obligation(&mut self, id: LeaseId, topic: &Topic) {
1055        if let Some(lease) = self.leases.get_mut(&id) {
1056            if let Some(obligation) = lease.obligations.get_mut(topic) {
1057                if obligation.satisfied {
1058                    obligation.satisfied = false;
1059                    lease.unmet += 1;
1060                }
1061            } else {
1062                lease
1063                    .obligations
1064                    .insert(topic.clone(), LeaseObligation { satisfied: false });
1065            }
1066        }
1067    }
1068
1069    /// Reopen at the meet of the observed position and all requested floors.
1070    ///
1071    /// A floor never rises, so this meet is the floor in practice. See the
1072    /// module header for why that makes reopen cost grow with lease age.
1073    fn resume_cursor(&self, topic: &Topic) -> Option<B::Cursor> {
1074        let holders = self.by_topic.get(topic)?;
1075        holders
1076            .iter()
1077            .filter_map(|id| self.leases.get(id)?.floors.get(topic).copied())
1078            .reduce(B::meet)
1079    }
1080
1081    fn resume_adds(&self) -> Vec<(Topic, B::Cursor)> {
1082        self.by_topic
1083            .keys()
1084            .filter_map(|topic| Some((topic.clone(), self.resume_cursor(topic)?)))
1085            .collect()
1086    }
1087
1088    /// Record acknowledgements before the updates enter the connection queue.
1089    fn prepare_adds(&mut self, adds: Vec<(Topic, B::Cursor)>) -> Vec<(u64, B::Mutate)> {
1090        let chunks = chunk_by_budget(
1091            adds,
1092            self.mutate.add_cap,
1093            self.mutate.byte_cap,
1094            |(topic, _)| topic_wire_cost(topic),
1095        );
1096        chunks
1097            .into_iter()
1098            .map(|adds| {
1099                for (topic, cursor) in &adds {
1100                    self.dirty_topics.insert(topic.clone());
1101                    let holders = self.by_topic.get(topic).cloned().unwrap_or_default();
1102                    for id in &holders {
1103                        self.reset_obligation(*id, topic);
1104                    }
1105                    self.registrations
1106                        .entry(topic.clone())
1107                        .or_insert_with(|| TopicRegistration {
1108                            target: None,
1109                            delivered: *cursor,
1110                            holders,
1111                            state: RegistrationState::Adding,
1112                        });
1113                }
1114                let id = self.next_update_id();
1115                let update = B::build_mutate(adds.clone(), [], id);
1116                self.pending_updates.insert(
1117                    id,
1118                    PendingUpdate {
1119                        adds,
1120                        removes: vec![],
1121                    },
1122                );
1123                (id, update)
1124            })
1125            .collect()
1126    }
1127
1128    fn prepare_removes(&mut self, removes: Vec<Topic>) -> Vec<(u64, B::Mutate)> {
1129        chunk_by_budget(
1130            removes,
1131            self.mutate.remove_cap,
1132            self.mutate.byte_cap,
1133            topic_wire_cost,
1134        )
1135        .into_iter()
1136        .map(|removes| {
1137            let id = self.next_update_id();
1138            let update = B::build_mutate([], removes.clone(), id);
1139            self.pending_updates.insert(
1140                id,
1141                PendingUpdate {
1142                    adds: vec![],
1143                    removes,
1144                },
1145            );
1146            (id, update)
1147        })
1148        .collect()
1149    }
1150
1151    /// Join an active registration, or remove it before requesting older data.
1152    fn join(&mut self, id: LeaseId, subs: Vec<(Topic, B::Cursor)>) -> Vec<(u64, B::Mutate)> {
1153        let mut adds = Vec::new();
1154        let mut removes = Vec::new();
1155        let mut joined = Vec::new();
1156        for (topic, floor) in subs {
1157            self.dirty_topics.insert(topic.clone());
1158            let Some(registration) = self.registrations.get_mut(&topic) else {
1159                adds.push((topic, floor));
1160                continue;
1161            };
1162            match &mut registration.state {
1163                RegistrationState::Removing => {}
1164                _ if !B::covers(&floor, &registration.delivered) => {
1165                    registration.state = RegistrationState::Removing;
1166                    for holder in &registration.holders {
1167                        if let Some(lease) = self.leases.get(holder) {
1168                            lease.connected.store(false, Ordering::Release);
1169                        }
1170                    }
1171                    removes.push(topic);
1172                }
1173                _ => {
1174                    registration.holders.insert(id);
1175                    joined.push(topic);
1176                }
1177            }
1178        }
1179        let mut updates = self.prepare_removes(removes);
1180        updates.extend(self.prepare_adds(adds));
1181        self.incoming_registered(id, joined);
1182        updates
1183    }
1184
1185    /// Apply ordered acknowledgement boundaries and return topics to re-add.
1186    fn applied(&mut self, id: u64, targets: Vec<(Topic, u64)>) -> Vec<(Topic, B::Cursor)> {
1187        let Some(update) = self.pending_updates.remove(&id) else {
1188            tracing::warn!(id, "received Applied for an unknown update");
1189            return Vec::new();
1190        };
1191        let targets: HashMap<_, _> = targets.into_iter().collect();
1192        let mut readds = Vec::new();
1193        for topic in update.removes {
1194            self.registrations.remove(&topic);
1195            if let Some(cursor) = self.resume_cursor(&topic) {
1196                readds.push((topic, cursor));
1197            }
1198        }
1199        let mut registered: HashMap<LeaseId, Vec<Topic>> = HashMap::new();
1200        for (topic, _) in update.adds {
1201            self.dirty_topics.insert(topic.clone());
1202            if let Some(registration) = self.registrations.get_mut(&topic) {
1203                if let Some(target) = targets.get(&topic) {
1204                    registration.target = Some(*target);
1205                }
1206                // An absent target leaves an existing registration unchanged.
1207                if matches!(registration.state, RegistrationState::Adding) {
1208                    registration.state = RegistrationState::Active;
1209                }
1210                for holder in &registration.holders {
1211                    registered.entry(*holder).or_default().push(topic.clone());
1212                }
1213            }
1214        }
1215        for (holder, topics) in registered {
1216            self.incoming_registered(holder, topics);
1217        }
1218        readds
1219    }
1220
1221    /// Check changed topics only. Send completion after their message batches.
1222    fn recheck(&mut self) -> Vec<LeaseId> {
1223        let failed: Vec<_> = self.failed_incoming.drain().collect();
1224        let mut candidates = HashSet::new();
1225        for topic in self.dirty_topics.drain() {
1226            let Some(registration) = self.registrations.get(&topic) else {
1227                continue;
1228            };
1229            let Some(target) = registration.target else {
1230                continue;
1231            };
1232            let target_cursor = B::Cursor::from(target);
1233            for id in &registration.holders {
1234                candidates.insert(*id);
1235                let Some(lease) = self.leases.get_mut(id) else {
1236                    continue;
1237                };
1238                let Some(obligation) = lease.obligations.get_mut(&topic) else {
1239                    continue;
1240                };
1241                if !obligation.satisfied
1242                    && (target == 0
1243                        || if lease.incoming.is_some() {
1244                            lease
1245                                .delivered
1246                                .get(&topic)
1247                                .is_some_and(|position| B::covers(position, &target_cursor))
1248                        } else {
1249                            B::covers(&registration.delivered, &target_cursor)
1250                        }
1251                        || lease
1252                            .floors
1253                            .get(&topic)
1254                            .is_some_and(|floor| B::covers(floor, &target_cursor)))
1255                {
1256                    obligation.satisfied = true;
1257                    lease.unmet -= 1;
1258                }
1259            }
1260        }
1261        candidates
1262            .into_iter()
1263            .filter_map(|id| {
1264                let lease = self.leases.get_mut(&id)?;
1265                if !lease.notified && lease.unmet == 0 {
1266                    if lease.incoming.is_none()
1267                        && lease.events.try_send(LeaseEvent::CatchUpComplete).is_err()
1268                    {
1269                        return Some(id);
1270                    }
1271                    lease.notified = true;
1272                }
1273                None
1274            })
1275            .chain(failed)
1276            .collect()
1277    }
1278
1279    /// Remove local interest immediately. The caller queues wire removals.
1280    fn deref(&mut self, id: LeaseId) -> Vec<Topic> {
1281        let Some(mut lease) = self.leases.remove(&id) else {
1282            return vec![];
1283        };
1284        lease.finish_pause(Instant::now(), "lease_removed");
1285        let mut removes = Vec::new();
1286        for topic in lease.floors.keys() {
1287            if let Some(holders) = self.by_topic.get_mut(topic) {
1288                holders.remove(&id);
1289                if holders.is_empty() {
1290                    self.by_topic.remove(topic);
1291                    self.last_seen.remove(topic);
1292                    removes.push(topic.clone());
1293                }
1294            }
1295            if let Some(registration) = self.registrations.get_mut(topic) {
1296                registration.holders.remove(&id);
1297            }
1298        }
1299        removes
1300    }
1301
1302    /// Route one frame by topic. A lease's position only moves forward.
1303    /// Reserve channel capacity before copying a lease's payload batch.
1304    fn demux<M: Clone>(
1305        &mut self,
1306        messages: Vec<M>,
1307        kind: DeliveryKind,
1308        topic_of: impl Fn(&M) -> Option<Topic>,
1309        cursor_of: impl Fn(&M) -> Option<B::Cursor>,
1310        event: impl Fn(Vec<M>) -> LeaseEvent<B>,
1311    ) -> Vec<LeaseId> {
1312        let mut batches: HashMap<LeaseId, Vec<&M>> = HashMap::new();
1313        for message in &messages {
1314            let Some(topic) = topic_of(message) else {
1315                continue;
1316            };
1317            let Some(registration) = self.registrations.get_mut(&topic) else {
1318                continue;
1319            };
1320            // A new registration cannot deliver before its add acknowledgement.
1321            if registration.target.is_none() {
1322                continue;
1323            }
1324            self.dirty_topics.insert(topic.clone());
1325            let cursor = cursor_of(message);
1326            if let Some(cursor) = cursor {
1327                B::advance(&mut registration.delivered, cursor);
1328                B::advance(
1329                    self.last_seen.entry(topic.clone()).or_insert(cursor),
1330                    cursor,
1331                );
1332            }
1333            for id in &registration.holders {
1334                let Some(lease) = self.leases.get_mut(id) else {
1335                    continue;
1336                };
1337                if lease.incoming.is_some() {
1338                    continue;
1339                }
1340                if let Some(cursor) = cursor {
1341                    let Some(position) = lease.delivered.get_mut(&topic) else {
1342                        continue;
1343                    };
1344                    if B::covers(position, &cursor) {
1345                        continue;
1346                    }
1347                    B::advance(position, cursor);
1348                }
1349                batches.entry(*id).or_default().push(message);
1350            }
1351        }
1352        batches
1353            .into_iter()
1354            .filter_map(|(id, batch)| {
1355                let lease = self.leases.get(&id)?;
1356                if let Ok(permit) = lease.events.try_reserve() {
1357                    permit.send(event(batch.into_iter().cloned().collect()));
1358                    None
1359                } else {
1360                    tracing::warn!(
1361                        lease = id.0,
1362                        ?kind,
1363                        "closing a lease whose delivery channel is full"
1364                    );
1365                    Some(id)
1366                }
1367            })
1368            .collect()
1369    }
1370
1371    fn caught_up(&self) -> bool {
1372        self.pending_updates.is_empty()
1373            && !self.has_pending_incoming()
1374            && self.leases.values().all(|lease| lease.unmet == 0)
1375    }
1376}
1377
1378/// The task owns both the wire and the ledger. Send updates with try_mutate.
1379/// Never wait for command capacity here: this task must keep reading events.
1380enum Step<B: TransportBinding>
1381where
1382    B::GroupMessage: Clone,
1383    B::WelcomeMessage: Clone,
1384{
1385    Cmd(Option<Cmd<B>>),
1386    Wire(Option<Event<B::GroupMessage, B::WelcomeMessage>>),
1387    Retry,
1388    Reconnect,
1389}
1390
1391async fn run_ledger<B: TransportBinding>(
1392    opener: Opener<B>,
1393    cmds: mpsc::UnboundedReceiver<Cmd<B>>,
1394    lease_cmds: mpsc::WeakUnboundedSender<Cmd<B>>,
1395    initially_suspended: bool,
1396    mutate: MutateLimits,
1397) where
1398    B::GroupMessage: Clone,
1399    B::WelcomeMessage: Clone,
1400{
1401    LedgerTask {
1402        opener,
1403        cmds,
1404        lease_cmds,
1405        ledger: Ledger {
1406            mutate,
1407            ..Ledger::default()
1408        },
1409        conn: None,
1410        reconnect_delay: RECONNECT_INITIAL_DELAY,
1411        reconnect_at: tokio::time::Instant::now(),
1412        wire_opened_at: None,
1413        wire_span: None,
1414        suspended: initially_suspended,
1415        wire_opens: 0,
1416        resume_notify: Vec::new(),
1417        outbox: Outbox::default(),
1418        update_budget: update_budget(),
1419        deferred: std::collections::VecDeque::new(),
1420    }
1421    .run()
1422    .await
1423}
1424
1425enum Flow {
1426    Continue,
1427    Shutdown,
1428}
1429
1430struct LedgerTask<B: TransportBinding>
1431where
1432    B::GroupMessage: Clone,
1433    B::WelcomeMessage: Clone,
1434{
1435    opener: Opener<B>,
1436    cmds: mpsc::UnboundedReceiver<Cmd<B>>,
1437    lease_cmds: mpsc::WeakUnboundedSender<Cmd<B>>,
1438    ledger: Ledger<B>,
1439    conn: Option<Connection<B>>,
1440    reconnect_delay: std::time::Duration,
1441    reconnect_at: tokio::time::Instant,
1442    wire_opened_at: Option<tokio::time::Instant>,
1443    wire_span: Option<tracing::Span>,
1444    suspended: bool,
1445    wire_opens: u64,
1446    resume_notify: Vec<oneshot::Sender<()>>,
1447    outbox: Outbox<B::Mutate>,
1448    update_budget: Bucket,
1449    deferred: std::collections::VecDeque<Cmd<B>>,
1450}
1451
1452impl<B: TransportBinding> LedgerTask<B>
1453where
1454    B::GroupMessage: Clone,
1455    B::WelcomeMessage: Clone,
1456{
1457    async fn run(mut self) {
1458        loop {
1459            // A finite snapshot lets queued leases share an update without
1460            // letting a continuous command producer starve wire events.
1461            let queued = self.deferred.len() + self.cmds.len();
1462            for _ in 0..queued {
1463                let cmd = self
1464                    .deferred
1465                    .pop_front()
1466                    .or_else(|| self.cmds.try_recv().ok());
1467                let Some(cmd) = cmd else { break };
1468                if let Flow::Shutdown = self.command(cmd).await {
1469                    return;
1470                }
1471            }
1472            let mut dropped = self.ledger.flush_incoming(Instant::now());
1473            dropped.extend(self.ledger.recheck());
1474            let removes = self.drop_leases(dropped);
1475            self.retire(removes);
1476            self.settle_idle_waiters();
1477            self.settle_caught_up_waiters();
1478            self.flush_outbox();
1479            let flow = match self.next_step().await {
1480                Step::Retry => Flow::Continue,
1481                Step::Cmd(None) => self.shutdown(),
1482                Step::Cmd(Some(cmd)) => self.command(cmd).await,
1483                Step::Wire(Some(event)) => self.wire_event(event),
1484                Step::Wire(None) => self.wire_died(),
1485                Step::Reconnect => self.reconnect().await,
1486            };
1487            if let Flow::Shutdown = flow {
1488                return;
1489            }
1490        }
1491    }
1492
1493    async fn command(&mut self, cmd: Cmd<B>) -> Flow {
1494        match cmd {
1495            Cmd::Lease { subs, depth, reply } => self.lease(subs, depth, None, reply).await,
1496            Cmd::LeaseOrdered {
1497                subs,
1498                depth,
1499                limits,
1500                reply,
1501            } => self.lease(subs, depth, Some(limits), reply).await,
1502            Cmd::Received { id, cursors } => {
1503                self.ledger.received(id, cursors);
1504                Flow::Continue
1505            }
1506            Cmd::Deref(id) => self.deref(id),
1507            Cmd::Suspend { reply } => self.suspend(reply),
1508            Cmd::Resume { reply } => self.resume(reply).await,
1509        }
1510    }
1511
1512    async fn next_step(&mut self) -> Step<B> {
1513        if let Some(cmd) = self.deferred.pop_front() {
1514            return Step::Cmd(Some(cmd));
1515        }
1516        let retry_after = self.update_budget.wait().max(OUTBOX_RETRY_INTERVAL);
1517        if self.ledger.has_pending_incoming() {
1518            // Only the ledger writes these channels. A reservation wakes this
1519            // task when a slot opens; the next loop flushes it before reading wire.
1520            let pending: Vec<_> = self
1521                .ledger
1522                .leases
1523                .values()
1524                .filter(|lease| !lease.incoming_pending.is_empty())
1525                .filter_map(|lease| {
1526                    let (sender, _) = lease.incoming.as_ref()?;
1527                    Some(Box::pin(sender.clone().reserve_owned()))
1528                })
1529                .collect();
1530            let until_deadline = self
1531                .ledger
1532                .leases
1533                .values()
1534                .filter_map(|lease| lease.paused_at)
1535                .map(|started| INCOMING_PAUSE_TIMEOUT.saturating_sub(started.elapsed()))
1536                .min()
1537                .unwrap_or(INCOMING_PAUSE_TIMEOUT);
1538            return tokio::select! {
1539                cmd = self.cmds.recv() => Step::Cmd(cmd),
1540                _ = futures::future::select_all(pending) => Step::Retry,
1541                _ = xmtp_common::time::sleep(until_deadline) => Step::Retry,
1542                _ = xmtp_common::time::sleep(retry_after), if !self.outbox.is_empty() => Step::Retry,
1543            };
1544        }
1545        match self.conn.as_mut() {
1546            Some(wire) => tokio::select! {
1547                cmd = self.cmds.recv() => Step::Cmd(cmd),
1548                event = wire.next() => Step::Wire(event),
1549                _ = xmtp_common::time::sleep(retry_after), if !self.outbox.is_empty() => Step::Retry,
1550            },
1551            None if !self.ledger.leases.is_empty() && !self.suspended => tokio::select! {
1552                cmd = self.cmds.recv() => Step::Cmd(cmd),
1553                _ = tokio::time::sleep_until(self.reconnect_at) => Step::Reconnect,
1554            },
1555            None => Step::Cmd(self.cmds.recv().await),
1556        }
1557    }
1558
1559    fn shutdown(&mut self) -> Flow {
1560        self.outbox.clear();
1561        self.close_wire_span("shutdown");
1562        if let Some(wire) = self.conn.take() {
1563            close_gracefully(wire);
1564        }
1565        Flow::Shutdown
1566    }
1567
1568    fn open_wire_span(&mut self) {
1569        self.update_budget = update_budget();
1570        // The opener already sent the first Update on this connection.
1571        self.update_budget.take();
1572        self.wire_opened_at = Some(tokio::time::Instant::now());
1573        self.wire_span = Some(tracing::info_span!(
1574            parent: None,
1575            "bidi_wire",
1576            operation = "bidi.wire_session",
1577            reason = tracing::field::Empty,
1578        ));
1579    }
1580
1581    fn close_wire_span(&mut self, reason: &'static str) {
1582        if let Some(span) = self.wire_span.take() {
1583            span.record("reason", reason);
1584        }
1585    }
1586
1587    async fn lease(
1588        &mut self,
1589        subs: Vec<(Topic, B::Cursor)>,
1590        depth: usize,
1591        incoming_limits: Option<IncomingBatchLimits>,
1592        reply: oneshot::Sender<Result<TopicLease<B>, TransportError>>,
1593    ) -> Flow {
1594        let mut positions = HashMap::new();
1595        let mut subs_unique: Vec<(Topic, B::Cursor)> = Vec::new();
1596        for (topic, floor) in subs {
1597            if let Some(index) = positions.get(&topic).copied() {
1598                let (_, cursor): &mut (Topic, B::Cursor) = &mut subs_unique[index];
1599                *cursor = B::meet(*cursor, floor);
1600            } else {
1601                positions.insert(topic.clone(), subs_unique.len());
1602                subs_unique.push((topic, floor));
1603            }
1604        }
1605        let subs = subs_unique;
1606        let added = subs
1607            .iter()
1608            .filter(|(topic, _)| !self.ledger.by_topic.contains_key(topic))
1609            .count();
1610        if self.ledger.by_topic.len() + added
1611            > xmtp_configuration::BACKEND_DEFAULT_MAX_STREAM_TOPICS
1612        {
1613            let _ = reply.send(Err(TransportError::TooManyTopics));
1614            return Flow::Continue;
1615        }
1616        let cold = self.conn.is_none()
1617            && self.ledger.leases.is_empty()
1618            && !self.suspended
1619            && tokio::time::Instant::now() >= self.reconnect_at;
1620        let topics = subs.iter().map(|(topic, _)| topic.clone()).collect();
1621        let (tx, events) = mpsc::channel(depth.max(1));
1622        let id = self.ledger.register(&subs, tx);
1623        let incoming = incoming_limits.map(|limits| {
1624            let (sender, receiver) = mpsc::channel(depth.max(1));
1625            if let Some(lease) = self.ledger.leases.get_mut(&id) {
1626                lease.incoming = Some((sender, limits));
1627            }
1628            receiver
1629        });
1630        let incoming_failure = self
1631            .ledger
1632            .leases
1633            .get(&id)
1634            .map(|lease| lease.incoming_failure.clone())
1635            .unwrap_or_default();
1636        let connected = self.ledger.leases[&id].connected.clone();
1637        if cold {
1638            self.outbox.updates.extend(self.ledger.prepare_adds(subs));
1639            let Some((_, initial)) = self.outbox.updates.pop_front() else {
1640                return Flow::Shutdown;
1641            };
1642            match self.open_preemptibly(initial).await {
1643                OpenOutcome::Opened(wire) => {
1644                    self.conn = Some(wire);
1645                    self.open_wire_span();
1646                    self.wire_opens += 1;
1647                }
1648                OpenOutcome::Failed(error) => {
1649                    self.ledger.deref(id);
1650                    self.ledger.reset_wire();
1651                    self.outbox.clear();
1652                    self.reconnect_delay = if error.is_locked_out() {
1653                        AUTH_LOCKOUT_COOLDOWN
1654                    } else {
1655                        (self.reconnect_delay * 2).min(RECONNECT_MAX_DELAY)
1656                    };
1657                    self.arm_reconnect();
1658                    let _ = reply.send(Err(TransportError::Open(error)));
1659                    return Flow::Continue;
1660                }
1661                OpenOutcome::Suspended(ack) => {
1662                    self.ledger.reset_wire();
1663                    self.outbox.clear();
1664                    self.suspend_preempted(ack);
1665                }
1666                OpenOutcome::Shutdown => return Flow::Shutdown,
1667            }
1668        } else if self.conn.is_some() {
1669            self.outbox.updates.extend(self.ledger.join(id, subs));
1670            let dropped = self.ledger.recheck();
1671            let removes = self.drop_leases(dropped);
1672            self.retire(removes);
1673        }
1674        let Some(cmds) = self.lease_cmds.upgrade() else {
1675            return Flow::Continue;
1676        };
1677        let _ = reply.send(Ok(TopicLease {
1678            id,
1679            topics,
1680            events,
1681            incoming,
1682            incoming_pending: Vec::new().into_iter(),
1683            incoming_failure,
1684            connected,
1685            cmds,
1686        }));
1687        Flow::Continue
1688    }
1689
1690    fn deref(&mut self, id: LeaseId) -> Flow {
1691        let removes = self.drop_leases(vec![id]);
1692        self.retire(removes);
1693        self.settle_idle_waiters();
1694        self.settle_caught_up_waiters();
1695        Flow::Continue
1696    }
1697
1698    fn drop_leases(&mut self, dropped: Vec<LeaseId>) -> Vec<Topic> {
1699        let mut removes = Vec::new();
1700        for lease in dropped {
1701            removes.extend(self.ledger.deref(lease));
1702        }
1703        // Cancel an unsent add only when none of its topics has a holder.
1704        let unused: HashSet<_> = self
1705            .ledger
1706            .pending_updates
1707            .iter()
1708            .filter_map(|(id, update)| {
1709                (!update.adds.is_empty()
1710                    && update
1711                        .adds
1712                        .iter()
1713                        .all(|(topic, _)| !self.ledger.by_topic.contains_key(topic)))
1714                .then_some(*id)
1715            })
1716            .collect();
1717        for id in self.outbox.purge(&unused) {
1718            if let Some(update) = self.ledger.pending_updates.remove(&id) {
1719                for (topic, _) in update.adds {
1720                    self.ledger.dirty_topics.insert(topic.clone());
1721                    // Keep a pending removal until Applied. A replacement must
1722                    // not add this topic before that old boundary is consumed.
1723                    if !self
1724                        .ledger
1725                        .registrations
1726                        .get(&topic)
1727                        .is_some_and(|registration| {
1728                            matches!(registration.state, RegistrationState::Removing)
1729                        })
1730                    {
1731                        self.ledger.registrations.remove(&topic);
1732                    }
1733                }
1734            }
1735        }
1736        removes
1737    }
1738
1739    fn suspend(&mut self, reply: oneshot::Sender<()>) -> Flow {
1740        tracing::info!(
1741            leases = self.ledger.leases.len(),
1742            had_wire = self.conn.is_some(),
1743            "bidi transport: suspending — going off the network"
1744        );
1745        self.suspended = true;
1746        self.outbox.clear();
1747        self.ledger.reset_wire();
1748        self.close_wire_span("suspend");
1749        if let Some(wire) = self.conn.take() {
1750            close_gracefully(wire);
1751        }
1752        for waiter in self.resume_notify.drain(..) {
1753            let _ = waiter.send(());
1754        }
1755        let _ = reply.send(());
1756        Flow::Continue
1757    }
1758
1759    /// Resume from suspension immediately. During an outage, join the pending
1760    /// catch-up without replacing the scheduled reconnect backoff.
1761    async fn resume(&mut self, reply: oneshot::Sender<()>) -> Flow {
1762        tracing::info!(
1763            leases = self.ledger.leases.len(),
1764            "bidi transport: resuming — catch up, then done"
1765        );
1766        let was_suspended = self.suspended;
1767        self.suspended = false;
1768        self.resume_notify.push(reply);
1769        if self.conn.is_some() {
1770            self.settle_caught_up_waiters();
1771            return Flow::Continue;
1772        }
1773        if self.ledger.leases.is_empty() {
1774            self.settle_idle_waiters();
1775            return Flow::Continue;
1776        }
1777        if was_suspended {
1778            self.reconnect_delay = RECONNECT_INITIAL_DELAY;
1779            self.reconnect().await
1780        } else {
1781            Flow::Continue
1782        }
1783    }
1784
1785    fn wire_event(&mut self, event: Event<B::GroupMessage, B::WelcomeMessage>) -> Flow {
1786        let has_incoming = self
1787            .ledger
1788            .leases
1789            .values()
1790            .any(|lease| lease.incoming.is_some());
1791        let incoming_dropped = if has_incoming {
1792            match &event {
1793                Event::Applied { id, targets } if !self.ledger.valid_targets(*id, targets) => {
1794                    return self.fail_incoming("Applied targets");
1795                }
1796                Event::GroupMessages { messages } => {
1797                    self.ledger.demux_incoming(messages, B::group_envelope)
1798                }
1799                Event::WelcomeMessages { messages } => {
1800                    self.ledger.demux_incoming(messages, B::welcome_envelope)
1801                }
1802                _ => Ok(Vec::new()),
1803            }
1804        } else {
1805            Ok(Vec::new())
1806        };
1807        let incoming_dropped = match incoming_dropped {
1808            Ok(dropped) => dropped,
1809            Err(reason) => return self.fail_incoming(reason),
1810        };
1811        let mut dropped = match event {
1812            Event::Started { .. } => Vec::new(),
1813            Event::Applied { id, targets } => {
1814                let readds = self.ledger.applied(id, targets);
1815                self.outbox.updates.extend(self.ledger.prepare_adds(readds));
1816                Vec::new()
1817            }
1818            Event::GroupMessages { messages } => self.ledger.demux(
1819                messages,
1820                DeliveryKind::Group,
1821                B::group_topic,
1822                B::group_cursor,
1823                LeaseEvent::GroupMessages,
1824            ),
1825            Event::WelcomeMessages { messages } => self.ledger.demux(
1826                messages,
1827                DeliveryKind::Welcome,
1828                B::welcome_topic,
1829                B::welcome_cursor,
1830                LeaseEvent::WelcomeMessages,
1831            ),
1832        };
1833        dropped.extend(incoming_dropped);
1834        dropped.extend(self.ledger.recheck());
1835        let removes = self.drop_leases(dropped);
1836        self.retire(removes);
1837        self.settle_idle_waiters();
1838        self.settle_caught_up_waiters();
1839        Flow::Continue
1840    }
1841
1842    fn fail_incoming(&mut self, reason: &'static str) -> Flow {
1843        let dropped = self
1844            .ledger
1845            .leases
1846            .iter()
1847            .filter_map(|(id, lease)| {
1848                lease.incoming.as_ref()?;
1849                *lease.incoming_failure.lock() = Some(TransportError::Protocol(reason));
1850                Some(*id)
1851            })
1852            .collect();
1853        let removes = self.drop_leases(dropped);
1854        self.retire(removes);
1855        self.settle_idle_waiters();
1856        Flow::Continue
1857    }
1858
1859    fn arm_reconnect(&mut self) -> std::time::Duration {
1860        let delay = self.reconnect_delay + xmtp_common::time::rand_offset(self.reconnect_delay);
1861        self.reconnect_at = tokio::time::Instant::now() + delay;
1862        delay
1863    }
1864
1865    fn wire_died(&mut self) -> Flow {
1866        self.outbox.clear();
1867        let failure = self.conn.as_ref().and_then(Connection::failure);
1868        drop(self.conn.take());
1869        let mut dropped: Vec<_> = self
1870            .ledger
1871            .leases
1872            .iter()
1873            .filter_map(|(id, lease)| {
1874                let (sender, _) = lease.incoming.as_ref()?;
1875                let event = failure
1876                    .as_ref()
1877                    .map_or(Ok(vec![IncomingEvent::Disconnected]), |error| {
1878                        Err(TransportError::Wire(error.clone()))
1879                    });
1880                let full = match sender.try_send(event) {
1881                    Ok(()) => false,
1882                    Err(error) => {
1883                        *lease.incoming_failure.lock() = Some(match error.into_inner() {
1884                            Err(error) => error,
1885                            Ok(_) => TransportError::Backpressure,
1886                        });
1887                        true
1888                    }
1889                };
1890                (full || failure.as_ref().is_some_and(|error| !error.is_retryable())).then_some(*id)
1891            })
1892            .collect();
1893        if failure.as_ref().is_some_and(|error| !error.is_retryable()) {
1894            // End the affected leases, but keep the cached actor available for a
1895            // later acquisition after the caller repairs credentials or the source.
1896            dropped.extend(self.ledger.leases.keys().copied());
1897        }
1898        self.drop_leases(dropped);
1899        self.ledger.reset_wire();
1900        self.close_wire_span("wire_end");
1901        let stable = self
1902            .wire_opened_at
1903            .take()
1904            .is_some_and(|opened| opened.elapsed() >= MIN_STABLE_UPTIME);
1905        self.reconnect_delay = if stable {
1906            RECONNECT_INITIAL_DELAY
1907        } else {
1908            (self.reconnect_delay * 2).min(RECONNECT_MAX_DELAY)
1909        };
1910        let retry_in = self.arm_reconnect();
1911        if !self.ledger.leases.is_empty() {
1912            tracing::warn!(
1913                leases = self.ledger.leases.len(),
1914                pending_updates = self.ledger.pending_updates.len(),
1915                retry_in_ms = retry_in.as_millis() as u64,
1916                "bidi transport: wire died; reopening from lease floors"
1917            );
1918        }
1919        self.settle_idle_waiters();
1920        Flow::Continue
1921    }
1922
1923    #[tracing::instrument(skip_all, fields(operation = "bidi.reconnect"))]
1924    async fn reconnect(&mut self) -> Flow {
1925        match self.reopen().await {
1926            AfterReopen::Proceed => Flow::Continue,
1927            AfterReopen::Suspended(ack) => {
1928                self.suspend_preempted(ack);
1929                Flow::Continue
1930            }
1931            AfterReopen::Shutdown => Flow::Shutdown,
1932        }
1933    }
1934
1935    fn suspend_preempted(&mut self, ack: oneshot::Sender<()>) {
1936        self.suspended = true;
1937        self.park_deferred_resumes();
1938        for waiter in self.resume_notify.drain(..) {
1939            let _ = waiter.send(());
1940        }
1941        let _ = ack.send(());
1942    }
1943
1944    async fn reopen(&mut self) -> AfterReopen {
1945        self.ledger.reset_wire();
1946        let adds = self.ledger.resume_adds();
1947        if adds.is_empty() {
1948            self.settle_idle_waiters();
1949            return AfterReopen::Proceed;
1950        }
1951        self.outbox.updates.extend(self.ledger.prepare_adds(adds));
1952        let Some((_, initial)) = self.outbox.updates.pop_front() else {
1953            return AfterReopen::Proceed;
1954        };
1955        match self.open_preemptibly(initial).await {
1956            OpenOutcome::Opened(wire) => {
1957                self.conn = Some(wire);
1958                self.open_wire_span();
1959                self.wire_opens += 1;
1960                AfterReopen::Proceed
1961            }
1962            OpenOutcome::Failed(error) => {
1963                self.outbox.clear();
1964                self.ledger.reset_wire();
1965                // A lockout uses its full cooldown before the next attempt.
1966                // Only the failure that started it spends a recovery cycle.
1967                let locked_out = error.is_locked_out();
1968                let attempted = error.attempted();
1969                if !locked_out && !error.is_retryable() {
1970                    tracing::error!("bidi reconnect failed permanently: {error}");
1971                    let error = std::sync::Arc::new(super::bidi::ConnectionFailure::Wire(
1972                        xmtp_proto::api::NetworkError::new(error),
1973                    ));
1974                    for lease in self.ledger.leases.values() {
1975                        if lease.incoming.is_some() {
1976                            *lease.incoming_failure.lock() =
1977                                Some(TransportError::Wire(error.clone()));
1978                        }
1979                    }
1980                    let ids = self.ledger.leases.keys().copied().collect();
1981                    self.drop_leases(ids);
1982                    self.reconnect_delay = (self.reconnect_delay * 2).min(RECONNECT_MAX_DELAY);
1983                    self.arm_reconnect();
1984                    self.park_deferred_resumes();
1985                    return AfterReopen::Proceed;
1986                }
1987                // Ordered consumers supervise their own finite outage budgets.
1988                // Report failed opens even though this shared actor keeps its
1989                // backoff and remains available to other consumers.
1990                let failure = std::sync::Arc::new(super::bidi::ConnectionFailure::Wire(
1991                    xmtp_proto::api::NetworkError::new(error),
1992                ));
1993                let mut dropped = Vec::new();
1994                for (id, lease) in &self.ledger.leases {
1995                    if let Some((sender, _)) = &lease.incoming
1996                        && attempted
1997                        && sender
1998                            .try_send(Err(TransportError::Wire(failure.clone())))
1999                            .is_err()
2000                    {
2001                        *lease.incoming_failure.lock() =
2002                            Some(TransportError::Wire(failure.clone()));
2003                        dropped.push(*id);
2004                    }
2005                }
2006                self.drop_leases(dropped);
2007                self.reconnect_delay = if locked_out {
2008                    AUTH_LOCKOUT_COOLDOWN
2009                } else {
2010                    (self.reconnect_delay * 2).min(RECONNECT_MAX_DELAY)
2011                };
2012                self.arm_reconnect();
2013                self.park_deferred_resumes();
2014                AfterReopen::Proceed
2015            }
2016            OpenOutcome::Suspended(ack) => {
2017                self.outbox.clear();
2018                self.ledger.reset_wire();
2019                AfterReopen::Suspended(ack)
2020            }
2021            OpenOutcome::Shutdown => AfterReopen::Shutdown,
2022        }
2023    }
2024
2025    /// Keep receiving commands during a dial. Suspend cancels the dial at once.
2026    /// Other commands keep their order and run after the dial ends.
2027    async fn open_preemptibly(&mut self, mutate: B::Mutate) -> OpenOutcome<B> {
2028        let open = self.opener.open(mutate);
2029        tokio::pin!(open);
2030        loop {
2031            tokio::select! {
2032                result = &mut open => {
2033                    return match result {
2034                        Ok(wire) => OpenOutcome::Opened(wire),
2035                        Err(e) => OpenOutcome::Failed(e),
2036                    };
2037                }
2038                cmd = self.cmds.recv() => match cmd {
2039                    Some(Cmd::Suspend { reply }) => return OpenOutcome::Suspended(reply),
2040                    Some(cmd) => self.deferred.push_back(cmd),
2041                    None => return OpenOutcome::Shutdown,
2042                },
2043            }
2044        }
2045    }
2046
2047    /// Merge concurrent resume calls into the same attempt and backoff.
2048    fn park_deferred_resumes(&mut self) {
2049        for cmd in std::mem::take(&mut self.deferred) {
2050            match cmd {
2051                Cmd::Resume { reply } => self.resume_notify.push(reply),
2052                other => self.deferred.push_back(other),
2053            }
2054        }
2055    }
2056
2057    fn settle_idle_waiters(&mut self) {
2058        if !self.ledger.leases.is_empty() {
2059            return;
2060        }
2061        self.ledger.reset_wire();
2062        for waiter in self.resume_notify.drain(..) {
2063            let _ = waiter.send(());
2064        }
2065    }
2066
2067    fn settle_caught_up_waiters(&mut self) {
2068        if self.conn.is_none() || !self.ledger.caught_up() || !self.outbox.is_empty() {
2069            return;
2070        }
2071        for waiter in self.resume_notify.drain(..) {
2072            let _ = waiter.send(());
2073        }
2074    }
2075
2076    fn retire(&mut self, removes: Vec<Topic>) {
2077        if self.ledger.leases.is_empty() {
2078            self.outbox.clear();
2079            self.ledger.reset_wire();
2080            self.close_wire_span("idle");
2081            if let Some(wire) = self.conn.take() {
2082                close_gracefully(wire);
2083            }
2084            return;
2085        }
2086        if self.conn.is_none() {
2087            return;
2088        }
2089        let mut to_remove = Vec::new();
2090        for topic in removes {
2091            if let Some(registration) = self.ledger.registrations.get_mut(&topic)
2092                && !matches!(registration.state, RegistrationState::Removing)
2093            {
2094                registration.state = RegistrationState::Removing;
2095                to_remove.push(topic);
2096            }
2097        }
2098        self.outbox
2099            .updates
2100            .extend(self.ledger.prepare_removes(to_remove));
2101    }
2102
2103    /// Combine a same-kind prefix without changing queued frames or ack state.
2104    /// Duplicate topics and add/remove boundaries stay in separate updates.
2105    fn coalesced_prefix(&self) -> Option<(usize, PendingUpdate<B::Cursor>)> {
2106        if self.outbox.updates.len() < 2 {
2107            return None;
2108        }
2109        let (first_id, _) = self.outbox.updates.front()?;
2110        let first = self.ledger.pending_updates.get(first_id)?;
2111        let adds_only = !first.adds.is_empty() && first.removes.is_empty();
2112        let removes_only = first.adds.is_empty() && !first.removes.is_empty();
2113        if !adds_only && !removes_only {
2114            return None;
2115        }
2116        // The deployment caps adds and removes separately, so the
2117        // merged frame is bounded by the cap for the kind it carries. Merging a
2118        // removes-only prefix up to `add_cap` would build a frame the backend
2119        // rejects with INVALID_ARGUMENT wherever a deployment publishes a
2120        // smaller `max_update_removes`.
2121        let topic_cap = if adds_only {
2122            self.ledger.mutate.add_cap
2123        } else {
2124            self.ledger.mutate.remove_cap
2125        };
2126        let mut topics: HashSet<_> = first
2127            .adds
2128            .iter()
2129            .map(|(topic, _)| topic)
2130            .chain(first.removes.iter())
2131            .collect();
2132        let mut bytes: usize = topics.iter().map(|topic| topic_wire_cost(topic)).sum();
2133        let mut count = 1;
2134        for (id, _) in self.outbox.updates.iter().skip(1) {
2135            let Some(next) = self.ledger.pending_updates.get(id) else {
2136                break;
2137            };
2138            if (adds_only && (next.adds.is_empty() || !next.removes.is_empty()))
2139                || (removes_only && (next.removes.is_empty() || !next.adds.is_empty()))
2140            {
2141                break;
2142            }
2143            let next_topics: Vec<_> = next
2144                .adds
2145                .iter()
2146                .map(|(topic, _)| topic)
2147                .chain(next.removes.iter())
2148                .collect();
2149            let next_bytes: usize = next_topics.iter().map(|topic| topic_wire_cost(topic)).sum();
2150            if topics.len() + next_topics.len() > topic_cap
2151                || bytes + next_bytes > self.ledger.mutate.byte_cap
2152                || next_topics.iter().any(|topic| topics.contains(topic))
2153            {
2154                break;
2155            }
2156            topics.extend(next_topics);
2157            bytes += next_bytes;
2158            count += 1;
2159        }
2160        if count == 1 {
2161            return None;
2162        }
2163        let mut merged = PendingUpdate {
2164            adds: Vec::new(),
2165            removes: Vec::new(),
2166        };
2167        for (id, _) in self.outbox.updates.iter().take(count) {
2168            let update = self.ledger.pending_updates.get(id)?;
2169            merged.adds.extend(update.adds.iter().cloned());
2170            merged.removes.extend(update.removes.iter().cloned());
2171        }
2172        Some((count, merged))
2173    }
2174
2175    /// Commit a coalesced acknowledgement ID only after the wire accepts it.
2176    /// Backpressure leaves the original queue and pending IDs intact.
2177    fn flush_outbox(&mut self) {
2178        let Some(wire) = self.conn.as_ref() else {
2179            return;
2180        };
2181        while let Some((id, _)) = self.outbox.updates.front() {
2182            if !self.update_budget.take() {
2183                return;
2184            }
2185            let id = *id;
2186            if let Some((count, merged)) = self.coalesced_prefix() {
2187                let update = B::build_mutate(merged.adds.clone(), merged.removes.clone(), id);
2188                if wire.try_mutate(update).is_err() {
2189                    self.update_budget.refund();
2190                    return;
2191                }
2192                for (old_id, _) in self.outbox.updates.drain(..count) {
2193                    self.ledger.pending_updates.remove(&old_id);
2194                }
2195                self.ledger.pending_updates.insert(id, merged);
2196                continue;
2197            }
2198            let Some((id, update)) = self.outbox.updates.pop_front() else {
2199                break;
2200            };
2201            match wire.try_mutate(update) {
2202                Ok(()) => {}
2203                Err(TryMutateError::Full(update)) | Err(TryMutateError::Closed(update)) => {
2204                    self.update_budget.refund();
2205                    self.outbox.updates.push_front((id, update));
2206                    return;
2207                }
2208            }
2209        }
2210    }
2211}
2212
2213enum OpenOutcome<B: BidiBinding> {
2214    Opened(Connection<B>),
2215    Failed(OpenError),
2216    Suspended(oneshot::Sender<()>),
2217    Shutdown,
2218}
2219
2220enum AfterReopen {
2221    Proceed,
2222    Suspended(oneshot::Sender<()>),
2223    Shutdown,
2224}
2225
2226struct Outbox<M> {
2227    updates: std::collections::VecDeque<(u64, M)>,
2228}
2229
2230impl<M> Default for Outbox<M> {
2231    fn default() -> Self {
2232        Self {
2233            updates: std::collections::VecDeque::new(),
2234        }
2235    }
2236}
2237
2238impl<M> Outbox<M> {
2239    fn is_empty(&self) -> bool {
2240        self.updates.is_empty()
2241    }
2242    fn clear(&mut self) {
2243        self.updates.clear();
2244    }
2245
2246    fn purge(&mut self, ids: &HashSet<u64>) -> Vec<u64> {
2247        let mut purged = Vec::new();
2248        self.updates.retain(|(id, _)| {
2249            if ids.contains(id) {
2250                purged.push(*id);
2251                false
2252            } else {
2253                true
2254            }
2255        });
2256        purged
2257    }
2258}
2259
2260/// Release the connection without blocking the ledger on command capacity.
2261/// Discard remaining events and abort the connection when the drain budget ends.
2262fn close_gracefully<B: TransportBinding>(wire: Connection<B>)
2263where
2264    B::GroupMessage: Clone,
2265    B::WelcomeMessage: Clone,
2266{
2267    xmtp_common::spawn(None, async move {
2268        let mut wire = wire;
2269        let drained = xmtp_common::time::timeout(GRACEFUL_CLOSE_BUDGET, async {
2270            if wire.finish().await.is_err() {
2271                return; // actor already gone — nothing to drain
2272            }
2273            while wire.next().await.is_some() {}
2274        })
2275        .await;
2276        if drained.is_err() {
2277            tracing::debug!(
2278                budget_ms = GRACEFUL_CLOSE_BUDGET.as_millis() as u64,
2279                "bidi transport: graceful-close drain budget expired; dropping the wire"
2280            );
2281        }
2282    });
2283}
2284
2285#[cfg(test)]
2286mod tests;