aimdb_core/session/server.rs
1//! The reactive **server** engine of the session substrate.
2//!
3//! - [`run_session`] drives one accepted [`Connection`]: a biased `select_biased!`
4//! loop interleaving inbound requests (RPC + subscribe + write) with outbound
5//! subscription events.
6//! - [`serve`] is the accept loop over a [`Listener`], honoring
7//! [`SessionLimits::max_connections`].
8//!
9//! Spawn-free (every per-connection/-subscription task lives in a
10//! [`FuturesUnordered`] the runner drives) and runtime-neutral (purely reactive,
11//! so no timer and no `tokio`/`embassy-*`).
12//!
13//! The loops use an **extract-then-act** shape: `select_biased!` only computes a
14//! small [`Step`], then the loop acts on it once the arm futures (and their
15//! borrows of `conn`/`subs`) drop — `futures`' macro, unlike `tokio::select!`,
16//! keeps non-selected futures alive across the handler.
17
18use alloc::boxed::Box;
19use alloc::string::{String, ToString};
20use alloc::sync::Arc;
21use alloc::vec::Vec;
22
23use async_channel::Sender;
24use futures_channel::oneshot;
25use futures_util::stream::{FuturesUnordered, StreamExt};
26use futures_util::{select_biased, FutureExt};
27use hashbrown::HashMap;
28
29use super::{
30 BoxFut, BoxStream, Connection, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload,
31 RpcError, SessionLimits, SubUpdate,
32};
33
34/// Per-session engine knobs.
35#[derive(Debug, Clone, Default)]
36pub struct SessionConfig {
37 /// Connection cap (consulted by [`serve`]) and per-connection subscription
38 /// cap (by [`run_session`]).
39 pub limits: SessionLimits,
40 /// If `true`, read one frame before authenticating and pass it to
41 /// [`Dispatch::authenticate`] as an in-band Hello. If `false` (default),
42 /// authenticate from [`PeerInfo`](super::PeerInfo) alone.
43 pub reads_hello: bool,
44 /// If `true`, emit an explicit [`Outbound::Subscribed`] ack before the first
45 /// event. If `false` (default) the ack is implicit (events carry the
46 /// subscription id back).
47 pub acks_subscribe: bool,
48}
49
50/// Bound for the per-connection event funnel: pending outbound updates a
51/// connection may buffer before pumps start dropping.
52const EVENT_BUFFER: usize = 256;
53
54/// One subscription update on its way back to the connection's send half.
55struct SubEvent {
56 sub: String,
57 seq: u64,
58 /// Concrete record topic, when the subscription tags its updates
59 /// (see [`SubUpdate::topic`]).
60 topic: Option<Arc<str>>,
61 data: Payload,
62}
63
64/// What [`run_session`]'s `select_biased!` decided this iteration — extracted so
65/// the work runs after the arm futures' borrows of `conn`/`subs` release.
66enum Step {
67 /// A logical frame arrived from the peer (decode + dispatch).
68 Frame(Vec<u8>),
69 /// Peer closed or the transport errored — end the session.
70 Closed,
71 /// A subscription update to encode and forward to the peer.
72 Event(SubEvent),
73 /// A subscription pump finished on its own (stream exhausted) — reap it and
74 /// prune its `cancels` entry by the carried sub id.
75 SubDrained(String),
76}
77
78/// Drive one accepted [`Connection`] until it closes.
79///
80/// Authenticates once, then interleaves (biased toward inbound reads, so a chatty
81/// subscription cannot starve the RPC path) incoming requests, outgoing
82/// subscription events, and reaping of finished subscription pumps. Dropping the
83/// engine cancels every live subscription.
84pub async fn run_session<C, D>(
85 mut conn: Box<dyn Connection>,
86 codec: &C,
87 dispatch: &D,
88 config: &SessionConfig,
89) where
90 C: EnvelopeCodec + ?Sized,
91 D: Dispatch + ?Sized,
92{
93 // Resolve the session context (Hello-frame or peer-only — see `reads_hello`).
94 let first = if config.reads_hello {
95 match conn.recv().await {
96 Ok(Some(frame)) => Some(frame),
97 // Peer closed or errored before sending the Hello — nothing to serve.
98 _ => return,
99 }
100 } else {
101 None
102 };
103 let ctx = match dispatch.authenticate(conn.peer(), first.as_deref()).await {
104 Ok(ctx) => ctx,
105 Err(_e) => {
106 log_warn!("session authenticate rejected: {:?}", _e);
107 return;
108 }
109 };
110
111 // Open the per-connection session once; the loop threads `&mut` into it.
112 let mut session = dispatch.open(&ctx);
113
114 // Event funnel: every per-subscription pump sends updates here; the main loop
115 // is the sole writer to the connection. Bounded, so a slow client cannot grow
116 // it without limit — pumps drop on overflow (events carry a monotonic `seq`).
117 let (event_tx, event_rx) = async_channel::bounded::<SubEvent>(EVENT_BUFFER);
118 // Per-connection subscription pumps; the engine future is their sole owner.
119 // Each resolves to its own sub id, so the loop can prune the matching
120 // `cancels` entry (a no-op if Unsubscribe already removed it).
121 let mut subs: FuturesUnordered<BoxFut<'static, String>> = FuturesUnordered::new();
122 // sub-id → cancel handle; dropping/firing the oneshot cancels the pump.
123 let mut cancels: HashMap<String, oneshot::Sender<()>> = HashMap::new();
124 // Reused encode scratch buffer.
125 let mut out = Vec::new();
126
127 loop {
128 // Biased toward inbound reads. The select only decides the next step; the
129 // work happens after, in the `match`, once the arm borrows release.
130 let step = {
131 // Per-iteration futures, fused for the select. `event_rx.recv()` is
132 // `!Unpin`, so pin it; `subs` (a `FusedStream`) parks on the empty set
133 // while the always-active `recv` arm keeps the select alive.
134 let mut recv = conn.recv().fuse();
135 let mut event = core::pin::pin!(event_rx.recv().fuse());
136 select_biased! {
137 // ---- inbound: one logical frame from the peer --------------
138 r = recv => match r {
139 Ok(Some(frame)) => Step::Frame(frame),
140 Ok(None) | Err(_) => Step::Closed, // peer closed / transport error
141 },
142 // ---- outbound: a subscription update to forward ------------
143 ev = event => match ev {
144 Ok(ev) => Step::Event(ev),
145 // Funnel closed — only if every sender dropped, which can't
146 // happen while the loop holds `event_tx`, so this is
147 // unreachable; end the session defensively if it ever does.
148 Err(_) => Step::Closed,
149 },
150 // ---- drain finished subscription pumps ---------------------
151 sub_id = subs.select_next_some() => Step::SubDrained(sub_id),
152 }
153 };
154
155 match step {
156 Step::Closed => break,
157 // A pump finished on its own (stream exhausted), not via Unsubscribe;
158 // drop its cancel handle so the ended subscription neither leaks nor
159 // keeps counting against `max_subs_per_connection`.
160 Step::SubDrained(sub_id) => {
161 cancels.remove(&sub_id);
162 }
163
164 Step::Event(ev) => {
165 out.clear();
166 let encoded = codec
167 .encode(
168 Outbound::Event {
169 sub: &ev.sub,
170 seq: ev.seq,
171 topic: ev.topic.as_deref(),
172 data: ev.data,
173 },
174 &mut out,
175 )
176 .is_ok();
177 if encoded && conn.send(&out).await.is_err() {
178 break;
179 }
180 }
181
182 Step::Frame(frame) => {
183 let msg = match codec.decode(&frame) {
184 Ok(msg) => msg,
185 Err(_e) => continue, // skip a malformed frame, keep the session
186 };
187 match msg {
188 Inbound::Request { id, method, params } => {
189 let result = session.call(&method, params).await;
190 out.clear();
191 if codec
192 .encode(Outbound::Reply { id, result }, &mut out)
193 .is_err()
194 {
195 continue;
196 }
197 if conn.send(&out).await.is_err() {
198 break;
199 }
200 }
201 Inbound::Subscribe { id, topic } => {
202 // The opening request id is the subscription's routing key;
203 // events carry it back as `Outbound::Event.sub`.
204 let sub_id = id.to_string();
205 if cancels.len() >= config.limits.max_subs_per_connection {
206 send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await;
207 continue;
208 }
209 match session.subscribe(&topic).await {
210 Ok(stream) => {
211 // Optional explicit ack (see `acks_subscribe`).
212 if config.acks_subscribe {
213 out.clear();
214 if codec
215 .encode(Outbound::Subscribed { sub: &sub_id }, &mut out)
216 .is_ok()
217 && conn.send(&out).await.is_err()
218 {
219 break;
220 }
221 }
222 // Optional late-join snapshots (one per covered
223 // record), before the first event. They share
224 // the subscription's `seq` space — numbered
225 // `1..=N` here, events continue at `N + 1` — so
226 // every snapshot is accounted for end-to-end:
227 // one dropped by a slow client sink (or skipped
228 // by the encode failure below) leaves a hole the
229 // client reports as `skipped`, and a loss at the
230 // tail of the burst surfaces on the first event.
231 let snaps = session.snapshots(&topic);
232 let total = snaps.len();
233 let mut seq: u64 = 0;
234 let mut send_failed = false;
235 for (i, (snap_topic, data)) in snaps.into_iter().enumerate() {
236 seq += 1;
237 out.clear();
238 let encoded = codec.encode(
239 Outbound::Snapshot {
240 sub: &sub_id,
241 seq,
242 // The client reserves a sink slot
243 // for this one, so the burst's
244 // loss total always lands.
245 last: i + 1 == total,
246 topic: &snap_topic,
247 data,
248 },
249 &mut out,
250 );
251 if let Err(_e) = encoded {
252 // A record whose serialized bytes aren't
253 // valid for this envelope — broken for
254 // every read of it, not just here, so say
255 // so rather than dropping it in silence.
256 // `seq` already advanced, so the client
257 // still counts it as loss; if this was the
258 // burst's last snapshot the `last` flag
259 // goes with it and no `snapshot_end`
260 // reaches the subscriber.
261 log_warn!(
262 "snapshot encode failed, skipping: sub={} topic={} err={:?}",
263 sub_id,
264 snap_topic,
265 _e
266 );
267 continue;
268 }
269 if conn.send(&out).await.is_err() {
270 send_failed = true;
271 break;
272 }
273 }
274 if send_failed {
275 break;
276 }
277 let (cancel_tx, cancel_rx) = oneshot::channel();
278 cancels.insert(sub_id.clone(), cancel_tx);
279 subs.push(Box::pin(pump_subscription(
280 sub_id,
281 stream,
282 event_tx.clone(),
283 cancel_rx,
284 seq,
285 )));
286 }
287 Err(e) => {
288 send_reply_err(&mut conn, codec, &mut out, id, e).await;
289 }
290 }
291 }
292 Inbound::Unsubscribe { sub } => {
293 // Dropping the sender resolves the pump's cancel future.
294 cancels.remove(&sub);
295 }
296 Inbound::Write { topic, payload } => {
297 // Fire-and-forget; single-writer-per-key stays intact.
298 let _ = session.write(&topic, payload).await;
299 }
300 Inbound::Ping => {
301 out.clear();
302 if codec.encode(Outbound::Pong, &mut out).is_ok()
303 && conn.send(&out).await.is_err()
304 {
305 break;
306 }
307 }
308 }
309 }
310 }
311 }
312
313 // Dropping `subs` here cancels every live subscription pump.
314 drop(subs);
315}
316
317/// Encode + send a `Reply` carrying an [`RpcError`]; best-effort (a send/encode
318/// failure just ends this attempt — the caller's loop handles a dead connection
319/// on its next `send`).
320async fn send_reply_err<C: EnvelopeCodec + ?Sized>(
321 conn: &mut Box<dyn Connection>,
322 codec: &C,
323 out: &mut Vec<u8>,
324 id: u64,
325 err: RpcError,
326) {
327 out.clear();
328 if codec
329 .encode(
330 Outbound::Reply {
331 id,
332 result: Err(err),
333 },
334 out,
335 )
336 .is_ok()
337 {
338 let _ = conn.send(out).await;
339 }
340}
341
342/// Pump one `Session::subscribe` stream into the connection's event funnel,
343/// tagging each update with a monotonic `seq`. Ends when the stream finishes or
344/// the cancel handle is dropped/fired (Unsubscribe or connection teardown).
345///
346/// `start_seq` is the last sequence number the late-join snapshot burst used
347/// (`0` when there were none), so events continue the *same* counter and a
348/// snapshot lost at the tail of the burst still shows up as a gap on the first
349/// event.
350///
351/// Returns its `sub_id` so [`run_session`] can prune the `cancels` entry for a
352/// pump that ended on its own; the Unsubscribe path already removed it, so that
353/// later prune is a no-op.
354async fn pump_subscription(
355 sub_id: String,
356 mut stream: BoxStream<'static, SubUpdate>,
357 tx: Sender<SubEvent>,
358 cancel: oneshot::Receiver<()>,
359 start_seq: u64,
360) -> String {
361 // Fuse the cancel receiver: a bare `oneshot::Receiver` reports
362 // `is_terminated()` once its sender drops, and `select_biased!` skips
363 // terminated arms — so the cancel would never fire. `Fuse` keeps the arm
364 // polled until it actually resolves.
365 let mut cancel = cancel.fuse();
366 let mut seq: u64 = start_seq;
367 loop {
368 // Independent arms, so a direct `select_biased!` is fine here.
369 let update = select_biased! {
370 // Resolves on explicit Unsubscribe (send) or on sender drop.
371 _ = cancel => break,
372 // `BoxStream` is not `FusedStream`, so fuse the per-iteration `next`.
373 next = stream.next().fuse() => match next {
374 Some(update) => update,
375 None => break, // stream exhausted
376 },
377 };
378 seq += update.skipped + 1;
379 // Non-blocking: drop on a full funnel (slow-client protection); only a
380 // disconnected funnel ends the pump.
381 match tx.try_send(SubEvent {
382 sub: sub_id.clone(),
383 seq,
384 topic: update.topic,
385 data: update.data,
386 }) {
387 Ok(()) => {}
388 Err(e) if e.is_full() => {} // drop on overflow
389 Err(_) => break, // funnel disconnected — connection gone
390 }
391 }
392 sub_id
393}
394
395/// Accept connections from `listener` and serve each with [`run_session`],
396/// bounded by [`SessionLimits::max_connections`]. The accept loop and all
397/// per-connection futures share one [`FuturesUnordered`] — spawn-free.
398pub async fn serve<L, C, D>(mut listener: L, codec: Arc<C>, dispatch: Arc<D>, config: SessionConfig)
399where
400 L: Listener,
401 C: EnvelopeCodec + 'static,
402 // `?Sized` so a caller can serve an `Arc<dyn Dispatch>`.
403 D: Dispatch + 'static + ?Sized,
404{
405 let mut conns: FuturesUnordered<BoxFut<'static, ()>> = FuturesUnordered::new();
406
407 loop {
408 // Extract the accept result first, then act (keeps the `listener` and
409 // `conns` borrows apart).
410 let accept = {
411 let mut accept = listener.accept().fuse();
412 select_biased! {
413 a = accept => a,
414 // Parks on the empty set, so the accept arm keeps the select alive.
415 () = conns.select_next_some() => continue,
416 }
417 };
418
419 match accept {
420 Ok(conn) => {
421 // Soft cap; `len()` counts finished-but-not-yet-reaped futures, so
422 // under an accept flood (the biased `accept` arm starves the reap
423 // arm) it may read high transiently — acceptable for a soft cap.
424 if conns.len() >= config.limits.max_connections {
425 log_warn!(
426 "max_connections={} reached, refusing client",
427 config.limits.max_connections
428 );
429 drop(conn);
430 continue;
431 }
432 let codec = codec.clone();
433 let dispatch = dispatch.clone();
434 let cfg = config.clone();
435 conns.push(Box::pin(async move {
436 run_session(conn, codec.as_ref(), dispatch.as_ref(), &cfg).await;
437 }));
438 }
439 Err(_e) => {
440 log_error!("accept failed: {:?}", _e);
441 // Keep serving existing connections despite a transient accept error.
442 }
443 }
444 }
445}