Skip to main content

aimdb_core/session/
client.rs

1//! The proactive **client** engine of the session substrate — the dual of the
2//! [`server`](super::server): it *dials* a [`Connection`] via a [`Dialer`],
3//! *sends* [`Inbound`] / *receives* [`Outbound`], and demultiplexes replies by `id`.
4//!
5//! [`run_client`] owns the demux core and returns a [`ClientHandle`] for
6//! caller-initiated RPC (`call`/`subscribe`/`write`) plus the engine future for
7//! the runner to drive (spawn-free). [`pump_client`] is a thin wrapper that
8//! mirrors records over the same engine.
9//!
10//! Runtime-neutral: the only runtime-specific primitive is *time* (reconnect
11//! backoff + keepalive), via the adapter's dyn-safe `RuntimeOps` clock; everything else is
12//! `futures` channels. The demux loop uses the same **extract-then-act** shape as
13//! the server (compute a [`ClientStep`], then act once the arm borrows release).
14
15use alloc::boxed::Box;
16use alloc::string::{String, ToString};
17use alloc::sync::Arc;
18use alloc::vec::Vec;
19use core::sync::atomic::{AtomicUsize, Ordering};
20
21use async_channel::{Receiver, Sender};
22use futures_channel::oneshot;
23use futures_util::{select_biased, FutureExt, StreamExt};
24use hashbrown::HashMap;
25
26use super::{
27    BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError,
28    SubUpdate, TransportError,
29};
30use crate::router::RouterBuilder;
31use crate::AimDb;
32
33/// Capacity of a subscription's client-side event sink. Bounded (was
34/// `unbounded`) so a slow consumer can't grow memory without limit under a fast
35/// wildcard set; matches the server's per-connection `EVENT_BUFFER`. On overflow
36/// the run loop drops the update and lets the loss surface as a `seq` gap
37/// (`SubUpdate::skipped`) on the next delivered update — for late-join snapshots
38/// too, which a wildcard subscription can burst well past this cap.
39const SUBSCRIBE_CHANNEL_CAP: usize = 256;
40
41/// Ceiling on [`ClientConfig::max_offline_queue`]: the channel preallocates its
42/// ring, so `usize::MAX` would abort in the allocator rather than reach it.
43const MAX_COMMAND_QUEUE_CAP: usize = 8192;
44
45/// Client engine knobs. Durations are in **milliseconds** so the engine stays
46/// `no_std`-clean; plain milliseconds turned into `core::time::Duration` for the clock.
47#[derive(Debug, Clone)]
48pub struct ClientConfig {
49    /// Redial after a dropped/failed connection instead of ending the engine.
50    /// Replays outbound traffic only: pending calls fail and open subscriptions
51    /// are not re-issued (so `pump_client` inbound mirroring stops after the first
52    /// disconnect; outbound survives).
53    pub reconnect: bool,
54    /// Base delay (ms) before the first redial; subsequent redials grow
55    /// exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay).
56    pub reconnect_delay: u64,
57    /// Upper bound (ms) for the reconnect backoff. Defaults to
58    /// [`reconnect_delay`](Self::reconnect_delay) (a fixed delay).
59    pub max_reconnect_delay: u64,
60    /// Maximum redial attempts before giving up. `0` = unlimited (default).
61    pub max_reconnect_attempts: usize,
62    /// Send a keepalive `Ping` after this many ms of an idle connection; any
63    /// traffic resets the idle window. `None` (default) disables it.
64    pub keepalive_interval: Option<u64>,
65    /// Capacity of the command channel, which buffers callers while the engine
66    /// isn't draining it (a pending dial, the backoff between redials). A full
67    /// channel evicts its oldest, visibly: a dropped `call` resolves
68    /// [`RpcError::Internal`], a dropped `subscribe` ends its stream. Defaults to
69    /// 256; clamped to `1..=MAX_COMMAND_QUEUE_CAP`.
70    pub max_offline_queue: usize,
71    /// Send a Ping handshake on connect and await the Pong before serving caller
72    /// commands. A real protocol swaps Ping/Pong for its Hello.
73    pub sends_hello: bool,
74}
75
76impl Default for ClientConfig {
77    fn default() -> Self {
78        Self {
79            reconnect: true,
80            reconnect_delay: 200,
81            max_reconnect_delay: 200,
82            max_reconnect_attempts: 0,
83            keepalive_interval: None,
84            max_offline_queue: 256,
85            sends_hello: false,
86        }
87    }
88}
89
90/// Exponential backoff (ms) for the `attempt`-th redial (1-based), capped at
91/// [`ClientConfig::max_reconnect_delay`].
92fn backoff_delay(config: &ClientConfig, attempt: usize) -> u64 {
93    let base = config.reconnect_delay;
94    let cap = config.max_reconnect_delay.max(base);
95    let shift = attempt.saturating_sub(1).min(16) as u32;
96    base.saturating_mul(1u64 << shift).min(cap)
97}
98
99/// A cheap-clone handle to a running [`run_client`] engine — the caller-facing
100/// RPC surface. Every method funnels a command to the engine, which owns the
101/// pending-call map and the wire.
102#[derive(Clone)]
103pub struct ClientHandle {
104    cmd_tx: Sender<ClientCmd>,
105    /// Wakes the engine to reclaim abandoned calls by inspection, when the exact
106    /// [`ClientCmd::CancelCall`] never reaches it (see [`Self::enqueue`]).
107    ///
108    /// A channel, not a flag: the caller learns of an eviction only from
109    /// `force_send`'s return value, by which point the engine may already have
110    /// looked, and a flag set then sits unread beside a parked engine. Capacity
111    /// one suffices — the sweep is idempotent, so one queued signal covers any
112    /// number of lost cancellations.
113    prune_tx: Sender<()>,
114    /// Correlation ids, allocated caller-side and shared by every clone.
115    ///
116    /// The *caller* numbers its requests so that abandoning one is race-free:
117    /// the id is known before the command is queued, so the cancellation that
118    /// follows on the same FIFO channel always names an entry the engine has
119    /// already created (see [`CancelOnDrop`]). With engine-assigned ids there is
120    /// an unavoidable window in which the caller gives up before learning the id
121    /// and has nothing to cancel. Calls and subscriptions draw from this one
122    /// counter because they share an id space on the wire.
123    ///
124    /// `usize` rather than `u64`: 64-bit atomics are not native on every
125    /// supported MCU (thumbv7em), and `portable_atomic::AtomicU64` covers that
126    /// only via its `critical-section` fallback — a dependency the engine will
127    /// not impose on every no_std consumer for a counter. The width bounds the
128    /// ids one process can issue — 2^32 on a 32-bit target, all of them
129    /// JSON-safe — and [`ClientHandle::next_id`] refuses rather than wraps at
130    /// the ceiling.
131    next_id: Arc<AtomicUsize>,
132}
133
134/// Commands the [`ClientHandle`] funnels to the engine. The caller allocates the
135/// correlation `id`; the engine owns the demux maps keyed by it.
136enum ClientCmd {
137    Call {
138        id: u64,
139        method: String,
140        params: Payload,
141        reply: oneshot::Sender<CallReply>,
142    },
143    /// The caller abandoned call `id` (its future was dropped). Frees the
144    /// pending-call entry immediately, without waiting for a reply that may
145    /// never come.
146    CancelCall {
147        id: u64,
148    },
149    Subscribe {
150        id: u64,
151        topic: String,
152        events: Sender<Result<SubUpdate, RpcError>>,
153    },
154    Write {
155        topic: String,
156        payload: Payload,
157    },
158}
159
160/// Frees a call's engine-side state if the caller stops awaiting it.
161///
162/// A dropped [`ClientHandle::call`] future takes the reply channel's receiver
163/// with it, which the engine cannot observe: a peer that holds the connection
164/// open but never answers produces neither a `Reply` nor a disconnect. The guard
165/// turns that silent drop into a [`ClientCmd::CancelCall`], so reclamation is
166/// exact and immediate — it needs no later call, no keepalive tick, and no scan
167/// of the pending table.
168///
169/// Alone among the commands it does not evict to make room — that would trade a
170/// caller's queued `Write` for bookkeeping. A full queue falls back to
171/// [`ClientHandle::prune_tx`], which frees the same entry without naming it.
172struct CancelOnDrop<'a> {
173    handle: &'a ClientHandle,
174    id: u64,
175    /// Cleared once the call resolves — a completed call has already left the
176    /// table, so cancelling it would only add channel traffic.
177    armed: bool,
178}
179
180impl Drop for CancelOnDrop<'_> {
181    fn drop(&mut self) {
182        if self.armed {
183            // Closed: the engine is gone with its table. Full: the sweep stands
184            // in for the exact id.
185            if self
186                .handle
187                .cmd_tx
188                .try_send(ClientCmd::CancelCall { id: self.id })
189                .is_err()
190            {
191                self.handle.request_prune();
192            }
193        }
194    }
195}
196
197impl ClientHandle {
198    /// Funnel a command to the engine, never blocking: a full channel evicts its
199    /// oldest, so the bound is the channel's own and holds across cloned handles
200    /// without an admission lock. Dropping the evictee drops its reply sender,
201    /// which is how the displaced caller learns. Fails once the engine has stopped.
202    ///
203    /// A [`ClientCmd::CancelCall`] is the one command that must not be evicted
204    /// *silently*: its entry is already in the engine's table, and nothing else
205    /// frees it before the connection ends. Eviction is FIFO-oldest and a
206    /// cancellation is always younger than its own request, so an evicted one
207    /// names an entry the engine already holds or never created — never one
208    /// still in flight. It degrades to a [`Self::request_prune`] signal, which
209    /// carries no id and so cannot itself be evicted.
210    fn enqueue(&self, cmd: ClientCmd) -> Result<(), RpcError> {
211        match self.cmd_tx.force_send(cmd) {
212            Ok(Some(ClientCmd::CancelCall { .. })) => {
213                self.request_prune();
214                Ok(())
215            }
216            Ok(_) => Ok(()),
217            Err(_) => Err(RpcError::Internal),
218        }
219    }
220
221    /// Ask the engine to reclaim every abandoned pending call. The sweep is
222    /// idempotent, so a full channel already carries the request.
223    fn request_prune(&self) {
224        let _ = self.prune_tx.try_send(());
225    }
226
227    /// Allocate the next correlation id. Monotonic across reconnects, so an id
228    /// is never reused by a later connection.
229    ///
230    /// Exhaustion is *refused*, not wrapped: `fetch_add` at the ceiling would
231    /// restart at `0` and re-issue ids still held by live subscriptions, whose
232    /// updates would then be demuxed into the wrong sink. Refusing turns that
233    /// silent mis-route into a failed `call`/`subscribe`. The ceiling is
234    /// `usize::MAX` — 2^32−1 on a 32-bit target, i.e. unreachable in practice
235    /// (4 billion allocations on one connection lineage).
236    ///
237    /// [`RpcError::Internal`] doubles as "engine stopped" (see [`Self::enqueue`]);
238    /// a dedicated variant would add wire surface (`session::aimx`'s codec maps
239    /// every variant to a protocol string) for a case that cannot be recovered
240    /// from anyway.
241    fn next_id(&self) -> Result<u64, RpcError> {
242        self.next_id
243            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |id| id.checked_add(1))
244            .map(|id| id as u64)
245            .map_err(|_| RpcError::Internal)
246    }
247
248    /// One-shot RPC: send a request and await its single reply. Returns
249    /// [`RpcError::Internal`] if the engine has stopped or the connection drops
250    /// before the reply arrives.
251    ///
252    /// Cancel-safe: dropping this future (a timeout losing the race, an aborted
253    /// task) releases the engine's pending-call entry rather than leaving it
254    /// until the connection ends.
255    pub async fn call(
256        &self,
257        method: impl Into<String>,
258        params: Payload,
259    ) -> Result<Payload, RpcError> {
260        let id = self.next_id()?;
261        let (reply, rx) = oneshot::channel();
262        self.enqueue(ClientCmd::Call {
263            id,
264            method: method.into(),
265            params,
266            reply,
267        })?;
268        // Armed across the await only: from here, any exit path that isn't the
269        // reply itself tells the engine to drop the entry.
270        let mut cancel = CancelOnDrop {
271            handle: self,
272            id,
273            armed: true,
274        };
275        let reply = rx.await;
276        cancel.armed = false;
277        reply.map_err(|_| RpcError::Internal)?
278    }
279
280    /// Open a subscription; returns the stream of updates immediately (the engine
281    /// sends the `Subscribe` request asynchronously). Dropping the stream stops
282    /// local delivery. The stream is not re-subscribed on reconnect (see
283    /// [`ClientConfig::reconnect`]) — re-call to resume.
284    ///
285    /// Each item is `Ok(`[`SubUpdate`]`)` for a delivered update, carrying the
286    /// concrete record topic when the server tags it (wildcard subscriptions fan
287    /// in many records under this one stream). A server rejection surfaces as a
288    /// terminal `Err(`[`RpcError`]`)` item, letting the caller distinguish a
289    /// denied subscription (do not retry) from a disconnect-shaped stream end
290    /// (retry to resume).
291    ///
292    /// Late-join snapshots arrive first, the last of them flagged
293    /// [`SubUpdate::snapshot_end`] with the burst's total loss in `skipped` (see
294    /// [`Session::snapshots`](super::Session::snapshots)). Read that flag to
295    /// close out the initial state, but drive the stream normally rather than
296    /// looping *until* it: a subscription matching no records never emits one,
297    /// and neither does one whose final snapshot frame was lost — the stream
298    /// stays open and simply proceeds to live events.
299    pub fn subscribe(
300        &self,
301        topic: impl Into<String>,
302    ) -> Result<BoxStream<'static, Result<SubUpdate, RpcError>>, RpcError> {
303        let (events, rx) =
304            async_channel::bounded::<Result<SubUpdate, RpcError>>(SUBSCRIBE_CHANNEL_CAP);
305        self.enqueue(ClientCmd::Subscribe {
306            id: self.next_id()?,
307            topic: topic.into(),
308            events,
309        })?;
310        // The receiver is itself a `Stream<Item = SubUpdate>`.
311        Ok(Box::pin(rx))
312    }
313
314    /// Fire-and-forget write to a remote topic (no reply).
315    pub fn write(&self, topic: impl Into<String>, payload: Payload) -> Result<(), RpcError> {
316        self.enqueue(ClientCmd::Write {
317            topic: topic.into(),
318            payload,
319        })
320    }
321}
322
323/// Build the client engine: returns a [`ClientHandle`] for issuing RPC and the
324/// engine future to drive on the runner (spawn-free). The future runs until all
325/// `ClientHandle` clones are dropped (graceful stop) — or, with
326/// [`ClientConfig::reconnect`] off, until the first disconnect.
327///
328/// `clock` is the adapter's dyn-safe [`RuntimeOps`](crate::executor::RuntimeOps)
329/// (e.g. `db.runtime_ops()`); the engine uses it for the reconnect backoff and
330/// keepalive — the *only* runtime dependency, so the rest of the engine is
331/// runtime-neutral.
332pub fn run_client<D, C>(
333    dialer: D,
334    codec: C,
335    config: ClientConfig,
336    clock: Arc<dyn crate::executor::RuntimeOps>,
337) -> (ClientHandle, BoxFut<'static, ()>)
338where
339    D: Dialer + 'static,
340    C: EnvelopeCodec + 'static,
341{
342    let (cmd_tx, cmd_rx) =
343        async_channel::bounded(config.max_offline_queue.clamp(1, MAX_COMMAND_QUEUE_CAP));
344    // Capacity one: the signal is a request to sweep, not a queue of them.
345    let (prune_tx, prune_rx) = async_channel::bounded(1);
346    let handle = ClientHandle {
347        cmd_tx,
348        prune_tx,
349        // Ids start at 1: `0` stays free as a "no correlation" sentinel for
350        // protocols that want one.
351        next_id: Arc::new(AtomicUsize::new(1)),
352    };
353    let fut = Box::pin(client_loop(dialer, codec, config, cmd_rx, prune_rx, clock));
354    (handle, fut)
355}
356
357/// Why one connection's session ended — decides reconnect vs stop.
358enum Ended {
359    /// The connection dropped/errored; redial if configured.
360    Disconnected,
361    /// Every [`ClientHandle`] was dropped — stop the engine.
362    HandlesDropped,
363}
364
365/// On engine exit, close and drain the command channel so buffered/in-flight
366/// commands are dropped — each `ClientCmd::Call` drops its `reply` sender, so a
367/// waiting [`ClientHandle::call`] resolves with [`RpcError::Internal`] instead of
368/// hanging.
369///
370/// Needed because `async-channel` keeps buffered items alive while any `Sender`
371/// exists, and dropping the `Receiver` only closes the queue without draining it.
372struct DrainOnExit<'a>(&'a Receiver<ClientCmd>);
373
374impl Drop for DrainOnExit<'_> {
375    fn drop(&mut self) {
376        self.0.close();
377        while self.0.try_recv().is_ok() {}
378    }
379}
380
381/// How an inbound update competes for room in its subscription's sink.
382#[derive(Clone, Copy, PartialEq, Eq)]
383enum Delivery {
384    /// A live event: dropped when the sink is full.
385    Event,
386    /// A snapshot with more of its burst to come: dropped when the sink is
387    /// full, and additionally stops one slot short so [`Delivery::BurstEnd`]
388    /// always fits.
389    BurstBody,
390    /// The burst's final snapshot: carries the burst's whole loss count and
391    /// [`SubUpdate::snapshot_end`], delivered into the slot
392    /// [`Delivery::BurstBody`] stopped short of.
393    ///
394    /// Only a `BurstBody` reserves that slot, so a single-snapshot burst rests
395    /// on ordering instead: the sink predates the subscribe reply, and the
396    /// server sends every snapshot before registering the event pump. A burst
397    /// with no snapshots sends no `BurstEnd` at all.
398    BurstEnd,
399}
400
401/// Route one subscription update (snapshot or event) to its sink, folding any
402/// loss since the last delivery into its [`SubUpdate::skipped`].
403///
404/// Server `seq` counts true production across the *whole* subscription — the
405/// late-join snapshot burst (`1..=N`) and then its events (`N + 1`…), with
406/// server-side buffer lag folded in via `+= skipped + 1` and funnel drops
407/// bumping the counter before dropping. So one delta against the last delivered
408/// seq captures every loss point, plus any prior local full-channel drop that
409/// left `last_seq` behind.
410///
411/// A gap only *reaches* the subscriber on an update that is actually delivered,
412/// which is why the burst reserves a slot for its final snapshot: a burst
413/// truncated at the tail would otherwise stay silent until some later event
414/// closed the sequence, and a static subscription may never produce one.
415///
416/// `seq` is peer-supplied and unvalidated, so neither its monotonicity nor its
417/// range can be assumed: the arithmetic saturates, and the cursor only ever
418/// advances. A repeated or reordered frame is still delivered — dropping it
419/// would discard data over a number — but it cannot rewind the cursor into
420/// reporting a gap that never happened.
421fn deliver(
422    subs: &mut HashMap<String, Sender<Result<SubUpdate, RpcError>>>,
423    last_seq: &mut HashMap<String, u64>,
424    sub: &str,
425    seq: u64,
426    topic: Option<Arc<str>>,
427    data: Payload,
428    mode: Delivery,
429) {
430    let prev = last_seq.get(sub).copied().unwrap_or(0);
431    let skipped = seq.saturating_sub(prev.saturating_add(1));
432    // `None` here is a late update for a dropped sub — ignore.
433    if let Some(tx) = subs.get(sub) {
434        // Hold the last slot back for the burst's final snapshot. Only this
435        // loop ever sends, so `len()` can only *fall* before the `try_send`
436        // below — a slot seen free here stays free, making that final delivery
437        // infallible rather than merely likely.
438        if mode == Delivery::BurstBody && tx.capacity().is_some_and(|cap| tx.len() + 1 >= cap) {
439            return; // folds into the next delivered update's `skipped`
440        }
441        let update = SubUpdate {
442            topic,
443            data,
444            skipped,
445            snapshot_end: mode == Delivery::BurstEnd,
446        };
447        match tx.try_send(Ok(update)) {
448            // Delivered — advance the per-sub cursor, never rewind it.
449            Ok(()) => {
450                last_seq.insert(sub.to_string(), seq.max(prev));
451            }
452            // Slow consumer: drop this update but leave `last_seq` so the
453            // shortfall folds into the next delivered update's `skipped`.
454            Err(e) if e.is_full() => {}
455            // Receiver gone — the sub was dropped; prune it.
456            Err(_) => {
457                subs.remove(sub);
458                last_seq.remove(sub);
459            }
460        }
461    }
462}
463
464/// What [`drive_connection`]'s `select_biased!` decided this iteration — extracted
465/// so the work runs after the arm futures' borrow of `conn` releases.
466enum ClientStep {
467    /// A frame (or close/error) arrived from the server.
468    Inbound(super::TransportResult<Option<Vec<u8>>>),
469    /// The keepalive timer fired — send a `Ping`.
470    Keepalive,
471    /// A cancellation was lost on the way here — reclaim abandoned calls by
472    /// inspecting the pending table instead of by id.
473    Prune,
474    /// A caller command (or `None` = all handles dropped).
475    Cmd(Option<ClientCmd>),
476}
477
478async fn client_loop<D, C>(
479    dialer: D,
480    codec: C,
481    config: ClientConfig,
482    cmd_rx: Receiver<ClientCmd>,
483    prune_rx: Receiver<()>,
484    clock: Arc<dyn crate::executor::RuntimeOps>,
485) where
486    D: Dialer,
487    C: EnvelopeCodec,
488{
489    // Whenever the engine returns, fail any buffered/in-flight calls (see guard).
490    let _drain = DrainOnExit(&cmd_rx);
491    // Consecutive failed attempts; drives backoff and the attempt cap.
492    let mut attempt: usize = 0;
493    loop {
494        let conn = match dialer.connect().await {
495            Ok(conn) => {
496                attempt = 0;
497                conn
498            }
499            Err(e) => {
500                log_warn!("client dial failed: {:?}", e);
501                // `Closed` is terminal — the dialer signals it will never succeed
502                // again (e.g. the caller stopped the bridge), so retrying would
503                // spin a permanently-failing redial forever. Only transient
504                // failures (`Io`, `Busy`) earn a backoff+retry. Other transports' dialers
505                // map connect failures to `Io`, never `Closed`, so this is safe.
506                if e == TransportError::Closed {
507                    return;
508                }
509                match reconnect_after(&mut attempt, &config, &*clock).await {
510                    true => continue,
511                    false => return,
512                }
513            }
514        };
515
516        match drive_connection(conn, &codec, &cmd_rx, &prune_rx, &config, &*clock).await {
517            Ended::HandlesDropped => return,
518            Ended::Disconnected => match reconnect_after(&mut attempt, &config, &*clock).await {
519                true => continue,
520                false => return,
521            },
522        }
523    }
524}
525
526/// Decide whether to redial: honor `reconnect`, the attempt cap, and the
527/// exponential backoff sleep (via the runtime clock). Returns `true` to retry,
528/// `false` to stop the engine.
529async fn reconnect_after(
530    attempt: &mut usize,
531    config: &ClientConfig,
532    clock: &dyn crate::executor::RuntimeOps,
533) -> bool {
534    if !config.reconnect {
535        return false;
536    }
537    *attempt += 1;
538    if config.max_reconnect_attempts != 0 && *attempt >= config.max_reconnect_attempts {
539        log_warn!(
540            "client giving up after {} reconnect attempts",
541            config.max_reconnect_attempts
542        );
543        return false;
544    }
545    clock
546        .sleep(core::time::Duration::from_millis(backoff_delay(
547            config, *attempt,
548        )))
549        .await;
550    true
551}
552
553/// What a caller awaiting [`ClientHandle::call`] eventually receives.
554type CallReply = Result<Payload, RpcError>;
555
556/// The engine's in-flight call table: correlation `id` → the caller's reply
557/// channel.
558type PendingCalls = HashMap<u64, oneshot::Sender<CallReply>>;
559
560/// Reclaim every entry whose caller stopped waiting — the fallback for a
561/// [`ClientCmd::CancelCall`] the bounded command queue dropped.
562///
563/// A dropped receiver is exactly what an abandoned [`ClientHandle::call`] leaves
564/// behind, so this needs no id and cannot miss an earlier loss. `O(N)`, but
565/// reached only from the eviction path; a surviving cancellation stays `O(1)`.
566fn sweep_abandoned(pending: &mut PendingCalls) {
567    pending.retain(|_, reply| !reply.is_canceled());
568}
569
570/// Drive one dialed [`Connection`]: optional handshake, then `biased` demux of
571/// server frames (resolve `Reply` by `id`, route `Event`/`Snapshot` to their
572/// subscription channels) interleaved with caller commands. Pending state is
573/// per-connection: a disconnect fails outstanding calls (their `oneshot`
574/// senders drop → callers see [`RpcError::Internal`]). Calls the *caller*
575/// abandoned arrive as [`ClientCmd::CancelCall`] and are removed by id in O(1),
576/// so an unanswering peer that keeps the link open can't grow the table — with
577/// no dependence on later traffic or on the keepalive being enabled. One the
578/// command queue dropped instead arrives on `prune_rx` as a request to sweep.
579/// `pending` is per-connection, so a signal raised while dialing lands on a
580/// fresh table — a no-op, since the disconnect already dropped every entry.
581async fn drive_connection<C>(
582    mut conn: Box<dyn Connection>,
583    codec: &C,
584    cmd_rx: &Receiver<ClientCmd>,
585    prune_rx: &Receiver<()>,
586    config: &ClientConfig,
587    clock: &dyn crate::executor::RuntimeOps,
588) -> Ended
589where
590    C: EnvelopeCodec + ?Sized,
591{
592    let mut pending: PendingCalls = HashMap::new();
593    // sub-id → event sink. The sub-id is `id.to_string()` of the opening
594    // request, matching the server's derivation so `Event.sub` routes back.
595    let mut subs: HashMap<String, Sender<Result<SubUpdate, RpcError>>> = HashMap::new();
596    // sub-id → last *delivered* wire `seq`. Baseline is 0, so the server's first
597    // seq (`1`) is the expected first delivery; any shortfall is loss. Not
598    // advanced on a locally-dropped event, so a full-channel drop also surfaces
599    // as a gap on the next delivery.
600    let mut last_seq: HashMap<String, u64> = HashMap::new();
601    let mut out = Vec::new();
602    let keepalive_ms = config.keepalive_interval;
603    // Keepalive is deadline-based: activity only records a timestamp (one dyn
604    // clock read, no allocation) and the boxed `clock.sleep` stays armed for a
605    // full idle window — re-created roughly once per interval, not on every
606    // processed frame (`dyn RuntimeOps::sleep` heap-allocates its future).
607    let mut last_activity = clock.now_nanos();
608    let mut keepalive_timer =
609        keepalive_ms.map(|ms| clock.sleep(core::time::Duration::from_millis(ms)).fuse());
610
611    // Handshake-as-caller: prove the link with Ping/Pong before serving commands.
612    if config.sends_hello {
613        out.clear();
614        if codec.encode_inbound(Inbound::Ping, &mut out).is_err() || conn.send(&out).await.is_err()
615        {
616            return Ended::Disconnected;
617        }
618        match conn.recv().await {
619            Ok(Some(frame)) => match codec.decode_outbound(&frame) {
620                Ok(Outbound::Pong) => {}
621                _ => return Ended::Disconnected,
622            },
623            _ => return Ended::Disconnected,
624        }
625    }
626
627    loop {
628        // Biased toward the server read. The select only decides the next step.
629        let step = {
630            let mut recv = conn.recv().fuse();
631            // The armed idle timer (see above); with no interval it parks on
632            // `pending()` forever. `Either` re-borrows the persistent timer,
633            // so this is allocation-free per iteration.
634            let mut keepalive = match keepalive_timer.as_mut() {
635                Some(timer) => futures_util::future::Either::Left(timer),
636                None => futures_util::future::Either::Right(futures_util::future::pending::<()>()),
637            };
638            // `recv()` is `!Unpin`, so pin it for the arm.
639            let mut cmd = core::pin::pin!(cmd_rx.recv().fuse());
640            let mut prune = core::pin::pin!(async {
641                if prune_rx.recv().await.is_err() {
642                    // Handles all dropped — the `cmd` arm reports that. A closed
643                    // channel resolves immediately and this arm outranks `cmd`,
644                    // so park rather than spin ahead of the shutdown.
645                    futures_util::future::pending::<()>().await;
646                }
647            }
648            .fuse());
649            select_biased! {
650                // ---- inbound from server: Reply / Event / Snapshot / Pong --
651                r = recv => ClientStep::Inbound(r),
652                // ---- keepalive: the idle timer fired ------------------------
653                _ = keepalive => ClientStep::Keepalive,
654                // ---- a lost cancellation asked for a sweep ------------------
655                // Above `cmd`: a full command queue is what raises the signal,
656                // so a lower arm would be starved by its own trigger.
657                _ = prune => ClientStep::Prune,
658                // ---- caller commands from ClientHandle ---------------------
659                // `recv()` errors only when every `ClientHandle` is dropped → `None`.
660                c = cmd => ClientStep::Cmd(c.ok()),
661            }
662        };
663
664        // Frames and commands are link activity; only a genuinely idle link
665        // needs a Ping. A sweep is local bookkeeping with no wire traffic, so a
666        // busy-cancelling caller must not suppress the keepalive with it.
667        if !matches!(step, ClientStep::Keepalive | ClientStep::Prune) {
668            last_activity = clock.now_nanos();
669        }
670
671        match step {
672            ClientStep::Inbound(recv) => {
673                let frame = match recv {
674                    Ok(Some(frame)) => frame,
675                    Ok(None) | Err(_) => return Ended::Disconnected,
676                };
677                match codec.decode_outbound(&frame) {
678                    Ok(Outbound::Reply { id, result }) => {
679                        if let Some(tx) = pending.remove(&id) {
680                            let _ = tx.send(result);
681                        } else if let Err(err) = result {
682                            // A subscribe is acked implicitly by its events; the
683                            // server replies only on failure, carrying the subscribe
684                            // `id` (never a pending call). Surface the rejection as a
685                            // terminal error item before dropping the sink, so the
686                            // subscriber can tell "denied" from a disconnect-shaped
687                            // stream end instead of re-subscribing forever.
688                            if let Some(tx) = subs.remove(&id.to_string()) {
689                                let _ = tx.try_send(Err(err));
690                                last_seq.remove(&id.to_string());
691                            }
692                        }
693                    }
694                    Ok(Outbound::Event {
695                        sub,
696                        seq,
697                        topic,
698                        data,
699                    }) => deliver(
700                        &mut subs,
701                        &mut last_seq,
702                        sub,
703                        seq,
704                        topic.map(Arc::from),
705                        data,
706                        Delivery::Event,
707                    ),
708                    // Snapshots ride the same accounting as events (they share
709                    // the subscription's `seq` space), so a late-join burst that
710                    // overruns a slow consumer's sink is reported as `skipped`
711                    // instead of vanishing. The burst's final snapshot is
712                    // delivered unconditionally, so "one snapshot per matched
713                    // record" is checkable the moment the burst ends — no live
714                    // event required.
715                    Ok(Outbound::Snapshot {
716                        sub,
717                        seq,
718                        last,
719                        topic,
720                        data,
721                    }) => deliver(
722                        &mut subs,
723                        &mut last_seq,
724                        sub,
725                        seq,
726                        Some(Arc::from(topic)),
727                        data,
728                        if last {
729                            Delivery::BurstEnd
730                        } else {
731                            Delivery::BurstBody
732                        },
733                    ),
734                    Ok(Outbound::Pong) => {}
735                    // Explicit subscribe ack — informational; the sink already exists.
736                    Ok(Outbound::Subscribed { .. }) => {}
737                    Err(_e) => continue, // skip a malformed frame, keep the connection
738                }
739            }
740
741            // A cancellation was dropped by the bounded command queue, so the
742            // engine can't be told *which* entry died — but it can see it.
743            ClientStep::Prune => sweep_abandoned(&mut pending),
744
745            ClientStep::Keepalive => {
746                // `keepalive_timer` is `Some` whenever this step fires.
747                let interval_ms = keepalive_ms.unwrap_or(0);
748                let idle_ms = clock.now_nanos().saturating_sub(last_activity) / 1_000_000;
749                if idle_ms >= interval_ms {
750                    // Genuinely idle for a full window: ping and re-arm.
751                    out.clear();
752                    if codec.encode_inbound(Inbound::Ping, &mut out).is_ok()
753                        && conn.send(&out).await.is_err()
754                    {
755                        return Ended::Disconnected;
756                    }
757                    last_activity = clock.now_nanos();
758                    keepalive_timer = Some(
759                        clock
760                            .sleep(core::time::Duration::from_millis(interval_ms))
761                            .fuse(),
762                    );
763                } else {
764                    // Activity happened while the timer was armed: no ping,
765                    // re-arm for the remainder of the idle window.
766                    keepalive_timer = Some(
767                        clock
768                            .sleep(core::time::Duration::from_millis(interval_ms - idle_ms))
769                            .fuse(),
770                    );
771                }
772            }
773
774            ClientStep::Cmd(cmd) => {
775                let cmd = match cmd {
776                    Some(cmd) => cmd,
777                    None => return Ended::HandlesDropped, // all handles dropped
778                };
779                match cmd {
780                    ClientCmd::Call {
781                        id,
782                        method,
783                        params,
784                        reply,
785                    } => {
786                        // The caller may have given up while the command sat in
787                        // the queue — don't spend a wire request on a reply
788                        // nobody is waiting for. Its `CancelCall` is already
789                        // queued behind this one and will find nothing to free.
790                        if reply.is_canceled() {
791                            continue;
792                        }
793                        pending.insert(id, reply);
794                        out.clear();
795                        let sent = codec
796                            .encode_inbound(Inbound::Request { id, method, params }, &mut out)
797                            .is_ok()
798                            && conn.send(&out).await.is_ok();
799                        if !sent {
800                            if let Some(tx) = pending.remove(&id) {
801                                let _ = tx.send(Err(RpcError::Internal));
802                            }
803                            return Ended::Disconnected;
804                        }
805                    }
806                    // The caller abandoned this call: drop its reply channel now
807                    // rather than holding it until a reply that may never arrive
808                    // (or the disconnect). A resolved or never-dispatched call
809                    // is simply absent — removing it is a no-op.
810                    ClientCmd::CancelCall { id } => {
811                        pending.remove(&id);
812                    }
813                    ClientCmd::Subscribe { id, topic, events } => {
814                        subs.insert(id.to_string(), events);
815                        out.clear();
816                        let sent = codec
817                            .encode_inbound(Inbound::Subscribe { id, topic }, &mut out)
818                            .is_ok()
819                            && conn.send(&out).await.is_ok();
820                        if !sent {
821                            return Ended::Disconnected;
822                        }
823                    }
824                    ClientCmd::Write { topic, payload } => {
825                        out.clear();
826                        let sent = codec
827                            .encode_inbound(Inbound::Write { topic, payload }, &mut out)
828                            .is_ok()
829                            && conn.send(&out).await.is_ok();
830                        if !sent {
831                            return Ended::Disconnected;
832                        }
833                    }
834                }
835            }
836        }
837    }
838}
839
840/// Mirror records between a local [`AimDb`] and a remote peer over a running
841/// [`run_client`] engine — the connector-link half of the client capability.
842///
843/// For the given connector `scheme` (e.g. `"aimx"`):
844/// - **outbound** routes (`db.collect_outbound_routes`) stream local record
845///   updates to the remote via [`ClientHandle::write`];
846/// - **inbound** routes (`db.collect_inbound_routes`) subscribe to the remote and
847///   produce each update into the local record through the producer/arbiter path
848///   — single-writer-per-key stays intact (a mirrored-in record is produced
849///   through its inbound producer, never a direct co-writer). Mirroring is
850///   latest-state and best-effort — a gap the server reports
851///   ([`SubUpdate::skipped`]) is logged and stepped over, never backfilled.
852///
853/// Returns one spawn-free pump future per route for the runner to drive
854/// (mirroring the `ConnectorBuilder::build -> Vec<BoxFuture>` spine); it drives
855/// the **same** engine as [`run_client`], never a second one.
856///
857/// Reconnect caveat: inbound pumps subscribe once and are not replayed across a
858/// reconnect (see [`ClientConfig::reconnect`]); outbound mirroring is unaffected.
859pub fn pump_client(db: &AimDb, scheme: &str, handle: &ClientHandle) -> Vec<BoxFut<'static, ()>> {
860    // The runtime context for context-aware (de)serializers.
861    let ctx = db.runtime_ctx();
862    let mut pumps: Vec<BoxFut<'static, ()>> = Vec::new();
863
864    // --- outbound: local record updates -> remote `write` ------------------
865    for crate::OutboundRoute {
866        topic: destination,
867        source,
868        ..
869    } in db.collect_outbound_routes(scheme)
870    {
871        let handle = handle.clone();
872        let ctx = ctx.clone();
873        pumps.push(Box::pin(async move {
874            let mut reader = source.subscribe();
875            loop {
876                // The fused reader yields destination + serialized payload
877                // (serialize failures are logged and skipped inside it).
878                let msg = match reader.recv(&ctx).await {
879                    Ok(m) => m,
880                    // Lagged (ring overflow) — skip the gap, keep mirroring.
881                    Err(crate::DbError::BufferLagged { .. }) => continue,
882                    // Buffer closed — the record is gone; end this mirror.
883                    Err(_) => break,
884                };
885                // Dynamic destination (topic provider) or the static link target.
886                let dest = msg.dest.unwrap_or_else(|| destination.clone());
887                if handle
888                    .write(dest, Payload::from(msg.payload.as_slice()))
889                    .is_err()
890                {
891                    break; // engine stopped — all handles dropped
892                }
893            }
894        }));
895    }
896
897    // --- inbound: remote events -> local producer (via the Router) ---------
898    // The Router applies each route's deserializer and produces the value; one
899    // subscription per unique remote topic feeds it.
900    let router = Arc::new(RouterBuilder::from_routes(db.collect_inbound_routes(scheme)).build());
901    for id in router.resource_ids() {
902        pumps.push(Box::pin(inbound_pump(
903            handle.clone(),
904            router.clone(),
905            id,
906            ctx.clone(),
907        )));
908    }
909
910    pumps
911}
912
913/// Drive one inbound mirror: subscribe to remote topic `id` and produce every
914/// update into the local record through the [`Router`](crate::router::Router).
915///
916/// **Loss contract: a mirror is latest-state, best-effort.** A mirrored record
917/// answers "what is the current value", not "what was every value" — so a gap
918/// reported by the server ([`SubUpdate::skipped`], raised when a subscription's
919/// sink overran) is *logged and stepped over*, never backfilled. The mirror does
920/// not resync, and a consumer must not read a mirrored record as a complete
921/// history. Callers that need gap-aware delivery subscribe through
922/// `AimxConnection` (`aimdb-client`), which surfaces `skipped` per update;
923/// [`ClientHandle::subscribe`] carries the same metadata for anyone riding the
924/// handle directly.
925async fn inbound_pump(
926    handle: ClientHandle,
927    router: Arc<crate::router::Router>,
928    id: Arc<str>,
929    ctx: crate::RuntimeContext,
930) {
931    let mut stream = match handle.subscribe(id.as_ref()) {
932        Ok(s) => s,
933        Err(_e) => return,
934    };
935    while let Some(update) = stream.next().await {
936        match update {
937            Ok(update) => {
938                // Per the loss contract above: surface the hole, keep mirroring.
939                if update.skipped > 0 {
940                    log_warn!(
941                        "mirror gap on '{}': {} update(s) lost (latest-state, no resync)",
942                        id.as_ref(),
943                        update.skipped
944                    );
945
946                    #[cfg(feature = "defmt")]
947                    defmt::warn!(
948                        "mirror gap on '{}': {} update(s) lost (latest-state, no resync)",
949                        id.as_ref(),
950                        update.skipped
951                    );
952                }
953                let _ = router.route(id.as_ref(), &update.data, &ctx);
954            }
955            // Server rejected the subscription — terminal, not replayed.
956            Err(_e) => break,
957        }
958    }
959}
960
961#[cfg(test)]
962mod tests {
963    use super::*;
964    use crate::session::{CodecError, PeerInfo, TransportResult};
965    use core::future::pending;
966
967    /// Sink connection: swallows every frame and never yields one, i.e. a peer
968    /// that keeps the link open and answers nothing.
969    #[derive(Default)]
970    struct SilentPeer {
971        peer: PeerInfo,
972    }
973
974    impl Connection for SilentPeer {
975        fn recv(&mut self) -> BoxFut<'_, TransportResult<Option<Vec<u8>>>> {
976            Box::pin(pending())
977        }
978        fn send<'a>(&'a mut self, _frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> {
979            Box::pin(async { Ok(()) })
980        }
981        fn peer(&self) -> &PeerInfo {
982            &self.peer
983        }
984    }
985
986    /// Frames are never inspected in these tests — only the pending-call
987    /// bookkeeping is.
988    struct NullCodec;
989
990    impl EnvelopeCodec for NullCodec {
991        fn decode(&self, _frame: &[u8]) -> Result<Inbound, CodecError> {
992            Err(CodecError::Malformed)
993        }
994        fn encode(&self, _msg: Outbound<'_>, _out: &mut Vec<u8>) -> Result<(), CodecError> {
995            Ok(())
996        }
997        fn encode_inbound(&self, _msg: Inbound, _out: &mut Vec<u8>) -> Result<(), CodecError> {
998            Ok(())
999        }
1000        fn decode_outbound<'a>(&self, _frame: &'a [u8]) -> Result<Outbound<'a>, CodecError> {
1001            Err(CodecError::Malformed)
1002        }
1003    }
1004
1005    /// A handle whose channel nobody drains — an engine still dialing, or
1006    /// sleeping between redials. `cap` is [`ClientConfig::max_offline_queue`].
1007    /// The prune receiver comes back too: dropping it would close the signal
1008    /// channel and silently no-op every [`ClientHandle::request_prune`].
1009    fn test_handle_capped(cap: usize) -> (ClientHandle, Receiver<ClientCmd>, Receiver<()>) {
1010        let (cmd_tx, cmd_rx) = async_channel::bounded(cap.max(1));
1011        let (prune_tx, prune_rx) = async_channel::bounded(1);
1012        (
1013            ClientHandle {
1014                cmd_tx,
1015                prune_tx,
1016                next_id: Arc::new(AtomicUsize::new(1)),
1017            },
1018            cmd_rx,
1019            prune_rx,
1020        )
1021    }
1022
1023    fn test_handle() -> (ClientHandle, Receiver<ClientCmd>, Receiver<()>) {
1024        test_handle_capped(256)
1025    }
1026
1027    /// Poll a call once — enough to queue its command — then abandon it, which
1028    /// is what a lost timeout race does to the future.
1029    async fn abandon_call(handle: &ClientHandle, method: &'static str) {
1030        let call = handle.call(method, Payload::from(&b"x"[..]));
1031        assert!(
1032            tokio::time::timeout(core::time::Duration::ZERO, call)
1033                .await
1034                .is_err(),
1035            "the call must not resolve"
1036        );
1037    }
1038
1039    /// The caller half of the reclaim path: abandoning a call queues a
1040    /// `CancelCall` naming exactly that call, so the engine never has to guess
1041    /// which entry died.
1042    #[tokio::test]
1043    async fn abandoning_a_call_queues_its_cancellation() {
1044        let (handle, cmd_rx, _prune_rx) = test_handle();
1045        abandon_call(&handle, "one").await;
1046
1047        match cmd_rx.try_recv() {
1048            Ok(ClientCmd::Call { id: 1, .. }) => {}
1049            _ => panic!("the call must be queued first"),
1050        }
1051        match cmd_rx.try_recv() {
1052            Ok(ClientCmd::CancelCall { id: 1 }) => {}
1053            _ => panic!("abandoning the call must queue its cancellation"),
1054        }
1055        assert!(cmd_rx.is_empty(), "no other command is emitted");
1056    }
1057
1058    /// A call that resolves normally must not also queue a cancellation — the
1059    /// engine already dropped its entry when it routed the reply.
1060    #[tokio::test]
1061    async fn a_resolved_call_queues_no_cancellation() {
1062        let (handle, cmd_rx, _prune_rx) = test_handle();
1063        let call = handle.call("one", Payload::from(&b"x"[..]));
1064        let answer = async {
1065            // Take the reply channel out of the queued command and answer it.
1066            match cmd_rx.recv().await {
1067                Ok(ClientCmd::Call { reply, .. }) => {
1068                    let _ = reply.send(Ok(Payload::from(&b"ok"[..])));
1069                }
1070                _ => panic!("the call must be queued"),
1071            }
1072        };
1073        let (reply, ()) = futures_util::future::join(call, answer).await;
1074        assert_eq!(&*reply.expect("the call resolves"), b"ok");
1075        assert!(
1076            cmd_rx.is_empty(),
1077            "a resolved call must not queue a cancellation"
1078        );
1079    }
1080
1081    /// Regression for the *concurrent* timeout batch: every call is issued and
1082    /// tracked while its receiver is live, then all of them are abandoned at
1083    /// once, with keepalive disabled and no later call to trigger a sweep.
1084    ///
1085    /// Each entry the engine frees drops its reply sender, which the (retained)
1086    /// receiver observes as `Canceled` — so this asserts the table actually
1087    /// empties rather than that some reclaim path merely ran. Pre-fix all 1000
1088    /// entries stayed until the connection ended.
1089    #[tokio::test]
1090    async fn a_concurrent_batch_of_cancellations_frees_every_entry() {
1091        const BATCH: u64 = 1_000;
1092        let (cmd_tx, cmd_rx) = async_channel::unbounded::<ClientCmd>();
1093        let config = ClientConfig {
1094            reconnect: false,
1095            // The point of the test: nothing but the cancellations can reclaim.
1096            keepalive_interval: None,
1097            ..Default::default()
1098        };
1099        let clock = crate::executor::test_support::NoopRuntimeOps;
1100        // Never signalled: this asserts the by-id path reclaims on its own.
1101        let (_prune_tx, prune_rx) = async_channel::bounded::<()>(1);
1102        let engine = drive_connection(
1103            Box::new(SilentPeer::default()),
1104            &NullCodec,
1105            &cmd_rx,
1106            &prune_rx,
1107            &config,
1108            &clock,
1109        );
1110
1111        let exercise = async {
1112            // All in flight at once: every receiver is alive as its call is
1113            // tracked, so nothing is reclaimable until the batch is abandoned.
1114            let mut replies = Vec::new();
1115            for id in 1..=BATCH {
1116                let (reply, rx) = oneshot::channel();
1117                cmd_tx
1118                    .try_send(ClientCmd::Call {
1119                        id,
1120                        method: String::from("hang"),
1121                        params: Payload::from(&b"x"[..]),
1122                        reply,
1123                    })
1124                    .expect("the channel is unbounded");
1125                replies.push(rx);
1126            }
1127            // Then the whole batch times out.
1128            for id in 1..=BATCH {
1129                cmd_tx
1130                    .try_send(ClientCmd::CancelCall { id })
1131                    .expect("the channel is unbounded");
1132            }
1133            for (i, rx) in replies.into_iter().enumerate() {
1134                assert!(
1135                    rx.await.is_err(),
1136                    "call {} was still held by the engine",
1137                    i + 1
1138                );
1139            }
1140        };
1141
1142        futures_util::pin_mut!(engine);
1143        futures_util::pin_mut!(exercise);
1144        match futures_util::future::select(engine, exercise).await {
1145            futures_util::future::Either::Left(_) => panic!("the engine ended early"),
1146            futures_util::future::Either::Right(((), _)) => {}
1147        }
1148    }
1149
1150    /// The id counter refuses rather than wraps at its ceiling: a wrapped id
1151    /// would re-issue one a live subscription still holds, and the engine would
1152    /// demux that subscription's updates into the wrong sink. Both allocating
1153    /// call sites fail, and neither reaches the engine — so no pending entry is
1154    /// ever created under an id that would be mis-routed.
1155    #[tokio::test]
1156    async fn exhausted_ids_are_refused_not_wrapped() {
1157        let (cmd_tx, cmd_rx) = async_channel::bounded(256);
1158        let (prune_tx, _prune_rx) = async_channel::bounded(1);
1159        let handle = ClientHandle {
1160            cmd_tx,
1161            prune_tx,
1162            next_id: Arc::new(AtomicUsize::new(usize::MAX)),
1163        };
1164
1165        // Bounded at zero: the refusal happens before the id is queued, so it
1166        // resolves on the first poll. A wrapping counter would instead queue the
1167        // call and await a reply forever — the timeout turns that regression
1168        // into a failure rather than a hang.
1169        let refused = tokio::time::timeout(
1170            core::time::Duration::ZERO,
1171            handle.call("one", Payload::from(&b"x"[..])),
1172        )
1173        .await
1174        .expect("a refused call must not reach the engine and await a reply");
1175        assert!(
1176            matches!(refused, Err(RpcError::Internal)),
1177            "a call at the id ceiling must be refused"
1178        );
1179        assert!(
1180            handle.subscribe("tele").is_err(),
1181            "a subscription at the id ceiling must be refused"
1182        );
1183        assert!(
1184            cmd_rx.is_empty(),
1185            "a refused allocation must not reach the engine"
1186        );
1187    }
1188
1189    /// Evict-oldest puts a cancellation on the same lossy footing as a write,
1190    /// but dropping one silently strands its pending entry until the connection
1191    /// ends — so the eviction has to be noticed. Nothing drains the queue here:
1192    /// the saturated state a pending dial or a backoff sleep produces.
1193    #[tokio::test]
1194    async fn an_evicted_cancellation_raises_the_prune_signal() {
1195        let (handle, cmd_rx, prune_rx) = test_handle_capped(1);
1196
1197        handle
1198            .enqueue(ClientCmd::CancelCall { id: 1 })
1199            .expect("the engine is still running");
1200        assert!(prune_rx.is_empty(), "nothing has been evicted yet");
1201
1202        // Fills the single slot, displacing the cancellation.
1203        handle
1204            .write("tele", Payload::from(&b"x"[..]))
1205            .expect("the engine is still running");
1206
1207        assert_eq!(cmd_rx.len(), 1, "the cap still holds");
1208        assert_eq!(
1209            prune_rx.len(),
1210            1,
1211            "an evicted cancellation must fall back to a sweep"
1212        );
1213    }
1214
1215    /// The negative control: eviction is the documented, caller-visible cost of
1216    /// the bound for ordinary commands. Signalling on every one would drag an
1217    /// `O(N)` sweep into steady-state backpressure.
1218    #[tokio::test]
1219    async fn an_evicted_write_raises_no_prune_signal() {
1220        let (handle, _cmd_rx, prune_rx) = test_handle_capped(1);
1221
1222        for topic in ["one", "two"] {
1223            handle
1224                .write(topic, Payload::from(&b"x"[..]))
1225                .expect("the engine is still running");
1226        }
1227
1228        assert!(
1229            prune_rx.is_empty(),
1230            "an evicted write is expected loss, not a leak"
1231        );
1232    }
1233
1234    /// A cancellation is bookkeeping; a queued `Write` is work the caller asked
1235    /// for. Cap 2, so the write and the call itself both fit and the
1236    /// cancellation is the only command that finds the queue full.
1237    #[tokio::test]
1238    async fn a_cancellation_never_evicts_a_queued_write() {
1239        let (handle, cmd_rx, prune_rx) = test_handle_capped(2);
1240
1241        handle
1242            .write("tele", Payload::from(&b"x"[..]))
1243            .expect("the engine is still running");
1244        abandon_call(&handle, "hang").await;
1245
1246        assert_eq!(cmd_rx.len(), 2, "the cap still holds");
1247        match cmd_rx.try_recv() {
1248            Ok(ClientCmd::Write { topic, .. }) => assert_eq!(topic, "tele"),
1249            _ => panic!("the caller's write must survive the cancellation"),
1250        }
1251        match cmd_rx.try_recv() {
1252            Ok(ClientCmd::Call { id: 1, .. }) => {}
1253            _ => panic!("the call itself must still be queued"),
1254        }
1255        assert_eq!(
1256            prune_rx.len(),
1257            1,
1258            "the cancellation it could not queue must fall back to a sweep"
1259        );
1260    }
1261
1262    /// The reclaim itself: an abandoned entry goes, a live one stays. Identity
1263    /// is the dropped receiver, which is why the sweep can stand in for a
1264    /// cancellation that never arrived.
1265    #[test]
1266    fn the_sweep_frees_abandoned_entries_and_keeps_live_ones() {
1267        let mut pending: PendingCalls = HashMap::new();
1268        let (abandoned, rx) = oneshot::channel();
1269        let (live, _live_rx) = oneshot::channel();
1270        pending.insert(1, abandoned);
1271        pending.insert(2, live);
1272        drop(rx); // the caller's future went away
1273
1274        sweep_abandoned(&mut pending);
1275
1276        assert!(
1277            !pending.contains_key(&1),
1278            "the abandoned call must be freed"
1279        );
1280        assert!(
1281            pending.contains_key(&2),
1282            "a caller still awaiting its reply must not be dropped"
1283        );
1284    }
1285
1286    /// End to end at capacity 1 against a peer that answers nothing: the exact
1287    /// loss the bound can inflict. The engine files a call while its caller
1288    /// still waits, the caller then abandons it — the cancellation lands in the
1289    /// drained queue — and a write evicts that cancellation. The engine still
1290    /// reclaims: keepalive off and no later command, so the signal is the only
1291    /// thing that can wake the sweep.
1292    ///
1293    /// Consuming the signal *is* the sweep running (one match arm), and
1294    /// [`the_sweep_frees_abandoned_entries_and_keeps_live_ones`] pins what it
1295    /// then does. The table is engine-local, and it cannot be watched from a
1296    /// retained receiver either: an entry whose receiver is retained is exactly
1297    /// the entry the sweep must keep. The timeout turns "never woke" into a
1298    /// failure rather than a hang.
1299    #[tokio::test]
1300    async fn an_evicted_cancellation_is_still_reclaimed() {
1301        use core::future::Future;
1302
1303        let (handle, cmd_rx, prune_rx) = test_handle_capped(1);
1304        let config = ClientConfig {
1305            reconnect: false,
1306            // Nothing but the signal may reclaim.
1307            keepalive_interval: None,
1308            ..Default::default()
1309        };
1310        let clock = crate::executor::test_support::NoopRuntimeOps;
1311        let engine = drive_connection(
1312            Box::new(SilentPeer::default()),
1313            &NullCodec,
1314            &cmd_rx,
1315            &prune_rx,
1316            &config,
1317            &clock,
1318        );
1319
1320        let exercise = async {
1321            // Queue the call, then let the engine file it *while the caller
1322            // still waits* — a receiver already dropped when the command is
1323            // read is discarded on arrival and never reaches the table.
1324            let mut call = Box::pin(handle.call("hang", Payload::from(&b"x"[..])));
1325            core::future::poll_fn(|cx| {
1326                assert!(
1327                    call.as_mut().poll(cx).is_pending(),
1328                    "the call must not resolve"
1329                );
1330                core::task::Poll::Ready(())
1331            })
1332            .await;
1333            tokio::task::yield_now().await;
1334            assert!(cmd_rx.is_empty(), "the engine must have filed the call");
1335
1336            // Abandon it: the queue is drained, so the cancellation lands. No
1337            // yield before the write — the engine must not get the chance to
1338            // remove the entry by id.
1339            drop(call);
1340            assert_eq!(cmd_rx.len(), 1, "the cancellation must be queued");
1341            assert!(prune_rx.is_empty(), "nothing has fallen back to a sweep");
1342            handle
1343                .write("tele", Payload::from(&b"x"[..]))
1344                .expect("the engine is still running");
1345            assert_eq!(prune_rx.len(), 1, "the eviction must have been noticed");
1346
1347            // The wake-up is the point: the engine is parked on a silent peer
1348            // with keepalive off, so only the signal itself can reach it.
1349            tokio::time::timeout(core::time::Duration::from_secs(5), async {
1350                while !prune_rx.is_empty() {
1351                    tokio::task::yield_now().await;
1352                }
1353            })
1354            .await
1355            .expect("the signal must wake the engine and be consumed");
1356        };
1357
1358        futures_util::pin_mut!(engine);
1359        futures_util::pin_mut!(exercise);
1360        match futures_util::future::select(engine, exercise).await {
1361            futures_util::future::Either::Left(_) => panic!("the engine ended early"),
1362            futures_util::future::Either::Right(((), _)) => {}
1363        }
1364    }
1365
1366    /// The cap is a hard bound: nothing drains this channel, as during a pending
1367    /// dial or a backoff sleep.
1368    #[tokio::test]
1369    async fn the_command_queue_is_bounded() {
1370        let (handle, cmd_rx, _prune_rx) = test_handle_capped(2);
1371
1372        for topic in ["one", "two", "three", "four"] {
1373            handle
1374                .write(topic, Payload::from(&b"x"[..]))
1375                .expect("the engine is still running");
1376        }
1377
1378        assert_eq!(cmd_rx.len(), 2, "the backlog must not exceed the cap");
1379        // Oldest-first: the two most recent writes are the ones kept.
1380        for expected in ["three", "four"] {
1381            match cmd_rx.try_recv() {
1382                Ok(ClientCmd::Write { topic, .. }) => assert_eq!(topic, expected),
1383                _ => panic!("the newest writes must survive eviction"),
1384            }
1385        }
1386    }
1387
1388    /// `usize::MAX` was the old "unbounded" sentinel; preallocated, it would abort
1389    /// in the allocator, so it has to degrade to the ceiling instead.
1390    #[tokio::test]
1391    async fn an_unbounded_cap_degrades_to_the_ceiling() {
1392        struct UndialableRemote;
1393        impl Dialer for UndialableRemote {
1394            fn connect(&self) -> BoxFut<'_, TransportResult<Box<dyn Connection>>> {
1395                Box::pin(async { Err(TransportError::Closed) })
1396            }
1397        }
1398
1399        let config = ClientConfig {
1400            max_offline_queue: usize::MAX,
1401            ..Default::default()
1402        };
1403        let (handle, _engine) = run_client(
1404            UndialableRemote,
1405            NullCodec,
1406            config,
1407            Arc::new(crate::executor::test_support::NoopRuntimeOps),
1408        );
1409
1410        handle
1411            .write("t", Payload::from(&b"x"[..]))
1412            .expect("the engine is still running");
1413    }
1414
1415    /// A zero cap is clamped, not honored: every command reaches the engine
1416    /// through this channel, so a zero-capacity one could never deliver.
1417    #[tokio::test]
1418    async fn a_zero_cap_is_clamped_to_one() {
1419        let (handle, cmd_rx, _prune_rx) = test_handle_capped(0);
1420        let (reply, rx) = oneshot::channel();
1421
1422        handle
1423            .enqueue(ClientCmd::Call {
1424                id: 1,
1425                method: String::from("one"),
1426                params: Payload::from(&b"x"[..]),
1427                reply,
1428            })
1429            .expect("the engine is still running");
1430        handle
1431            .write("later", Payload::from(&b"x"[..]))
1432            .expect("the engine is still running");
1433
1434        assert_eq!(cmd_rx.len(), 1, "a clamped cap still holds exactly one");
1435        assert!(rx.await.is_err(), "and the evicted caller learns it");
1436    }
1437
1438    /// The cap is the channel's own, so it holds across cloned handles without an
1439    /// admission lock: a full channel evicts rather than admitting a second sender.
1440    #[tokio::test]
1441    async fn cloned_handles_cannot_exceed_the_cap() {
1442        let (handle, cmd_rx, _prune_rx) = test_handle_capped(1);
1443        let other = handle.clone();
1444
1445        handle
1446            .write("first", Payload::from(&b"x"[..]))
1447            .expect("the engine is still running");
1448        other
1449            .write("second", Payload::from(&b"x"[..]))
1450            .expect("the engine is still running");
1451
1452        assert_eq!(cmd_rx.len(), 1, "clones share one bounded channel");
1453        match cmd_rx.try_recv() {
1454            Ok(ClientCmd::Write { topic, .. }) => assert_eq!(topic, "second"),
1455            _ => panic!("oldest-first eviction must hold across clones"),
1456        }
1457    }
1458
1459    /// A trimmed command is not silently lost: it carried the only sender of its
1460    /// reply channel, so the caller resolves rather than awaiting a reply the
1461    /// engine will never send.
1462    #[tokio::test]
1463    async fn a_trimmed_call_fails_its_caller() {
1464        let (handle, _cmd_rx, _prune_rx) = test_handle_capped(1);
1465        let (reply, rx) = oneshot::channel();
1466        handle
1467            .enqueue(ClientCmd::Call {
1468                id: 1,
1469                method: String::from("one"),
1470                params: Payload::from(&b"x"[..]),
1471                reply,
1472            })
1473            .expect("the engine is still running");
1474
1475        // At cap 1 the next command displaces it.
1476        handle
1477            .write("later", Payload::from(&b"x"[..]))
1478            .expect("the engine is still running");
1479
1480        assert!(
1481            rx.await.is_err(),
1482            "a trimmed call must not leave its caller waiting"
1483        );
1484    }
1485
1486    type SubSinks = HashMap<String, Sender<Result<SubUpdate, RpcError>>>;
1487    type SubCursors = HashMap<String, u64>;
1488
1489    /// One sink and its cursor, for driving [`deliver`] directly.
1490    fn test_sink() -> (SubSinks, SubCursors, Receiver<Result<SubUpdate, RpcError>>) {
1491        let (tx, rx) = async_channel::bounded(SUBSCRIBE_CHANNEL_CAP);
1492        let mut subs = HashMap::new();
1493        subs.insert(String::from("1"), tx);
1494        (subs, HashMap::new(), rx)
1495    }
1496
1497    fn deliver_event(subs: &mut SubSinks, last_seq: &mut SubCursors, seq: u64) {
1498        deliver(
1499            subs,
1500            last_seq,
1501            "1",
1502            seq,
1503            None,
1504            Payload::from(&b"x"[..]),
1505            Delivery::Event,
1506        );
1507    }
1508
1509    /// `seq` arrives off the wire unvalidated, so the ceiling is reachable by a
1510    /// peer. Computing the gap as `prev + 1` panicked in debug on the *next*
1511    /// frame, and wrapped to a nonsense gap in release.
1512    #[tokio::test]
1513    async fn a_saturated_sequence_does_not_overflow() {
1514        let (mut subs, mut last_seq, rx) = test_sink();
1515
1516        deliver_event(&mut subs, &mut last_seq, u64::MAX);
1517        deliver_event(&mut subs, &mut last_seq, 5);
1518
1519        let skips: Vec<u64> = core::iter::from_fn(|| rx.try_recv().ok())
1520            .map(|u| u.unwrap().skipped)
1521            .collect();
1522        assert_eq!(skips.len(), 2, "both frames must be delivered");
1523        assert_eq!(skips[1], 0, "a saturated cursor cannot manufacture a gap");
1524    }
1525
1526    /// A repeated or reordered `seq` is delivered but must not rewind the cursor:
1527    /// a lower cursor makes the *next* valid frame look like a jump, reporting a
1528    /// loss that never happened.
1529    #[tokio::test]
1530    async fn a_rewound_sequence_does_not_invent_a_gap() {
1531        let (mut subs, mut last_seq, rx) = test_sink();
1532
1533        for seq in [1, 3, 2, 4] {
1534            deliver_event(&mut subs, &mut last_seq, seq);
1535        }
1536
1537        let skips: Vec<u64> = core::iter::from_fn(|| rx.try_recv().ok())
1538            .map(|u| u.unwrap().skipped)
1539            .collect();
1540        assert_eq!(
1541            skips,
1542            alloc::vec![0, 1, 0, 0],
1543            "only the real gap (2 missing between 1 and 3) may be reported"
1544        );
1545        assert_eq!(last_seq.get("1"), Some(&4));
1546    }
1547
1548    /// The mirror's loss contract (see [`inbound_pump`]): a server-reported gap
1549    /// is stepped over, not treated as a fault. The update *carrying* `skipped`
1550    /// is itself produced into the local record — it is current state, which is
1551    /// all a mirror promises — and delivery continues past it.
1552    #[tokio::test]
1553    async fn a_mirror_gap_still_routes_and_keeps_mirroring() {
1554        let seen: Arc<spin::Mutex<Vec<Vec<u8>>>> = Arc::new(spin::Mutex::new(Vec::new()));
1555        let recorder = seen.clone();
1556        let ingest: crate::connector::IngestFn =
1557            Arc::new(move |_ctx: &crate::RuntimeContext, bytes: &[u8]| {
1558                recorder.lock().push(bytes.to_vec());
1559                Ok(())
1560            });
1561        let routes = alloc::vec![(String::from("tele"), ingest)];
1562        let router = Arc::new(RouterBuilder::from_routes(routes).build());
1563        let ctx =
1564            crate::RuntimeContext::new(Arc::new(crate::executor::test_support::NoopRuntimeOps));
1565        let (handle, cmd_rx, _prune_rx) = test_handle();
1566
1567        let pump = inbound_pump(handle, router, Arc::from("tele"), ctx);
1568        let remote = async {
1569            // The pump subscribes on its first poll; take its sink and play a
1570            // gapped update followed by a clean one.
1571            let events = match cmd_rx.recv().await {
1572                Ok(ClientCmd::Subscribe { events, .. }) => events,
1573                _ => panic!("the pump must subscribe"),
1574            };
1575            for (data, skipped) in [(&b"gapped"[..], 7u64), (&b"after"[..], 0)] {
1576                events
1577                    .send(Ok(SubUpdate {
1578                        topic: None,
1579                        data: Payload::from(data),
1580                        skipped,
1581                        snapshot_end: false,
1582                    }))
1583                    .await
1584                    .expect("the pump holds the receiver");
1585            }
1586            // Dropping the sink ends the stream, so the pump returns.
1587            drop(events);
1588        };
1589        futures_util::future::join(pump, remote).await;
1590
1591        let seen = seen.lock();
1592        assert_eq!(seen.len(), 2, "the gap must not drop or end the mirror");
1593        assert_eq!(
1594            seen[0].as_slice(),
1595            b"gapped",
1596            "the update reporting the gap must still be produced"
1597        );
1598        assert_eq!(seen[1].as_slice(), b"after", "mirroring must continue");
1599    }
1600}