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}