From 452eb2f9537ab9b67ece8acbcd27c0a4ecc5d024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 18:19:44 +0000 Subject: [PATCH 01/34] feat(session): implement Phase 2 connector-session contracts and server engine - Introduced `session/mod.rs` with frozen trait signatures for connector-session contracts. - Added `server.rs` implementing the reactive server engine for handling connections. - Created tests in `session_engine.rs` to validate RPC, streaming subscriptions, and fire-and-forget writes using an in-memory transport. - Implemented a channel-backed `Connection`, `Listener`, and `Dialer` for testing purposes. - Developed a line-oriented `EnvelopeCodec` for encoding and decoding messages. - Established an echo dispatch to handle RPC calls and subscriptions, ensuring role-neutral communication. --- Makefile | 8 + aimdb-core/Cargo.toml | 6 + aimdb-core/src/lib.rs | 12 + aimdb-core/src/session/client.rs | 313 ++++++++++++++++++ aimdb-core/src/session/mod.rs | 489 +++++++++++++++++++++++++++++ aimdb-core/src/session/server.rs | 285 +++++++++++++++++ aimdb-core/tests/session_engine.rs | 363 +++++++++++++++++++++ 7 files changed, 1476 insertions(+) create mode 100644 aimdb-core/src/session/client.rs create mode 100644 aimdb-core/src/session/mod.rs create mode 100644 aimdb-core/src/session/server.rs create mode 100644 aimdb-core/tests/session_engine.rs diff --git a/Makefile b/Makefile index 91ad9d1..19cab8e 100644 --- a/Makefile +++ b/Makefile @@ -63,6 +63,10 @@ build: cargo build --package aimdb-core --features "std,tracing,profiling" @printf "$(YELLOW) → Building aimdb-core (no_std + alloc + metrics)$(NC)\n" cargo build --package aimdb-core --no-default-features --features "alloc,metrics" + @printf "$(YELLOW) → Building aimdb-core (no_std + alloc + connector-session contracts)$(NC)\n" + cargo build --package aimdb-core --no-default-features --features "alloc,connector-session" + @printf "$(YELLOW) → Building aimdb-core (std + connector-session engines)$(NC)\n" + cargo build --package aimdb-core --features "std,connector-session" @printf "$(YELLOW) → Building tokio adapter$(NC)\n" cargo build --package aimdb-tokio-adapter --features "tokio-runtime,tracing,metrics" @printf "$(YELLOW) → Building tokio adapter (with profiling)$(NC)\n" @@ -108,6 +112,10 @@ test: cargo test --package aimdb-core --no-default-features --features "alloc,json-serialize" @printf "$(YELLOW) → Testing aimdb-core remote module$(NC)\n" cargo test --package aimdb-core --lib --features "std" remote:: + @printf "$(YELLOW) → Testing aimdb-core connector-session (contracts object-safety)$(NC)\n" + cargo test --package aimdb-core --lib --features "std,connector-session" session:: + @printf "$(YELLOW) → Testing aimdb-core connector-session engines (session_engine)$(NC)\n" + cargo test --package aimdb-core --features "std,connector-session" --test session_engine @printf "$(YELLOW) → Testing tokio adapter$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing tokio adapter (with metrics)$(NC)\n" diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 5a51d8f..b207f31 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -36,6 +36,12 @@ alloc = ["serde"] # Enable heap in no_std # get `record.latest()?.as_json()` without std/AimX. `std` enables it for AimX. json-serialize = ["alloc", "serde_json"] +# Phase 0 connector-session contracts (`crate::session`): the frozen, dyn-safe +# trait skeletons for the connector capability model — Connection/Listener/Dialer, +# Dispatch/EnvelopeCodec, Sink/Source + shared types. Contracts only, no engine +# logic; compiles on `no_std + alloc`. See docs/design/detailed/037-phase0-contracts.md. +connector-session = ["alloc"] + # Observability features (available on both std/no_std) tracing = ["dep:tracing"] # Works in both std and no_std environments defmt = ["dep:defmt"] # Embedded logging via probe (no_std) diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 6dcc24d..e813f5c 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -35,6 +35,8 @@ pub mod record_id; #[cfg(feature = "std")] pub mod remote; pub mod router; +#[cfg(feature = "connector-session")] +pub mod session; pub mod time; pub mod transform; pub mod transport; @@ -86,6 +88,16 @@ pub use typed_record::{AnyRecord, AnyRecordExt, TypedRecord}; #[cfg(feature = "json-serialize")] pub use codec::{JsonCodec, RemoteSerialize, SerdeJsonCodec}; +// Phase 0 connector-session contracts (feature `connector-session`, no_std + +// alloc compatible). Frozen trait skeletons only — see +// docs/design/detailed/037-phase0-contracts.md. +#[cfg(feature = "connector-session")] +pub use session::{ + AuthError, BoxFut, BoxStream, CodecError, Connection, Dialer, Dispatch, EnvelopeCodec, Inbound, + Listener, Outbound, Payload, PeerInfo, RpcError, SessionCtx, SessionLimits, Sink, Source, + TransportError, TransportResult, +}; + // Stage profiling exports (feature-gated) #[cfg(feature = "profiling")] pub use profiling::{RecordProfilingMetrics, StageMetrics, StageProfilingInfo}; diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs new file mode 100644 index 0000000..50e28e6 --- /dev/null +++ b/aimdb-core/src/session/client.rs @@ -0,0 +1,313 @@ +//! Phase 2 **client** engine — the proactive half of the shared session +//! substrate (doc 034 § "shared with a client engine"; doc 035 Client +//! capability). Std-only, the dual of [`server`](super::server): it *dials* a +//! [`Connection`] via a [`Dialer`] instead of accepting one, *sends* [`Inbound`] +//! and *receives* [`Outbound`] (roles swapped vs the server), and demultiplexes +//! replies by `id`. +//! +//! Per the Phase 2 client-surface gate (resolved: **one engine, both +//! surfaces**), [`run_client`] owns the demux-by-`id` core and returns a +//! [`ClientHandle`] exposing caller-initiated RPC (`call`/`subscribe`/`write`). +//! Record *mirroring* (`pump_client(db, scheme, …)`) is a thin wrapper that +//! lands in **Phase 3** alongside the AimX route collection it needs — it will +//! drive this same engine, not a second one. +//! +//! Spawn-free: [`run_client`] returns the engine future for the runner to drive; +//! it never spawns. + +use std::collections::HashMap; +use std::time::Duration; + +use tokio::sync::{mpsc, oneshot}; + +use super::{ + BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, +}; + +/// Client engine knobs. +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// Redial after a dropped/failed connection instead of ending the engine. + pub reconnect: bool, + /// Delay before each redial when `reconnect` is set. + pub reconnect_delay: Duration, + /// Send a Ping handshake on connect and wait for the Pong before accepting + /// caller commands (the proactive "handshake-as-caller"). Mirrors the + /// server's `reads_hello`; a real protocol swaps Ping/Pong for its Hello. + pub sends_hello: bool, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + reconnect: true, + reconnect_delay: Duration::from_millis(200), + sends_hello: false, + } + } +} + +/// A cheap-clone handle to a running [`run_client`] engine — the caller-facing +/// RPC surface. Every method funnels a command to the engine, which owns the +/// pending-call map and the wire. +#[derive(Clone)] +pub struct ClientHandle { + cmd_tx: mpsc::UnboundedSender, +} + +/// Commands the [`ClientHandle`] funnels to the engine (the engine assigns the +/// correlation `id`, so it stays the sole owner of the demux map). +enum ClientCmd { + Call { + method: String, + params: Payload, + reply: oneshot::Sender>, + }, + Subscribe { + topic: String, + events: mpsc::UnboundedSender, + }, + Write { + topic: String, + payload: Payload, + }, +} + +impl ClientHandle { + /// One-shot RPC: send a request and await its single reply. Returns + /// [`RpcError::Internal`] if the engine has stopped or the connection drops + /// before the reply arrives. + pub async fn call( + &self, + method: impl Into, + params: Payload, + ) -> Result { + let (reply, rx) = oneshot::channel(); + self.cmd_tx + .send(ClientCmd::Call { + method: method.into(), + params, + reply, + }) + .map_err(|_| RpcError::Internal)?; + rx.await.map_err(|_| RpcError::Internal)? + } + + /// Open a subscription; returns a stream of updates immediately (the + /// `Subscribe` request is sent to the server asynchronously by the engine). + /// Dropping the stream stops local delivery; an explicit remote Unsubscribe + /// is left to Phase 3 (the connector mirroring path). + pub fn subscribe( + &self, + topic: impl Into, + ) -> Result, RpcError> { + let (events, rx) = mpsc::unbounded_channel::(); + self.cmd_tx + .send(ClientCmd::Subscribe { + topic: topic.into(), + events, + }) + .map_err(|_| RpcError::Internal)?; + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + Ok(Box::pin(stream)) + } + + /// Fire-and-forget write to a remote topic (no reply). + pub fn write(&self, topic: impl Into, payload: Payload) -> Result<(), RpcError> { + self.cmd_tx + .send(ClientCmd::Write { + topic: topic.into(), + payload, + }) + .map_err(|_| RpcError::Internal) + } +} + +/// Build the client engine: returns a [`ClientHandle`] for issuing RPC and the +/// engine future to drive on the runner (spawn-free). The future runs until all +/// `ClientHandle` clones are dropped (graceful stop) — or, with +/// [`ClientConfig::reconnect`] off, until the first disconnect. +pub fn run_client( + dialer: D, + codec: C, + config: ClientConfig, +) -> (ClientHandle, BoxFut<'static, ()>) +where + D: Dialer + 'static, + C: EnvelopeCodec + 'static, +{ + let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let handle = ClientHandle { cmd_tx }; + let fut = Box::pin(client_loop(dialer, codec, config, cmd_rx)); + (handle, fut) +} + +/// Why one connection's session ended — decides reconnect vs stop. +enum Ended { + /// The connection dropped/errored; redial if configured. + Disconnected, + /// Every [`ClientHandle`] was dropped — stop the engine. + HandlesDropped, +} + +async fn client_loop( + dialer: D, + codec: C, + config: ClientConfig, + mut cmd_rx: mpsc::UnboundedReceiver, +) where + D: Dialer, + C: EnvelopeCodec, +{ + loop { + let conn = match dialer.connect().await { + Ok(conn) => conn, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("client dial failed: {:?}", _e); + if config.reconnect { + tokio::time::sleep(config.reconnect_delay).await; + continue; + } + return; + } + }; + + match drive_connection(conn, &codec, &mut cmd_rx, &config).await { + Ended::HandlesDropped => return, + Ended::Disconnected => { + if config.reconnect { + tokio::time::sleep(config.reconnect_delay).await; + continue; + } + return; + } + } + } +} + +/// Drive one dialed [`Connection`]: optional handshake, then `biased` demux of +/// server frames (resolve `Reply` by `id`, route `Event`/`Snapshot` to their +/// subscription channels) interleaved with caller commands. Pending state is +/// per-connection: a disconnect fails outstanding calls (their `oneshot` +/// senders drop → callers see [`RpcError::Internal`]). +async fn drive_connection( + mut conn: Box, + codec: &C, + cmd_rx: &mut mpsc::UnboundedReceiver, + config: &ClientConfig, +) -> Ended +where + C: EnvelopeCodec + ?Sized, +{ + let mut next_id: u64 = 1; + let mut pending: HashMap>> = HashMap::new(); + // sub-id → event sink. The sub-id is `id.to_string()` of the opening + // request, matching the server's derivation so `Event.sub` routes back. + let mut subs: HashMap> = HashMap::new(); + let mut out = Vec::new(); + + // Handshake-as-caller: prove the link with Ping/Pong before serving commands. + if config.sends_hello { + out.clear(); + if codec.encode_inbound(Inbound::Ping, &mut out).is_err() || conn.send(&out).await.is_err() + { + return Ended::Disconnected; + } + match conn.recv().await { + Ok(Some(frame)) => match codec.decode_outbound(&frame) { + Ok(Outbound::Pong) => {} + _ => return Ended::Disconnected, + }, + _ => return Ended::Disconnected, + } + } + + loop { + tokio::select! { + biased; + + // ---- inbound from server: Reply / Event / Snapshot / Pong ------ + recv = conn.recv() => { + let frame = match recv { + Ok(Some(frame)) => frame, + Ok(None) | Err(_) => return Ended::Disconnected, + }; + match codec.decode_outbound(&frame) { + Ok(Outbound::Reply { id, result }) => { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(result); + } + } + Ok(Outbound::Event { sub, seq: _, data }) => { + let dead = match subs.get(sub) { + Some(tx) => tx.send(data).is_err(), + None => false, // late event for a dropped sub — ignore + }; + if dead { + subs.remove(sub); + } + } + Ok(Outbound::Snapshot { topic, data }) => { + if let Some(tx) = subs.get(topic) { + let _ = tx.send(data); + } + } + Ok(Outbound::Pong) => {} + Err(_e) => continue, // skip a malformed frame, keep the connection + } + } + + // ---- caller commands from ClientHandle ------------------------- + cmd = cmd_rx.recv() => { + let cmd = match cmd { + Some(cmd) => cmd, + None => return Ended::HandlesDropped, // all handles dropped + }; + match cmd { + ClientCmd::Call { method, params, reply } => { + let id = next_id; + next_id += 1; + pending.insert(id, reply); + out.clear(); + let sent = codec + .encode_inbound(Inbound::Request { id, method, params }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(Err(RpcError::Internal)); + } + return Ended::Disconnected; + } + } + ClientCmd::Subscribe { topic, events } => { + let id = next_id; + next_id += 1; + subs.insert(id.to_string(), events); + out.clear(); + let sent = codec + .encode_inbound(Inbound::Subscribe { id, topic }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + return Ended::Disconnected; + } + } + ClientCmd::Write { topic, payload } => { + out.clear(); + let sent = codec + .encode_inbound(Inbound::Write { topic, payload }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + return Ended::Disconnected; + } + } + } + } + } + } +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs new file mode 100644 index 0000000..5a0a7dc --- /dev/null +++ b/aimdb-core/src/session/mod.rs @@ -0,0 +1,489 @@ +//! Frozen Phase 0 connector-session contracts (trait skeletons only). +//! +//! This module locks the cross-cutting trait **signatures** that every later +//! phase of the connector-convergence initiative (issue #39 — embedded remote +//! access) depends on. It ships **contracts, not behavior**: every method body +//! is `unimplemented!()`. The engines (`run_session` / `serve` / `run_client`), +//! the pump helpers, and the transport/dispatch impls all arrive in Phases 1–6. +//! +//! See [`docs/design/detailed/037-phase0-contracts.md`] for the decision record, +//! and the canonical signature sketches this module copies verbatim: +//! - transport + [`EnvelopeCodec`] + [`Dispatch`]: doc 034 (§ The three layers) +//! - [`Sink`] / [`Source`] / [`Dialer`]: doc 035 (§ The toolkit) +//! +//! Everything here is `dyn`-safe and compiles on `std` **and** `no_std + alloc` +//! (boxed-future pattern throughout, no `std`/`tokio`/`serde_json` at the +//! contract level). + +extern crate alloc; + +use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; +use core::future::Future; +use core::pin::Pin; + +use futures_core::Stream; + +use crate::transport::{ConnectorConfig, PublishError}; + +// --------------------------------------------------------------------------- +// Phase 2 engines (std-only). The frozen contracts above stay `no_std + alloc`; +// the reactive `serve`/`run_session` (server) and proactive `run_client`/ +// `pump_client` (client) engines need `tokio` and therefore gate on `std`. This +// keeps the Phase 0 acceptance criterion intact: `--features connector-session` +// still cross-compiles to `thumbv7em` because the no_std build sees only the +// contracts, never the engines. Phase 5 is where the engines themselves go +// `no_std` (Embassy/heapless). See docs/design/detailed/036/037. +// --------------------------------------------------------------------------- + +#[cfg(feature = "std")] +mod client; +#[cfg(feature = "std")] +mod server; + +#[cfg(feature = "std")] +pub use client::{run_client, ClientConfig, ClientHandle}; +#[cfg(feature = "std")] +pub use server::{run_session, serve, SessionConfig}; + +// =========================================================================== +// Shared aliases +// =========================================================================== + +/// Boxed, `Send` future — the object-safe async return shape used by every +/// trait here, matching the existing `Connector` / `ProducerTrait` pattern. +pub type BoxFut<'a, T> = Pin + Send + 'a>>; + +/// Boxed, `Send` stream — the reply shape of a subscription +/// ([`Dispatch::subscribe`]). +pub type BoxStream<'a, T> = Pin + Send + 'a>>; + +/// The record-value seam between the outer [`EnvelopeCodec`] and the inner M16 +/// record-value `JsonCodec` (Decision 1: **raw bytes**). +/// +/// Opaque serialized bytes; cheap-clone (refcount bump) for WS fan-out, +/// `no_std + alloc`-native, no new dependency. Bytes flow opaque through the hot +/// paths; typed/structured conversion happens only at the ends that need it +/// (`serde_json::Value` materializes only inside RPC handlers that inspect +/// structure). `bytes::Bytes` is reserved for a later need (cheap sub-slicing / +/// zero-copy binary framing). +pub type Payload = Arc<[u8]>; + +/// Result of a transport-layer operation. +pub type TransportResult = Result; + +// =========================================================================== +// Supporting types (stubs — sufficient for the signatures to compile) +// =========================================================================== + +/// Remote-peer metadata carried by a [`Connection`] (remote addr, headers, +/// pre-resolved auth). +/// +/// Opaque placeholder. The concrete fields — and whether one shape carries both +/// AimX `SecurityPolicy` and WS `Permissions` — are **deferred to Phase 4**. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct PeerInfo {} + +/// The authenticated session context threaded through [`Dispatch`] calls. +/// +/// Minimal/opaque placeholder. Auth fields are **deferred to Phase 4** +/// (the auth-context shape gate). +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct SessionCtx {} + +/// Engine-local bounds for a session (consumed by the Phase 2 engines, not by +/// the contracts here). +/// +/// Whether these become `heapless`/const-generic vs runtime config is +/// **deferred to Phase 5** (bounded-resource policy). +#[derive(Debug, Clone)] +pub struct SessionLimits { + /// Maximum concurrently served connections. + pub max_connections: usize, + /// Maximum live subscriptions per connection. + pub max_subs_per_connection: usize, +} + +impl Default for SessionLimits { + fn default() -> Self { + Self { + max_connections: 16, + max_subs_per_connection: 32, + } + } +} + +/// Transport-layer failure (Layer 1). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TransportError { + /// The connection was closed or reset by the peer. + Closed, + /// An underlying I/O operation failed. + Io, +} + +/// Envelope-codec failure — a frame could not be decoded/encoded. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CodecError { + /// The frame was not valid for this envelope format. + Malformed, +} + +/// Dispatch-layer (application) failure for `call` / `subscribe` / `write`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RpcError { + /// No such method or topic. + NotFound, + /// The caller lacks permission for this operation. + Denied, + /// The handler failed. + Internal, +} + +/// Authentication failure raised by [`Dispatch::authenticate`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthError { + /// Credentials were missing or rejected. + Unauthorized, +} + +// =========================================================================== +// Logical message set (role-neutral; the server's `Inbound` is the client's +// out-bound and vice versa — doc 034 § The substrate is shared with a client +// engine). Field types align with the existing AimX wire (`remote::protocol`). +// =========================================================================== + +/// A logical request arriving over a [`Connection`] (what the server receives). +pub enum Inbound { + /// An RPC call expecting a single [`Outbound::Reply`]. + Request { + /// Correlation id, echoed in the reply. + id: u64, + /// Method name (e.g. `"record.set"`, `"query"`). + method: String, + /// Unparsed method parameters. + params: Payload, + }, + /// Open a subscription producing many [`Outbound::Event`]s. + Subscribe { + /// Correlation id for the subscription handshake. + id: u64, + /// Topic to subscribe to. + topic: String, + }, + /// Close a previously opened subscription. + Unsubscribe { + /// Subscription id to cancel. + sub: String, + }, + /// A fire-and-forget write (no reply). + Write { + /// Destination topic. + topic: String, + /// Unparsed record value. + payload: Payload, + }, + /// Keepalive. + Ping, +} + +/// A logical message sent back over a [`Connection`] (what the server emits). +pub enum Outbound<'a> { + /// Reply to an [`Inbound::Request`]. + Reply { + /// Correlation id of the originating request. + id: u64, + /// The result, or an [`RpcError`]. + result: Result, + }, + /// A subscription update. + Event { + /// Subscription id this event belongs to. + sub: &'a str, + /// Monotonic sequence number. + seq: u64, + /// Unparsed record value. + data: Payload, + }, + /// An initial snapshot emitted when a subscription opens (late-join). + Snapshot { + /// Topic the snapshot is for. + topic: &'a str, + /// Unparsed record value. + data: Payload, + }, + /// Keepalive response. + Pong, +} + +// =========================================================================== +// Layer 1 — transport (the std / Embassy seam). Doc 034 § Layer 1; `Dialer` +// from doc 035 § The toolkit (the dual of `Listener`). Framing lives *in* the +// transport: `recv` returns one logical frame. +// =========================================================================== + +/// A framed, bidirectional pipe — role-neutral (yielded by either +/// [`Listener::accept`] or [`Dialer::connect`]). +pub trait Connection: Send { + /// Receive one logical frame. `Ok(None)` signals the peer closed. + fn recv(&mut self) -> BoxFut<'_, TransportResult>>>; + + /// Send one logical frame. + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>>; + + /// Peer metadata (remote addr, headers, pre-resolved auth). + fn peer(&self) -> &PeerInfo; +} + +/// The accepting (server) side — produces [`Connection`]s we did not initiate. +pub trait Listener: Send { + /// Accept the next inbound connection. + fn accept(&mut self) -> BoxFut<'_, TransportResult>>; +} + +/// The initiating (client) side — the dual of [`Listener`]; dials out and +/// produces the same [`Connection`]. +pub trait Dialer: Send { + /// Open a connection to the configured remote. + fn connect(&self) -> BoxFut<'_, TransportResult>>; +} + +// =========================================================================== +// Layer 3 — dispatch (the semantics). Doc 034 § Layer 3 + § EnvelopeCodec. +// RPC and streaming unify in ONE trait (Decision 2): three reply cardinalities +// — `call` (one) / `subscribe` (many) / `write` (none). +// =========================================================================== + +/// The application dispatch: authenticate a session, then serve calls, +/// subscriptions, and writes against a [`SessionCtx`]. +pub trait Dispatch: Send + Sync { + /// Resolve a [`SessionCtx`] from peer metadata and/or the first frame + /// (WS supplies pre-resolved identity via [`PeerInfo`]; UDS reads a Hello). + fn authenticate<'a>( + &'a self, + peer: &'a PeerInfo, + first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result>; + + /// One-shot RPC: one request → one reply. + fn call<'a>( + &'a self, + ctx: &'a SessionCtx, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result>; + + /// Streaming: open a subscription that yields many payloads. The stream is + /// `'static` so it can hold its own buffer reader inside the engine's + /// `FuturesUnordered` (doc 034 risk list). + fn subscribe( + &self, + ctx: &SessionCtx, + topic: &str, + ) -> Result, RpcError>; + + /// Fire-and-forget write: no reply. Routes through the existing + /// producer/arbiter path (single-writer-per-key stays intact). + fn write<'a>( + &'a self, + ctx: &'a SessionCtx, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>>; +} + +/// The protocol-envelope codec: frame bytes ↔ one logical message set. Distinct +/// from, and layered above, the M16 record-value `JsonCodec` it nests; the wire +/// format (NDJSON / WS-JSON / `serde-json-core`) stays pluggable. +/// +/// Per Decision 1, `decode` yields `params`/`data` as an *unparsed* [`Payload`] +/// (a slice of the frame) and `encode` splices a [`Payload`] in verbatim. +/// +/// **Symmetric (both engines, one codec).** The first pair below is the +/// *server* direction (read requests / write replies), frozen in Phase 0. The +/// [`encode_inbound`](EnvelopeCodec::encode_inbound) / +/// [`decode_outbound`](EnvelopeCodec::decode_outbound) pair is the *client* +/// direction (write requests / read replies), added in Phase 2 so `run_client` +/// reuses the **same** codec object rather than a per-role copy — the +/// role-neutral-substrate invariant (doc 036). The two frozen signatures are +/// unchanged; this is purely additive. +pub trait EnvelopeCodec: Send + Sync { + /// Decode one frame into a logical [`Inbound`] message (server reads a request). + fn decode(&self, frame: &[u8]) -> Result; + + /// Encode a logical [`Outbound`] message, appending its bytes to `out` + /// (server writes a reply/event). + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError>; + + /// Encode a logical [`Inbound`] message, appending its bytes to `out` + /// (client writes a request). The dual of [`decode`](EnvelopeCodec::decode). + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError>; + + /// Decode one frame into a logical [`Outbound`] message (client reads a + /// reply/event). The dual of [`encode`](EnvelopeCodec::encode); the result + /// borrows the frame (`Outbound`'s `sub`/`topic` are `&str` slices into it). + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError>; +} + +// =========================================================================== +// Data-plane capabilities (doc 035 § The toolkit). Connectionless: an external +// library owns any session. +// =========================================================================== + +/// AimDB → external data-plane (Decision 3: `publish` stays a **sibling** +/// capability). This is today's [`Connector`](crate::transport::Connector) +/// contract verbatim — no rename or migration here; reconciling the two names +/// is Phase 1. +pub trait Sink: Send + Sync { + /// Publish a serialized record value to a protocol-specific destination. + fn publish( + &self, + dest: &str, + cfg: &ConnectorConfig, + bytes: &[u8], + ) -> BoxFut<'_, Result<(), PublishError>>; +} + +/// External → AimDB data-plane — a stream of inbound frames (the one genuinely +/// new data-plane trait; replaces the hand-rolled read loop). +pub trait Source: Send { + /// Yield the next `(topic, payload)`, or `None` when the source is done. + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>>; +} + +// =========================================================================== +// Object-safety: referencing each trait as `&dyn Trait` in non-test code forces +// the dyn-compatibility check on *all* targets (std and `no_std + alloc`), not +// just under `cargo test`. The `#[cfg(test)]` block below additionally builds a +// `Box` from a mock per the acceptance criteria. +// =========================================================================== + +#[allow(dead_code)] +fn _assert_object_safe( + _connection: &dyn Connection, + _listener: &dyn Listener, + _dialer: &dyn Dialer, + _dispatch: &dyn Dispatch, + _codec: &dyn EnvelopeCodec, + _sink: &dyn Sink, + _source: &dyn Source, +) { +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockConnection; + impl Connection for MockConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + unimplemented!() + } + fn send<'a>(&'a mut self, _frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + unimplemented!() + } + fn peer(&self) -> &PeerInfo { + unimplemented!() + } + } + + struct MockListener; + impl Listener for MockListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + unimplemented!() + } + } + + struct MockDialer; + impl Dialer for MockDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + unimplemented!() + } + } + + struct MockDispatch; + impl Dispatch for MockDispatch { + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + unimplemented!() + } + fn call<'a>( + &'a self, + _ctx: &'a SessionCtx, + _method: &'a str, + _params: Payload, + ) -> BoxFut<'a, Result> { + unimplemented!() + } + fn subscribe( + &self, + _ctx: &SessionCtx, + _topic: &str, + ) -> Result, RpcError> { + unimplemented!() + } + fn write<'a>( + &'a self, + _ctx: &'a SessionCtx, + _topic: &'a str, + _payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + unimplemented!() + } + } + + struct MockCodec; + impl EnvelopeCodec for MockCodec { + fn decode(&self, _frame: &[u8]) -> Result { + unimplemented!() + } + fn encode(&self, _msg: Outbound<'_>, _out: &mut Vec) -> Result<(), CodecError> { + unimplemented!() + } + fn encode_inbound(&self, _msg: Inbound, _out: &mut Vec) -> Result<(), CodecError> { + unimplemented!() + } + fn decode_outbound<'a>(&self, _frame: &'a [u8]) -> Result, CodecError> { + unimplemented!() + } + } + + struct MockSink; + impl Sink for MockSink { + fn publish( + &self, + _dest: &str, + _cfg: &ConnectorConfig, + _bytes: &[u8], + ) -> BoxFut<'_, Result<(), PublishError>> { + unimplemented!() + } + } + + struct MockSource; + impl Source for MockSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + unimplemented!() + } + } + + /// Acceptance criterion: every frozen trait is `dyn`-usable. + #[test] + fn traits_are_object_safe() { + let _connection: Box = Box::new(MockConnection); + let _listener: Box = Box::new(MockListener); + let _dialer: Box = Box::new(MockDialer); + let _dispatch: Box = Box::new(MockDispatch); + let _codec: Box = Box::new(MockCodec); + let _sink: Box = Box::new(MockSink); + let _source: Box = Box::new(MockSource); + } +} diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs new file mode 100644 index 0000000..37feae6 --- /dev/null +++ b/aimdb-core/src/session/server.rs @@ -0,0 +1,285 @@ +//! Phase 2 **server** engine — the reactive half of the shared session +//! substrate (doc 034 § Layer 2). Written once here, std-only; it generalizes +//! the two hand-rolled loops it will replace in Phases 3–4: +//! +//! - [`run_session`] = `remote/handler.rs`'s biased `select!` per-connection loop +//! (RPC + streaming + writes over one [`Connection`]), transport-erased. +//! - [`serve`] = `remote/supervisor.rs`'s accept loop, generalized over +//! [`Listener`] and honoring [`SessionLimits::max_connections`]. +//! +//! Spawn-free: every per-connection and per-subscription task lives in a +//! [`FuturesUnordered`] owned by the engine future the runner drives — no +//! `tokio::spawn`. + +use std::collections::HashMap; +use std::sync::Arc; + +use futures_util::stream::{FuturesUnordered, StreamExt}; +use tokio::sync::{mpsc, oneshot}; + +use super::{ + BoxFut, BoxStream, Connection, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, + RpcError, SessionLimits, +}; + +/// Per-session engine knobs. +#[derive(Debug, Clone, Default)] +pub struct SessionConfig { + /// Bounds for one session (connection cap is consulted by [`serve`]; + /// per-connection subscription cap by [`run_session`]). + pub limits: SessionLimits, + /// How identity is resolved: + /// - `true` (UDS-style) — read one frame before authenticating and pass it + /// to [`Dispatch::authenticate`] as the in-band Hello. + /// - `false` (WS-style, the default) — authenticate from + /// [`PeerInfo`](super::PeerInfo) alone (identity pre-resolved at the HTTP + /// upgrade), no frame consumed. + pub reads_hello: bool, +} + +/// One subscription update on its way back to the connection's send half. +struct SubEvent { + sub: String, + seq: u64, + data: Payload, +} + +/// Drive one accepted [`Connection`] until it closes. +/// +/// Authenticates once, then interleaves — `biased`, request-read first so a +/// chatty subscription cannot starve the RPC path — incoming requests, outgoing +/// subscription events funneled from the per-subscription pumps, and draining of +/// finished subscription futures. Dropping the engine (runner cancelled) drops +/// `subs`, cancelling every live subscription. +pub async fn run_session( + mut conn: Box, + codec: &C, + dispatch: &D, + config: &SessionConfig, +) where + C: EnvelopeCodec + ?Sized, + D: Dispatch + ?Sized, +{ + // Resolve the session context (Hello-frame or peer-only — see `reads_hello`). + let first = if config.reads_hello { + match conn.recv().await { + Ok(Some(frame)) => Some(frame), + // Peer closed or errored before sending the Hello — nothing to serve. + _ => return, + } + } else { + None + }; + let ctx = match dispatch.authenticate(conn.peer(), first.as_deref()).await { + Ok(ctx) => ctx, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("session authenticate rejected: {:?}", _e); + return; + } + }; + + // Event funnel: every per-subscription pump sends its updates here; the main + // loop is the sole writer to the connection. + let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + // Per-connection subscription pumps; the engine future is their sole owner. + let mut subs: FuturesUnordered> = FuturesUnordered::new(); + // sub-id → cancel handle (dropping/sending the oneshot cancels the pump, + // race-free unlike a bare `Notify`). + let mut cancels: HashMap> = HashMap::new(); + // Reused encode scratch buffer. + let mut out = Vec::new(); + + loop { + tokio::select! { + biased; + + // ---- inbound: one logical frame from the peer ------------------ + recv = conn.recv() => { + let frame = match recv { + Ok(Some(frame)) => frame, + Ok(None) => break, // peer closed + Err(_e) => break, // transport error + }; + let msg = match codec.decode(&frame) { + Ok(msg) => msg, + Err(_e) => continue, // skip a malformed frame, keep the session + }; + match msg { + Inbound::Request { id, method, params } => { + let result = dispatch.call(&ctx, &method, params).await; + out.clear(); + if codec.encode(Outbound::Reply { id, result }, &mut out).is_err() { + continue; + } + if conn.send(&out).await.is_err() { + break; + } + } + Inbound::Subscribe { id, topic } => { + // The request id that opened the subscription is its + // routing key; events carry it back as `Outbound::Event.sub`. + let sub_id = id.to_string(); + if cancels.len() >= config.limits.max_subs_per_connection { + send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await; + continue; + } + match dispatch.subscribe(&ctx, &topic) { + Ok(stream) => { + let (cancel_tx, cancel_rx) = oneshot::channel(); + cancels.insert(sub_id.clone(), cancel_tx); + subs.push(Box::pin(pump_subscription( + sub_id, + stream, + event_tx.clone(), + cancel_rx, + ))); + } + Err(e) => { + send_reply_err(&mut conn, codec, &mut out, id, e).await; + } + } + } + Inbound::Unsubscribe { sub } => { + // Dropping the sender resolves the pump's cancel future. + cancels.remove(&sub); + } + Inbound::Write { topic, payload } => { + // Fire-and-forget; routes through Dispatch (single-writer-per-key intact). + let _ = dispatch.write(&ctx, &topic, payload).await; + } + Inbound::Ping => { + out.clear(); + if codec.encode(Outbound::Pong, &mut out).is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } + } + } + + // ---- outbound: a subscription update to forward ---------------- + Some(ev) = event_rx.recv() => { + out.clear(); + let encoded = codec + .encode(Outbound::Event { sub: &ev.sub, seq: ev.seq, data: ev.data }, &mut out) + .is_ok(); + if encoded && conn.send(&out).await.is_err() { + break; + } + } + + // ---- drain finished subscription pumps ------------------------- + // `Some(_) =` guards against the empty-`FuturesUnordered` panic + // (it reports `is_terminated()`); the always-active `recv` arm + // keeps the select alive. + Some(()) = subs.next() => {} + } + } + + // Sole owner of `subs` and `cancels` drops here → every live subscription + // pump is cancelled. + drop(subs); +} + +/// Encode + send a `Reply` carrying an [`RpcError`]; best-effort (a send/encode +/// failure just ends this attempt — the caller's loop handles a dead connection +/// on its next `send`). +async fn send_reply_err( + conn: &mut Box, + codec: &C, + out: &mut Vec, + id: u64, + err: RpcError, +) { + out.clear(); + if codec + .encode( + Outbound::Reply { + id, + result: Err(err), + }, + out, + ) + .is_ok() + { + let _ = conn.send(out).await; + } +} + +/// Pump one `Dispatch::subscribe` stream into the connection's event funnel, +/// tagging each update with a monotonic `seq`. Ends when the stream finishes or +/// the cancel handle is dropped/fired (Unsubscribe or connection teardown). +async fn pump_subscription( + sub_id: String, + mut stream: BoxStream<'static, Payload>, + tx: mpsc::UnboundedSender, + mut cancel: oneshot::Receiver<()>, +) { + let mut seq: u64 = 0; + loop { + tokio::select! { + biased; + // Resolves on explicit Unsubscribe (send) or on sender drop. + _ = &mut cancel => break, + next = stream.next() => match next { + Some(data) => { + seq += 1; + if tx.send(SubEvent { sub: sub_id.clone(), seq, data }).is_err() { + break; // funnel closed → connection gone + } + } + None => break, // stream exhausted + } + } + } +} + +/// Accept connections from `listener` and serve each with [`run_session`], +/// bounded by [`SessionLimits::max_connections`]. The accept loop and all +/// per-connection futures share one [`FuturesUnordered`] — spawn-free, mirroring +/// `remote/supervisor.rs`. +pub async fn serve(mut listener: L, codec: Arc, dispatch: Arc, config: SessionConfig) +where + L: Listener, + C: EnvelopeCodec + 'static, + D: Dispatch + 'static, +{ + let mut conns: FuturesUnordered> = FuturesUnordered::new(); + + loop { + tokio::select! { + biased; + + accept = listener.accept() => match accept { + Ok(conn) => { + // Soft cap; `len()` is conservative (a completed-but-undrained + // future still counts), which only ever refuses one extra. + if conns.len() >= config.limits.max_connections { + #[cfg(feature = "tracing")] + tracing::warn!( + "max_connections={} reached, refusing client", + config.limits.max_connections + ); + drop(conn); + continue; + } + let codec = codec.clone(); + let dispatch = dispatch.clone(); + let cfg = config.clone(); + conns.push(Box::pin(async move { + run_session(conn, codec.as_ref(), dispatch.as_ref(), &cfg).await; + })); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("accept failed: {:?}", _e); + // Keep serving existing connections despite a transient accept error. + } + }, + + Some(()) = conns.next() => {} + } + } +} diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs new file mode 100644 index 0000000..d4f7704 --- /dev/null +++ b/aimdb-core/tests/session_engine.rs @@ -0,0 +1,363 @@ +//! Phase 2 exit criterion (doc 036 / issue.md): a `serve` server and a +//! `run_client` client engine, talking over a throwaway in-memory pipe, +//! round-trip **RPC + a streaming subscription + a fire-and-forget write** in +//! both directions — proving the shared substrate (`Connection` / +//! `EnvelopeCodec` / `Inbound`/`Outbound`) is genuinely role-neutral. +//! +//! The substrate here is deliberately throwaway: a channel-backed `Connection` +//! (framing-in-transport: one `Vec` per logical frame), a `Listener`/ +//! `Dialer` pair over a connect channel, a tiny line-oriented `EnvelopeCodec`, +//! and an echo `Dispatch`. The real UDS/NDJSON/AimX impls land in Phase 3. + +#![cfg(feature = "connector-session")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures::StreamExt; + +use aimdb_core::session::{ + run_client, serve, AuthError, BoxFut, BoxStream, ClientConfig, CodecError, Connection, Dialer, + Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, + SessionConfig, SessionCtx, TransportError, TransportResult, +}; + +// =========================================================================== +// Channel-backed transport (Layer 1) +// =========================================================================== + +/// A framed bidirectional pipe: send to the peer, receive from the peer. One +/// `Vec` == one logical frame (framing lives in the transport). +struct ChannelConn { + tx: tokio::sync::mpsc::UnboundedSender>, + rx: tokio::sync::mpsc::UnboundedReceiver>, + peer: PeerInfo, +} + +fn conn_pair() -> (ChannelConn, ChannelConn) { + let (a_tx, a_rx) = tokio::sync::mpsc::unbounded_channel(); + let (b_tx, b_rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ChannelConn { + tx: a_tx, + rx: b_rx, + peer: PeerInfo::default(), + }, + ChannelConn { + tx: b_tx, + rx: a_rx, + peer: PeerInfo::default(), + }, + ) +} + +impl Connection for ChannelConn { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { Ok(self.rx.recv().await) }) // None == peer closed + } + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + let tx = self.tx.clone(); + let bytes = frame.to_vec(); + Box::pin(async move { tx.send(bytes).map_err(|_| TransportError::Closed) }) + } + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +struct ChannelListener { + incoming: tokio::sync::mpsc::UnboundedReceiver>, +} + +impl Listener for ChannelListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { self.incoming.recv().await.ok_or(TransportError::Closed) }) + } +} + +struct ChannelDialer { + connect_tx: tokio::sync::mpsc::UnboundedSender>, +} + +impl Dialer for ChannelDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + let connect_tx = self.connect_tx.clone(); + Box::pin(async move { + let (server_side, client_side) = conn_pair(); + connect_tx + .send(Box::new(server_side) as Box) + .map_err(|_| TransportError::Closed)?; + Ok(Box::new(client_side) as Box) + }) + } +} + +fn transport_pair() -> (ChannelListener, ChannelDialer) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ChannelListener { incoming: rx }, + ChannelDialer { connect_tx: tx }, + ) +} + +// =========================================================================== +// Tiny line-oriented EnvelopeCodec (symmetric: both engine directions) +// =========================================================================== + +struct LineCodec; + +fn payload_from(s: &str) -> Payload { + Arc::from(s.as_bytes()) +} + +fn utf8(b: &[u8]) -> Result<&str, CodecError> { + std::str::from_utf8(b).map_err(|_| CodecError::Malformed) +} + +fn rpc_code(e: &RpcError) -> &'static str { + match e { + RpcError::NotFound => "notfound", + RpcError::Denied => "denied", + _ => "internal", + } +} + +fn code_rpc(s: &str) -> RpcError { + match s { + "notfound" => RpcError::NotFound, + "denied" => RpcError::Denied, + _ => RpcError::Internal, + } +} + +impl EnvelopeCodec for LineCodec { + // --- server direction -------------------------------------------------- + fn decode(&self, frame: &[u8]) -> Result { + let s = utf8(frame)?; + let (tag, rest) = s.split_once('\n').unwrap_or((s, "")); + match tag { + "REQ" => { + let (id, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (method, params) = r.split_once('\n').unwrap_or((r, "")); + Ok(Inbound::Request { + id: id.parse().map_err(|_| CodecError::Malformed)?, + method: method.to_string(), + params: payload_from(params), + }) + } + "SUB" => { + let (id, topic) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + Ok(Inbound::Subscribe { + id: id.parse().map_err(|_| CodecError::Malformed)?, + topic: topic.to_string(), + }) + } + "UNSUB" => Ok(Inbound::Unsubscribe { + sub: rest.to_string(), + }), + "WRITE" => { + let (topic, payload) = rest.split_once('\n').unwrap_or((rest, "")); + Ok(Inbound::Write { + topic: topic.to_string(), + payload: payload_from(payload), + }) + } + "PING" => Ok(Inbound::Ping), + _ => Err(CodecError::Malformed), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError> { + let s = match msg { + Outbound::Reply { id, result } => match result { + Ok(data) => format!("REPLY\n{}\nOK\n{}", id, utf8(&data)?), + Err(e) => format!("REPLY\n{}\nERR\n{}", id, rpc_code(&e)), + }, + Outbound::Event { sub, seq, data } => { + format!("EVENT\n{}\n{}\n{}", sub, seq, utf8(&data)?) + } + Outbound::Snapshot { topic, data } => format!("SNAP\n{}\n{}", topic, utf8(&data)?), + Outbound::Pong => "PONG".to_string(), + }; + out.extend_from_slice(s.as_bytes()); + Ok(()) + } + + // --- client direction (Phase 2 dual) ----------------------------------- + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + let s = match msg { + Inbound::Request { id, method, params } => { + format!("REQ\n{}\n{}\n{}", id, method, utf8(¶ms)?) + } + Inbound::Subscribe { id, topic } => format!("SUB\n{}\n{}", id, topic), + Inbound::Unsubscribe { sub } => format!("UNSUB\n{}", sub), + Inbound::Write { topic, payload } => format!("WRITE\n{}\n{}", topic, utf8(&payload)?), + Inbound::Ping => "PING".to_string(), + }; + out.extend_from_slice(s.as_bytes()); + Ok(()) + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + let s = utf8(frame)?; + let (tag, rest) = s.split_once('\n').unwrap_or((s, "")); + match tag { + "REPLY" => { + let (id, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (kind, tail) = r.split_once('\n').unwrap_or((r, "")); + let result = match kind { + "OK" => Ok(payload_from(tail)), + "ERR" => Err(code_rpc(tail)), + _ => return Err(CodecError::Malformed), + }; + Ok(Outbound::Reply { + id: id.parse().map_err(|_| CodecError::Malformed)?, + result, + }) + } + "EVENT" => { + let (sub, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (seq, data) = r.split_once('\n').unwrap_or((r, "")); + Ok(Outbound::Event { + sub, + seq: seq.parse().map_err(|_| CodecError::Malformed)?, + data: payload_from(data), + }) + } + "SNAP" => { + let (topic, data) = rest.split_once('\n').unwrap_or((rest, "")); + Ok(Outbound::Snapshot { + topic, + data: payload_from(data), + }) + } + "PONG" => Ok(Outbound::Pong), + _ => Err(CodecError::Malformed), + } + } +} + +// =========================================================================== +// Echo dispatch (Layer 3) +// =========================================================================== + +/// Shared log of `(topic, payload)` writes the server received, for assertion. +type WriteLog = Arc)>>>; + +struct EchoDispatch { + writes: WriteLog, +} + +impl Dispatch for EchoDispatch { + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + Box::pin(async { Ok(SessionCtx::default()) }) + } + + fn call<'a>( + &'a self, + _ctx: &'a SessionCtx, + _method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + // Echo the params straight back. + Box::pin(async move { Ok(params) }) + } + + fn subscribe( + &self, + _ctx: &SessionCtx, + topic: &str, + ) -> Result, RpcError> { + // Three synthetic updates derived from the topic, then end. + let items: Vec = (1..=3) + .map(|i| payload_from(&format!("{topic}#{i}"))) + .collect(); + Ok(Box::pin(futures::stream::iter(items))) + } + + fn write<'a>( + &'a self, + _ctx: &'a SessionCtx, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + let writes = self.writes.clone(); + let topic = topic.to_string(); + Box::pin(async move { + writes.lock().unwrap().push((topic, payload.to_vec())); + Ok(()) + }) + } +} + +// =========================================================================== +// The exit-criterion test +// =========================================================================== + +#[tokio::test] +async fn echo_roundtrip_rpc_streaming_and_write() { + let (listener, dialer) = transport_pair(); + let writes = Arc::new(Mutex::new(Vec::new())); + let dispatch = Arc::new(EchoDispatch { + writes: writes.clone(), + }); + + // Server engine on the runner's stand-in (a task — the engine itself is + // spawn-free; the test harness drives the one returned future). + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig::default(), + )); + + // Client engine: handshake-as-caller (Ping/Pong), no reconnect for the test. + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: Duration::from_millis(10), + sends_hello: true, + }, + ); + let client = tokio::spawn(client_fut); + + // 1) RPC: one request → one reply (echo). + let reply = handle.call("echo", payload_from("hello")).await.unwrap(); + assert_eq!(&*reply, b"hello", "RPC reply should echo the params"); + + // 2) Streaming: subscribe → three events routed back by sub id. + let mut stream = handle.subscribe("temp").unwrap(); + let e1 = stream.next().await.expect("event 1"); + let e2 = stream.next().await.expect("event 2"); + let e3 = stream.next().await.expect("event 3"); + assert_eq!(&*e1, b"temp#1"); + assert_eq!(&*e2, b"temp#2"); + assert_eq!(&*e3, b"temp#3"); + + // 3) Fire-and-forget write, then a follow-up RPC. FIFO on the single + // connection guarantees the write frame is processed before the reply + // returns, so the write is observable by the time the call resolves. + handle.write("room", payload_from("on")).unwrap(); + let _ = handle.call("noop", payload_from("x")).await.unwrap(); + let got = writes.lock().unwrap().clone(); + assert_eq!( + got, + vec![("room".to_string(), b"on".to_vec())], + "server should have received the write" + ); + + // Teardown: dropping the only handle stops the client engine gracefully; + // the server loop is unbounded, so abort it. + drop(handle); + drop(stream); + client + .await + .expect("client engine should stop cleanly when handles drop"); + server.abort(); +} From 92046920e715cb7a965e435e63a32eabe0cdc04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 18:41:38 +0000 Subject: [PATCH 02/34] port aimdb_client to the shared session engine --- Cargo.lock | 1 + Makefile | 2 + aimdb-client/Cargo.toml | 10 +- aimdb-client/src/engine.rs | 173 ++++++++++++++++ aimdb-client/src/lib.rs | 4 + aimdb-client/tests/aimx_session.rs | 191 ++++++++++++++++++ aimdb-core/Cargo.toml | 6 +- aimdb-core/src/session/aimx/codec.rs | 244 +++++++++++++++++++++++ aimdb-core/src/session/aimx/mod.rs | 14 ++ aimdb-core/src/session/aimx/transport.rs | 105 ++++++++++ aimdb-core/src/session/mod.rs | 5 + 11 files changed, 752 insertions(+), 3 deletions(-) create mode 100644 aimdb-client/src/engine.rs create mode 100644 aimdb-client/tests/aimx_session.rs create mode 100644 aimdb-core/src/session/aimx/codec.rs create mode 100644 aimdb-core/src/session/aimx/mod.rs create mode 100644 aimdb-core/src/session/aimx/transport.rs diff --git a/Cargo.lock b/Cargo.lock index 650947a..b146500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -50,6 +50,7 @@ version = "0.6.0" dependencies = [ "aimdb-core", "anyhow", + "futures", "serde", "serde_json", "tempfile", diff --git a/Makefile b/Makefile index 19cab8e..18987dd 100644 --- a/Makefile +++ b/Makefile @@ -116,6 +116,8 @@ test: cargo test --package aimdb-core --lib --features "std,connector-session" session:: @printf "$(YELLOW) → Testing aimdb-core connector-session engines (session_engine)$(NC)\n" cargo test --package aimdb-core --features "std,connector-session" --test session_engine + @printf "$(YELLOW) → Testing aimdb-client (engine-based AimX client + UDS round-trip)$(NC)\n" + cargo test --package aimdb-client @printf "$(YELLOW) → Testing tokio adapter$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing tokio adapter (with metrics)$(NC)\n" diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index cc9cf1d..46d4251 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -14,8 +14,13 @@ metrics = ["aimdb-core/metrics"] profiling = ["aimdb-core/profiling"] [dependencies] -# Core dependencies - protocol types from aimdb-core -aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = ["std"] } +# Core dependencies - protocol types from aimdb-core. `connector-session` +# exposes the shared session engine (`run_client`/`ClientHandle`) plus the AimX +# UDS transport + codec that the engine-based client (`crate::engine`) builds on. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ + "std", + "connector-session", +] } # Serialization serde = { version = "1", features = ["derive"] } @@ -23,6 +28,7 @@ serde_json = "1" # Async runtime (must match AimDB runtime) tokio = { version = "1", features = ["net", "io-util", "macros", "fs"] } +futures = { version = "0.3", default-features = false, features = ["alloc"] } # Error handling anyhow = "1" diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs new file mode 100644 index 0000000..85b6423 --- /dev/null +++ b/aimdb-client/src/engine.rs @@ -0,0 +1,173 @@ +//! Engine-based AimX client (Phase 3, client-first). +//! +//! Rebuilds the client on the shared session engine: a [`UdsDialer`] + the +//! symmetric [`AimxCodec`] drive [`run_client`], which owns the wire, the +//! request-id demux, and (optionally) reconnect. The public surface is the +//! cheap-clone [`ClientHandle`] plus typed convenience wrappers and +//! per-subscription [`futures::Stream`]s — a deliberate **break** from the old +//! synchronous [`crate::connection::AimxClient`] (`&mut self`, single global +//! `receive_event()` queue), which stays until the server port retires it. +//! +//! `run_client` is itself spawn-free (it returns a future for a runner to +//! drive); this convenience layer is a *client application*, so it drives the +//! engine on a `tokio::spawn`ed task held by [`AimxConnection`]. Dropping the +//! connection drops the handle, which stops the engine gracefully. + +use std::path::Path; + +use futures::StreamExt; +use serde::Serialize; +use serde_json::json; +use tokio::task::JoinHandle; + +use aimdb_core::session::aimx::{AimxCodec, UdsDialer}; +use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; + +use crate::error::{ClientError, ClientResult}; +use crate::protocol::{RecordMetadata, WelcomeMessage}; + +/// A live connection to an AimDB instance over the shared session engine. +/// +/// Holds the cheap-clone [`ClientHandle`] (use [`handle`](Self::handle) to issue +/// raw `call`/`subscribe`/`write`) and the driven engine task. Typed wrappers +/// cover the common AimX methods. +pub struct AimxConnection { + handle: ClientHandle, + engine: JoinHandle<()>, + server_info: WelcomeMessage, +} + +impl AimxConnection { + /// Dial `socket_path`, start the engine, and complete the `hello` handshake. + /// + /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than + /// a privileged frame — the reshaped wire's deliberate simplification. A dial + /// failure surfaces here as the `hello` call failing (the engine runs with + /// reconnect off so connect-time errors are prompt). + pub async fn connect(socket_path: impl AsRef) -> ClientResult { + let dialer = UdsDialer::new(socket_path.as_ref()); + let config = ClientConfig { + reconnect: false, + sends_hello: false, + ..ClientConfig::default() + }; + let (handle, engine_fut) = run_client(dialer, AimxCodec, config); + let engine = tokio::spawn(engine_fut); + + // Handshake-as-RPC: the server replies with its Welcome. + let hello = json!({ "client": "aimdb-client" }); + let reply = handle + .call("hello", to_payload(&hello)?) + .await + .map_err(|_| { + ClientError::connection_failed( + socket_path.as_ref().display().to_string(), + "handshake failed (engine could not reach server)", + ) + })?; + let server_info: WelcomeMessage = from_payload(&reply)?; + + Ok(Self { + handle, + engine, + server_info, + }) + } + + /// The raw engine handle — `call` / `subscribe` / `write` for methods the + /// typed wrappers below don't cover. + pub fn handle(&self) -> &ClientHandle { + &self.handle + } + + /// The server's `Welcome` (permissions, writable records) from the handshake. + pub fn server_info(&self) -> &WelcomeMessage { + &self.server_info + } + + /// List all registered records. + pub async fn list_records(&self) -> ClientResult> { + let reply = self.call("record.list", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Get a record's current value. + pub async fn get_record(&self, name: &str) -> ClientResult { + let reply = self + .call("record.get", to_payload(&json!({ "name": name }))?) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Set a record's value (RPC; awaits the server's reply). + pub async fn set_record( + &self, + name: &str, + value: serde_json::Value, + ) -> ClientResult { + let reply = self + .call( + "record.set", + to_payload(&json!({ "name": name, "value": value }))?, + ) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Subscribe to a record's updates. Returns a stream of decoded JSON values; + /// the engine routes events back by the request id it owns, so there is no + /// `subscription_id` to track. Dropping the stream stops local delivery. + pub fn subscribe(&self, name: &str) -> ClientResult> { + let raw = self.handle.subscribe(name).map_err(rpc_err)?; + // Decode each Payload into a JSON value; drop any that fail to parse. + let decoded = raw.filter_map(|p| async move { serde_json::from_slice(&p).ok() }); + Ok(Box::pin(decoded)) + } + + /// Fire-and-forget write to a record (no reply; routes through the server's + /// producer/arbiter path — single-writer-per-key stays intact). + pub fn write_record(&self, name: &str, value: serde_json::Value) -> ClientResult<()> { + self.handle + .write(name, to_payload(&json!({ "value": value }))?) + .map_err(rpc_err) + } + + /// Issue a raw RPC and map a transport/engine failure to [`ClientError`]. + async fn call(&self, method: &str, params: Payload) -> ClientResult { + self.handle.call(method, params).await.map_err(rpc_err) + } +} + +impl Drop for AimxConnection { + fn drop(&mut self) { + // Dropping `handle` already stops the engine; abort is just promptness. + self.engine.abort(); + } +} + +/// Serialize a value into a record-value [`Payload`]. +fn to_payload(value: &T) -> ClientResult { + Ok(Payload::from(serde_json::to_vec(value)?.as_slice())) +} + +/// The JSON literal `null` as a [`Payload`] — for methods that take no params. +fn null_payload() -> Payload { + Payload::from(&b"null"[..]) +} + +/// Decode a [`Payload`] into a typed value. +fn from_payload(bytes: &[u8]) -> ClientResult { + Ok(serde_json::from_slice(bytes)?) +} + +/// Map an engine [`RpcError`] onto a [`ClientError`]. +fn rpc_err(e: RpcError) -> ClientError { + match e { + RpcError::NotFound => { + ClientError::server_error("not_found", "method or record not found", None) + } + RpcError::Denied => ClientError::server_error("denied", "permission denied", None), + // `Internal` today, plus any future non-exhaustive variant. + _ => ClientError::server_error("internal", "engine/transport failure", None), + } +} diff --git a/aimdb-client/src/lib.rs b/aimdb-client/src/lib.rs index 0592dfd..70412d4 100644 --- a/aimdb-client/src/lib.rs +++ b/aimdb-client/src/lib.rs @@ -35,12 +35,16 @@ pub mod connection; pub mod discovery; +pub mod engine; pub mod error; pub mod protocol; // Re-export main types for convenience pub use connection::{AimxClient, DrainResponse}; +// Engine-based client (Phase 3) — the shared-session-engine replacement for the +// synchronous `AimxClient`. Both coexist until the server port retires the old one. pub use discovery::{discover_instances, find_instance, InstanceInfo}; +pub use engine::AimxConnection; pub use error::{ClientError, ClientResult}; pub use protocol::{ cli_hello, parse_message, serialize_message, Event, EventMessage, RecordMetadata, Request, diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs new file mode 100644 index 0000000..8d3b12b --- /dev/null +++ b/aimdb-client/tests/aimx_session.rs @@ -0,0 +1,191 @@ +//! Phase 3 client-first exit criterion: the engine-based [`AimxConnection`] +//! round-trips the AimX-v2 wire — `hello` handshake, RPC (`record.get`/`set`), +//! a streaming subscription, and a fire-and-forget write — against a `serve` +//! engine test-server over a **real Unix-domain socket**. +//! +//! The server side uses the production [`AimxCodec`] + [`UdsConnection`]; the +//! only test-local pieces are a `UdsListener` (the accepting transport half, +//! deferred from core to the server port) and a small echo-ish `Dispatch`. This +//! proves the reshaped wire end-to-end before the real server `Dispatch` exists. + +use std::sync::{Arc, Mutex}; + +use aimdb_client::AimxConnection; +use aimdb_core::session::aimx::{AimxCodec, UdsConnection}; +use aimdb_core::session::{ + serve, AuthError, BoxFut, BoxStream, Connection, Dispatch, Listener, Payload, PeerInfo, + RpcError, SessionConfig, SessionCtx, TransportError, TransportResult, +}; +use serde_json::json; +use tokio::net::UnixListener; + +// --------------------------------------------------------------------------- +// Test-local accepting transport (the `UdsListener` core gains in the server port) +// --------------------------------------------------------------------------- + +struct TestUdsListener { + inner: UnixListener, +} + +impl Listener for TestUdsListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let (stream, _addr) = self.inner.accept().await.map_err(|_| TransportError::Io)?; + Ok(Box::new(UdsConnection::new(stream)) as Box) + }) + } +} + +// --------------------------------------------------------------------------- +// Minimal AimX dispatch (stand-in for the real server `Dispatch`, server port) +// --------------------------------------------------------------------------- + +type WriteLog = Arc)>>>; + +struct TestDispatch { + writes: WriteLog, +} + +fn payload(v: serde_json::Value) -> Payload { + Payload::from(serde_json::to_vec(&v).unwrap().as_slice()) +} + +impl Dispatch for TestDispatch { + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + Box::pin(async { Ok(SessionCtx::default()) }) + } + + fn call<'a>( + &'a self, + _ctx: &'a SessionCtx, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + let method = method.to_string(); + Box::pin(async move { + match method.as_str() { + "hello" => Ok(payload(json!({ + "version": "2.0", + "server": "test", + "permissions": ["read", "write"], + "writable_records": ["temp"], + }))), + "record.list" => Ok(payload(json!([{ "name": "temp", "writable": true }]))), + "record.get" => { + // Echo the requested name back as a fixed value. + let v: serde_json::Value = serde_json::from_slice(¶ms).unwrap_or_default(); + let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(""); + if name == "temp" { + Ok(payload(json!(42))) + } else { + Err(RpcError::NotFound) + } + } + "record.set" => Ok(payload(json!({ "ok": true }))), + _ => Err(RpcError::NotFound), + } + }) + } + + fn subscribe( + &self, + _ctx: &SessionCtx, + topic: &str, + ) -> Result, RpcError> { + // Three synthetic updates derived from the topic, then end. + let items: Vec = (1..=3) + .map(|i| payload(json!({ "topic": topic, "n": i }))) + .collect(); + Ok(Box::pin(futures::stream::iter(items))) + } + + fn write<'a>( + &'a self, + _ctx: &'a SessionCtx, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + let writes = self.writes.clone(); + let topic = topic.to_string(); + Box::pin(async move { + writes.lock().unwrap().push((topic, payload.to_vec())); + Ok(()) + }) + } +} + +// --------------------------------------------------------------------------- +// The exit-criterion test +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn aimx_client_roundtrip_over_uds() { + use futures::StreamExt; + + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + // Bind before connecting so the client's dial always finds the socket. + let listener = TestUdsListener { + inner: UnixListener::bind(&sock).unwrap(), + }; + let writes: WriteLog = Arc::new(Mutex::new(Vec::new())); + let dispatch = Arc::new(TestDispatch { + writes: writes.clone(), + }); + let server = tokio::spawn(serve( + listener, + Arc::new(AimxCodec), + dispatch, + SessionConfig::default(), + )); + + // Connect: this performs the `hello` handshake and captures the Welcome. + let conn = AimxConnection::connect(&sock).await.expect("connect"); + assert_eq!(conn.server_info().server, "test"); + assert!(conn + .server_info() + .permissions + .contains(&"write".to_string())); + + // RPC: record.get + let temp = conn.get_record("temp").await.expect("get temp"); + assert_eq!(temp, json!(42)); + + // RPC: record.get on a missing record maps to a server error. + assert!(conn.get_record("missing").await.is_err()); + + // RPC: record.set + let set = conn.set_record("temp", json!(7)).await.expect("set temp"); + assert_eq!(set, json!({ "ok": true })); + + // Streaming subscription: three events routed back by the engine-owned id. + let mut stream = conn.subscribe("temp").expect("subscribe"); + for n in 1..=3 { + let ev = stream.next().await.expect("event"); + assert_eq!(ev, json!({ "topic": "temp", "n": n })); + } + + // Fire-and-forget write, then a follow-up RPC. FIFO over the single + // connection guarantees the write is processed before the reply returns. + conn.write_record("temp", json!("on")).expect("write"); + let _ = conn.get_record("temp").await.expect("get after write"); + let got = writes.lock().unwrap().clone(); + assert_eq!( + got.len(), + 1, + "server should have received exactly one write" + ); + assert_eq!(got[0].0, "temp"); + assert_eq!( + serde_json::from_slice::(&got[0].1).unwrap(), + json!({ "value": "on" }) + ); + + drop(conn); // stops the client engine + server.abort(); +} diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index b207f31..9aca642 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -80,7 +80,11 @@ serde = { workspace = true, optional = true } # Error handling - only for std environments thiserror = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } +# `raw_value` lets the connector-session `EnvelopeCodec` splice an already- +# serialized record-value `Payload` into a JSON envelope without re-escaping +# (037 Decision 1). Only compiled when `serde_json` is pulled in (std / +# json-serialize); the no_std `connector-session` contracts build never sees it. +serde_json = { workspace = true, optional = true, features = ["raw_value"] } # Async runtime - only for std environments with remote access tokio = { workspace = true, features = [ diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs new file mode 100644 index 0000000..99143d0 --- /dev/null +++ b/aimdb-core/src/session/aimx/codec.rs @@ -0,0 +1,244 @@ +//! AimX-v2 NDJSON envelope codec (Phase 3, std-only). +//! +//! The reshaped AimX wire: one JSON object per line, tagged by a `"t"` field, +//! mapping verbatim onto the engine's role-neutral [`Inbound`]/[`Outbound`] +//! message set. This is **not** backward-compatible with the legacy AimX wire — +//! the no-compat decision lets the wire follow the engine's clean model instead +//! of the engine bending to preserve the old framing (see +//! `docs/design/detailed/038-phase3-aimx-client.md`): +//! +//! - `record.subscribe` is an engine-native [`Inbound::Subscribe`] keyed by the +//! request `id`; there is **no** `{"subscription_id":"sub-N"}` ack and events +//! carry that `id` back as [`Outbound::Event::sub`] — the client owns the id. +//! - events carry only `{sub, seq, data}`; the legacy server-side `timestamp` / +//! `dropped` fields are gone (a client stamps on receipt if it cares). +//! - the Hello/Welcome handshake is a normal `call("hello", …)` over the client +//! handle, so `authenticate` stays peer-only — no privileged handshake frame. +//! +//! Per [037](../../../../docs/design/detailed/037-phase0-contracts.md) +//! Decision 1 the record-value `Payload` is spliced into / sliced out of the +//! textual envelope verbatim via [`serde_json::value::RawValue`] — no +//! intermediate `Value` tree, no re-escaping, one serde pass. + +use alloc::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; + +use crate::session::{CodecError, EnvelopeCodec, Inbound, Outbound, Payload, RpcError}; + +/// The zero-sized AimX-v2 NDJSON codec. +pub struct AimxCodec; + +/// One wire frame. A single flat, all-optional struct (rather than an internally +/// tagged enum, which cannot borrow) so both directions can zero-copy-borrow the +/// `&str` / [`RawValue`] fields out of the frame slice. Each logical message +/// fills the subset of fields its `"t"` tag implies; the rest skip-serialize. +#[derive(Serialize, Deserialize)] +struct Frame<'a> { + t: &'a str, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + seq: Option, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + method: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + topic: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + sub: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + params: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + payload: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + data: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + ok: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + err: Option<&'a str>, +} + +impl<'a> Frame<'a> { + /// An empty frame with only the tag set; fill the relevant fields after. + fn tagged(t: &'a str) -> Self { + Self { + t, + id: None, + seq: None, + method: None, + topic: None, + sub: None, + params: None, + payload: None, + data: None, + ok: None, + err: None, + } + } +} + +/// Borrow a [`RawValue`] view over already-serialized payload bytes (validates +/// the bytes are one JSON value, but does not re-serialize the structure). +fn as_raw(bytes: &[u8]) -> Result<&RawValue, CodecError> { + serde_json::from_slice(bytes).map_err(|_| CodecError::Malformed) +} + +/// Slice a [`RawValue`]'s verbatim JSON bytes back out into an owned [`Payload`]; +/// a missing field decodes as the JSON literal `null`. +fn payload_of(raw: Option<&RawValue>) -> Payload { + match raw { + Some(r) => Arc::from(r.get().as_bytes()), + None => Arc::from(&b"null"[..]), + } +} + +fn err_code(e: &RpcError) -> &'static str { + match e { + RpcError::NotFound => "not_found", + RpcError::Denied => "denied", + RpcError::Internal => "internal", + } +} + +fn code_err(s: &str) -> RpcError { + match s { + "not_found" => RpcError::NotFound, + "denied" => RpcError::Denied, + _ => RpcError::Internal, + } +} + +fn write_frame(out: &mut alloc::vec::Vec, frame: &Frame<'_>) -> Result<(), CodecError> { + // `serde_json::to_writer` is gated behind serde_json's `std` feature (the + // workspace builds it on `alloc` only), so serialize via `to_vec` and splice. + let bytes = serde_json::to_vec(frame).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) +} + +impl EnvelopeCodec for AimxCodec { + // --- server direction: read a request, write a reply/event ------------- + fn decode(&self, frame: &[u8]) -> Result { + let f: Frame = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match f.t { + "req" => Ok(Inbound::Request { + id: f.id.ok_or(CodecError::Malformed)?, + method: f.method.ok_or(CodecError::Malformed)?.into(), + params: payload_of(f.params), + }), + "sub" => Ok(Inbound::Subscribe { + id: f.id.ok_or(CodecError::Malformed)?, + topic: f.topic.ok_or(CodecError::Malformed)?.into(), + }), + "unsub" => Ok(Inbound::Unsubscribe { + sub: f.sub.ok_or(CodecError::Malformed)?.into(), + }), + "write" => Ok(Inbound::Write { + topic: f.topic.ok_or(CodecError::Malformed)?.into(), + payload: payload_of(f.payload), + }), + "ping" => Ok(Inbound::Ping), + _ => Err(CodecError::Malformed), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut alloc::vec::Vec) -> Result<(), CodecError> { + match msg { + Outbound::Reply { id, result } => { + let mut frame = Frame::tagged("reply"); + frame.id = Some(id); + match &result { + Ok(data) => { + let raw = as_raw(data)?; + frame.ok = Some(raw); + write_frame(out, &frame) + } + Err(e) => { + frame.err = Some(err_code(e)); + write_frame(out, &frame) + } + } + } + Outbound::Event { sub, seq, data } => { + let raw = as_raw(&data)?; + let mut frame = Frame::tagged("event"); + frame.sub = Some(sub); + frame.seq = Some(seq); + frame.data = Some(raw); + write_frame(out, &frame) + } + Outbound::Snapshot { topic, data } => { + let raw = as_raw(&data)?; + let mut frame = Frame::tagged("snap"); + frame.topic = Some(topic); + frame.data = Some(raw); + write_frame(out, &frame) + } + Outbound::Pong => write_frame(out, &Frame::tagged("pong")), + } + } + + // --- client direction: write a request, read a reply/event ------------- + fn encode_inbound( + &self, + msg: Inbound, + out: &mut alloc::vec::Vec, + ) -> Result<(), CodecError> { + match msg { + Inbound::Request { id, method, params } => { + let raw = as_raw(¶ms)?; + let mut frame = Frame::tagged("req"); + frame.id = Some(id); + frame.method = Some(&method); + frame.params = Some(raw); + write_frame(out, &frame) + } + Inbound::Subscribe { id, topic } => { + let mut frame = Frame::tagged("sub"); + frame.id = Some(id); + frame.topic = Some(&topic); + write_frame(out, &frame) + } + Inbound::Unsubscribe { sub } => { + let mut frame = Frame::tagged("unsub"); + frame.sub = Some(&sub); + write_frame(out, &frame) + } + Inbound::Write { topic, payload } => { + let raw = as_raw(&payload)?; + let mut frame = Frame::tagged("write"); + frame.topic = Some(&topic); + frame.payload = Some(raw); + write_frame(out, &frame) + } + Inbound::Ping => write_frame(out, &Frame::tagged("ping")), + } + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + let f: Frame<'a> = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match f.t { + "reply" => { + let id = f.id.ok_or(CodecError::Malformed)?; + let result = match (f.ok, f.err) { + (Some(_), _) => Ok(payload_of(f.ok)), + (None, Some(code)) => Err(code_err(code)), + (None, None) => return Err(CodecError::Malformed), + }; + Ok(Outbound::Reply { id, result }) + } + "event" => Ok(Outbound::Event { + sub: f.sub.ok_or(CodecError::Malformed)?, + seq: f.seq.ok_or(CodecError::Malformed)?, + data: payload_of(f.data), + }), + "snap" => Ok(Outbound::Snapshot { + topic: f.topic.ok_or(CodecError::Malformed)?, + data: payload_of(f.data), + }), + "pong" => Ok(Outbound::Pong), + _ => Err(CodecError::Malformed), + } + } +} diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs new file mode 100644 index 0000000..62ccba2 --- /dev/null +++ b/aimdb-core/src/session/aimx/mod.rs @@ -0,0 +1,14 @@ +//! AimX-v2 transport + codec (Phase 3, std-only) — the concrete substrate the +//! shared session engine rides for AimX remote access. +//! +//! Client-first scope: ships the dialing transport ([`UdsDialer`] + +//! [`UdsConnection`]) and the symmetric [`AimxCodec`] (both engine directions), +//! enough to drive `run_client`. The accepting `UdsListener` and the server-side +//! `Dispatch` land with the server port; the substrate here is role-neutral and +//! is reused by both sides. + +mod codec; +mod transport; + +pub use codec::AimxCodec; +pub use transport::{UdsConnection, UdsDialer}; diff --git a/aimdb-core/src/session/aimx/transport.rs b/aimdb-core/src/session/aimx/transport.rs new file mode 100644 index 0000000..a0df6aa --- /dev/null +++ b/aimdb-core/src/session/aimx/transport.rs @@ -0,0 +1,105 @@ +//! AimX UDS transport (Phase 3, std-only) — a [`Connection`] over a Unix-domain +//! socket with NDJSON framing in the transport: one line == one logical frame. +//! +//! Client-first scope: this ships the dialing half ([`UdsDialer`] + +//! [`UdsConnection`]) that the proactive `run_client` engine drives. The +//! accepting half (`UdsListener`) lands with the server port — the substrate is +//! role-neutral, so the same [`UdsConnection`] will serve both sides. + +use std::path::PathBuf; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::UnixStream; + +use crate::session::{BoxFut, Connection, Dialer, PeerInfo, TransportError, TransportResult}; + +/// A framed bidirectional pipe over a Unix-domain socket. Framing lives in the +/// transport: [`recv`](Connection::recv) returns one newline-delimited frame +/// (newline stripped); [`send`](Connection::send) appends the newline. +pub struct UdsConnection { + reader: BufReader, + writer: OwnedWriteHalf, + peer: PeerInfo, +} + +impl UdsConnection { + /// Wrap an already-connected [`UnixStream`] (used by both the dialer here and + /// the future server-side listener). + pub fn new(stream: UnixStream) -> Self { + let (read_half, write_half) = stream.into_split(); + Self { + reader: BufReader::new(read_half), + writer: write_half, + peer: PeerInfo::default(), + } + } +} + +impl Connection for UdsConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { + let mut line = String::new(); + match self.reader.read_line(&mut line).await { + Ok(0) => Ok(None), // EOF — peer closed + Ok(_) => { + // Strip the trailing '\n' (and a stray '\r' if present); the + // frame is the line content, the codec owns the rest. + while matches!(line.as_bytes().last(), Some(b'\n' | b'\r')) { + line.pop(); + } + Ok(Some(line.into_bytes())) + } + Err(_) => Err(TransportError::Io), + } + }) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + Box::pin(async move { + self.writer + .write_all(frame) + .await + .map_err(|_| TransportError::Closed)?; + self.writer + .write_all(b"\n") + .await + .map_err(|_| TransportError::Closed)?; + self.writer + .flush() + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// The initiating (client) side: dials a Unix-domain socket and yields a +/// [`UdsConnection`]. Cheap to clone the path, so `run_client` can redial on +/// reconnect. +pub struct UdsDialer { + socket_path: PathBuf, +} + +impl UdsDialer { + /// Dial the socket at `socket_path` on each [`connect`](Dialer::connect). + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + } + } +} + +impl Dialer for UdsDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let stream = UnixStream::connect(&self.socket_path) + .await + .map_err(|_| TransportError::Io)?; + Ok(Box::new(UdsConnection::new(stream)) as Box) + }) + } +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 5a0a7dc..93595d9 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -40,6 +40,11 @@ mod client; #[cfg(feature = "std")] mod server; +// Concrete AimX-v2 substrate (UDS transport + NDJSON codec), std-only. Phase 3 +// client-first: the dialing half + symmetric codec that `run_client` drives. +#[cfg(feature = "std")] +pub mod aimx; + #[cfg(feature = "std")] pub use client::{run_client, ClientConfig, ClientHandle}; #[cfg(feature = "std")] From f108bc5c5e8eadedc5d26c98674d73b56d9a15a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 18:54:09 +0000 Subject: [PATCH 03/34] feat(session): split Dispatch into shared Dispatch + per-connection Session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 server-port prep (issue #39). The AimX wire reshape dissolved every seam except one: record.drain needs lazy per-connection cursors, but Dispatch is a shared Arc with &self — nowhere for mutable per-connection state. Split the dispatch role: - Dispatch (Send + Sync, one Arc per server): authenticate + open() factory. - Session (Send, one Box per accepted connection): call/subscribe/write on &mut self. run_session owns the Box and threads &mut into it. subscribe is defaulted to NotFound (its 'static stream is side-neutral). Additive + object-safe (mirrors the Phase-2 encode_inbound/decode_outbound precedent); recorded in 037. Engine round-trip + AimX client tests still green; contracts still cross-compile to thumbv7em. Co-Authored-By: Claude Opus 4.8 (1M context) --- aimdb-client/tests/aimx_session.rs | 27 +++-- aimdb-core/src/session/mod.rs | 79 ++++++++----- aimdb-core/src/session/server.rs | 15 ++- aimdb-core/tests/session_engine.rs | 28 +++-- docs/design/detailed/037-phase0-contracts.md | 112 +++++++++++++++++++ 5 files changed, 211 insertions(+), 50 deletions(-) create mode 100644 docs/design/detailed/037-phase0-contracts.md diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index 8d3b12b..2629f1a 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -14,7 +14,7 @@ use aimdb_client::AimxConnection; use aimdb_core::session::aimx::{AimxCodec, UdsConnection}; use aimdb_core::session::{ serve, AuthError, BoxFut, BoxStream, Connection, Dispatch, Listener, Payload, PeerInfo, - RpcError, SessionConfig, SessionCtx, TransportError, TransportResult, + RpcError, Session, SessionConfig, SessionCtx, TransportError, TransportResult, }; use serde_json::json; use tokio::net::UnixListener; @@ -59,9 +59,21 @@ impl Dispatch for TestDispatch { Box::pin(async { Ok(SessionCtx::default()) }) } + fn open(&self, _ctx: &SessionCtx) -> Box { + Box::new(TestSession { + writes: self.writes.clone(), + }) + } +} + +/// Per-connection session for the test dispatch. +struct TestSession { + writes: WriteLog, +} + +impl Session for TestSession { fn call<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, method: &'a str, params: Payload, ) -> BoxFut<'a, Result> { @@ -91,11 +103,7 @@ impl Dispatch for TestDispatch { }) } - fn subscribe( - &self, - _ctx: &SessionCtx, - topic: &str, - ) -> Result, RpcError> { + fn subscribe(&mut self, topic: &str) -> Result, RpcError> { // Three synthetic updates derived from the topic, then end. let items: Vec = (1..=3) .map(|i| payload(json!({ "topic": topic, "n": i }))) @@ -104,8 +112,7 @@ impl Dispatch for TestDispatch { } fn write<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, topic: &'a str, payload: Payload, ) -> BoxFut<'a, Result<(), RpcError>> { diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 93595d9..f975213 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -260,12 +260,24 @@ pub trait Dialer: Send { // =========================================================================== // Layer 3 — dispatch (the semantics). Doc 034 § Layer 3 + § EnvelopeCodec. -// RPC and streaming unify in ONE trait (Decision 2): three reply cardinalities -// — `call` (one) / `subscribe` (many) / `write` (none). +// RPC and streaming unify in ONE per-connection role (Decision 2): three reply +// cardinalities — `call` (one) / `subscribe` (many) / `write` (none). +// +// The role is split across two traits so the shared, immutable half (one +// `Arc` per server) and the per-connection mutable half (one +// `Box` per accepted connection) each own what they need: +// +// - [`Dispatch`] — `Send + Sync`, shared: `authenticate` + an `open` factory. +// - [`Session`] — `Send`, per-connection: `call` / `subscribe` / `write` on +// `&mut self`, so a connection can hold mutable state (e.g. `record.drain`'s +// lazy per-record cursors — the one seam the AimX wire reshape did not +// dissolve) without a lock. See doc 037 (the additive server-port refinement, +// mirroring the Phase-2 `encode_inbound`/`decode_outbound` precedent). // =========================================================================== -/// The application dispatch: authenticate a session, then serve calls, -/// subscriptions, and writes against a [`SessionCtx`]. +/// The shared application dispatch: authenticate a connection, then open a +/// per-connection [`Session`]. One `Arc` is shared across every +/// connection a server accepts, so it stays `Sync` and behind `&self`. pub trait Dispatch: Send + Sync { /// Resolve a [`SessionCtx`] from peer metadata and/or the first frame /// (WS supplies pre-resolved identity via [`PeerInfo`]; UDS reads a Hello). @@ -275,28 +287,42 @@ pub trait Dispatch: Send + Sync { first: Option<&'a [u8]>, ) -> BoxFut<'a, Result>; + /// Open the per-connection [`Session`] once, after [`authenticate`]. The + /// returned session owns the connection's mutable dispatch state (drain + /// cursors today, per-session auth identity in Phase 4) that the shared + /// `Arc` cannot hold behind `&self`; the engine threads `&mut` into it. + fn open(&self, ctx: &SessionCtx) -> Box; +} + +/// The per-connection session: serves calls, subscriptions, and writes for one +/// accepted [`Connection`]. The engine ([`run_session`]) owns the +/// `Box` and threads `&mut self` into each method, so a session can +/// hold per-connection mutable state without a lock — while the shared, +/// immutable role stays on [`Dispatch`]. +pub trait Session: Send { /// One-shot RPC: one request → one reply. fn call<'a>( - &'a self, - ctx: &'a SessionCtx, + &'a mut self, method: &'a str, params: Payload, ) -> BoxFut<'a, Result>; /// Streaming: open a subscription that yields many payloads. The stream is - /// `'static` so it can hold its own buffer reader inside the engine's - /// `FuturesUnordered` (doc 034 risk list). - fn subscribe( - &self, - ctx: &SessionCtx, - topic: &str, - ) -> Result, RpcError>; + /// `'static` (it captures cloned handles), so it outlives the `&mut` borrow + /// and lives in the engine's `FuturesUnordered` (doc 034 risk list). + /// + /// Defaulted to [`RpcError::NotFound`] so a dispatch with no streaming + /// surface need not implement it (doc 037 § the server-port refinement — + /// the stream is side-neutral, so it is defaulted here for symmetry). + fn subscribe(&mut self, topic: &str) -> Result, RpcError> { + let _ = topic; + Err(RpcError::NotFound) + } /// Fire-and-forget write: no reply. Routes through the existing /// producer/arbiter path (single-writer-per-key stays intact). fn write<'a>( - &'a self, - ctx: &'a SessionCtx, + &'a mut self, topic: &'a str, payload: Payload, ) -> BoxFut<'a, Result<(), RpcError>>; @@ -368,12 +394,13 @@ pub trait Source: Send { // `Box` from a mock per the acceptance criteria. // =========================================================================== -#[allow(dead_code)] +#[allow(dead_code, clippy::too_many_arguments)] fn _assert_object_safe( _connection: &dyn Connection, _listener: &dyn Listener, _dialer: &dyn Dialer, _dispatch: &dyn Dispatch, + _session: &dyn Session, _codec: &dyn EnvelopeCodec, _sink: &dyn Sink, _source: &dyn Source, @@ -420,24 +447,25 @@ mod tests { ) -> BoxFut<'a, Result> { unimplemented!() } + fn open(&self, _ctx: &SessionCtx) -> Box { + unimplemented!() + } + } + + struct MockSession; + impl Session for MockSession { fn call<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, _method: &'a str, _params: Payload, ) -> BoxFut<'a, Result> { unimplemented!() } - fn subscribe( - &self, - _ctx: &SessionCtx, - _topic: &str, - ) -> Result, RpcError> { + fn subscribe(&mut self, _topic: &str) -> Result, RpcError> { unimplemented!() } fn write<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, _topic: &'a str, _payload: Payload, ) -> BoxFut<'a, Result<(), RpcError>> { @@ -487,6 +515,7 @@ mod tests { let _listener: Box = Box::new(MockListener); let _dialer: Box = Box::new(MockDialer); let _dispatch: Box = Box::new(MockDispatch); + let _session: Box = Box::new(MockSession); let _codec: Box = Box::new(MockCodec); let _sink: Box = Box::new(MockSink); let _source: Box = Box::new(MockSource); diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 37feae6..1ca5ec0 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -79,6 +79,11 @@ pub async fn run_session( } }; + // Open the per-connection session once. It owns the connection's mutable + // dispatch state (e.g. `record.drain` cursors); the loop below threads + // `&mut` into its `call` / `subscribe` / `write`. + let mut session = dispatch.open(&ctx); + // Event funnel: every per-subscription pump sends its updates here; the main // loop is the sole writer to the connection. let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); @@ -107,7 +112,7 @@ pub async fn run_session( }; match msg { Inbound::Request { id, method, params } => { - let result = dispatch.call(&ctx, &method, params).await; + let result = session.call(&method, params).await; out.clear(); if codec.encode(Outbound::Reply { id, result }, &mut out).is_err() { continue; @@ -124,7 +129,7 @@ pub async fn run_session( send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await; continue; } - match dispatch.subscribe(&ctx, &topic) { + match session.subscribe(&topic) { Ok(stream) => { let (cancel_tx, cancel_rx) = oneshot::channel(); cancels.insert(sub_id.clone(), cancel_tx); @@ -145,8 +150,8 @@ pub async fn run_session( cancels.remove(&sub); } Inbound::Write { topic, payload } => { - // Fire-and-forget; routes through Dispatch (single-writer-per-key intact). - let _ = dispatch.write(&ctx, &topic, payload).await; + // Fire-and-forget; routes through the session (single-writer-per-key intact). + let _ = session.write(&topic, payload).await; } Inbound::Ping => { out.clear(); @@ -208,7 +213,7 @@ async fn send_reply_err( } } -/// Pump one `Dispatch::subscribe` stream into the connection's event funnel, +/// Pump one `Session::subscribe` stream into the connection's event funnel, /// tagging each update with a monotonic `seq`. Ends when the stream finishes or /// the cancel handle is dropped/fired (Unsubscribe or connection teardown). async fn pump_subscription( diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index d4f7704..a88d62c 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -18,7 +18,7 @@ use futures::StreamExt; use aimdb_core::session::{ run_client, serve, AuthError, BoxFut, BoxStream, ClientConfig, CodecError, Connection, Dialer, - Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, + Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, Session, SessionConfig, SessionCtx, TransportError, TransportResult, }; @@ -257,9 +257,22 @@ impl Dispatch for EchoDispatch { Box::pin(async { Ok(SessionCtx::default()) }) } + fn open(&self, _ctx: &SessionCtx) -> Box { + Box::new(EchoSession { + writes: self.writes.clone(), + }) + } +} + +/// Per-connection echo session — the shared `EchoDispatch` clones its write log +/// into each one at `open` time. +struct EchoSession { + writes: WriteLog, +} + +impl Session for EchoSession { fn call<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, _method: &'a str, params: Payload, ) -> BoxFut<'a, Result> { @@ -267,11 +280,7 @@ impl Dispatch for EchoDispatch { Box::pin(async move { Ok(params) }) } - fn subscribe( - &self, - _ctx: &SessionCtx, - topic: &str, - ) -> Result, RpcError> { + fn subscribe(&mut self, topic: &str) -> Result, RpcError> { // Three synthetic updates derived from the topic, then end. let items: Vec = (1..=3) .map(|i| payload_from(&format!("{topic}#{i}"))) @@ -280,8 +289,7 @@ impl Dispatch for EchoDispatch { } fn write<'a>( - &'a self, - _ctx: &'a SessionCtx, + &'a mut self, topic: &'a str, payload: Payload, ) -> BoxFut<'a, Result<(), RpcError>> { diff --git a/docs/design/detailed/037-phase0-contracts.md b/docs/design/detailed/037-phase0-contracts.md new file mode 100644 index 0000000..52405db --- /dev/null +++ b/docs/design/detailed/037-phase0-contracts.md @@ -0,0 +1,112 @@ +# Phase 0 — frozen connector-session contracts (decision record) + +**Version:** 1.0 (ratified) +**Status:** 🟢 Decided +**Realizes:** [036 — Masterplan](036-remote-access-masterplan.md) Phase 0 +**Depends on:** [033 — Remote access as a connector](033-remote-access-as-connector.md), [034 — Server-connection trait](034-server-connection-trait.md), [035 — Connector capability model](035-connector-capability-model.md), [M16 — AimX JSON codec](../032-M16-aimx-json-codec.md) +**Implements:** [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs) (feature `connector-session`) +**Last Updated:** May 29, 2026 +**Milestone:** M17+ (connector convergence) + +--- + +## TL;DR + +This record **freezes the cross-cutting contracts** every later phase consumes, so Phases 2–6 never rework signatures mid-flight. It ships two things: + +1. **Four cross-cutting decisions** (below), each resolved with rationale and explicit deferrals. +2. **`dyn`-safe trait skeletons** — signatures only, `unimplemented!()` bodies — in [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs) behind the `connector-session` feature, compiling on `std` **and** `no_std + alloc` (`thumbv7em-none-eabihf`). + +No engine logic, no pumps, no transport impls, no wire-protocol changes — **contracts, not behavior**. + +--- + +## Decisions + +### Decision 1 — `Payload` seam type → **raw bytes** ✅ + +`Payload = Arc<[u8]>` — opaque serialized bytes, the interchange between the outer `EnvelopeCodec` (protocol frame) and the inner M16 record-value `JsonCodec` (record value). + +**Rationale.** Cheap-clone (refcount bump) for WS fan-out, `no_std + alloc`-native, no new dependency. The alternative (`serde_json::Value`) is zero-churn for std ports but couples the envelope to `serde_json` and forces a `Value` tree on the hot paths. Raw bytes keep **one** serde pass on the hot paths; a JSON tree materializes only where an RPC handler inspects structure. + +**Convert-boundary rule** (the discipline this type enforces): +- *emit:* `T → bytes` once via `serde_json::to_vec` / `serde-json-core` — no intermediate `Value` tree. +- *ingest:* `bytes → T` once via `from_slice`. +- *pass-through:* streaming a record to subscribers and routing source→producer never touch the payload. +- *structured:* a `serde_json::Value` materializes only inside RPC handlers that inspect structure (query filters, graph introspection) — never on the streaming/produce loops. + +**EnvelopeCodec implication.** `decode` yields `params`/`data` as an *unparsed* `Payload` (a slice of the frame); `encode` splices a `Payload` in verbatim. Embedding raw JSON bytes inside a textual NDJSON/WS-JSON envelope will need `serde_json::value::RawValue` (std) or a manual fixed-buffer splice (`serde-json-core`) to avoid re-escaping — an implementation concern for the Phase 2+ codecs, not a contract change. + +**Future option.** `bytes::Bytes` only if cheap sub-slicing / zero-copy binary framing is later needed; `Arc<[u8]>` is the no-dependency default until then. + +### Decision 2 — RPC + streaming unify in one `Dispatch` → **one trait** ✅ + +A single `Dispatch` trait carries all three reply cardinalities: `call` (one reply) / `subscribe` (many) / `write` (none). They differ only by return type, not by abstraction. + +**Rationale.** Both existing stacks (AimX-remote, WS connector) already interleave RPC and streaming over **one** connection via a `biased select!`. A subscription is just a method whose reply is a `Stream` rather than a single value. Forking into separate RPC and streaming traits only pays off with separate transports, which AimDB does not have. + +### Decision 3 — `publish` placement → **sibling capability** ✅ (ratify) + +`publish` stays a sibling data-plane capability, **not** absorbed into the session trait. `Sink` = today's [`Connector`](../../../aimdb-core/src/transport.rs#L159) contract **verbatim** — no rename, no migration in Phase 0. + +**Rationale.** Already decided in [033 §5](033-remote-access-as-connector.md); `publish` is the client/sink role, the session trait is the server role. MQTT *inbound* is the degenerate session case (a single `Connection`, no `Listener`). Reconciling the `Sink`/`Connector` names is **Phase 1**, not here. + +### Decision 4 — embedded engine order → **client-first** ✅ + +The embedded push target is *an MCU dials a gateway and pushes records*, so the **Client** path (`Dialer` + `run_client`) is the embedded-critical one. + +**Rationale.** The smallest engine — one connection, no accept loop, no fan-out — gives the best odds against #39's ~60–100 KB / ≥256 KB RAM budget. This orders the later phases, not the Phase 0 contracts: Phases 2–4 still build **both** sides (the substrate is role-neutral); Phase 5 does substrate-first then `run_client`; Phase 6 ships the `Dialer` half first. The frozen substrate (`Connection` / `EnvelopeCodec` / `Inbound` / `Outbound`) is deliberately role-neutral so server and client share it. + +--- + +## Deferred — recorded here, resolved elsewhere + +| deferral | lands in | +|---|---| +| Auth-context shape — one `SessionCtx` for AimX `SecurityPolicy` + WS `Permissions`? (`SessionCtx`/`PeerInfo` are opaque placeholders for now) | **Phase 4** | +| `Source` cardinality + backpressure (one multiplexed `Source` per scheme; block vs drop+count) | **Phase 1** | +| Client surface — `pump_client` mirroring-only vs caller RPC (`request_id_counter`) | ✅ **Phase 2** — *resolved: one engine, both. `run_client`+`ClientHandle` shipped; the caller-RPC half landed in **Phase 3** as `AimxConnection` ([038](038-phase3-aimx-client.md)); `pump_client` mirroring deferred to the server port (its route-collection deps share the server `Dispatch` machinery)* | +| Envelope wire convergence — NDJSON vs WS-JSON (the codec is pluggable) | deferrable past **Phase 4** | +| Bounded-resource policy — `heapless`/const-generic vs runtime config (`SessionLimits` is a stub) | **Phase 5** | +| Memory budget validation against #39 target | **Phase 7** | + +--- + +## The frozen contract sheet + +All in [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs), feature `connector-session`. Signatures copied verbatim from their canonical sketches — transport + `EnvelopeCodec` + `Dispatch` from [034 § The three layers](034-server-connection-trait.md), `Sink` / `Source` / `Dialer` from [035 § The toolkit](035-connector-capability-model.md). + +**Shared aliases & types** +- `BoxFut<'a, T> = Pin + Send + 'a>>`, `BoxStream<'a, T> = Pin + Send + 'a>>` +- `Payload = Arc<[u8]>` (Decision 1) +- `SessionCtx`, `PeerInfo` — opaque placeholders (auth deferred to Phase 4); `SessionLimits` — stub (Phase 5) +- `Inbound` / `Outbound<'a>` — role-neutral logical message set (field types align with the existing AimX wire in `remote::protocol`: `id`/`seq` are `u64`, `topic`/`sub`/`method` are `String`/`&str`) +- supporting errors: `TransportError` (+ `TransportResult`), `CodecError`, `RpcError`, `AuthError` + +**Transport (Layer 1)** — `Connection` (`recv`/`send`/`peer`), `Listener` (`accept`), `Dialer` (`connect`, the dual of `Listener`). + +**Dispatch (Layer 3)** — `Dispatch` (`authenticate`/`open`) + `Session` (`call`/`subscribe`/`write`), `EnvelopeCodec` (`decode`/`encode`). + +> **Phase 3 server-port refinement (additive).** The dispatch role was **split** into a shared `Dispatch` (`Send + Sync`, one `Arc` per server: `authenticate` + an `open(&SessionCtx) -> Box` factory) and a per-connection `Session` (`Send`, one `Box` per accepted connection: `call`/`subscribe`/`write` on `&mut self`). The engine (`run_session`) owns the `Box` and threads `&mut` into it, so a connection can hold mutable dispatch state — `record.drain`'s lazy per-record cursors today, per-session auth identity in Phase 4 — without a lock. This is the **one seam the AimX wire reshape did not dissolve** (it is about `Dispatch` being a shared `&self`, not the wire). `Session::subscribe` is **defaulted** to `Err(RpcError::NotFound)` since its stream is `'static` (it captures cloned handles) and so is side-neutral. The split is additive and object-safe (the Phase-0 `Box` object-safety tests still hold, now covering `Session`); it mirrors the precedent of the Phase-2 `encode_inbound`/`decode_outbound` addition below — made when the server port was built, the phase where per-connection dispatch state is nailed down. + +> **Phase 2 refinement (additive).** `EnvelopeCodec` gained its *client* direction — `encode_inbound` + `decode_outbound` — so the proactive `run_client` engine reuses the **same** codec object as the reactive server (the role-neutral-substrate invariant). The two Phase 0 server-direction signatures are unchanged; this is a pure addition, made when the client engine was built (the engine phase is where the client codec direction is nailed down). + +**Data-plane (035)** — `Sink` (`publish` = today's `Connector` verbatim), `Source` (`next`). + +### Faithful-translation notes (where a verbatim copy needed a concrete choice) + +- **Transport frame type.** 034 sketches `Connection::recv -> Option`. Per Decision 1 (no `bytes` crate) the owned transport frame is `Vec`; `EnvelopeCodec::decode(&[u8])` borrows it. `Payload = Arc<[u8]>` is reserved for the *record-value* seam, not raw transport frames. +- **`Sink::publish` lifetimes.** Match today's `Connector::publish` exactly: the returned `BoxFut<'_, …>` is tied to `&self` only (params get independent elided lifetimes). The new session traits keep 034's `<'a>` form where the future borrows params (`call`/`write`/`authenticate`/`send`). +- **`: Send` on `Dialer` / `Source`.** Added for consistency with the rest of the boxed-future family (they are driven inside the runner's `FuturesUnordered`); 035's sketch omitted the bound. + +--- + +## Acceptance criteria — status + +- [x] `037-phase0-contracts.md` exists, with a resolution + rationale for Decisions 1–4 and a "deferred to Phase N" line per deferral. +- [x] Trait skeletons for all Scope types compile on `std` (`cargo check -p aimdb-core --features connector-session`). +- [x] Same skeletons compile for `no_std + alloc` (`cargo check -p aimdb-core --target thumbv7em-none-eabihf --no-default-features --features "alloc,connector-session"`). +- [x] Every trait is object-safe — `_assert_object_safe(&dyn …)` (all targets) + `traits_are_object_safe` test building `Box` per trait. +- [x] Skeletons are unimplemented (`unimplemented!()`) — contracts, not behavior. +- [x] [036](036-remote-access-masterplan.md)'s decision-gate table marks the Phase-0 rows resolved. +``` From 4d8d07ee954fba8b99982cc5fc32bf08b3677655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 19:17:48 +0000 Subject: [PATCH 04/34] feat(session): port AimX server onto serve/run_session (UdsListener + AimxDispatch) Phase 3 server half (issue #39). Replaces the test-local UdsListener + TestDispatch with production server code: - UdsListener: Listener over tokio UnixListener (the accepting transport half, deferred from the client port; reuses the role-neutral UdsConnection). - AimxDispatch (shared) + AimxSession (per-connection): method bodies ported from remote/handler.rs onto the Session seam. record.drain's lazy per-record cursors live in AimxSession. subscribe reuses stream_record_updates; write/record.set reuse set_record_from_json (single-writer-per-key intact). SecurityPolicy enforced per-call (ReadOnly denies; writable_records membership). v2 client param shapes: record.get {name}, record.set {name,value}, write {value}. - build_aimx_server: supervisor.rs's socket setup (remove-stale/bind/chmod) then the spawn-free serve() engine; max_connections/max_subs -> SessionLimits. The aimx_session exit test now stands up a real AimDb and drives the production server end-to-end (hello/get/set/subscribe/write) over a real UDS socket. Legacy handler.rs/supervisor.rs remain untouched (main green); retired in a later stage. record.query is a sync fn returning the 'static handler future (an async fn(&self) would force AimxSession: Sync, which the drain_readers box is not). Welcome's writable_records is derived from the policy directly so build_aimx_server is self-contained standalone. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + aimdb-client/Cargo.toml | 4 + aimdb-client/tests/aimx_session.rs | 257 +++++--------- aimdb-core/src/session/aimx/dispatch.rs | 423 +++++++++++++++++++++++ aimdb-core/src/session/aimx/mod.rs | 17 +- aimdb-core/src/session/aimx/transport.rs | 39 ++- 6 files changed, 563 insertions(+), 178 deletions(-) create mode 100644 aimdb-core/src/session/aimx/dispatch.rs diff --git a/Cargo.lock b/Cargo.lock index b146500..bce86bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -49,6 +49,7 @@ name = "aimdb-client" version = "0.6.0" dependencies = [ "aimdb-core", + "aimdb-tokio-adapter", "anyhow", "futures", "serde", diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index 46d4251..3d1f91a 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -38,3 +38,7 @@ thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "test-util"] } tokio-test = "0.4" tempfile = "3" +# The aimx_session exit test stands up a real AimDb + production AimX server +# (`build_aimx_server`) and drives it with the engine-based `AimxConnection`. +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } +serde = { version = "1", features = ["derive"] } diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index 2629f1a..f16d5f2 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -1,197 +1,126 @@ -//! Phase 3 client-first exit criterion: the engine-based [`AimxConnection`] -//! round-trips the AimX-v2 wire — `hello` handshake, RPC (`record.get`/`set`), -//! a streaming subscription, and a fire-and-forget write — against a `serve` -//! engine test-server over a **real Unix-domain socket**. +//! Phase 3 **server-port** exit criterion: the engine-based [`AimxConnection`] +//! round-trips the reshaped AimX-v2 wire — `hello` handshake, RPC +//! (`record.get`/`record.set`), a streaming subscription, and a +//! fire-and-forget write — against the **production** server +//! ([`build_aimx_server`] → `serve`/`run_session` + `AimxDispatch`) over a real +//! Unix-domain socket. //! -//! The server side uses the production [`AimxCodec`] + [`UdsConnection`]; the -//! only test-local pieces are a `UdsListener` (the accepting transport half, -//! deferred from core to the server port) and a small echo-ish `Dispatch`. This -//! proves the reshaped wire end-to-end before the real server `Dispatch` exists. +//! This swaps the client-half milestone's test-local `UdsListener` + +//! `TestDispatch` for real server code standing up an actual `AimDb`, proving +//! the reshaped wire end-to-end through the shared session engine. -use std::sync::{Arc, Mutex}; +use std::sync::Arc; +use std::time::Duration; use aimdb_client::AimxConnection; -use aimdb_core::session::aimx::{AimxCodec, UdsConnection}; -use aimdb_core::session::{ - serve, AuthError, BoxFut, BoxStream, Connection, Dispatch, Listener, Payload, PeerInfo, - RpcError, Session, SessionConfig, SessionCtx, TransportError, TransportResult, -}; +use aimdb_core::buffer::BufferCfg; +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +use aimdb_core::session::aimx::build_aimx_server; +use aimdb_core::AimDbBuilder; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; use serde_json::json; -use tokio::net::UnixListener; -// --------------------------------------------------------------------------- -// Test-local accepting transport (the `UdsListener` core gains in the server port) -// --------------------------------------------------------------------------- - -struct TestUdsListener { - inner: UnixListener, -} - -impl Listener for TestUdsListener { - fn accept(&mut self) -> BoxFut<'_, TransportResult>> { - Box::pin(async move { - let (stream, _addr) = self.inner.accept().await.map_err(|_| TransportError::Io)?; - Ok(Box::new(UdsConnection::new(stream)) as Box) - }) - } -} - -// --------------------------------------------------------------------------- -// Minimal AimX dispatch (stand-in for the real server `Dispatch`, server port) -// --------------------------------------------------------------------------- - -type WriteLog = Arc)>>>; - -struct TestDispatch { - writes: WriteLog, -} - -fn payload(v: serde_json::Value) -> Payload { - Payload::from(serde_json::to_vec(&v).unwrap().as_slice()) -} - -impl Dispatch for TestDispatch { - fn authenticate<'a>( - &'a self, - _peer: &'a PeerInfo, - _first: Option<&'a [u8]>, - ) -> BoxFut<'a, Result> { - Box::pin(async { Ok(SessionCtx::default()) }) - } - - fn open(&self, _ctx: &SessionCtx) -> Box { - Box::new(TestSession { - writes: self.writes.clone(), - }) - } -} - -/// Per-connection session for the test dispatch. -struct TestSession { - writes: WriteLog, +/// A writable config-style record (SingleLatest, no producer → remotely settable). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Setting { + level: u64, } -impl Session for TestSession { - fn call<'a>( - &'a mut self, - method: &'a str, - params: Payload, - ) -> BoxFut<'a, Result> { - let method = method.to_string(); - Box::pin(async move { - match method.as_str() { - "hello" => Ok(payload(json!({ - "version": "2.0", - "server": "test", - "permissions": ["read", "write"], - "writable_records": ["temp"], - }))), - "record.list" => Ok(payload(json!([{ "name": "temp", "writable": true }]))), - "record.get" => { - // Echo the requested name back as a fixed value. - let v: serde_json::Value = serde_json::from_slice(¶ms).unwrap_or_default(); - let name = v.get("name").and_then(|n| n.as_str()).unwrap_or(""); - if name == "temp" { - Ok(payload(json!(42))) - } else { - Err(RpcError::NotFound) - } - } - "record.set" => Ok(payload(json!({ "ok": true }))), - _ => Err(RpcError::NotFound), - } - }) - } - - fn subscribe(&mut self, topic: &str) -> Result, RpcError> { - // Three synthetic updates derived from the topic, then end. - let items: Vec = (1..=3) - .map(|i| payload(json!({ "topic": topic, "n": i }))) - .collect(); - Ok(Box::pin(futures::stream::iter(items))) - } - - fn write<'a>( - &'a mut self, - topic: &'a str, - payload: Payload, - ) -> BoxFut<'a, Result<(), RpcError>> { - let writes = self.writes.clone(); - let topic = topic.to_string(); - Box::pin(async move { - writes.lock().unwrap().push((topic, payload.to_vec())); - Ok(()) - }) - } +/// A streamed record (SpmcRing) fed by a producer in the test. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Reading { + n: u64, } -// --------------------------------------------------------------------------- -// The exit-criterion test -// --------------------------------------------------------------------------- - #[tokio::test] -async fn aimx_client_roundtrip_over_uds() { - use futures::StreamExt; - +async fn aimx_roundtrip_over_uds_production_server() { let dir = tempfile::tempdir().unwrap(); let sock = dir.path().join("aimdb.sock"); - // Bind before connecting so the client's dial always finds the socket. - let listener = TestUdsListener { - inner: UnixListener::bind(&sock).unwrap(), - }; - let writes: WriteLog = Arc::new(Mutex::new(Vec::new())); - let dispatch = Arc::new(TestDispatch { - writes: writes.clone(), + // Build a real AimDb with two remote-accessible records. + let mut builder = AimDbBuilder::new().runtime(Arc::new(TokioAdapter)); + builder.configure::("setting", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); }); - let server = tokio::spawn(serve( - listener, - Arc::new(AimxCodec), - dispatch, - SessionConfig::default(), - )); - - // Connect: this performs the `hello` handshake and captures the Welcome. + builder.configure::("events", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 64 }) + .with_remote_access(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + // Seed the writable record before connecting so `record.get` has a value. + db.set_record_from_json("setting", json!({ "level": 1 })) + .expect("seed setting"); + + // ReadWrite policy with `setting` writable; `events` stays read-only. + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("setting"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy) + .max_connections(8) + .max_subs_per_connection(8); + + // Production server future, driven on a task (the engine itself is spawn-free). + let server = tokio::spawn(build_aimx_server(db.clone(), config).expect("bind server")); + + // Connect: performs the `hello` handshake and captures the Welcome. let conn = AimxConnection::connect(&sock).await.expect("connect"); - assert_eq!(conn.server_info().server, "test"); + assert_eq!(conn.server_info().server, "aimdb"); assert!(conn .server_info() .permissions .contains(&"write".to_string())); + assert!(conn + .server_info() + .writable_records + .contains(&"setting".to_string())); - // RPC: record.get - let temp = conn.get_record("temp").await.expect("get temp"); - assert_eq!(temp, json!(42)); + // RPC: record.get on the seeded record. + let got = conn.get_record("setting").await.expect("get setting"); + assert_eq!(got, json!({ "level": 1 })); // RPC: record.get on a missing record maps to a server error. assert!(conn.get_record("missing").await.is_err()); - // RPC: record.set - let set = conn.set_record("temp", json!(7)).await.expect("set temp"); - assert_eq!(set, json!({ "ok": true })); + // RPC: record.set (permission-checked) echoes the new value. + let set = conn + .set_record("setting", json!({ "level": 7 })) + .await + .expect("set setting"); + assert_eq!(set.get("value").unwrap(), &json!({ "level": 7 })); + assert_eq!( + conn.get_record("setting").await.unwrap(), + json!({ "level": 7 }) + ); - // Streaming subscription: three events routed back by the engine-owned id. - let mut stream = conn.subscribe("temp").expect("subscribe"); - for n in 1..=3 { - let ev = stream.next().await.expect("event"); - assert_eq!(ev, json!({ "topic": "temp", "n": n })); + // Streaming: a producer feeds `events`; the subscription routes updates back. + let producer = db.producer::("events").expect("producer"); + tokio::spawn(async move { + for n in 1..=50 { + producer.produce(Reading { n }); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + let mut stream = conn.subscribe("events").expect("subscribe"); + for _ in 0..3 { + let ev = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("event within timeout") + .expect("event"); + assert!(ev.get("n").is_some(), "event carries a Reading: {ev}"); } // Fire-and-forget write, then a follow-up RPC. FIFO over the single // connection guarantees the write is processed before the reply returns. - conn.write_record("temp", json!("on")).expect("write"); - let _ = conn.get_record("temp").await.expect("get after write"); - let got = writes.lock().unwrap().clone(); - assert_eq!( - got.len(), - 1, - "server should have received exactly one write" - ); - assert_eq!(got[0].0, "temp"); - assert_eq!( - serde_json::from_slice::(&got[0].1).unwrap(), - json!({ "value": "on" }) - ); + conn.write_record("setting", json!({ "level": 9 })) + .expect("write"); + let after = conn.get_record("setting").await.expect("get after write"); + assert_eq!(after, json!({ "level": 9 })); drop(conn); // stops the client engine server.abort(); diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs new file mode 100644 index 0000000..0c76c58 --- /dev/null +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -0,0 +1,423 @@ +//! AimX server dispatch (Phase 3 server port, std-only) — the method semantics +//! of AimX remote access, ported off the hand-rolled `remote/handler.rs` loop +//! onto the shared session engine ([`serve`]/[`run_session`]). +//! +//! The dispatch role is split per the Phase-3 server-port refinement (doc 037): +//! - [`AimxDispatch`] is the **shared** half (one `Arc` per server): peer-only +//! `authenticate` + an `open` factory. +//! - [`AimxSession`] is the **per-connection** half the engine owns by value, so +//! `record.drain`'s lazy per-record cursors live in it (`drain_readers`) — the +//! one seam the AimX wire reshape did not dissolve. +//! +//! Method bodies are ported verbatim from `remote/handler.rs`, reusing the same +//! db introspection helpers; only the reply shape changes (the reshaped AimX-v2 +//! wire's `Result` instead of the legacy rich `Response`). +//! Param shapes follow the v2 client ([`aimdb_client::AimxConnection`]): +//! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`. + +use std::collections::HashMap; +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; + +use futures_util::StreamExt; +use serde_json::{json, Value}; +use tokio::net::UnixListener; + +use crate::buffer::JsonBufferReader; +use crate::builder::BoxFuture; +use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage}; +use crate::session::aimx::{AimxCodec, UdsListener}; +use crate::session::{ + serve, AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, + SessionConfig, SessionCtx, SessionLimits, +}; +use crate::{AimDb, DbError, DbResult, RuntimeAdapter}; + +/// The shared AimX dispatch — `authenticate` (peer-only) + the [`AimxSession`] +/// factory. One `Arc` is shared across every accepted connection. +pub struct AimxDispatch { + db: Arc>, + config: Arc, +} + +impl AimxDispatch { + /// Build a dispatch over `db` with the given remote-access `config`. + pub fn new(db: Arc>, config: AimxConfig) -> Self { + Self { + db, + config: Arc::new(config), + } + } +} + +impl Dispatch for AimxDispatch +where + R: RuntimeAdapter + 'static, +{ + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + // Peer-only: AimX over UDS relies on socket file permissions for access + // control; the `auth_token` identity is not yet threaded into per-call + // checks (Phase 4 — the auth-context-shape gate). Permission *policy* + // (ReadOnly / writable_records) is enforced per-call from the config. + Box::pin(async { Ok(SessionCtx::default()) }) + } + + fn open(&self, _ctx: &SessionCtx) -> Box { + Box::new(AimxSession { + db: self.db.clone(), + config: self.config.clone(), + drain_readers: HashMap::new(), + }) + } +} + +/// One AimX connection's mutable dispatch state. The engine owns this by value +/// and threads `&mut self` into each method, so `drain_readers` (lazy per-record +/// cursors) need no lock. +struct AimxSession { + db: Arc>, + config: Arc, + /// Per-record drain readers, created lazily on first `record.drain`. + drain_readers: HashMap>, +} + +impl Session for AimxSession +where + R: RuntimeAdapter + 'static, +{ + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + Box::pin(async move { + let params: Value = serde_json::from_slice(¶ms).unwrap_or(Value::Null); + self.dispatch_call(method, params) + .await + .map(|v| to_payload(&v)) + }) + } + + fn subscribe(&mut self, topic: &str) -> Result, RpcError> { + // The engine owns the subscription lifecycle (keyed by request id) and + // the per-connection cap (SessionLimits); no `generate_subscription_id` + // / `max_subs` bookkeeping here. + let stream = + crate::remote::stream::stream_record_updates(&self.db, topic).map_err(map_db_err)?; + Ok(Box::pin(stream.map(|v| to_payload(&v)))) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + Box::pin(async move { + self.ensure_writable(topic)?; + // The v2 client wraps the value as `{"value": }`; fall back to the + // whole payload if the wrapper is absent. + let v: Value = serde_json::from_slice(&payload).unwrap_or(Value::Null); + let value = v.get("value").cloned().unwrap_or(v); + // Routes through the producer/arbiter path — single-writer-per-key + // stays enforced inside `set_record_from_json`. + self.db + .set_record_from_json(topic, value) + .map_err(map_db_err) + }) + } +} + +impl AimxSession +where + R: RuntimeAdapter + 'static, +{ + /// Match the method and produce its JSON result (or an [`RpcError`]). + async fn dispatch_call(&mut self, method: &str, params: Value) -> Result { + match method { + "hello" => Ok(self.welcome()), + "record.list" => Ok(json!(self.db.list_records())), + "record.get" => { + let name = str_field(¶ms, "name").ok_or(RpcError::NotFound)?; + self.db.try_latest_as_json(&name).ok_or(RpcError::NotFound) + } + "record.set" => self.record_set(params), + "record.drain" => self.record_drain(params), + "record.query" => self + .record_query(params)? + .await + .map_err(|_| RpcError::Internal), + "graph.nodes" => Ok(json!(self.db.inner().dependency_graph().nodes)), + "graph.edges" => Ok(json!(self.db.inner().dependency_graph().edges)), + "graph.topo_order" => Ok(json!(self.db.inner().dependency_graph().topo_order())), + #[cfg(feature = "profiling")] + "profiling.reset" => { + self.ensure_write_permission()?; + self.db.reset_stage_profiling(); + Ok(json!({ "reset": true })) + } + #[cfg(feature = "metrics")] + "buffer_metrics.reset" => { + self.ensure_write_permission()?; + self.db.reset_buffer_metrics(); + Ok(json!({ "reset": true })) + } + _ => Err(RpcError::NotFound), + } + } + + /// `record.set` (RPC): permission-checked write that echoes the new value. + fn record_set(&self, params: Value) -> Result { + let name = str_field(¶ms, "name").ok_or(RpcError::Internal)?; + let value = params.get("value").cloned().ok_or(RpcError::Internal)?; + self.ensure_writable(&name)?; + self.db + .set_record_from_json(&name, value) + .map_err(map_db_err)?; + // Echo the updated value when available (matches the legacy reply shape). + Ok(match self.db.try_latest_as_json(&name) { + Some(updated) => json!({ "status": "success", "value": updated }), + None => json!({ "status": "success" }), + }) + } + + /// `record.drain`: lazily create a per-record cursor on first call, then + /// return everything accumulated since the previous drain (capped by an + /// optional `limit`). + fn record_drain(&mut self, params: Value) -> Result { + let name = str_field(¶ms, "name").ok_or(RpcError::Internal)?; + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(usize::MAX); + + if !self.drain_readers.contains_key(&name) { + let id = self + .db + .inner() + .resolve_str(&name) + .ok_or(RpcError::NotFound)?; + let record = self.db.inner().storage(id).ok_or(RpcError::NotFound)?; + // `subscribe_json` fails if the record was not configured with + // `.with_remote_access()`. + let reader = record.subscribe_json().map_err(map_db_err)?; + self.drain_readers.insert(name.clone(), reader); + } + + let reader = self.drain_readers.get_mut(&name).expect("inserted above"); + let mut values = Vec::new(); + while values.len() < limit { + match reader.try_recv_json() { + Ok(val) => values.push(val), + Err(DbError::BufferEmpty) => break, + // Ring overflowed since the last drain — cursor resets; keep going. + Err(DbError::BufferLagged { .. }) => continue, + Err(_) => break, + } + } + + let count = values.len(); + Ok(json!({ "record_name": name, "values": values, "count": count })) + } + + /// `record.query`: resolve the persistence query handler registered in the + /// db's `Extensions` (absent → not configured) and return its handler + /// future. + /// + /// Deliberately **not** an `async fn`: an `async fn(&self)` future would + /// capture `&self` across its await, forcing `AimxSession: Sync` — which the + /// per-connection `drain_readers` (`Box`, not `Sync`) is not. + /// Returning the (`'static`, `Send`) handler future lets the borrow of + /// `self` end here; the caller awaits the owned future. + #[allow(clippy::type_complexity)] + fn record_query( + &self, + params: Value, + ) -> Result< + core::pin::Pin< + Box> + Send + 'static>, + >, + RpcError, + > { + let handler = self + .db + .extensions() + .get::() + .ok_or(RpcError::Internal)?; + let name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("*") + .to_string(); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .and_then(|v| usize::try_from(v).ok()); + let start = params.get("start").and_then(|v| v.as_u64()); + let end = params.get("end").and_then(|v| v.as_u64()); + Ok(handler(crate::remote::QueryHandlerParams { + name, + limit, + start, + end, + })) + } + + /// Build the `Welcome` from the security policy + writable records. + /// + /// `writable_records` is derived from the policy directly (not the per-record + /// `writable` marking the builder applies) so the server is self-contained + /// when `build_aimx_server` is used standalone; it is intersected with the + /// records that actually exist so the server never advertises a phantom key. + fn welcome(&self) -> Value { + let (permissions, writable_records) = match &self.config.security_policy { + SecurityPolicy::ReadOnly => (vec!["read".to_string()], Vec::new()), + SecurityPolicy::ReadWrite { writable_records } => { + let existing: std::collections::HashSet = self + .db + .list_records() + .into_iter() + .map(|m: RecordMetadata| m.record_key) + .collect(); + let writable = writable_records + .iter() + .filter(|name| existing.contains(name.as_str())) + .cloned() + .collect(); + (vec!["read".to_string(), "write".to_string()], writable) + } + }; + let welcome = WelcomeMessage { + version: "2.0".to_string(), + server: "aimdb".to_string(), + permissions, + writable_records, + max_subscriptions: Some(self.config.max_subs_per_connection), + authenticated: Some(false), + }; + json!(welcome) + } + + /// Deny unless the policy is ReadWrite and `name` is in its writable set. + fn ensure_writable(&self, name: &str) -> Result<(), RpcError> { + match &self.config.security_policy { + SecurityPolicy::ReadWrite { writable_records } if writable_records.contains(name) => { + Ok(()) + } + _ => Err(RpcError::Denied), + } + } + + /// Deny under a ReadOnly policy (used by the `*.reset` admin methods). + #[cfg(any(feature = "profiling", feature = "metrics"))] + fn ensure_write_permission(&self) -> Result<(), RpcError> { + match self.config.security_policy { + SecurityPolicy::ReadOnly => Err(RpcError::Denied), + SecurityPolicy::ReadWrite { .. } => Ok(()), + } + } +} + +/// Serialize a JSON value into an owned record-value [`Payload`] (one serde pass +/// at the reply boundary, per doc 037 Decision 1). +fn to_payload(v: &Value) -> Payload { + Payload::from(serde_json::to_vec(v).unwrap_or_default().as_slice()) +} + +/// Extract a string field from a params object. +fn str_field(params: &Value, key: &str) -> Option { + params.get(key).and_then(|v| v.as_str()).map(String::from) +} + +/// Map a [`DbError`] onto the reshaped wire's coarse [`RpcError`] set. +fn map_db_err(e: DbError) -> RpcError { + match e { + DbError::RecordKeyNotFound { .. } | DbError::InvalidRecordId { .. } => RpcError::NotFound, + DbError::PermissionDenied { .. } => RpcError::Denied, + _ => RpcError::Internal, + } +} + +/// Build the AimX **server** future: bind the Unix-domain socket (remove a stale +/// socket file, `bind`, `set_permissions`) — synchronously, so bind errors +/// surface from `build()` — then return the spawn-free [`serve`] engine driving +/// [`AimxDispatch`] over [`AimxCodec`]. Replaces the legacy +/// `remote/supervisor.rs` accept loop; the `max_connections` cap moves into +/// [`SessionLimits`]. +pub fn build_aimx_server(db: Arc>, config: AimxConfig) -> DbResult +where + R: RuntimeAdapter + 'static, +{ + #[cfg(feature = "tracing")] + tracing::info!( + "Initializing AimX server on socket: {}", + config.socket_path.display() + ); + + // Remove an existing socket file if present. + if config.socket_path.exists() { + std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to remove existing socket file {}", + config.socket_path.display() + ), + source: e, + })?; + } + + let listener = UnixListener::bind(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to bind Unix socket at {}", + config.socket_path.display() + ), + source: e, + })?; + + // Set socket file permissions. + let permissions = config.socket_permissions.unwrap_or(0o600); + let mut perms = std::fs::metadata(&config.socket_path) + .map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to read socket metadata for {}", + config.socket_path.display() + ), + source: e, + })? + .permissions(); + perms.set_mode(permissions); + std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to set socket permissions for {}", + config.socket_path.display() + ), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!( + "AimX socket bound at {} (mode {:o})", + config.socket_path.display(), + permissions + ); + + let session_config = SessionConfig { + limits: SessionLimits { + max_connections: config.max_connections, + max_subs_per_connection: config.max_subs_per_connection, + }, + reads_hello: false, + }; + let dispatch = Arc::new(AimxDispatch::new(db, config)); + let listener = UdsListener::new(listener); + + Ok(Box::pin(serve( + listener, + Arc::new(AimxCodec), + dispatch, + session_config, + ))) +} diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs index 62ccba2..c4539ff 100644 --- a/aimdb-core/src/session/aimx/mod.rs +++ b/aimdb-core/src/session/aimx/mod.rs @@ -1,14 +1,15 @@ -//! AimX-v2 transport + codec (Phase 3, std-only) — the concrete substrate the -//! shared session engine rides for AimX remote access. +//! AimX-v2 transport + codec + dispatch (Phase 3, std-only) — the concrete +//! substrate the shared session engine rides for AimX remote access. //! -//! Client-first scope: ships the dialing transport ([`UdsDialer`] + -//! [`UdsConnection`]) and the symmetric [`AimxCodec`] (both engine directions), -//! enough to drive `run_client`. The accepting `UdsListener` and the server-side -//! `Dispatch` land with the server port; the substrate here is role-neutral and -//! is reused by both sides. +//! Role-neutral substrate ([`UdsConnection`] + the symmetric [`AimxCodec`]) plus +//! both sides: the dialing transport ([`UdsDialer`]) that drives `run_client`, +//! and the accepting transport ([`UdsListener`]) + server [`AimxDispatch`] that +//! [`build_aimx_server`] drives via `serve`. mod codec; +mod dispatch; mod transport; pub use codec::AimxCodec; -pub use transport::{UdsConnection, UdsDialer}; +pub use dispatch::{build_aimx_server, AimxDispatch}; +pub use transport::{UdsConnection, UdsDialer, UdsListener}; diff --git a/aimdb-core/src/session/aimx/transport.rs b/aimdb-core/src/session/aimx/transport.rs index a0df6aa..710c3d4 100644 --- a/aimdb-core/src/session/aimx/transport.rs +++ b/aimdb-core/src/session/aimx/transport.rs @@ -1,18 +1,20 @@ //! AimX UDS transport (Phase 3, std-only) — a [`Connection`] over a Unix-domain //! socket with NDJSON framing in the transport: one line == one logical frame. //! -//! Client-first scope: this ships the dialing half ([`UdsDialer`] + -//! [`UdsConnection`]) that the proactive `run_client` engine drives. The -//! accepting half (`UdsListener`) lands with the server port — the substrate is -//! role-neutral, so the same [`UdsConnection`] will serve both sides. +//! Both transport roles ride the same role-neutral [`UdsConnection`]: the +//! dialing half ([`UdsDialer`]) that the proactive `run_client` engine drives, +//! and the accepting half ([`UdsListener`]) that the reactive `serve` engine +//! drives (added with the server port). use std::path::PathBuf; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; -use tokio::net::UnixStream; +use tokio::net::{UnixListener, UnixStream}; -use crate::session::{BoxFut, Connection, Dialer, PeerInfo, TransportError, TransportResult}; +use crate::session::{ + BoxFut, Connection, Dialer, Listener, PeerInfo, TransportError, TransportResult, +}; /// A framed bidirectional pipe over a Unix-domain socket. Framing lives in the /// transport: [`recv`](Connection::recv) returns one newline-delimited frame @@ -103,3 +105,28 @@ impl Dialer for UdsDialer { }) } } + +/// The accepting (server) side: wraps an already-bound [`UnixListener`] and +/// yields a [`UdsConnection`] per accepted client. The dual of [`UdsDialer`]; +/// `serve` drives it. Socket setup (remove-stale / `bind` / `set_permissions`) +/// happens once in [`build_aimx_server`](super::build_aimx_server) before the +/// listener is handed here, mirroring the legacy supervisor's synchronous bind. +pub struct UdsListener { + inner: UnixListener, +} + +impl UdsListener { + /// Wrap an already-bound [`UnixListener`]. + pub fn new(inner: UnixListener) -> Self { + Self { inner } + } +} + +impl Listener for UdsListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let (stream, _addr) = self.inner.accept().await.map_err(|_| TransportError::Io)?; + Ok(Box::new(UdsConnection::new(stream)) as Box) + }) + } +} From 654cf23c784d3d51b756643a0ff8d715e95301e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 19:31:33 +0000 Subject: [PATCH 05/34] feat(session): subscribe-ack + pump_client record mirroring (AimxClientConnector) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 server port (issue #39), client refinements: - Subscribe-ack (run_client): a server subscribe-failure Reply carries an id the client never registered as a pending call. Recognize it and drop the matching event sink so a rejected subscribe ends the stream (None) instead of hanging forever. Contract: success is acknowledged implicitly by events flowing; the server replies only on failure. Covered by a new session_engine test. - pump_client(db, scheme, handle): mirrors records both directions over a running run_client engine — outbound routes stream local updates via ClientHandle::write; inbound routes subscribe and produce into local records through the Router (arbiter path; single-writer-per-key intact). Uses the type-erased consumer/serializer + producer/deserializer machinery (db.runtime_any() supplies the ctx for context-aware (de)serializers). - AimxClientConnector: a ConnectorBuilder for the `aimx://` scheme so records can .link_to/.link_from an AimX peer; on build it dials via run_client and returns the pump futures + engine future for the runner (spawn-free). The registerable wrapper around pump_client, mirroring build_aimx_server on the server side. New aimdb-client test mirrors a record client->server and server->client through the real connector path. Co-Authored-By: Claude Opus 4.8 (1M context) --- aimdb-client/tests/pump_client.rs | 130 ++++++++++++++++++ .../src/session/aimx/client_connector.rs | 74 ++++++++++ aimdb-core/src/session/aimx/mod.rs | 2 + aimdb-core/src/session/client.rs | 97 +++++++++++++ aimdb-core/src/session/mod.rs | 2 +- aimdb-core/tests/session_engine.rs | 43 ++++++ 6 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 aimdb-client/tests/pump_client.rs create mode 100644 aimdb-core/src/session/aimx/client_connector.rs diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs new file mode 100644 index 0000000..2fd93f1 --- /dev/null +++ b/aimdb-client/tests/pump_client.rs @@ -0,0 +1,130 @@ +//! Phase 3 server-port exit criterion (§4): `pump_client` mirrors a record +//! **both directions** between a local AimDb and a remote AimDb over the shared +//! session engine. +//! +//! Topology: a server `AimDb` (served by `build_aimx_server`) and a client +//! `AimDb` whose records carry `aimx://` connector links. `run_client` opens the +//! connection; `pump_client` wires the client's outbound/inbound routes to the +//! `ClientHandle`: +//! - **client → server**: producing the client's `cfg` record streams it to the +//! server via `ClientHandle::write` → the server's `record.set` path. +//! - **server → client**: updating the server's `tele` record streams it back +//! through a subscription → the client's inbound producer (arbiter path). + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +use aimdb_core::session::aimx::{build_aimx_server, AimxClientConnector}; +use aimdb_core::session::ClientConfig; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Msg { + v: u64, +} + +/// Re-assert `db.` reaches `want`, re-driving `push` each tick so the test +/// is robust against subscription-registration timing (a fresh subscriber may +/// only see values produced after it attaches). +async fn mirror_reaches( + db: &Arc>, + key: &str, + want: &serde_json::Value, + mut push: impl FnMut(), +) -> bool { + for _ in 0..100 { + push(); + tokio::time::sleep(Duration::from_millis(20)).await; + if db.try_latest_as_json(key).as_ref() == Some(want) { + return true; + } + } + false +} + +#[tokio::test] +async fn pump_client_mirrors_record_both_directions() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + // --- server: cfg (writable target) + tele (streamed source) ------------ + let mut sb = AimDbBuilder::new().runtime(Arc::new(TokioAdapter)); + sb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + sb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + let (server_db, server_runner) = sb.build().await.expect("build server db"); + let server_db = Arc::new(server_db); + tokio::spawn(server_runner.run()); + + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("cfg"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy); + let server = tokio::spawn(build_aimx_server(server_db.clone(), config).expect("bind server")); + + // --- client: cfg links *to* the server, tele links *from* it ----------- + // The AimxClientConnector registers the `aimx://` scheme (so the links + // validate) and, on build, dials the server + drives the mirroring pumps. + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(AimxClientConnector::new(&sock).with_config(ClientConfig { + reconnect: true, + reconnect_delay: Duration::from_millis(50), + sends_hello: false, + })); + cb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("aimx://cfg") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + cb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("aimx://tele") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + // build() collects the connector's engine + pump futures; the runner drives + // them (spawn-free engine, driven on a task here). + let (client_db, client_runner) = cb.build().await.expect("build client db"); + let client_db = Arc::new(client_db); + tokio::spawn(client_runner.run()); + + // client → server: producing client `cfg` mirrors to server `cfg`. + let want_cfg = json!({ "v": 7 }); + let mirrored_out = mirror_reaches(&server_db, "cfg", &want_cfg, || { + client_db + .set_record_from_json("cfg", json!({ "v": 7 })) + .expect("set client cfg"); + }) + .await; + assert!( + mirrored_out, + "client→server mirror did not reach the server" + ); + + // server → client: updating server `tele` mirrors to client `tele`. + let want_tele = json!({ "v": 9 }); + let mirrored_in = mirror_reaches(&client_db, "tele", &want_tele, || { + server_db + .set_record_from_json("tele", json!({ "v": 9 })) + .expect("set server tele"); + }) + .await; + assert!(mirrored_in, "server→client mirror did not reach the client"); + + server.abort(); +} diff --git a/aimdb-core/src/session/aimx/client_connector.rs b/aimdb-core/src/session/aimx/client_connector.rs new file mode 100644 index 0000000..bcc9025 --- /dev/null +++ b/aimdb-core/src/session/aimx/client_connector.rs @@ -0,0 +1,74 @@ +//! AimX **client** connector (Phase 3 server port, std-only) — registers the +//! `aimx://` scheme so records can `.link_to`/`.link_from` an AimX peer, and on +//! build dials that peer and drives the mirroring pumps. +//! +//! This is the registerable wrapper around [`pump_client`](crate::session::pump_client): +//! `build` opens the connection with [`run_client`](crate::session::run_client) +//! and returns one spawn-free future per route (plus the engine future) for the +//! runner to drive — collapsing the AimX *client* onto the shared session engine +//! the same way [`build_aimx_server`](super::build_aimx_server) does the server. + +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; + +use crate::builder::AimDb; +use crate::connector::ConnectorBuilder; +use crate::session::{pump_client, run_client, ClientConfig}; +use crate::{DbResult, RuntimeAdapter}; + +use super::{AimxCodec, UdsDialer}; + +type BoxFuture = Pin + Send + 'static>>; + +/// A connector that mirrors records to/from an AimX peer over a Unix-domain +/// socket. Register it with [`AimDbBuilder::with_connector`](crate::AimDbBuilder::with_connector) +/// so `aimx://` links validate; its `build` wires every collected route +/// to the connection. +pub struct AimxClientConnector { + socket_path: PathBuf, + config: ClientConfig, +} + +impl AimxClientConnector { + /// Mirror records over the AimX peer listening at `socket_path`. + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + config: ClientConfig::default(), + } + } + + /// Override the client engine config (reconnect policy, etc.). + pub fn with_config(mut self, config: ClientConfig) -> Self { + self.config = config; + self + } +} + +impl ConnectorBuilder for AimxClientConnector +where + R: RuntimeAdapter + 'static, +{ + fn build<'a>( + &'a self, + db: &'a AimDb, + ) -> Pin>> + Send + 'a>> { + Box::pin(async move { + let (handle, engine_fut) = run_client( + UdsDialer::new(self.socket_path.clone()), + AimxCodec, + self.config.clone(), + ); + // One pump future per route; they hold `ClientHandle` clones, so the + // engine stays alive as long as any mirror runs. `handle` drops here. + let mut futures = pump_client(db, "aimx", &handle); + futures.push(engine_fut); + Ok(futures) + }) + } + + fn scheme(&self) -> &str { + "aimx" + } +} diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs index c4539ff..2e9d339 100644 --- a/aimdb-core/src/session/aimx/mod.rs +++ b/aimdb-core/src/session/aimx/mod.rs @@ -6,10 +6,12 @@ //! and the accepting transport ([`UdsListener`]) + server [`AimxDispatch`] that //! [`build_aimx_server`] drives via `serve`. +mod client_connector; mod codec; mod dispatch; mod transport; +pub use client_connector::AimxClientConnector; pub use codec::AimxCodec; pub use dispatch::{build_aimx_server, AimxDispatch}; pub use transport::{UdsConnection, UdsDialer, UdsListener}; diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 50e28e6..1d2342b 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -16,6 +16,7 @@ //! it never spawns. use std::collections::HashMap; +use std::sync::Arc; use std::time::Duration; use tokio::sync::{mpsc, oneshot}; @@ -23,6 +24,9 @@ use tokio::sync::{mpsc, oneshot}; use super::{ BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, }; +use crate::connector::SerializerKind; +use crate::router::RouterBuilder; +use crate::{AimDb, RuntimeAdapter}; /// Client engine knobs. #[derive(Debug, Clone)] @@ -239,6 +243,15 @@ where Ok(Outbound::Reply { id, result }) => { if let Some(tx) = pending.remove(&id) { let _ = tx.send(result); + } else if result.is_err() { + // Subscribe-ack contract: a successful subscribe is + // acknowledged implicitly by its events flowing; the + // server replies only on *failure* (unknown record, + // sub cap). Such a Reply carries the subscribe `id`, + // which was never registered as a pending call — so + // drop the matching event sink to end the stream + // (`None`) instead of leaving it hanging forever. + subs.remove(&id.to_string()); } } Ok(Outbound::Event { sub, seq: _, data }) => { @@ -311,3 +324,87 @@ where } } } + +/// Mirror records between a local [`AimDb`] and a remote peer over a running +/// [`run_client`] engine — the connector-link half of the client capability. +/// +/// For the given connector `scheme` (e.g. `"aimx"`): +/// - **outbound** routes (`db.collect_outbound_routes`) stream local record +/// updates to the remote via [`ClientHandle::write`]; +/// - **inbound** routes (`db.collect_inbound_routes`) subscribe to the remote and +/// produce each update into the local record through the producer/arbiter path +/// — single-writer-per-key stays intact (a mirrored-in record is produced +/// through its inbound producer, never a direct co-writer). +/// +/// Returns one spawn-free pump future per route for the runner to drive +/// (mirroring the `ConnectorBuilder::build -> Vec` spine); it drives +/// the **same** engine as [`run_client`], never a second one. +pub fn pump_client( + db: &AimDb, + scheme: &str, + handle: &ClientHandle, +) -> Vec> +where + R: RuntimeAdapter + 'static, +{ + use futures_util::StreamExt; + + // The type-erased runtime context for context-aware (de)serializers. + let ctx = db.runtime_any(); + let mut pumps: Vec> = Vec::new(); + + // --- outbound: local record updates -> remote `write` ------------------ + for (destination, consumer, serializer, _config, topic_provider) in + db.collect_outbound_routes(scheme) + { + let handle = handle.clone(); + let ctx = ctx.clone(); + pumps.push(Box::pin(async move { + let mut reader = match consumer.subscribe_any().await { + Ok(r) => r, + Err(_e) => return, + }; + while let Ok(value) = reader.recv_any().await { + // Dynamic destination (topic provider) or the static link target. + let dest = topic_provider + .as_ref() + .and_then(|p| p.topic_any(&*value)) + .unwrap_or_else(|| destination.clone()); + let bytes = match &serializer { + SerializerKind::Raw(ser) => match ser(&*value) { + Ok(b) => b, + Err(_e) => continue, + }, + SerializerKind::Context(ser) => match ser(ctx.clone(), &*value) { + Ok(b) => b, + Err(_e) => continue, + }, + }; + if handle.write(dest, Payload::from(bytes.as_slice())).is_err() { + break; // engine stopped — all handles dropped + } + } + })); + } + + // --- inbound: remote events -> local producer (via the Router) --------- + // The Router applies each route's deserializer and produces the value; one + // subscription per unique remote topic feeds it. + let router = Arc::new(RouterBuilder::from_routes(db.collect_inbound_routes(scheme)).build()); + for id in router.resource_ids() { + let handle = handle.clone(); + let router = router.clone(); + let ctx = ctx.clone(); + pumps.push(Box::pin(async move { + let mut stream = match handle.subscribe(id.as_ref()) { + Ok(s) => s, + Err(_e) => return, + }; + while let Some(payload) = stream.next().await { + let _ = router.route(id.as_ref(), &payload, Some(&ctx)).await; + } + })); + } + + pumps +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index f975213..297259c 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -46,7 +46,7 @@ mod server; pub mod aimx; #[cfg(feature = "std")] -pub use client::{run_client, ClientConfig, ClientHandle}; +pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; #[cfg(feature = "std")] pub use server::{run_session, serve, SessionConfig}; diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index a88d62c..3d806ad 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -281,6 +281,10 @@ impl Session for EchoSession { } fn subscribe(&mut self, topic: &str) -> Result, RpcError> { + // Sentinel: let a known topic fail so the subscribe-ack path is testable. + if topic == "bad" { + return Err(RpcError::NotFound); + } // Three synthetic updates derived from the topic, then end. let items: Vec = (1..=3) .map(|i| payload_from(&format!("{topic}#{i}"))) @@ -369,3 +373,42 @@ async fn echo_roundtrip_rpc_streaming_and_write() { .expect("client engine should stop cleanly when handles drop"); server.abort(); } + +/// Subscribe-ack: a subscribe the server rejects must surface as a stream that +/// *ends* (`None`) rather than one that hangs forever (the pre-fix behavior). +#[tokio::test] +async fn failed_subscribe_ends_stream_via_ack() { + let (listener, dialer) = transport_pair(); + let dispatch = Arc::new(EchoDispatch { + writes: Arc::new(Mutex::new(Vec::new())), + }); + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig::default(), + )); + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: Duration::from_millis(10), + sends_hello: false, + }, + ); + let client = tokio::spawn(client_fut); + + // The "bad" topic is rejected server-side; the failure Reply must close the + // stream. A generous timeout guards against the old hang-forever behavior. + let mut stream = handle.subscribe("bad").unwrap(); + let ended = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("failed subscribe must end the stream, not hang"); + assert!(ended.is_none(), "rejected subscribe should yield no events"); + + drop(handle); + drop(stream); + let _ = client.await; + server.abort(); +} From 40ba95cb5b997ac584a41e3bc19c47d9ce411cae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 19:35:04 +0000 Subject: [PATCH 06/34] format --- aimdb-client/tests/aimx_session.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index f16d5f2..dd31073 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -115,6 +115,21 @@ async fn aimx_roundtrip_over_uds_production_server() { assert!(ev.get("n").is_some(), "event carries a Reading: {ev}"); } + // Graph introspection wrappers. + let nodes = conn.graph_nodes().await.expect("graph nodes"); + assert!( + nodes.len() >= 2, + "configured records should appear as nodes" + ); + let _edges = conn.graph_edges().await.expect("graph edges"); + let topo = conn.graph_topo_order().await.expect("topo order"); + assert!(!topo.is_empty(), "topo order should list the records"); + + // Drain wrapper: cold-start creates the per-connection cursor; the response + // echoes the record name. + let drained = conn.drain_record("events").await.expect("drain events"); + assert_eq!(drained.record_name, "events"); + // Fire-and-forget write, then a follow-up RPC. FIFO over the single // connection guarantees the write is processed before the reply returns. conn.write_record("setting", json!({ "level": 9 })) From cf969b56e088cabc98491416d719af4aeef6c272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Fri, 29 May 2026 19:36:21 +0000 Subject: [PATCH 07/34] feat(client): grow AimxConnection to the full tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the typed wrappers tools/aimdb-cli + tools/aimdb-mcp need before they can migrate off the legacy AimxClient: drain_record(+_with_limit) -> DrainResponse, query, graph_nodes/edges/topo_order, reset_stage_profiling, reset_buffer_metrics (all over the cheap-clone ClientHandle). DrainResponse now lives in engine.rs so it survives connection.rs's removal in the next stage. Additive only — no caller switches yet, so main stays green. Covered by the aimx_session production-server test (graph + drain). Co-Authored-By: Claude Opus 4.8 (1M context) --- aimdb-client/src/engine.rs | 76 +++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 85b6423..ab176b0 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -16,7 +16,7 @@ use std::path::Path; use futures::StreamExt; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::task::JoinHandle; @@ -26,6 +26,18 @@ use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Pay use crate::error::{ClientError, ClientResult}; use crate::protocol::{RecordMetadata, WelcomeMessage}; +/// Response from a `record.drain` call: the values accumulated since the +/// previous drain for this connection's per-record cursor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DrainResponse { + /// Echo of the queried record name. + pub record_name: String, + /// Chronologically ordered values (raw JSON, as written by the producer). + pub values: Vec, + /// Number of values returned. + pub count: usize, +} + /// A live connection to an AimDB instance over the shared session engine. /// /// Holds the cheap-clone [`ClientHandle`] (use [`handle`](Self::handle) to issue @@ -132,6 +144,68 @@ impl AimxConnection { .map_err(rpc_err) } + /// Drain all values accumulated since the previous drain of `name` (a + /// destructive read against this connection's per-record cursor). + pub async fn drain_record(&self, name: &str) -> ClientResult { + let reply = self + .call("record.drain", to_payload(&json!({ "name": name }))?) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Drain at most `limit` values from `name`. + pub async fn drain_record_with_limit( + &self, + name: &str, + limit: u32, + ) -> ClientResult { + let reply = self + .call( + "record.drain", + to_payload(&json!({ "name": name, "limit": limit }))?, + ) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Run a persistence query (requires the server's `with_persistence()`). + pub async fn query(&self, params: serde_json::Value) -> ClientResult { + let reply = self.call("record.query", to_payload(¶ms)?).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// All nodes in the dependency graph. + pub async fn graph_nodes(&self) -> ClientResult> { + let reply = self.call("graph.nodes", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// All edges in the dependency graph. + pub async fn graph_edges(&self) -> ClientResult> { + let reply = self.call("graph.edges", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Record keys in topological order. + pub async fn graph_topo_order(&self) -> ClientResult> { + let reply = self.call("graph.topo_order", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Reset stage-profiling counters (server built with `profiling`; needs write + /// permission). + pub async fn reset_stage_profiling(&self) -> ClientResult { + let reply = self.call("profiling.reset", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Reset buffer-metrics counters (server built with `metrics`; needs write + /// permission). + pub async fn reset_buffer_metrics(&self) -> ClientResult { + let reply = self.call("buffer_metrics.reset", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + /// Issue a raw RPC and map a transport/engine failure to [`ClientError`]. async fn call(&self, method: &str, params: Payload) -> ClientResult { self.handle.call(method, params).await.map_err(rpc_err) From 13d9063d4113af90fbb87c8ef705763b575bb247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 05:46:42 +0000 Subject: [PATCH 08/34] Refactor AimxClient to AimxConnection across aimdb-cli and aimdb-mcp - Updated dependencies in Cargo.toml to include futures crate. - Replaced instances of AimxClient with AimxConnection in graph, record, and watch commands in aimdb-cli. - Modified live output formatting to accommodate changes in event structure. - Refactored connection handling in aimdb-mcp to use AimxConnection instead of AimxClient. - Ensured all relevant functions and tests are updated to reflect the new connection structure. --- Cargo.lock | 2 + Makefile | 38 +- aimdb-client/src/connection.rs | 292 --- aimdb-client/src/discovery.rs | 4 +- aimdb-client/src/lib.rs | 36 +- aimdb-core/Cargo.toml | 3 + aimdb-core/src/builder.rs | 16 +- aimdb-core/src/remote/handler.rs | 1759 ----------------- aimdb-core/src/remote/mod.rs | 5 +- aimdb-core/src/remote/query.rs | 32 + aimdb-core/src/remote/supervisor.rs | 191 -- .../tests/drain_integration_tests.rs | 29 +- examples/remote-access-demo/Cargo.toml | 1 + examples/remote-access-demo/src/client.rs | 693 +------ tools/aimdb-cli/Cargo.toml | 1 + tools/aimdb-cli/src/commands/graph.rs | 10 +- tools/aimdb-cli/src/commands/record.rs | 8 +- tools/aimdb-cli/src/commands/watch.rs | 49 +- tools/aimdb-cli/src/output/live.rs | 98 +- tools/aimdb-mcp/src/connection.rs | 20 +- tools/aimdb-mcp/src/resources/records.rs | 4 +- tools/aimdb-mcp/src/tools/architecture.rs | 4 +- tools/aimdb-mcp/src/tools/buffer_metrics.rs | 10 +- tools/aimdb-mcp/src/tools/graph.rs | 14 +- tools/aimdb-mcp/src/tools/instance.rs | 4 +- tools/aimdb-mcp/src/tools/profiling.rs | 10 +- tools/aimdb-mcp/src/tools/record.rs | 18 +- tools/aimdb-mcp/src/tools/schema.rs | 6 +- 28 files changed, 313 insertions(+), 3044 deletions(-) delete mode 100644 aimdb-client/src/connection.rs delete mode 100644 aimdb-core/src/remote/handler.rs create mode 100644 aimdb-core/src/remote/query.rs delete mode 100644 aimdb-core/src/remote/supervisor.rs diff --git a/Cargo.lock b/Cargo.lock index bce86bb..c6fa289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "chrono", "clap", "colored", + "futures", "serde", "serde_json", "serde_yaml", @@ -2563,6 +2564,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-tokio-adapter", + "futures", "serde", "serde_json", "tokio", diff --git a/Makefile b/Makefile index 18987dd..1d7a7d9 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,15 @@ # AimDB Makefile # Simple automation for common development tasks -.PHONY: help build test clean fmt fmt-check clippy doc all check test-embedded test-wasm wasm wasm-test examples deny audit security publish publish-check +.PHONY: help build test clean clean-embedded fmt fmt-check clippy doc all check test-embedded test-wasm wasm wasm-test examples deny audit security publish publish-check .DEFAULT_GOAL := help +# Separate target dir for embedded checks so an interrupted example build +# (cargo build --target thumbv7em-none-eabihf) cannot leave corrupted .rmeta +# files that break the next cargo check run (E0786). Clean it with +# `make clean-embedded`. +EMBEDDED_CHECK_TARGET_DIR := target/embedded-check + # Colors for output GREEN := \033[0;32m YELLOW := \033[0;33m @@ -255,6 +261,12 @@ doc: clean: @printf "$(GREEN)Cleaning...$(NC)\n" cargo clean + @rm -rf $(EMBEDDED_CHECK_TARGET_DIR) + +clean-embedded: + @printf "$(GREEN)Cleaning embedded check artifacts...$(NC)\n" + @rm -rf $(EMBEDDED_CHECK_TARGET_DIR) + cargo clean --target thumbv7em-none-eabihf ## Testing commands test-wasm: @@ -266,29 +278,29 @@ test-wasm: test-embedded: @printf "$(BLUE)Testing embedded/MCU cross-compilation compatibility...$(NC)\n" @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std minimal) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features "alloc,json-serialize" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,json-serialize" @printf "$(YELLOW) → Checking aimdb-core (no_std/embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-embassy-adapter on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with network support on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-net-support" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,embassy-net-support" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with profiling on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,profiling" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,profiling" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with metrics on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,metrics" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,metrics" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" @printf "$(YELLOW) → Checking aimdb-knx-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-knx-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" + cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" ## Example projects examples: diff --git a/aimdb-client/src/connection.rs b/aimdb-client/src/connection.rs deleted file mode 100644 index 02b7ad9..0000000 --- a/aimdb-client/src/connection.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! AimX Client Connection -//! -//! Async client for connecting to AimDB instances via Unix domain sockets. - -use crate::error::{ClientError, ClientResult}; -use crate::protocol::{ - cli_hello, parse_message, serialize_message, Event, EventMessage, RecordMetadata, Request, - RequestExt, Response, ResponseExt, WelcomeMessage, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; -use tokio::net::UnixStream; - -/// Timeout for connection operations -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); - -/// AimX protocol client -pub struct AimxClient { - socket_path: PathBuf, - stream: OwnedWriteHalf, - reader: BufReader, - request_id_counter: u64, - server_info: WelcomeMessage, -} - -impl AimxClient { - /// Connect to an AimDB instance - pub async fn connect(socket_path: impl AsRef) -> ClientResult { - let socket_path = socket_path.as_ref().to_path_buf(); - - // Connect with timeout - let stream = tokio::time::timeout(CONNECTION_TIMEOUT, UnixStream::connect(&socket_path)) - .await - .map_err(|_| { - ClientError::connection_failed( - socket_path.display().to_string(), - "connection timeout", - ) - })? - .map_err(|e| { - ClientError::connection_failed(socket_path.display().to_string(), e.to_string()) - })?; - - // Split into reader and writer - let (reader_stream, writer_stream) = stream.into_split(); - - let reader = BufReader::new(reader_stream); - let mut client = Self { - socket_path, - stream: writer_stream, - reader, - request_id_counter: 0, - server_info: WelcomeMessage { - version: String::new(), - server: String::new(), - permissions: Vec::new(), - writable_records: Vec::new(), - max_subscriptions: None, - authenticated: None, - }, - }; - - // Perform handshake - client.handshake().await?; - - Ok(client) - } - - /// Perform protocol handshake - async fn handshake(&mut self) -> ClientResult<()> { - // Send Hello - let hello = cli_hello(); - self.write_message(&hello).await?; - - // Receive Welcome - let welcome: WelcomeMessage = self.read_message().await?; - self.server_info = welcome; - - Ok(()) - } - - /// Get server information - pub fn server_info(&self) -> &WelcomeMessage { - &self.server_info - } - - /// Send a request and wait for response - async fn send_request( - &mut self, - method: &str, - params: Option, - ) -> ClientResult { - self.request_id_counter += 1; - let id = self.request_id_counter; - - let request = if let Some(params) = params { - Request::with_params(id, method, params) - } else { - Request::new(id, method) - }; - - self.write_message(&request).await?; - - let response: Response = self.read_message().await?; - - match response.into_result() { - Ok(result) => Ok(result), - Err(error) => Err(ClientError::server_error( - error.code, - error.message, - error.details, - )), - } - } - - /// List all registered records - pub async fn list_records(&mut self) -> ClientResult> { - let result = self.send_request("record.list", None).await?; - let records: Vec = serde_json::from_value(result)?; - Ok(records) - } - - /// Reset stage profiling counters for every record on the server. - /// - /// Requires the server to be built with the `profiling` feature and the - /// connection to have write permission. - pub async fn reset_stage_profiling(&mut self) -> ClientResult { - self.send_request("profiling.reset", None).await - } - - /// Reset buffer introspection counters for every record on the server. - /// - /// Requires the server to be built with the `metrics` feature and the - /// connection to have write permission. - pub async fn reset_buffer_metrics(&mut self) -> ClientResult { - self.send_request("buffer_metrics.reset", None).await - } - - /// Get current value of a record - pub async fn get_record(&mut self, name: &str) -> ClientResult { - let params = json!({ "record": name }); - self.send_request("record.get", Some(params)).await - } - - /// Set value of a writable record - pub async fn set_record( - &mut self, - name: &str, - value: serde_json::Value, - ) -> ClientResult { - let params = json!({ - "name": name, - "value": value - }); - self.send_request("record.set", Some(params)).await - } - - /// Subscribe to record updates - pub async fn subscribe(&mut self, name: &str, queue_size: usize) -> ClientResult { - let params = json!({ - "name": name, - "queue_size": queue_size - }); - let result = self.send_request("record.subscribe", Some(params)).await?; - - let subscription_id = result["subscription_id"] - .as_str() - .ok_or_else(|| { - ClientError::Other(anyhow::anyhow!("Missing subscription_id in response")) - })? - .to_string(); - - Ok(subscription_id) - } - - /// Unsubscribe from record updates - pub async fn unsubscribe(&mut self, subscription_id: &str) -> ClientResult<()> { - let params = json!({ "subscription_id": subscription_id }); - self.send_request("record.unsubscribe", Some(params)) - .await?; - Ok(()) - } - - /// Receive next event from subscription - pub async fn receive_event(&mut self) -> ClientResult { - let event_msg: EventMessage = self.read_message().await?; - Ok(event_msg.event) - } - - /// Drain all pending values from a record's drain reader. - /// - /// Returns all values accumulated since the last drain call, - /// in chronological order. This is a destructive read — drained - /// values will not be returned again. - /// - /// The first call for a given record creates the drain reader and - /// returns empty (cold start). Subsequent calls return accumulated values. - pub async fn drain_record(&mut self, name: &str) -> ClientResult { - let params = json!({ "name": name }); - let result = self.send_request("record.drain", Some(params)).await?; - let response: DrainResponse = serde_json::from_value(result)?; - Ok(response) - } - - /// Drain with a limit on the number of values returned. - pub async fn drain_record_with_limit( - &mut self, - name: &str, - limit: u32, - ) -> ClientResult { - let params = json!({ - "name": name, - "limit": limit, - }); - let result = self.send_request("record.drain", Some(params)).await?; - let response: DrainResponse = serde_json::from_value(result)?; - Ok(response) - } - - // ======================================================================== - // Graph Introspection Methods - // ======================================================================== - - /// Get all nodes in the dependency graph. - /// - /// Returns a list of GraphNode objects representing all records - /// and their connections in the database. - pub async fn graph_nodes(&mut self) -> ClientResult> { - let result = self.send_request("graph.nodes", None).await?; - let nodes: Vec = serde_json::from_value(result)?; - Ok(nodes) - } - - /// Get all edges in the dependency graph. - /// - /// Returns a list of GraphEdge objects representing data flow - /// connections between records. - pub async fn graph_edges(&mut self) -> ClientResult> { - let result = self.send_request("graph.edges", None).await?; - let edges: Vec = serde_json::from_value(result)?; - Ok(edges) - } - - /// Get the topological ordering of records. - /// - /// Returns the record keys in topological order, ensuring all - /// dependencies are listed before dependents. Useful for understanding - /// data flow and initialization order. - pub async fn graph_topo_order(&mut self) -> ClientResult> { - let result = self.send_request("graph.topo_order", None).await?; - let order: Vec = serde_json::from_value(result)?; - Ok(order) - } - - /// Write a message to the stream - async fn write_message(&mut self, msg: &T) -> ClientResult<()> { - let data = serialize_message(msg)?; - self.stream.write_all(data.as_bytes()).await?; - self.stream.flush().await?; - Ok(()) - } - - /// Read a message from the stream - async fn read_message serde::Deserialize<'de>>(&mut self) -> ClientResult { - let mut line = String::new(); - self.reader.read_line(&mut line).await?; - - if line.is_empty() { - return Err(ClientError::connection_failed( - self.socket_path.display().to_string(), - "connection closed by server", - )); - } - - parse_message(&line).map_err(|e| e.into()) - } -} - -/// Response from a record.drain call -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DrainResponse { - /// Echo of the queried record name - pub record_name: String, - /// Chronologically ordered values (raw JSON, as written by the producer) - pub values: Vec, - /// Number of values returned - pub count: usize, -} diff --git a/aimdb-client/src/discovery.rs b/aimdb-client/src/discovery.rs index c93ded2..c682620 100644 --- a/aimdb-client/src/discovery.rs +++ b/aimdb-client/src/discovery.rs @@ -2,7 +2,7 @@ //! //! Scans known directories for running AimDB instances. -use crate::connection::AimxClient; +use crate::engine::AimxConnection; use crate::error::{ClientError, ClientResult}; use crate::protocol::WelcomeMessage; use std::path::PathBuf; @@ -78,7 +78,7 @@ async fn probe_instance(socket_path: &PathBuf) -> ClientResult { // Try to connect with a short timeout let connect_timeout = Duration::from_millis(500); - let client = tokio::time::timeout(connect_timeout, AimxClient::connect(socket_path)) + let client = tokio::time::timeout(connect_timeout, AimxConnection::connect(socket_path)) .await .map_err(|_| { ClientError::connection_failed( diff --git a/aimdb-client/src/lib.rs b/aimdb-client/src/lib.rs index 70412d4..7d17311 100644 --- a/aimdb-client/src/lib.rs +++ b/aimdb-client/src/lib.rs @@ -1,50 +1,48 @@ //! AimDB Client Library //! -//! This library provides a client implementation for the AimX v1 remote access protocol, -//! enabling connections to running AimDB instances via Unix domain sockets. +//! This library provides a client implementation for the AimX remote access +//! protocol, enabling connections to running AimDB instances via Unix domain +//! sockets. //! //! ## Overview //! //! The client library offers: -//! - **Connection Management**: Async client for Unix domain socket communication -//! - **Protocol Implementation**: AimX v1 handshake and message handling +//! - **Connection Management**: [`AimxConnection`] over the shared session engine +//! - **Protocol Implementation**: the reshaped AimX-v2 handshake + RPC/streaming //! - **Instance Discovery**: Automatic detection of running AimDB instances -//! - **Record Operations**: List, get, set, subscribe to records +//! - **Record Operations**: list, get, set, subscribe, drain, graph, query //! //! ## Usage //! //! ```no_run -//! use aimdb_client::AimxClient; +//! use aimdb_client::AimxConnection; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // Connect to an AimDB instance -//! let mut client = AimxClient::connect("/tmp/aimdb.sock").await?; -//! +//! // Connect to an AimDB instance (performs the `hello` handshake). +//! let conn = AimxConnection::connect("/tmp/aimdb.sock").await?; +//! //! // List all records -//! let records = client.list_records().await?; +//! let records = conn.list_records().await?; //! println!("Found {} records", records.len()); -//! +//! //! // Get a specific record -//! let value = client.get_record("server::Temperature").await?; +//! let value = conn.get_record("server::Temperature").await?; //! println!("Temperature: {:?}", value); -//! +//! //! Ok(()) //! } //! ``` -pub mod connection; pub mod discovery; pub mod engine; pub mod error; pub mod protocol; -// Re-export main types for convenience -pub use connection::{AimxClient, DrainResponse}; -// Engine-based client (Phase 3) — the shared-session-engine replacement for the -// synchronous `AimxClient`. Both coexist until the server port retires the old one. +// Re-export main types for convenience. `AimxConnection` is the engine-based +// client (the synchronous `AimxClient` was retired with the AimX server port). pub use discovery::{discover_instances, find_instance, InstanceInfo}; -pub use engine::AimxConnection; +pub use engine::{AimxConnection, DrainResponse}; pub use error::{ClientError, ClientResult}; pub use protocol::{ cli_hello, parse_message, serialize_message, Event, EventMessage, RecordMetadata, Request, diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 9aca642..b8064c7 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -26,6 +26,9 @@ std = [ "json-serialize", "tokio", "aimdb-executor/std", + # AimX remote access now rides the shared session engine: `with_remote_access` + # builds the `session::aimx` server, so std pulls in the connector-session module. + "connector-session", ] # Heap allocation in no_std environments diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index c1ddd4e..ee3da9e 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -876,16 +876,19 @@ where #[cfg(feature = "tracing")] tracing::info!("Record future collection complete"); - // Collect the remote-access supervisor future, if configured (std only). + // Collect the AimX remote-access server future, if configured (std only). + // The server now rides the shared session engine (`session::aimx`), + // replacing the hand-rolled supervisor/handler loops. #[cfg(feature = "std")] if let Some(remote_cfg) = self.remote_config { #[cfg(feature = "tracing")] tracing::info!( - "Building remote access supervisor for socket: {}", + "Building AimX remote-access server for socket: {}", remote_cfg.socket_path.display() ); - // Apply security policy to mark writable records + // Apply security policy to mark writable records (so `record.list` + // reports the `writable` flag; the server also enforces the policy). let writable_keys = remote_cfg.security_policy.writable_records(); for key_str in writable_keys { if let Some(id) = inner.resolve_str(&key_str) { @@ -896,12 +899,11 @@ where } } - let supervisor_future = - crate::remote::supervisor::build_supervisor_future(db.clone(), remote_cfg)?; - futures_acc.push(supervisor_future); + let server_future = crate::session::aimx::build_aimx_server(db.clone(), remote_cfg)?; + futures_acc.push(server_future); #[cfg(feature = "tracing")] - tracing::info!("Remote access supervisor future collected"); + tracing::info!("AimX remote-access server future collected"); } // Collect connector futures. After issue #88 connector builders return diff --git a/aimdb-core/src/remote/handler.rs b/aimdb-core/src/remote/handler.rs deleted file mode 100644 index b9e3ed3..0000000 --- a/aimdb-core/src/remote/handler.rs +++ /dev/null @@ -1,1759 +0,0 @@ -//! Connection handler for AimX protocol -//! -//! Handles individual client connections, including handshake, authentication, -//! and protocol method dispatch. -//! -//! # Architecture: Event Funnel Pattern -//! -//! Subscriptions use a funnel pattern for clean event delivery: -//! - Each `record.subscribe` pushes a future onto a per-connection -//! [`futures_util::stream::FuturesUnordered`] that the connection's -//! outer `select!` loop drives. -//! - Subscription futures send events to a shared mpsc channel (the "funnel"). -//! - The same outer loop drains the funnel and writes events to the -//! `UnixStream`, so NDJSON line integrity is preserved without a -//! dedicated writer task. - -use crate::remote::{ - AimxConfig, Event, HelloMessage, RecordMetadata, Request, Response, WelcomeMessage, -}; -use crate::{AimDb, DbError, DbResult}; - -#[cfg(feature = "std")] -use std::collections::HashMap; -#[cfg(feature = "std")] -use std::sync::Arc; - -#[cfg(feature = "std")] -use futures_core::Stream; -#[cfg(feature = "std")] -use futures_util::stream::{FuturesUnordered, StreamExt}; -#[cfg(feature = "std")] -use serde_json::json; -#[cfg(feature = "std")] -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[cfg(feature = "std")] -use tokio::net::UnixStream; -#[cfg(feature = "std")] -use tokio::sync::{mpsc, Notify}; - -#[cfg(feature = "std")] -use crate::builder::BoxFuture; - -/// Connection state for managing subscriptions -/// -/// Tracks all active subscriptions for a single client connection. Each -/// subscription is identified by its `subscription_id` and carries an -/// `Arc` that `record.unsubscribe` fires to wake the -/// per-subscription future, which then exits on its next poll — -/// cancellation is immediate (no need to wait for the next stream -/// event). Connection teardown does not need to fire the notify — -/// dropping the per-connection `FuturesUnordered` (in -/// [`handle_connection`]) drops every subscription future, which is the -/// primary cancellation path. -#[cfg(feature = "std")] -struct ConnectionState { - /// Active subscriptions by subscription_id → cancel notify. - subscriptions: HashMap>, - - /// Counter for generating unique subscription IDs - next_subscription_id: u64, - - /// Event funnel: all subscription futures send events here. - /// This channel feeds the connection's send loop. - event_tx: mpsc::UnboundedSender, - - /// Per-record drain readers, created lazily on first record.drain call. - /// One drain reader per record, per connection. - drain_readers: HashMap>, -} - -#[cfg(feature = "std")] -impl ConnectionState { - /// Creates a new connection state - fn new(event_tx: mpsc::UnboundedSender) -> Self { - Self { - subscriptions: HashMap::new(), - next_subscription_id: 1, - event_tx, - drain_readers: HashMap::new(), - } - } - - /// Generates a unique subscription ID for this connection - fn generate_subscription_id(&mut self) -> String { - let id = format!("sub-{}", self.next_subscription_id); - self.next_subscription_id += 1; - id - } -} - -/// Handles an incoming client connection -/// -/// Processes the AimX protocol handshake and manages the client session. -/// Implements the event funnel pattern for subscription event delivery. -/// -/// # Architecture -/// -/// ```text -/// ┌──────────────────────┐ -/// │ Subscription future 1│───┐ -/// │ (in FuturesUnordered)│ │ -/// └──────────────────────┘ │ -/// ├──► Event Funnel ───► select! loop ───► UnixStream -/// ┌──────────────────────┐ │ (mpsc) (interleaved -/// │ Subscription future 2│───┘ writes) -/// │ (in FuturesUnordered)│ -/// └──────────────────────┘ -/// ``` -/// -/// The main loop uses `tokio::select! { biased; }` to interleave: -/// - Reading requests from the stream -/// - Writing events from subscriptions -/// - Draining completed subscription futures -/// -/// `biased;` polls the request arm first so a chatty subscription -/// cannot starve the request path. Cancellation is by drop: when the -/// outer loop exits, the per-connection `FuturesUnordered` is dropped -/// and every subscription future with it. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `stream` - Unix domain socket stream -/// -/// # Errors -/// Returns error if handshake fails or stream operations error -#[cfg(feature = "std")] -pub async fn handle_connection( - db: Arc>, - config: AimxConfig, - stream: UnixStream, -) -> DbResult<()> -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::info!("New remote access connection established"); - - // Perform protocol handshake - let mut stream = match perform_handshake(stream, &config, &db).await { - Ok(stream) => stream, - Err(e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Handshake failed: {}", e); - return Err(e); - } - }; - - #[cfg(feature = "tracing")] - tracing::info!("Handshake complete, client ready"); - - // Create event funnel: all subscription futures send events here - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - - // Initialize connection state - let mut conn_state = ConnectionState::new(event_tx); - - // Per-connection FuturesUnordered of subscription futures. Each - // `record.subscribe` pushes one future here; cancellation flows - // through `Arc` (Unsubscribe) or by dropping `subs` when the - // connection ends. - let mut subs: FuturesUnordered = FuturesUnordered::new(); - - // Main loop: interleave reading requests, writing events, and draining - // completed subscription futures. `biased;` keeps request reads polled - // first so a chatty subscription cannot starve the request path. - loop { - let mut line = String::new(); - - tokio::select! { - biased; - - // Handle incoming requests - read_result = stream.read_line(&mut line) => { - match read_result { - Ok(0) => { - // Client closed connection - #[cfg(feature = "tracing")] - tracing::info!("Client disconnected gracefully"); - break; - } - Ok(_) => { - #[cfg(feature = "tracing")] - tracing::debug!("Received request: {}", line.trim()); - - // Parse request - let request: Request = match serde_json::from_str(line.trim()) { - Ok(req) => req, - Err(e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse request: {}", e); - - // Send error response (use ID 0 if we can't parse the request) - let error_response = - Response::error(0, "parse_error", format!("Invalid JSON: {}", e)); - if let Err(_e) = send_response(&mut stream, &error_response).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send error response: {}", _e); - break; - } - continue; - } - }; - - // Dispatch request to appropriate handler - let response = handle_request( - &db, - &config, - &mut conn_state, - &mut subs, - request, - ) - .await; - - // Send response - if let Err(_e) = send_response(&mut stream, &response).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send response: {}", _e); - break; - } - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Error reading from stream: {}", _e); - break; - } - } - } - - // Handle outgoing events from subscriptions - Some(event) = event_rx.recv() => { - if let Err(_e) = send_event(&mut stream, &event).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send event: {}", _e); - break; - } - } - - // Drain finished subscription futures so `subs` does not grow - // unboundedly. Using `Some(_) = next()` (rather than - // `select_next_some()`) is the safe form: an empty - // `FuturesUnordered` reports `is_terminated() == true`, and - // `select_next_some` panics in that state. With the pattern - // guard the arm is simply disabled when `next()` resolves to - // `None`, and the always-active `read_line` arm keeps the - // select alive. - Some(_) = subs.next() => {} - } - } - - // Dropping `subs` here cancels every still-running subscription future - // — the connection's `FuturesUnordered` is their sole owner. - drop(subs); - - #[cfg(feature = "tracing")] - tracing::info!("Connection handler terminating"); - - Ok(()) -} - -/// Sends an event to the client -/// -/// Serializes the event to JSON and writes it to the stream with a newline. -/// -/// # Arguments -/// * `stream` - The connection stream -/// * `event` - The event to send -/// -/// # Errors -/// Returns error if serialization or write fails -#[cfg(feature = "std")] -async fn send_event(stream: &mut BufReader, event: &Event) -> DbResult<()> { - // Wrap event in protocol envelope - let event_msg = json!({ "event": event }); - - let event_json = serde_json::to_string(&event_msg).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize event".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(event_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write event".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write event newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::trace!("Sent event for subscription: {}", event.subscription_id); - - Ok(()) -} - -/// Sends a response to the client -/// -/// Serializes the response to JSON and writes it to the stream with a newline. -/// -/// # Arguments -/// * `stream` - The connection stream -/// * `response` - The response to send -/// -/// # Errors -/// Returns error if serialization or write fails -#[cfg(feature = "std")] -async fn send_response(stream: &mut BufReader, response: &Response) -> DbResult<()> { - let response_json = serde_json::to_string(response).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize response".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(response_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write response".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write response newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!("Sent response"); - - Ok(()) -} - -/// Performs the AimX protocol handshake -/// -/// Handshake flow: -/// 1. Client sends HelloMessage with protocol version -/// 2. Server validates version compatibility -/// 3. Server sends WelcomeMessage with accepted version -/// 4. Optional: Authenticate with token -/// -/// # Arguments -/// * `stream` - Unix domain socket stream -/// * `config` - Remote access configuration -/// * `db` - Database instance (for querying writable records) -/// -/// # Returns -/// `BufReader` if handshake succeeds -/// -/// # Errors -/// Returns error if: -/// - Protocol version incompatible -/// - Authentication fails -/// - IO error during handshake -#[cfg(feature = "std")] -async fn perform_handshake( - stream: UnixStream, - config: &AimxConfig, - db: &Arc>, -) -> DbResult> -where - R: crate::RuntimeAdapter + 'static, -{ - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - - // Read Hello message from client - let mut line = String::new(); - reader - .read_line(&mut line) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to read Hello message".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!("Received handshake: {}", line.trim()); - - // Parse Hello message - let hello: HelloMessage = - serde_json::from_str(line.trim()).map_err(|e| DbError::JsonWithContext { - context: "Failed to parse Hello message".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!( - "Client hello: version={}, client={}", - hello.version, - hello.client - ); - - // Version validation: accept "1.0" or "1" - if hello.version != "1.0" && hello.version != "1" { - let error_msg = format!( - r#"{{"error":"unsupported_version","message":"Server supports version 1.0, client requested {}"}}"#, - hello.version - ); - - #[cfg(feature = "tracing")] - tracing::warn!("Unsupported version: {}", hello.version); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::InvalidOperation { - operation: "handshake".to_string(), - reason: format!("Unsupported version: {}", hello.version), - }); - } - - // Check authentication if required - let authenticated = if let Some(expected_token) = &config.auth_token { - match &hello.auth_token { - Some(provided_token) if provided_token == expected_token => { - #[cfg(feature = "tracing")] - tracing::debug!("Authentication successful"); - true - } - Some(_) => { - let error_msg = - r#"{"error":"authentication_failed","message":"Invalid auth token"}"#; - - #[cfg(feature = "tracing")] - tracing::warn!("Authentication failed: invalid token"); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::PermissionDenied { - operation: "authentication".to_string(), - }); - } - None => { - let error_msg = - r#"{"error":"authentication_required","message":"Auth token required"}"#; - - #[cfg(feature = "tracing")] - tracing::warn!("Authentication failed: no token provided"); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::PermissionDenied { - operation: "authentication".to_string(), - }); - } - } - } else { - false - }; - - // Determine permissions based on security policy - let permissions = match &config.security_policy { - crate::remote::SecurityPolicy::ReadOnly => vec!["read".to_string()], - crate::remote::SecurityPolicy::ReadWrite { .. } => { - vec!["read".to_string(), "write".to_string()] - } - }; - - // Get writable records by querying database for writable record names - let writable_records = match &config.security_policy { - crate::remote::SecurityPolicy::ReadOnly => vec![], - crate::remote::SecurityPolicy::ReadWrite { - writable_records: _writable_type_ids, - } => { - // Get all records from database - let all_records: Vec = db.list_records(); - - // Filter to those that are marked writable - all_records - .into_iter() - .filter(|meta| meta.writable) - .map(|meta| meta.name) - .collect() - } - }; - - // Send Welcome message - let welcome = WelcomeMessage { - version: "1.0".to_string(), - server: "aimdb".to_string(), - permissions, - writable_records, - max_subscriptions: Some(config.max_subs_per_connection), - authenticated: Some(authenticated), - }; - - let welcome_json = serde_json::to_string(&welcome).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize Welcome message".to_string(), - source: e, - })?; - - writer - .write_all(welcome_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write Welcome message".to_string(), - source: e, - })?; - - writer - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write Welcome newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!("Sent Welcome message to client"); - - // Reunite the stream - let stream = reader - .into_inner() - .reunite(writer) - .map_err(|e| DbError::Io { - source: std::io::Error::other(e.to_string()), - })?; - - Ok(BufReader::new(stream)) -} - -/// Handles a single request and returns a response -/// -/// Dispatches to the appropriate handler based on the request method. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `conn_state` - Connection state (for subscription management) -/// * `request` - The parsed request -/// -/// # Returns -/// Response to send to the client -#[cfg(feature = "std")] -async fn handle_request( - db: &Arc>, - config: &AimxConfig, - conn_state: &mut ConnectionState, - subs: &mut FuturesUnordered, - request: Request, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!( - "Handling request: method={}, id={}", - request.method, - request.id - ); - - match request.method.as_str() { - "record.list" => handle_record_list(db, config, request.id).await, - "record.get" => handle_record_get(db, config, request.id, request.params).await, - "record.set" => handle_record_set(db, config, request.id, request.params).await, - "record.subscribe" => { - handle_record_subscribe(db, config, conn_state, subs, request.id, request.params).await - } - "record.unsubscribe" => { - handle_record_unsubscribe(conn_state, request.id, request.params).await - } - "record.drain" => handle_record_drain(db, conn_state, request.id, request.params).await, - "record.query" => handle_record_query(db, request.id, request.params).await, - "graph.nodes" => handle_graph_nodes(db, request.id).await, - "graph.edges" => handle_graph_edges(db, request.id).await, - "graph.topo_order" => handle_graph_topo_order(db, request.id).await, - #[cfg(feature = "profiling")] - "profiling.reset" => handle_profiling_reset(db, config, request.id).await, - #[cfg(feature = "metrics")] - "buffer_metrics.reset" => handle_buffer_metrics_reset(db, config, request.id).await, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Unknown method: {}", request.method); - - Response::error( - request.id, - "method_not_found", - format!("Unknown method: {}", request.method), - ) - } - } -} - -/// Handles record.list method -/// -/// Returns metadata for all registered records in the database. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of RecordMetadata -#[cfg(feature = "std")] -async fn handle_record_list( - db: &Arc>, - _config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Listing records"); - - // Get all record metadata from database - let records: Vec = db.list_records(); - - #[cfg(feature = "tracing")] - tracing::debug!("Found {} records", records.len()); - - // Convert to JSON and return - Response::success(request_id, json!(records)) -} - -/// Handles profiling.reset method -/// -/// Clears stage profiling counters for every record. Requires write permission. -#[cfg(all(feature = "std", feature = "profiling"))] -async fn handle_profiling_reset( - db: &Arc>, - config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - if matches!( - config.security_policy, - crate::remote::SecurityPolicy::ReadOnly - ) { - return Response::error( - request_id, - "permission_denied", - "profiling.reset requires write permission (ReadOnly security policy)".to_string(), - ); - } - - db.reset_stage_profiling(); - - #[cfg(feature = "tracing")] - tracing::info!("Stage profiling counters reset"); - - Response::success(request_id, json!({ "reset": true })) -} - -/// Handles buffer_metrics.reset method -/// -/// Clears buffer introspection counters for every record. Requires write permission. -#[cfg(all(feature = "std", feature = "metrics"))] -async fn handle_buffer_metrics_reset( - db: &Arc>, - config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - if matches!( - config.security_policy, - crate::remote::SecurityPolicy::ReadOnly - ) { - return Response::error( - request_id, - "permission_denied", - "buffer_metrics.reset requires write permission (ReadOnly security policy)".to_string(), - ); - } - - db.reset_buffer_metrics(); - - #[cfg(feature = "tracing")] - tracing::info!("Buffer metrics counters reset"); - - Response::success(request_id, json!({ "reset": true })) -} - -/// Handles record.get method -/// -/// Returns the current value of a record as JSON. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "record" field with record name) -/// -/// # Returns -/// Success response with record value as JSON, or error if: -/// - Missing/invalid "record" parameter -/// - Record not found -/// - Record not configured with `.with_remote_access()` -/// - No value available in atomic snapshot -#[cfg(feature = "std")] -async fn handle_record_get( - db: &Arc>, - _config: &AimxConfig, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(map)) => match map.get("record") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'record' parameter"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'record' parameter".to_string(), - ); - } - }, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Getting value for record: {}", record_name); - - // Try to peek the record's JSON value - match db.try_latest_as_json(&record_name) { - Some(value) => { - #[cfg(feature = "tracing")] - tracing::debug!("Successfully retrieved value for {}", record_name); - - Response::success(request_id, value) - } - None => { - #[cfg(feature = "tracing")] - tracing::warn!("No value available for record: {}", record_name); - - Response::error( - request_id, - "not_found", - format!("No value available for record: {}", record_name), - ) - } - } -} - -/// Handles record.set method -/// -/// Sets a record value from JSON (write operation). -/// -/// **SAFETY:** Enforces the "No Producer Override" rule: -/// - Only allows writes to configuration records (producer_count == 0) -/// - Prevents remote access from interfering with application logic -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" and "value" fields) -/// -/// # Returns -/// Success response, or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Permission denied (not writable or has active producers) -/// - Deserialization failed -#[cfg(feature = "std")] -async fn handle_record_set( - db: &Arc>, - config: &AimxConfig, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - use crate::remote::SecurityPolicy; - - // Check if write operations are allowed - let writable_records = match &config.security_policy { - SecurityPolicy::ReadOnly => { - #[cfg(feature = "tracing")] - tracing::warn!("record.set called but security policy is ReadOnly"); - - return Response::error( - request_id, - "permission_denied", - "Write operations not allowed (ReadOnly security policy)".to_string(), - ); - } - SecurityPolicy::ReadWrite { writable_records } => writable_records, - }; - - // Extract record name and value from params - let (record_name, value) = match params { - Some(serde_json::Value::Object(ref map)) => { - let name = match map.get("name") { - Some(serde_json::Value::String(n)) => n.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'name' parameter in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }; - - let val = match map.get("value") { - Some(v) => v.clone(), - None => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing 'value' parameter in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing 'value' parameter".to_string(), - ); - } - }; - - (name, val) - } - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Setting value for record: {}", record_name); - - // Check if record is in the writable_records set (using record key) - if !writable_records.contains(&record_name) { - #[cfg(feature = "tracing")] - tracing::warn!("Record '{}' not in writable_records set", record_name); - - return Response::error( - request_id, - "permission_denied", - format!( - "Record '{}' is not writable. \ - Configure with .with_writable_record() to allow writes.", - record_name - ), - ); - } - - // Attempt to set the value - // This will enforce the "no producer override" rule internally - match db.set_record_from_json(&record_name, value) { - Ok(()) => { - #[cfg(feature = "tracing")] - tracing::info!("Successfully set value for record: {}", record_name); - - // Get the updated value to return in response - let result = if let Some(updated_value) = db.try_latest_as_json(&record_name) { - serde_json::json!({ - "status": "success", - "value": updated_value, - }) - } else { - serde_json::json!({ - "status": "success", - }) - }; - - Response::success(request_id, result) - } - Err(e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to set value for record '{}': {}", record_name, e); - - // Map internal errors to appropriate response codes - let (code, message) = match e { - crate::DbError::RecordKeyNotFound { key } => { - ("not_found", format!("Record '{}' not found", key)) - } - crate::DbError::PermissionDenied { operation } => { - // This is the "has active producers" error - ("permission_denied", operation) - } - crate::DbError::JsonWithContext { context, .. } => ( - "validation_error", - format!("JSON validation failed: {}", context), - ), - crate::DbError::RuntimeError { message } => ("internal_error", message), - _ => ("internal_error", format!("Failed to set value: {}", e)), - }; - - Response::error(request_id, code, message) - } - } -} - -/// Handles record.subscribe method -/// -/// Subscribes to live updates for a record. Pushes a per-subscription -/// future onto the connection's [`FuturesUnordered`] (`subs`) — there is -/// no `tokio::spawn`; the connection's outer loop drives the future. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `conn_state` - Connection state (for subscription tracking) -/// * `subs` - Per-connection set of subscription futures (this fn pushes one) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" field with record name) -/// -/// # Returns -/// Success response with subscription_id or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Too many subscriptions -#[cfg(feature = "std")] -async fn handle_record_subscribe( - db: &Arc>, - config: &AimxConfig, - conn_state: &mut ConnectionState, - subs: &mut FuturesUnordered, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("name") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'name' parameter in record.subscribe"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object in record.subscribe"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - // Optional: send_initial flag (default true) - let _send_initial = params - .as_ref() - .and_then(|p| p.as_object()) - .and_then(|map| map.get("send_initial")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - #[cfg(feature = "tracing")] - tracing::debug!("Subscribing to record: {}", record_name); - - // Check max subscriptions per connection. - if conn_state.subscriptions.len() >= config.max_subs_per_connection { - #[cfg(feature = "tracing")] - tracing::warn!( - "Too many subscriptions: {} (max: {})", - conn_state.subscriptions.len(), - config.max_subs_per_connection - ); - - return Response::error( - request_id, - "too_many_subscriptions", - format!( - "Maximum subscriptions reached: {}", - config.max_subs_per_connection - ), - ); - } - - // Subscribe to the record's JSON event stream - let value_stream = match crate::remote::stream::stream_record_updates(db, &record_name) { - Ok(s) => s, - Err(e) => { - // Map internal errors to appropriate response codes - let (code, message) = match &e { - crate::DbError::RecordKeyNotFound { key } => { - #[cfg(feature = "tracing")] - tracing::warn!("Record not found: {}", key); - ("not_found", format!("Record '{}' not found", key)) - } - _ => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to subscribe to record updates: {}", e); - ("internal_error", format!("Failed to subscribe: {}", e)) - } - }; - - return Response::error(request_id, code, message); - } - }; - - // Generate unique subscription ID and cancel notify - let subscription_id = conn_state.generate_subscription_id(); - let cancel = Arc::new(Notify::new()); - - // Push the subscription future onto the connection's set. The future - // exits — and is therefore dropped — when either the cancel notify - // fires (Unsubscribe, immediate) or the outer connection loop exits - // (drops `subs`, which drops the future). - let event_tx = conn_state.event_tx.clone(); - let sub_id_for_future = subscription_id.clone(); - let cancel_for_future = cancel.clone(); - subs.push(Box::pin(run_subscription( - value_stream, - sub_id_for_future, - event_tx, - cancel_for_future, - ))); - - conn_state - .subscriptions - .insert(subscription_id.clone(), cancel); - - #[cfg(feature = "tracing")] - tracing::info!( - "Created subscription {} for record {}", - subscription_id, - record_name - ); - - // Return success response - Response::success( - request_id, - json!({ - "subscription_id": subscription_id, - }), - ) -} - -/// Per-subscription future: forwards JSON values from the record stream -/// into the connection's event funnel as `Event` messages with sequence -/// numbers and RFC-style "secs.nanos" timestamps. -/// -/// Exits when any of: -/// - the `cancel` notify fires (by `record.unsubscribe`) — wakes the -/// future immediately; the in-flight `stream.next()` is cancelled by -/// `select!` losing its arm, which drops the underlying -/// `JsonBufferReader` even if the record is currently quiet; -/// - the upstream stream ends (e.g. `BufferClosed`); -/// - the event funnel is closed (connection going down). -/// -/// Connection-close cancellation does not rely on the notify — the -/// connection's `FuturesUnordered` is the sole owner of this future and -/// dropping the set drops the future. -#[cfg(feature = "std")] -async fn run_subscription( - stream: S, - subscription_id: String, - event_tx: mpsc::UnboundedSender, - cancel: Arc, -) where - S: Stream + Send + 'static, -{ - futures_util::pin_mut!(stream); - let mut sequence: u64 = 1; - - #[cfg(feature = "tracing")] - tracing::debug!( - "Subscription future started for subscription: {}", - subscription_id - ); - - loop { - // `biased;` polls the cancel arm first so a notify issued while - // a value is also ready terminates the subscription rather than - // emitting one more event. `Notify` stores a permit if no - // waiter is parked, so a notify-before-first-poll is honoured - // on the first iteration. - let json_value = tokio::select! { - biased; - - _ = cancel.notified() => { - #[cfg(feature = "tracing")] - tracing::debug!( - "Subscription {} cancelled via Unsubscribe", - subscription_id - ); - break; - } - - maybe_value = stream.next() => match maybe_value { - Some(v) => v, - None => break, - }, - }; - - // Generate timestamp in "secs.nanosecs" format - let duration = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let timestamp = format!("{}.{:09}", duration.as_secs(), duration.subsec_nanos()); - - let event = Event { - subscription_id: subscription_id.clone(), - sequence, - data: json_value, - timestamp, - dropped: None, // TODO: Implement dropped event tracking - }; - - if event_tx.send(event).is_err() { - #[cfg(feature = "tracing")] - tracing::debug!( - "Event channel closed, terminating subscription: {}", - subscription_id - ); - break; - } - - sequence += 1; - } - - #[cfg(feature = "tracing")] - tracing::debug!("Subscription future terminated: {}", subscription_id); -} - -/// Handles record.unsubscribe method -/// -/// Cancels an active subscription. -/// -/// # Arguments -/// * `conn_state` - Connection state (for subscription tracking) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "subscription_id" field) -/// -/// # Returns -/// Success response, or error if subscription not found -#[cfg(feature = "std")] -async fn handle_record_unsubscribe( - conn_state: &mut ConnectionState, - request_id: u64, - params: Option, -) -> Response { - // Parse subscription_id parameter - let subscription_id = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("subscription_id") { - Some(serde_json::Value::String(id)) => id.clone(), - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'subscription_id' parameter".to_string(), - ) - } - }, - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing 'subscription_id' parameter".to_string(), - ) - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Unsubscribing from subscription_id: {}", subscription_id); - - // Look up and remove the subscription. Firing the notify wakes the - // per-subscription future immediately — the `biased;` cancel arm in - // `run_subscription`'s select! returns next, the stream-poll future - // is dropped (releasing the underlying `JsonBufferReader` even if - // the record is quiet), and the subscription future exits. It is - // then reaped from `subs` by the connection's outer drain loop. - match conn_state.subscriptions.remove(&subscription_id) { - Some(cancel) => { - cancel.notify_one(); - - #[cfg(feature = "tracing")] - tracing::debug!("Cancelled subscription {}", subscription_id); - - Response::success( - request_id, - serde_json::json!({ - "subscription_id": subscription_id, - "status": "cancelled" - }), - ) - } - None => { - #[cfg(feature = "tracing")] - tracing::warn!("Subscription not found: {}", subscription_id); - - Response::error( - request_id, - "not_found", - format!("Subscription '{}' not found", subscription_id), - ) - } - } -} - -/// Handles record.drain method -/// -/// Drains all pending values from a record's drain reader. On the first call for -/// a given record, creates a dedicated drain reader (returns empty). Subsequent -/// calls return all values accumulated since the previous drain. -/// -/// # Arguments -/// * `db` - Database instance -/// * `conn_state` - Connection state (for drain reader management) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" field, optional "limit") -/// -/// # Returns -/// Success response with `record_name`, `values` array, and `count`, or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Record not configured with `.with_remote_access()` -#[cfg(feature = "std")] -async fn handle_record_drain( - db: &Arc>, - conn_state: &mut ConnectionState, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("name") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }, - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - // Optional: limit parameter - // Use try_from instead of `as` to avoid silent truncation on 32-bit targets - // (values that don't fit in usize are treated as "no limit"). - let limit = params - .as_ref() - .and_then(|p| p.as_object()) - .and_then(|map| map.get("limit")) - .and_then(|v| v.as_u64()) - .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) - .unwrap_or(usize::MAX); - - #[cfg(feature = "tracing")] - tracing::debug!( - "Draining record: {} (limit: {})", - record_name, - if limit == usize::MAX { - "all".to_string() - } else { - limit.to_string() - } - ); - - // Lazily create drain reader on first call for this record - if !conn_state.drain_readers.contains_key(&record_name) { - // Resolve record key → RecordId → AnyRecord → subscribe_json() - let id = match db.inner().resolve_str(&record_name) { - Some(id) => id, - None => { - return Response::error( - request_id, - "not_found", - format!("Record '{}' not found", record_name), - ); - } - }; - - let record = match db.inner().storage(id) { - Some(r) => r, - None => { - return Response::error( - request_id, - "not_found", - format!("Record '{}' storage not found", record_name), - ); - } - }; - - let reader = match record.subscribe_json() { - Ok(r) => r, - Err(e) => { - return Response::error( - request_id, - "remote_access_not_enabled", - format!( - "Record '{}' not configured with .with_remote_access(): {}", - record_name, e - ), - ); - } - }; - - conn_state.drain_readers.insert(record_name.clone(), reader); - } - - // Drain all pending values from the reader - let reader = conn_state.drain_readers.get_mut(&record_name).unwrap(); - let mut values = Vec::new(); - - loop { - if values.len() >= limit { - break; - } - match reader.try_recv_json() { - Ok(val) => values.push(val), - Err(DbError::BufferEmpty) => break, - Err(DbError::BufferLagged { .. }) => { - // Ring overflowed since last drain — cursor resets. - // Log warning, keep draining. - #[cfg(feature = "tracing")] - tracing::warn!( - "Drain reader lagged for record '{}' — some values were lost", - record_name - ); - continue; - } - Err(_) => break, - } - } - - let count = values.len(); - - #[cfg(feature = "tracing")] - tracing::debug!("Drained {} values from record '{}'", count, record_name); - - Response::success( - request_id, - json!({ - "record_name": record_name, - "values": values, - "count": count, - }), - ) -} - -// ============================================================================ -// Persistence Query (record.query) -// ============================================================================ - -/// Type-erased query handler registered by `aimdb-persistence` via Extensions. -/// -/// This keeps `aimdb-core` free of persistence-specific imports. The handler is -/// a boxed async function that accepts query parameters (record pattern, limit, -/// start/end timestamps) and returns a JSON value with the results. -/// -/// Registered by `aimdb_persistence` via the `with_persistence()` builder extension. -pub type QueryHandlerFn = Box< - dyn Fn( - QueryHandlerParams, - ) -> core::pin::Pin< - Box> + Send>, - > + Send - + Sync, ->; - -/// Parameters for the type-erased query handler. -#[derive(Debug, Clone)] -pub struct QueryHandlerParams { - /// Record pattern (supports `*` wildcard). - pub name: String, - /// Maximum results per matching record. - pub limit: Option, - /// Optional start timestamp (Unix ms). - pub start: Option, - /// Optional end timestamp (Unix ms). - pub end: Option, -} - -/// Handles `record.query` method. -/// -/// Delegates to a [`QueryHandlerFn`] stored in the database's `Extensions` -/// TypeMap. If no handler is registered (i.e. persistence is not configured), -/// returns an error. -#[cfg(feature = "std")] -async fn handle_record_query( - db: &Arc>, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract the query handler from Extensions. - let handler = match db.extensions().get::() { - Some(h) => h, - None => { - return Response::error( - request_id, - "not_configured", - "Persistence not configured. Call .with_persistence() on the builder.".to_string(), - ); - } - }; - - // Parse parameters - let (name, limit, start, end) = match ¶ms { - Some(serde_json::Value::Object(map)) => { - let name = map - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("*") - .to_string(); - let limit = map - .get("limit") - .and_then(|v| v.as_u64()) - .and_then(|v| usize::try_from(v).ok()); - let start = map.get("start").and_then(|v| v.as_u64()); - let end = map.get("end").and_then(|v| v.as_u64()); - (name, limit, start, end) - } - _ => ("*".to_string(), None, None, None), - }; - - let query_params = QueryHandlerParams { - name, - limit, - start, - end, - }; - - match handler(query_params).await { - Ok(result) => Response::success(request_id, result), - Err(msg) => Response::error(request_id, "query_error", msg), - } -} - -// ============================================================================ -// Graph Introspection Methods -// ============================================================================ - -/// Handles graph.nodes method -/// -/// Returns all nodes in the dependency graph with their metadata. -/// Each node represents a record with its origin, buffer type, and connections. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of GraphNode objects: -/// - `key`: Record key (e.g., "temp.vienna") -/// - `origin`: How the record gets its values (source, link, transform, passive) -/// - `buffer_type`: Buffer type ("spmc_ring", "single_latest", "mailbox", "none") -/// - `buffer_capacity`: Optional buffer capacity -/// - `tap_count`: Number of taps attached -/// - `has_outbound_link`: Whether an outbound connector is configured -#[cfg(feature = "std")] -async fn handle_graph_nodes(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting dependency graph nodes"); - - let graph = db.inner().dependency_graph(); - let nodes = &graph.nodes; - - #[cfg(feature = "tracing")] - tracing::debug!("Returning {} graph nodes", nodes.len()); - - Response::success(request_id, json!(nodes)) -} - -/// Handles graph.edges method -/// -/// Returns all edges in the dependency graph representing data flow between records. -/// Edges are directed from source to target and include the edge type. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of GraphEdge objects: -/// - `from`: Source record key -/// - `to`: Target record key -/// - `edge_type`: Type of connection (TransformInput, TransformJoinInput, etc.) -#[cfg(feature = "std")] -async fn handle_graph_edges(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting dependency graph edges"); - - let graph = db.inner().dependency_graph(); - let edges = &graph.edges; - - #[cfg(feature = "tracing")] - tracing::debug!("Returning {} graph edges", edges.len()); - - Response::success(request_id, json!(edges)) -} - -/// Handles graph.topo_order method -/// -/// Returns the topological ordering of records in the dependency graph. -/// This ordering ensures that all dependencies are processed before dependents. -/// Used for spawn ordering and understanding data flow. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of record keys in topological order: -/// - Sources and passive records first -/// - Transform outputs after their inputs -/// - Respects the DAG structure for proper initialization order -#[cfg(feature = "std")] -async fn handle_graph_topo_order(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting topological order"); - - let graph = db.inner().dependency_graph(); - let topo_order = graph.topo_order(); - - #[cfg(feature = "tracing")] - tracing::debug!( - "Returning topological order with {} records", - topo_order.len() - ); - - Response::success(request_id, json!(topo_order)) -} - -#[cfg(all(test, feature = "std"))] -mod tests { - use super::*; - use futures_core::Stream; - use std::pin::Pin; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - use std::task::{Context, Poll}; - - /// A `Stream` that never yields a value but - /// flips a flag when dropped. Used to verify that dropping the - /// per-connection `FuturesUnordered` drops the per-subscription - /// future, which in turn drops its underlying record stream — the - /// invariant the AimX spawn-free refactor depends on for cancellation - /// on connection close. - struct DropTracker { - dropped: Arc, - } - - impl Drop for DropTracker { - fn drop(&mut self) { - self.dropped.store(true, Ordering::SeqCst); - } - } - - impl Stream for DropTracker { - type Item = serde_json::Value; - fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - // Park forever; we only care that the stream gets dropped. - Poll::Pending - } - } - - #[tokio::test] - async fn dropping_subs_set_drops_subscription_stream() { - let dropped = Arc::new(AtomicBool::new(false)); - let stream = DropTracker { - dropped: dropped.clone(), - }; - - let (event_tx, _event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let mut subs: FuturesUnordered = FuturesUnordered::new(); - subs.push(Box::pin(run_subscription( - stream, - "sub-1".to_string(), - event_tx, - cancel, - ))); - - // Drive the set once so the future is actually pinned/installed. - tokio::task::yield_now().await; - let _ = futures_util::future::poll_fn(|cx| { - let _ = Pin::new(&mut subs).poll_next(cx); - Poll::Ready(()) - }) - .await; - - assert!( - !dropped.load(Ordering::SeqCst), - "drop must not have fired yet" - ); - - // Dropping the set drops every contained future, which in turn - // drops the stream owned by `run_subscription`. - drop(subs); - - assert!( - dropped.load(Ordering::SeqCst), - "dropping the FuturesUnordered must drop the subscription stream" - ); - } - - #[tokio::test] - async fn unsubscribe_terminates_subscription_immediately() { - use futures_util::stream::unfold; - - // Channel-backed stream so the future is parked on `stream.next()` - // with no value pending — the whole point of switching from - // `AtomicBool` to `Notify` is that we no longer need a second - // value to wake the future. The notify itself must wake it. - let (val_tx, val_rx) = mpsc::unbounded_channel::(); - let values = unfold( - val_rx, - |mut rx| async move { rx.recv().await.map(|v| (v, rx)) }, - ); - - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let cancel_for_future = cancel.clone(); - let handle = tokio::spawn(run_subscription( - values, - "sub-1".to_string(), - event_tx, - cancel_for_future, - )); - - // Feed one value and confirm it propagates as an Event. - val_tx.send(serde_json::json!({"v": 1})).unwrap(); - let event = event_rx.recv().await.expect("expected one event"); - assert_eq!(event.subscription_id, "sub-1"); - - // Fire the cancel notify WITHOUT feeding any further values. - // The future is parked on `stream.next()` over an empty - // channel; the notify must wake it via the `biased;` cancel - // arm of `select!`, even though the underlying stream is quiet. - cancel.notify_one(); - - // The future must complete promptly on its own — no abort, - // no further values needed. Timeout caps the test in case - // immediate cancellation is silently broken. - tokio::time::timeout(std::time::Duration::from_secs(1), handle) - .await - .expect("subscription future should exit promptly after notify_one()") - .expect("future panicked"); - - // And no further event should have been produced. - assert!( - event_rx.try_recv().is_err(), - "no further events should be sent after cancel" - ); - } - - #[tokio::test] - async fn dropping_subs_set_drops_inner_stream_state() { - // Stronger integration-style check: a real channel-backed stream - // (the same shape `stream_record_updates` returns via `unfold`) - // is held inside a `run_subscription` future, which is held by a - // `FuturesUnordered`. Dropping the set must drop the channel's - // receiver, which we observe by `val_tx.send(...)` failing. - use futures_util::stream::unfold; - - let (val_tx, val_rx) = mpsc::unbounded_channel::(); - let values = unfold( - val_rx, - |mut rx| async move { rx.recv().await.map(|v| (v, rx)) }, - ); - - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let mut subs: FuturesUnordered = FuturesUnordered::new(); - subs.push(Box::pin(run_subscription( - values, - "sub-1".to_string(), - event_tx, - cancel, - ))); - - // Drive the set until the subscription is observably alive. - val_tx.send(serde_json::json!({"v": 1})).unwrap(); - tokio::select! { - event = event_rx.recv() => { - assert_eq!(event.unwrap().subscription_id, "sub-1"); - } - _ = subs.next() => panic!("subscription future ended unexpectedly"), - } - - // Connection going away: drop the whole set. This must drop the - // boxed future, which drops the stream, which drops `val_rx`. - drop(subs); - - assert!( - val_tx.send(serde_json::json!({"v": 2})).is_err(), - "after dropping the FuturesUnordered, the inner stream's \ - receiver must be dropped — `send` is the observable proxy" - ); - } -} diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index b1ad5bc..b9f48b4 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -37,15 +37,14 @@ mod config; mod error; mod metadata; mod protocol; +mod query; pub use config::{AimxConfig, SecurityPolicy}; pub use error::{RemoteError, RemoteResult}; -pub use handler::{QueryHandlerFn, QueryHandlerParams}; pub use metadata::RecordMetadata; pub use protocol::{ErrorObject, Event, HelloMessage, Request, Response, WelcomeMessage}; +pub use query::{QueryHandlerFn, QueryHandlerParams}; // Internal exports for implementation -pub(crate) mod handler; #[cfg(feature = "std")] pub(crate) mod stream; -pub(crate) mod supervisor; diff --git a/aimdb-core/src/remote/query.rs b/aimdb-core/src/remote/query.rs new file mode 100644 index 0000000..b2595ea --- /dev/null +++ b/aimdb-core/src/remote/query.rs @@ -0,0 +1,32 @@ +//! Type-erased persistence query handler for the AimX `record.query` method. +//! +//! Kept free of persistence-specific imports so `aimdb-core` need not depend on +//! `aimdb-persistence`: the handler is a boxed async function registered in the +//! database's `Extensions` TypeMap by `aimdb_persistence::with_persistence()`, +//! and invoked by the AimX server dispatch when a client calls `record.query`. + +/// Type-erased query handler registered by `aimdb-persistence` via Extensions. +/// +/// A boxed async function that accepts query parameters (record pattern, limit, +/// start/end timestamps) and returns a JSON value with the results. +pub type QueryHandlerFn = Box< + dyn Fn( + QueryHandlerParams, + ) -> core::pin::Pin< + Box> + Send>, + > + Send + + Sync, +>; + +/// Parameters for the type-erased query handler. +#[derive(Debug, Clone)] +pub struct QueryHandlerParams { + /// Record pattern (supports `*` wildcard). + pub name: String, + /// Maximum results per matching record. + pub limit: Option, + /// Optional start timestamp (Unix ms). + pub start: Option, + /// Optional end timestamp (Unix ms). + pub end: Option, +} diff --git a/aimdb-core/src/remote/supervisor.rs b/aimdb-core/src/remote/supervisor.rs deleted file mode 100644 index 28b6004..0000000 --- a/aimdb-core/src/remote/supervisor.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Remote access supervisor -//! -//! Manages the Unix domain socket server and drives per-connection -//! handlers for remote clients connecting via the AimX protocol. Each -//! accepted connection is pushed onto a per-supervisor -//! [`FuturesUnordered`]; there is no `tokio::spawn`. - -use crate::builder::BoxFuture; -use crate::remote::AimxConfig; -use crate::{AimDb, DbError, DbResult}; - -#[cfg(feature = "std")] -use std::sync::Arc; - -#[cfg(feature = "std")] -use std::os::unix::fs::PermissionsExt; - -#[cfg(feature = "std")] -use futures_util::stream::{FuturesUnordered, StreamExt}; -#[cfg(feature = "std")] -use tokio::net::UnixListener; - -/// Builds the remote access supervisor future. -/// -/// Synchronously: binds the Unix domain socket and sets file permissions -/// (so binding errors surface from `build()` rather than at task-start time). -/// -/// The returned [`BoxFuture`] is appended to the `AimDbRunner` accumulator; -/// when driven, it accepts incoming connections in a loop and pushes each -/// per-connection handler onto a [`FuturesUnordered`]. `tokio::select!` -/// with `biased;` keeps `accept` polled ahead of connection drains so a -/// chatty client cannot starve new connects. -/// -/// # Arguments -/// * `db` - Database instance (for introspection and subscriptions) -/// * `config` - Remote access configuration -/// -/// # Errors -/// Returns error if: -/// - Socket path already exists and cannot be removed -/// - Socket binding fails -/// - Permission setting fails -#[cfg(feature = "std")] -pub fn build_supervisor_future(db: Arc>, config: AimxConfig) -> DbResult -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::info!( - "Initializing remote access supervisor on socket: {}", - config.socket_path.display() - ); - - // Remove existing socket file if it exists - if config.socket_path.exists() { - #[cfg(feature = "tracing")] - tracing::debug!( - "Removing existing socket file: {}", - config.socket_path.display() - ); - - std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to remove existing socket file {}", - config.socket_path.display() - ), - source: e, - })?; - } - - // Bind to Unix domain socket - let listener = UnixListener::bind(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to bind Unix socket at {}", - config.socket_path.display() - ), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!( - "Unix socket bound successfully: {}", - config.socket_path.display() - ); - - // Set socket file permissions - let mut perms = std::fs::metadata(&config.socket_path) - .map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to read socket metadata for {}", - config.socket_path.display() - ), - source: e, - })? - .permissions(); - - let permissions = config.socket_permissions.unwrap_or(0o600); - perms.set_mode(permissions); - - std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to set socket permissions for {}", - config.socket_path.display() - ), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!("Socket permissions set to {:o}", permissions); - - // The accept loop is the future the runner drives. Per-connection - // handler futures live in a `FuturesUnordered` owned by this future; - // dropping the supervisor (e.g. when the runner is cancelled) drops - // every active connection in turn. - let supervisor_future: BoxFuture = Box::pin(async move { - #[cfg(feature = "tracing")] - tracing::info!("Remote access supervisor task started"); - - let mut connections: FuturesUnordered = FuturesUnordered::new(); - - loop { - tokio::select! { - biased; - - // Accept the next incoming connection - accept_res = listener.accept() => match accept_res { - Ok((stream, _addr)) => { - // Refuse if we are already at the connection cap. - // The accepted `UnixStream` is dropped, which closes - // the socket; the client sees a closed connection. - // - // `connections.len()` is conservative: a connection - // future that has completed but not yet been yielded - // by `connections.next()` still counts. With - // `biased;` the drain arm only runs once `accept` - // returns Pending, so back-to-back accepts can see - // a transiently inflated count after a disconnect - // burst. Erring toward refusing one extra client - // is fine — the cap is a soft ceiling, not an SLA. - if connections.len() >= config.max_connections { - #[cfg(feature = "tracing")] - tracing::warn!( - "max_connections={} reached, refusing new client", - config.max_connections - ); - drop(stream); - continue; - } - - #[cfg(feature = "tracing")] - tracing::debug!("Accepted new client connection"); - - let db_clone = db.clone(); - let config_clone = config.clone(); - connections.push(Box::pin(async move { - if let Err(_e) = crate::remote::handler::handle_connection( - db_clone, - config_clone, - stream, - ) - .await - { - #[cfg(feature = "tracing")] - tracing::error!("Connection handler error: {}", _e); - } - - #[cfg(feature = "tracing")] - tracing::debug!("Connection handler terminated"); - })); - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to accept connection: {}", _e); - // Continue accepting other connections despite error - } - }, - - // Drain finished connection futures. Using `Some(_) = next()` - // (rather than `select_next_some()`) is the safe form: an - // empty `FuturesUnordered` reports `is_terminated() == true`, - // and `select_next_some` panics in that state. With the - // pattern guard, the arm is simply disabled when `next()` - // resolves to `None`, and the always-active `accept` - // arm keeps the select alive. - Some(_) = connections.next() => {} - } - } - }); - - Ok(supervisor_future) -} diff --git a/aimdb-tokio-adapter/tests/drain_integration_tests.rs b/aimdb-tokio-adapter/tests/drain_integration_tests.rs index 347de22..a9132ed 100644 --- a/aimdb-tokio-adapter/tests/drain_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/drain_integration_tests.rs @@ -2,13 +2,14 @@ //! //! Tests the full record history drain pipeline: //! - AimDB server with remote access enabled -//! - AimxClient connecting via Unix domain socket +//! - AimxConnection connecting via Unix domain socket //! - record.drain protocol method (cold start, accumulation, limits, overflow) //! //! These tests exercise: handler.rs dispatch → ConnectionState.drain_readers → //! JsonReaderAdapter.try_recv_json() → TokioBufferReader.try_recv() -use aimdb_client::{AimxClient, DrainResponse}; +use aimdb_client::AimxConnection; +use aimdb_client::DrainResponse; use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::AimDbBuilder; @@ -142,7 +143,7 @@ async fn test_drain_cold_start_returns_empty() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; // Connect client - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // First drain creates the reader — returns empty (cold start) let response = client.drain_record("test::Temperature").await.unwrap(); @@ -169,7 +170,7 @@ async fn test_drain_returns_accumulated_values() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // First drain — cold start let response = client.drain_record("test::Temperature").await.unwrap(); @@ -223,7 +224,7 @@ async fn test_drain_sequential_only_new_values() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -293,7 +294,7 @@ async fn test_drain_with_limit() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -358,7 +359,7 @@ async fn test_drain_single_latest_at_most_one() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Counter").await.unwrap(); @@ -399,7 +400,7 @@ async fn test_drain_nonexistent_record_error() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Drain a record that doesn't exist let result = client.drain_record("test::DoesNotExist").await; @@ -421,7 +422,7 @@ async fn test_drain_requires_remote_access() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Drain a record that exists but lacks .with_remote_access() let result = client.drain_record("test::Counter").await; @@ -446,7 +447,7 @@ async fn test_drain_with_ring_overflow() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start — creates the drain reader let _ = client.drain_record("test::Counter").await.unwrap(); @@ -494,7 +495,7 @@ async fn test_drain_multiple_records_independent() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start both records let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -545,7 +546,7 @@ async fn test_drain_response_structure() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -590,7 +591,7 @@ async fn test_drain_with_zero_limit() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -635,7 +636,7 @@ async fn test_drain_independent_of_other_consumers() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start the drain reader let _ = client.drain_record("test::Temperature").await.unwrap(); diff --git a/examples/remote-access-demo/Cargo.toml b/examples/remote-access-demo/Cargo.toml index bc683fe..53fb889 100644 --- a/examples/remote-access-demo/Cargo.toml +++ b/examples/remote-access-demo/Cargo.toml @@ -27,6 +27,7 @@ aimdb-tokio-adapter = { path = "../../aimdb-tokio-adapter", features = [ ] } aimdb-client = { path = "../../aimdb-client" } tokio = { version = "1.48", features = ["full"] } +futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0", features = ["derive"] } diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs index 0822cca..3c45646 100644 --- a/examples/remote-access-demo/src/client.rs +++ b/examples/remote-access-demo/src/client.rs @@ -1,658 +1,169 @@ //! Remote Access Demo - Client //! -//! Simple client that connects to the demo server and calls record.list +//! Connects to the demo server over the engine-based [`AimxConnection`] (the +//! shared session engine + reshaped AimX-v2 wire) and walks through the AimX +//! surface: list / get / set, the producer-override safety check, `record.drain` +//! history, and a live subscription. //! //! Run with: //! ``` //! cargo run --bin client //! ``` -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; - -#[derive(Debug, Serialize)] -struct Request { - id: u64, - method: String, - #[serde(skip_serializing_if = "Option::is_none")] - params: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum Response { - Success { id: u64, result: serde_json::Value }, - Error { id: u64, error: ErrorObject }, -} - -#[derive(Debug, Deserialize)] -struct EventMessage { - event: Event, -} - -#[derive(Debug, Deserialize)] -struct Event { - subscription_id: String, - sequence: u64, - timestamp: String, - data: serde_json::Value, - #[serde(default)] - dropped: Option, -} +use std::time::Duration; -#[derive(Debug, Deserialize)] -struct ErrorObject { - code: String, - message: String, - #[serde(default)] - details: Option, -} - -#[derive(Debug, Deserialize)] -struct WelcomeMessage { - version: String, - server: String, - permissions: Vec, - writable_records: Vec, - #[serde(default)] - max_subscriptions: Option, - #[serde(default)] - #[allow(dead_code)] // Parsed from JSON but not used in demo - authenticated: Option, -} - -fn main() -> Result<(), Box> { - println!("🔌 Connecting to AimDB server..."); +use aimdb_client::AimxConnection; +use futures::StreamExt; +use serde_json::json; +#[tokio::main] +async fn main() -> Result<(), Box> { let socket_path = "/tmp/aimdb-demo.sock"; - let mut stream = UnixStream::connect(socket_path).map_err(|e| { - format!( - "Failed to connect to {}: {}\nMake sure the server is running!", - socket_path, e - ) - })?; - - let mut reader = BufReader::new(stream.try_clone()?); + println!("🔌 Connecting to AimDB server at {socket_path} ..."); - println!("✅ Connected!"); - println!(); - - // Send Hello message - println!("📤 Sending handshake..."); - let hello = json!({ - "version": "1.0", - "client": "aimdb-demo-client", - "capabilities": [], - }); - - writeln!(stream, "{}", hello)?; - stream.flush()?; - - // Read Welcome message - let mut line = String::new(); - reader.read_line(&mut line)?; + let conn = AimxConnection::connect(socket_path).await.map_err(|e| { + format!("Failed to connect to {socket_path}: {e}\nMake sure the server is running!") + })?; - let welcome: WelcomeMessage = serde_json::from_str(&line)?; - println!("📥 Received welcome from server: {}", welcome.server); + let welcome = conn.server_info(); + println!("✅ Connected! Welcome from server: {}", welcome.server); println!(" Version: {}", welcome.version); println!(" Permissions: {:?}", welcome.permissions); println!(" Writable records: {:?}", welcome.writable_records); - println!(" Max subscriptions: {:?}", welcome.max_subscriptions); println!(); - // Send record.list request + // ── record.list ────────────────────────────────────────────────────── println!("📤 Requesting record list..."); - let request = Request { - id: 1, - method: "record.list".to_string(), - params: None, - }; - - let request_json = serde_json::to_string(&request)?; - writeln!(stream, "{}", request_json)?; - stream.flush()?; - - // Read response - let mut response_line = String::new(); - reader.read_line(&mut response_line)?; - - let response: Response = serde_json::from_str(&response_line)?; - - match response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("📋 Registered Records:"); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - if let Some(details) = error.details { - println!(" Details: {}", details); - } - } + let records = conn.list_records().await?; + println!("📋 {} registered records:", records.len()); + for r in &records { + println!(" • {} ({})", r.record_key, r.name); } - println!(); // ── Point-in-time reads: record.get ────────────────────────────────── - // record.get serves a single "current value", so it only works on buffers - // that have a canonical latest. SingleLatest (Config/AppSettings, below) - // does. SpmcRing does NOT: a ring keeps a *history* for independent - // consumers, so there is no one "latest" to return — record.get answers - // not_found by design. Read rings with record.drain (history) or - // record.subscribe (live), both demonstrated further down. - - println!("📤 record.get on Temperature (SpmcRing — expecting not_found)..."); - let get_request = Request { - id: 2, - method: "record.get".to_string(), - params: Some(json!({"record": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&get_request)?)?; - stream.flush()?; - - let mut get_response_line = String::new(); - reader.read_line(&mut get_response_line)?; - let get_response: Response = serde_json::from_str(&get_response_line)?; - - match get_response { - Response::Error { id, error } if error.code == "not_found" => { - println!("✅ Expected not_found (request_id: {}): {}", id, error.message); - println!( - " ℹ️ Rings have no point-in-time latest — use record.drain / record.subscribe (below)." - ); - } - Response::Success { id, result } => { - println!("⚠️ Unexpected success (request_id: {}):", id); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + // A SpmcRing keeps a *history* for independent consumers, so there is no one + // "latest" — record.get answers not_found by design. Read rings with + // record.drain (history) or record.subscribe (live), both shown below. + println!("📤 record.get on Temperature (SpmcRing — expecting an error)..."); + match conn.get_record("server::Temperature").await { + Ok(v) => println!("⚠️ Unexpected success: {v}"), + Err(_) => println!( + "✅ Expected error — rings have no point-in-time latest; use drain / subscribe." + ), } - println!(); - // Test record.get for Config println!("📤 record.get on Config (SingleLatest — point-in-time read)..."); - let config_request = Request { - id: 4, - method: "record.get".to_string(), - params: Some(json!({"record": "server::Config"})), - }; - - let config_request_json = serde_json::to_string(&config_request)?; - writeln!(stream, "{}", config_request_json)?; - stream.flush()?; - - let mut config_response_line = String::new(); - reader.read_line(&mut config_response_line)?; - let config_response: Response = serde_json::from_str(&config_response_line)?; - - match config_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("⚙️ Current Config:"); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + match conn.get_record("server::Config").await { + Ok(v) => println!("⚙️ Current Config:\n{}", serde_json::to_string_pretty(&v)?), + Err(e) => println!("❌ Error: {e}"), } - println!(); + // ── record.set (write operations) ──────────────────────────────────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("✍️ Testing record.set (Write Operations)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); - // Test 1: Get current AppSettings - println!("📤 Getting current AppSettings..."); - let get_settings_request = Request { - id: 5, - method: "record.get".to_string(), - params: Some(json!({"record": "server::AppSettings"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&get_settings_request)?)?; - stream.flush()?; - - let mut settings_response_line = String::new(); - reader.read_line(&mut settings_response_line)?; - let settings_response: Response = serde_json::from_str(&settings_response_line)?; - - match settings_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("⚙️ Original AppSettings:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); + println!("📤 Current AppSettings:"); + match conn.get_record("server::AppSettings").await { + Ok(v) => println!("{}\n", serde_json::to_string_pretty(&v)?), + Err(e) => { + println!("❌ Error: {e}"); return Ok(()); } - }; + } - // Test 2: Modify and set new AppSettings println!("📤 Updating AppSettings (enabling feature_flag_alpha)..."); let new_settings = json!({ "log_level": "debug", "max_connections": 200, "feature_flag_alpha": true }); - - let set_request = Request { - id: 6, - method: "record.set".to_string(), - params: Some(json!({ - "name": "server::AppSettings", - "value": new_settings - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&set_request)?)?; - stream.flush()?; - - let mut set_response_line = String::new(); - reader.read_line(&mut set_response_line)?; - let set_response: Response = serde_json::from_str(&set_response_line)?; - - match set_response { - Response::Success { id, result } => { - println!("✅ Success! record.set completed (request_id: {})", id); - println!(); - println!("✨ Updated AppSettings:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - if let Some(details) = error.details { - println!(" Details: {}", details); - } + match conn.set_record("server::AppSettings", new_settings).await { + Ok(v) => println!("✅ record.set ok:\n{}\n", serde_json::to_string_pretty(&v)?), + Err(e) => { + println!("❌ Error: {e}"); return Ok(()); } } - // Test 3: Verify the change by getting again - println!("📤 Verifying update by getting AppSettings again..."); - let verify_request = Request { - id: 7, - method: "record.get".to_string(), - params: Some(json!({"record": "server::AppSettings"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&verify_request)?)?; - stream.flush()?; - - let mut verify_response_line = String::new(); - reader.read_line(&mut verify_response_line)?; - let verify_response: Response = serde_json::from_str(&verify_response_line)?; - - match verify_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("✔️ Verified - AppSettings after update:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + println!("📤 Verifying update..."); + match conn.get_record("server::AppSettings").await { + Ok(v) => println!("✔️ AppSettings after update:\n{}\n", serde_json::to_string_pretty(&v)?), + Err(e) => println!("❌ Error: {e}"), } - // Test 4: Try to set Temperature (should fail - has producer) + // ── Safety: overriding a record with a producer must be denied ──────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("🛡️ Testing Safety: Try to override Temperature (has producer)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - + println!("🛡️ Safety: try to override Temperature (has producer)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); println!("📤 Attempting to set Temperature (SHOULD FAIL)..."); - let bad_set_request = Request { - id: 8, - method: "record.set".to_string(), - params: Some(json!({ - "name": "server::Temperature", - "value": { - "sensor_id": "hacked-sensor", - "celsius": 999.9, - "timestamp": 0 - } - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&bad_set_request)?)?; - stream.flush()?; - - let mut bad_set_response_line = String::new(); - reader.read_line(&mut bad_set_response_line)?; - let bad_set_response: Response = serde_json::from_str(&bad_set_response_line)?; - - match bad_set_response { - Response::Success { id, result } => { - println!("❌ UNEXPECTED! record.set succeeded when it should have failed!"); - println!(" Request ID: {}", id); - println!(" Result: {}", result); - println!(" ⚠️ This is a security issue - producer protection not working!"); - } - Response::Error { id, error } => { - println!( - "✅ EXPECTED FAILURE! Safety check worked (request_id: {})", - id - ); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - println!(" 🛡️ Protection confirmed: Cannot override records with producers"); - if let Some(details) = error.details { - println!(" Details: {}", details); - } - } + match conn + .set_record( + "server::Temperature", + json!({ "sensor_id": "hacked", "celsius": 999.9, "timestamp": 0.0 }), + ) + .await + { + Ok(v) => println!("❌ UNEXPECTED success — producer protection failed: {v}"), + Err(_) => println!("✅ Expected failure — cannot override a record with a producer.\n"), } - println!(); - + // ── record.drain (history) ─────────────────────────────────────────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("🧪 Testing Record History (record.drain)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - - // Drain #1: Cold start — creates the drain reader, returns empty - println!("📤 Drain #1: Creating drain reader for Temperature (cold start)..."); - let drain1_request = Request { - id: 9, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain1_request)?)?; - stream.flush()?; - - let mut drain1_line = String::new(); - reader.read_line(&mut drain1_line)?; - let drain1_response: Response = serde_json::from_str(&drain1_line)?; - - match &drain1_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - println!("✅ Drain #1 response (request_id: {})", id); - println!(" Values returned: {} (expected 0 on cold start)", count); - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #1 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - // Wait for values to accumulate (server produces every 2s) - println!("⏳ Waiting 7 seconds for temperature readings to accumulate..."); - std::thread::sleep(std::time::Duration::from_secs(7)); - - // Drain #2: Should return accumulated values (~3 readings at 2s interval) - println!("📤 Drain #2: Fetching accumulated Temperature history..."); - let drain2_request = Request { - id: 10, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain2_request)?)?; - stream.flush()?; - - let mut drain2_line = String::new(); - reader.read_line(&mut drain2_line)?; - let drain2_response: Response = serde_json::from_str(&drain2_line)?; - - match &drain2_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - let values = result["values"].as_array(); - println!("✅ Drain #2 response (request_id: {})", id); - println!(" Values returned: {} (expected ~3)", count); - if let Some(vals) = values { - for (i, val) in vals.iter().enumerate() { - let celsius = val["celsius"].as_f64().unwrap_or(0.0); - let sensor = val["sensor_id"].as_str().unwrap_or("?"); - println!(" 📊 [{}] {:.1} °C from {}", i, celsius, sensor); - } - } - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #2 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - // Drain #3: Immediately after — should be empty (nothing new since last drain) - println!("📤 Drain #3: Immediate re-drain (should be empty)..."); - let drain3_request = Request { - id: 11, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain3_request)?)?; - stream.flush()?; - - let mut drain3_line = String::new(); - reader.read_line(&mut drain3_line)?; - let drain3_response: Response = serde_json::from_str(&drain3_line)?; - - match &drain3_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - println!("✅ Drain #3 response (request_id: {})", id); - println!( - " Values returned: {} (expected 0 — nothing new since last drain)", - count - ); - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #3 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + println!("🧪 Record History (record.drain)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + println!("📤 Drain #1: cold start (creates the cursor, returns empty)..."); + let d1 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (expected 0 on cold start)\n", d1.count); + + println!("⏳ Waiting 7s for temperature readings to accumulate..."); + tokio::time::sleep(Duration::from_secs(7)).await; + + println!("📤 Drain #2: accumulated history..."); + let d2 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (expected ~3)", d2.count); + for (i, v) in d2.values.iter().enumerate() { + let celsius = v["celsius"].as_f64().unwrap_or(0.0); + let sensor = v["sensor_id"].as_str().unwrap_or("?"); + println!(" 📊 [{i}] {celsius:.1} °C from {sensor}"); } - - // Drain #4: Test with limit parameter - println!("⏳ Waiting 5 seconds, then draining with limit=2..."); - std::thread::sleep(std::time::Duration::from_secs(5)); - - let drain4_request = Request { - id: 12, - method: "record.drain".to_string(), - params: Some(json!({ - "name": "server::Temperature", - "limit": 2 - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain4_request)?)?; - stream.flush()?; - - let mut drain4_line = String::new(); - reader.read_line(&mut drain4_line)?; - let drain4_response: Response = serde_json::from_str(&drain4_line)?; - - match &drain4_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - let values = result["values"].as_array(); - println!("✅ Drain #4 response (request_id: {})", id); - println!(" Values returned: {} (limit was 2)", count); - if let Some(vals) = values { - for (i, val) in vals.iter().enumerate() { - let celsius = val["celsius"].as_f64().unwrap_or(0.0); - let sensor = val["sensor_id"].as_str().unwrap_or("?"); - println!(" 📊 [{}] {:.1} °C from {}", i, celsius, sensor); - } - } - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #4 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("📡 Testing Subscriptions"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); - // Subscribe to Temperature updates - println!("📤 Subscribing to Temperature updates..."); - let subscribe_request = Request { - id: 13, - method: "record.subscribe".to_string(), - params: Some(json!({ - "name": "server::Temperature" - })), - }; - - let subscribe_json = serde_json::to_string(&subscribe_request)?; - writeln!(stream, "{}", subscribe_json)?; - stream.flush()?; + println!("📤 Drain #3: immediate re-drain (should be empty)..."); + let d3 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (expected 0)\n", d3.count); - // Read subscription response - let mut subscribe_response_line = String::new(); - reader.read_line(&mut subscribe_response_line)?; + println!("⏳ Waiting 5s, then draining with limit=2..."); + tokio::time::sleep(Duration::from_secs(5)).await; + let d4 = conn + .drain_record_with_limit("server::Temperature", 2) + .await?; + println!(" Values: {} (limit was 2)\n", d4.count); - let subscribe_response: Response = serde_json::from_str(&subscribe_response_line)?; - - let subscription_id = match subscribe_response { - Response::Success { id, result } => { - println!("✅ Subscribed! (request_id: {})", id); - let sub_id = result["subscription_id"].as_str().unwrap().to_string(); - println!(" Subscription ID: {}", sub_id); - println!(); - println!("📊 Receiving live temperature updates (will receive 5 events)..."); - println!(); - sub_id - } - Response::Error { id, error } => { - println!("❌ Subscription failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - return Ok(()); - } - }; + // ── Subscriptions ──────────────────────────────────────────────────── + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("📡 Subscriptions"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); - // Receive 5 events + println!("📤 Subscribing to Temperature (will receive 5 events)..."); + let mut stream = conn.subscribe("server::Temperature")?; for i in 1..=5 { - let mut event_line = String::new(); - reader.read_line(&mut event_line)?; - - // Try to parse as EventMessage - if let Ok(event_msg) = serde_json::from_str::(&event_line) { - let event = event_msg.event; - println!("📨 Event #{} (seq: {})", i, event.sequence); - println!(" Subscription: {}", event.subscription_id); - println!(" Timestamp: {}", event.timestamp); - if let Some(dropped) = event.dropped { - println!(" ⚠️ Dropped events: {}", dropped); - } - println!(" Data: {}", serde_json::to_string_pretty(&event.data)?); - println!(); - } else { - println!("⚠️ Received unexpected message: {}", event_line.trim()); - } - - // Small delay to show streaming behavior - std::thread::sleep(std::time::Duration::from_millis(500)); - } - - // Unsubscribe - println!("📤 Unsubscribing from Temperature..."); - let unsubscribe_request = Request { - id: 14, - method: "record.unsubscribe".to_string(), - params: Some(json!({ - "subscription_id": subscription_id - })), - }; - - let unsubscribe_json = serde_json::to_string(&unsubscribe_request)?; - writeln!(stream, "{}", unsubscribe_json)?; - stream.flush()?; - - // Read unsubscribe response - let mut unsubscribe_response_line = String::new(); - reader.read_line(&mut unsubscribe_response_line)?; - - // Parse response - filter out any stray events - let unsubscribe_response: Result = - serde_json::from_str(&unsubscribe_response_line); - - match unsubscribe_response { - Ok(Response::Success { id, result }) => { - println!("✅ Unsubscribed! (request_id: {})", id); - println!( - " Status: {}", - result["status"].as_str().unwrap_or("unknown") - ); - println!(); - } - Ok(Response::Error { id, error }) => { - println!("❌ Unsubscribe failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - Err(_) => { - // Might be a stray event, try reading next line - println!("⚠️ Received unexpected message, retrying..."); - let mut retry_line = String::new(); - reader.read_line(&mut retry_line)?; - match serde_json::from_str::(&retry_line) { - Ok(Response::Success { id, result }) => { - println!("✅ Unsubscribed! (request_id: {})", id); - println!( - " Status: {}", - result["status"].as_str().unwrap_or("unknown") - ); - println!(); - } - Ok(Response::Error { id, error }) => { - println!("❌ Unsubscribe failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - Err(e) => { - println!("⚠️ Failed to parse unsubscribe response: {}", e); - } + match stream.next().await { + Some(v) => println!("📨 Event #{i}: {}", serde_json::to_string(&v)?), + None => { + println!("⚠️ Stream ended early"); + break; } } } + // Dropping the stream stops local delivery (no explicit unsubscribe needed). + drop(stream); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); + println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("👋 Disconnecting..."); - Ok(()) } diff --git a/tools/aimdb-cli/Cargo.toml b/tools/aimdb-cli/Cargo.toml index f3f5f86..124dba9 100644 --- a/tools/aimdb-cli/Cargo.toml +++ b/tools/aimdb-cli/Cargo.toml @@ -25,6 +25,7 @@ aimdb-core = { version = "1.1.0", path = "../../aimdb-core", features = [ ] } serde = { version = "1", features = ["derive"] } serde_json = "1" +futures = "0.3" # CLI framework clap = { version = "4", features = ["derive", "cargo"] } diff --git a/tools/aimdb-cli/src/commands/graph.rs b/tools/aimdb-cli/src/commands/graph.rs index 0c22187..4c6e2aa 100644 --- a/tools/aimdb-cli/src/commands/graph.rs +++ b/tools/aimdb-cli/src/commands/graph.rs @@ -4,8 +4,8 @@ use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; use serde_json::Value; @@ -79,7 +79,7 @@ impl GraphCommand { async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let nodes = client.graph_nodes().await?; @@ -98,7 +98,7 @@ async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let edges = client.graph_edges().await?; @@ -117,7 +117,7 @@ async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let order = client.graph_topo_order().await?; @@ -136,7 +136,7 @@ async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResul async fn export_dot(socket: Option<&str>, name: &str) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let nodes = client.graph_nodes().await?; let edges = client.graph_edges().await?; diff --git a/tools/aimdb-cli/src/commands/record.rs b/tools/aimdb-cli/src/commands/record.rs index 5375e57..d83507c 100644 --- a/tools/aimdb-cli/src/commands/record.rs +++ b/tools/aimdb-cli/src/commands/record.rs @@ -2,8 +2,8 @@ use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; /// Record management commands @@ -89,7 +89,7 @@ async fn list_records( writable_only: bool, ) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let mut records = client.list_records().await?; @@ -113,7 +113,7 @@ async fn list_records( async fn get_record(name: &str, socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let value = client.get_record(name).await?; @@ -151,7 +151,7 @@ async fn set_record( } let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let result = client.set_record(name, value).await?; diff --git a/tools/aimdb-cli/src/commands/watch.rs b/tools/aimdb-cli/src/commands/watch.rs index 16dfad7..465f573 100644 --- a/tools/aimdb-cli/src/commands/watch.rs +++ b/tools/aimdb-cli/src/commands/watch.rs @@ -2,9 +2,10 @@ use crate::error::CliResult; use crate::output::live; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; +use futures::StreamExt; use tokio::signal; /// Watch a record for live updates @@ -50,54 +51,48 @@ async fn watch_record( max_count: usize, show_full: bool, ) -> CliResult<()> { + let _ = queue_size; // queue sizing is now an engine concern; kept as a CLI flag let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let conn = AimxConnection::connect(&instance.socket_path).await?; - // Subscribe to record - let subscription_id = client.subscribe(record_name, queue_size).await?; + // Subscribe to the record (the engine routes updates back by request id; no + // server-allocated subscription id to track). + let mut stream = conn.subscribe(record_name)?; - // Print start message - live::print_watch_start(record_name, &subscription_id); + live::print_watch_start(record_name); // Set up Ctrl+C handler let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { if signal::ctrl_c().await.is_ok() { let _ = cancel_tx.send(()); } }); - // Receive events - let mut count = 0; + // Receive updates. The reshaped wire carries no server sequence, so the + // watcher counts locally. + let mut count: u64 = 0; let unlimited = max_count == 0; loop { tokio::select! { - event = client.receive_event() => { - let event = event?; - - // Only show events for this subscription - if event.subscription_id == subscription_id { - live::print_event(&event, show_full); - - count += 1; - if !unlimited && count >= max_count { - break; + next = stream.next() => { + match next { + Some(data) => { + count += 1; + live::print_event(count, &data, show_full); + if !unlimited && count >= max_count as u64 { + break; + } } + None => break, // stream ended (record closed or subscribe rejected) } } - _ = &mut cancel_rx => { - // User pressed Ctrl+C - break; - } + _ = &mut cancel_rx => break, // Ctrl+C } } - // Unsubscribe cleanly - client.unsubscribe(&subscription_id).await?; - + // Dropping the stream stops local delivery (no explicit unsubscribe needed). live::print_watch_stop(); - Ok(()) } diff --git a/tools/aimdb-cli/src/output/live.rs b/tools/aimdb-cli/src/output/live.rs index 9424bed..d9a1954 100644 --- a/tools/aimdb-cli/src/output/live.rs +++ b/tools/aimdb-cli/src/output/live.rs @@ -1,77 +1,42 @@ //! Live Output Formatting (for watch command) -use aimdb_client::protocol::Event; -use chrono::{DateTime, Utc}; +use chrono::Utc; use colored::Colorize; -/// Format an event for live display -pub fn format_event(event: &Event, show_full: bool) -> String { - let mut output = String::new(); - - // Parse timestamp - let timestamp = parse_timestamp(&event.timestamp); - let time_str = timestamp +/// Format a subscription update for live display. +/// +/// The reshaped AimX-v2 wire drops the server-minted `timestamp`/`dropped` +/// fields, so the watcher stamps the receipt time locally and tracks its own +/// sequence counter; `data` is the decoded record value. +pub fn format_event(seq: u64, data: &serde_json::Value, show_full: bool) -> String { + let time_str = Utc::now() .format("%Y-%m-%d %H:%M:%S%.3f") .to_string() .dimmed(); + let seq_str = format!("seq:{seq}").cyan(); - // Format sequence - let seq_str = format!("seq:{}", event.sequence).cyan(); - - // Build status line - output.push_str(&format!("{} | {} | ", time_str, seq_str)); - - // Show dropped events warning if any - if let Some(dropped) = event.dropped { - let warning = format!("⚠️ {} events dropped | ", dropped).yellow(); - output.push_str(&warning.to_string()); - } - - // Format data + let mut output = format!("{time_str} | {seq_str} | "); if show_full { - // Pretty print full JSON - if let Ok(formatted) = serde_json::to_string_pretty(&event.data) { + if let Ok(formatted) = serde_json::to_string_pretty(data) { output.push('\n'); output.push_str(&formatted); } else { - output.push_str(&format!("{}", event.data)); + output.push_str(&format!("{data}")); } } else { - // Compact single-line JSON - output.push_str(&format!("{}", event.data)); + output.push_str(&format!("{data}")); } - output } -/// Parse timestamp string to DateTime -/// Expects format: seconds.nanoseconds (e.g., "1730379296.123456789") -fn parse_timestamp(timestamp_str: &str) -> DateTime { - // Try parsing as floating point (seconds.nanoseconds) - if let Ok(secs_f64) = timestamp_str.parse::() { - let secs = secs_f64.trunc() as i64; - let nsec = ((secs_f64.fract() * 1_000_000_000.0).round()) as u32; - if let Some(dt) = DateTime::from_timestamp(secs, nsec) { - return dt; - } - } - - // Fallback to now if parsing fails - Utc::now() -} - -/// Print a live event to stdout -pub fn print_event(event: &Event, show_full: bool) { - println!("{}", format_event(event, show_full)); +/// Print a live update to stdout. +pub fn print_event(seq: u64, data: &serde_json::Value, show_full: bool) { + println!("{}", format_event(seq, data, show_full)); } /// Print subscription start message -pub fn print_watch_start(record_name: &str, subscription_id: &str) { - println!( - "📡 Watching record: {} (subscription: {})", - record_name.bold(), - subscription_id.dimmed() - ); +pub fn print_watch_start(record_name: &str) { + println!("📡 Watching record: {}", record_name.bold()); println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); } @@ -89,30 +54,19 @@ mod tests { #[test] fn test_format_event() { - let event = Event { - subscription_id: "sub123".to_string(), - sequence: 42, - timestamp: "1730379296".to_string(), - data: json!({"temperature": 23.5}), - dropped: None, - }; - - let formatted = format_event(&event, false); + let data = json!({"temperature": 23.5}); + let formatted = format_event(42, &data, false); assert!(formatted.contains("seq:42")); assert!(formatted.contains("temperature")); } #[test] fn test_format_event_with_dropped() { - let event = Event { - subscription_id: "sub123".to_string(), - sequence: 42, - timestamp: "1730379296".to_string(), - data: json!({"value": 1}), - dropped: Some(5), - }; - - let formatted = format_event(&event, false); - assert!(formatted.contains("5 events dropped")); + // AimX-v2 wire does not carry dropped counts; format_event receives + // only the decoded value. Verify data is still rendered correctly. + let data = json!({"value": 1}); + let formatted = format_event(42, &data, false); + assert!(formatted.contains("seq:42")); + assert!(formatted.contains("value")); } } diff --git a/tools/aimdb-mcp/src/connection.rs b/tools/aimdb-mcp/src/connection.rs index 6de6773..0e80025 100644 --- a/tools/aimdb-mcp/src/connection.rs +++ b/tools/aimdb-mcp/src/connection.rs @@ -3,7 +3,7 @@ //! Manages persistent connections to AimDB instances to avoid //! reconnecting on every tool call. Includes auto-reconnect logic. -use aimdb_client::connection::AimxClient; +use aimdb_client::AimxConnection; use aimdb_client::ClientError; use std::collections::HashMap; use std::sync::Arc; @@ -23,8 +23,8 @@ pub struct ConnectionPool { /// Track which connections we've attempted (for logging/metrics) connections: Arc>>, /// Persistent drain clients — kept alive so drain readers accumulate values - /// Key: socket_path, Value: shared AimxClient - drain_clients: Arc>>>>, + /// Key: socket_path, Value: shared AimxConnection + drain_clients: Arc>>>>, } impl std::fmt::Debug for ConnectionPool { @@ -47,11 +47,11 @@ impl ConnectionPool { /// Get or create a connection to an AimDB instance /// - /// Note: Since AimxClient doesn't implement Clone, we create a fresh + /// Note: Since AimxConnection doesn't implement Clone, we create a fresh /// connection each time. The pool tracks connection metadata for /// monitoring and future optimization (e.g., persistent connections - /// via Arc> if AimxClient becomes Sync). - pub async fn get_connection(&self, socket_path: &str) -> Result { + /// via Arc> if AimxConnection becomes Sync). + pub async fn get_connection(&self, socket_path: &str) -> Result { let mut pool = self.connections.lock().await; // Update or insert connection metadata @@ -68,9 +68,9 @@ impl ConnectionPool { pool.insert(socket_path.to_string(), ConnectionEntry { last_used: now }); } - // Always create a new connection (until AimxClient supports cloning/sharing) + // Always create a new connection (until AimxConnection supports cloning/sharing) drop(pool); // Release lock before async operation - AimxClient::connect(socket_path).await + AimxConnection::connect(socket_path).await } /// Remove a connection from the pool (called when operations fail) @@ -90,7 +90,7 @@ impl ConnectionPool { pub async fn get_drain_client( &self, socket_path: &str, - ) -> Result>, ClientError> { + ) -> Result>, ClientError> { let drain_map = self.drain_clients.lock().await; if let Some(client) = drain_map.get(socket_path) { @@ -103,7 +103,7 @@ impl ConnectionPool { // Drop lock before async connect drop(drain_map); - let client = AimxClient::connect(socket_path).await?; + let client = AimxConnection::connect(socket_path).await?; let shared = Arc::new(tokio::sync::Mutex::new(client)); let mut drain_map = self.drain_clients.lock().await; diff --git a/tools/aimdb-mcp/src/resources/records.rs b/tools/aimdb-mcp/src/resources/records.rs index 3b5e8de..73c2617 100644 --- a/tools/aimdb-mcp/src/resources/records.rs +++ b/tools/aimdb-mcp/src/resources/records.rs @@ -5,7 +5,7 @@ use crate::error::{McpError, McpResult}; use crate::protocol::{Resource, ResourceContent, ResourceReadResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde_json::json; use std::path::Path; use tracing::debug; @@ -82,7 +82,7 @@ pub async fn read_records_resource(socket_path: &str) -> McpResult) -> McpResult })?; // Connect and list live records - let mut client = AimxClient::connect(&socket_path) + let client = AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)?; diff --git a/tools/aimdb-mcp/src/tools/buffer_metrics.rs b/tools/aimdb-mcp/src/tools/buffer_metrics.rs index 3bb6d68..f14a05e 100644 --- a/tools/aimdb-mcp/src/tools/buffer_metrics.rs +++ b/tools/aimdb-mcp/src/tools/buffer_metrics.rs @@ -6,7 +6,7 @@ //! on the server). use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::Deserialize; use serde_json::{json, Value}; use tracing::debug; @@ -24,13 +24,13 @@ struct ResetBufferMetricsParams { socket_path: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(socket_path: &str) -> McpResult { if let Some(pool) = super::connection_pool() { pool.get_connection(socket_path) .await .map_err(McpError::Client) } else { - AimxClient::connect(socket_path) + AimxConnection::connect(socket_path) .await .map_err(McpError::Client) } @@ -45,7 +45,7 @@ pub async fn get_buffer_metrics(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("get_buffer_metrics: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; let raw = client.list_records().await.map_err(McpError::Client)?; let matching: Vec<_> = raw @@ -75,7 +75,7 @@ pub async fn reset_buffer_metrics(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("reset_buffer_metrics: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; match client.reset_buffer_metrics().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/graph.rs b/tools/aimdb-mcp/src/tools/graph.rs index 0728558..bdc774e 100644 --- a/tools/aimdb-mcp/src/tools/graph.rs +++ b/tools/aimdb-mcp/src/tools/graph.rs @@ -1,7 +1,7 @@ //! Graph introspection tools (graph_nodes, graph_edges, graph_topo_order) use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -55,13 +55,13 @@ pub async fn graph_nodes(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -100,13 +100,13 @@ pub async fn graph_edges(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -143,13 +143,13 @@ pub async fn graph_topo_order(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; diff --git a/tools/aimdb-mcp/src/tools/instance.rs b/tools/aimdb-mcp/src/tools/instance.rs index e4a43fd..4f16750 100644 --- a/tools/aimdb-mcp/src/tools/instance.rs +++ b/tools/aimdb-mcp/src/tools/instance.rs @@ -1,7 +1,7 @@ //! Instance-related tools (discover_instances, get_instance_info) use crate::error::McpResult; -use aimdb_client::{self, connection::AimxClient}; +use aimdb_client::{self, AimxConnection}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -103,7 +103,7 @@ pub async fn get_instance_info(args: Option) -> McpResult { pool.get_connection(&socket_path).await? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path).await? + AimxConnection::connect(&socket_path).await? }; // Get server info from the welcome message diff --git a/tools/aimdb-mcp/src/tools/profiling.rs b/tools/aimdb-mcp/src/tools/profiling.rs index 1e91ba2..1a97d47 100644 --- a/tools/aimdb-mcp/src/tools/profiling.rs +++ b/tools/aimdb-mcp/src/tools/profiling.rs @@ -8,7 +8,7 @@ //! feature; without it, records simply carry no `stage_profiling` data. use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::Deserialize; use serde_json::{json, Value}; use tracing::debug; @@ -25,13 +25,13 @@ struct ResetStageProfilingParams { socket_path: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(socket_path: &str) -> McpResult { if let Some(pool) = super::connection_pool() { pool.get_connection(socket_path) .await .map_err(McpError::Client) } else { - AimxClient::connect(socket_path) + AimxConnection::connect(socket_path) .await .map_err(McpError::Client) } @@ -49,7 +49,7 @@ pub async fn get_stage_profiling(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("get_stage_profiling: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; let records = client.list_records().await.map_err(McpError::Client)?; let mut out = Vec::new(); @@ -110,7 +110,7 @@ pub async fn reset_stage_profiling(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("reset_stage_profiling: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; match client.reset_stage_profiling().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/record.rs b/tools/aimdb-mcp/src/tools/record.rs index ad90c17..9c1266d 100644 --- a/tools/aimdb-mcp/src/tools/record.rs +++ b/tools/aimdb-mcp/src/tools/record.rs @@ -1,7 +1,7 @@ //! Record-related tools (list_records, get_record, set_record) use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -95,13 +95,13 @@ pub async fn list_records(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -159,13 +159,13 @@ pub async fn get_record(args: Option) -> McpResult { ); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -206,13 +206,13 @@ pub async fn set_record(args: Option) -> McpResult { ); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -264,7 +264,7 @@ pub async fn drain_record(args: Option) -> McpResult { .await .map_err(McpError::Client)?; - let mut client = client_arc.lock().await; + let client = client_arc.lock().await; // Drain record values let response = match params.limit { @@ -325,7 +325,7 @@ mod tests { let err = result.unwrap_err(); assert!( - err.message().contains("Failed to connect") || err.message().contains("No such file") + err.message().contains("Connection failed") || err.message().contains("No such file") ); } diff --git a/tools/aimdb-mcp/src/tools/schema.rs b/tools/aimdb-mcp/src/tools/schema.rs index fc48c5f..12f69cd 100644 --- a/tools/aimdb-mcp/src/tools/schema.rs +++ b/tools/aimdb-mcp/src/tools/schema.rs @@ -1,7 +1,7 @@ //! Schema query tool - infers JSON Schema from record values use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tracing::debug; @@ -117,13 +117,13 @@ pub async fn query_schema(args: Option) -> McpResult { ); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; From 370bd379fa9a56b6c75c47bfc63895531beebdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 11:48:30 +0000 Subject: [PATCH 09/34] Refactor WebSocket connector and session management - Simplified `WebSocketConnectorImpl` by removing the `raw_payload` field and related logic, delegating payload framing to the `WsCodec`. - Introduced `WsDispatch` and `WsSession` for handling WebSocket connections, separating concerns for dispatching messages and managing session state. - Added `WsServerConnection` to manage WebSocket connections on the server side, including multi-topic subscription handling. - Retired the previous session management logic in favor of a more modular approach using `run_session`. - Implemented a new `SnapshotProvider` trait for late-join functionality, allowing clients to receive the current state of topics upon subscription. - Enhanced the transport layer with `WsDialer` and `WsClientConnection` for client-side WebSocket handling. - Updated server state management to include shared dispatch and client manager. - Added tests for multi-topic subscription handling and ensured compatibility with existing message protocols. --- aimdb-client/tests/pump_client.rs | 5 + aimdb-core/src/session/aimx/codec.rs | 5 + aimdb-core/src/session/aimx/dispatch.rs | 18 +- aimdb-core/src/session/client.rs | 123 ++- aimdb-core/src/session/mod.rs | 111 ++- aimdb-core/src/session/server.rs | 31 +- aimdb-core/tests/session_engine.rs | 37 +- aimdb-websocket-connector/src/builder.rs | 42 +- .../src/client/builder.rs | 82 +- .../src/client/connector.rs | 704 ------------------ aimdb-websocket-connector/src/client/mod.rs | 2 - .../src/client_manager.rs | 472 ++++-------- aimdb-websocket-connector/src/codec.rs | 479 ++++++++++++ aimdb-websocket-connector/src/connector.rs | 25 +- aimdb-websocket-connector/src/dispatch.rs | 388 ++++++++++ aimdb-websocket-connector/src/lib.rs | 16 + aimdb-websocket-connector/src/server.rs | 66 +- aimdb-websocket-connector/src/session.rs | 388 +--------- aimdb-websocket-connector/src/transport.rs | 237 ++++++ 19 files changed, 1683 insertions(+), 1548 deletions(-) delete mode 100644 aimdb-websocket-connector/src/client/connector.rs create mode 100644 aimdb-websocket-connector/src/codec.rs create mode 100644 aimdb-websocket-connector/src/dispatch.rs create mode 100644 aimdb-websocket-connector/src/transport.rs diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 2fd93f1..d02e7e9 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -79,6 +79,11 @@ async fn pump_client_mirrors_record_both_directions() { .with_connector(AimxClientConnector::new(&sock).with_config(ClientConfig { reconnect: true, reconnect_delay: Duration::from_millis(50), + max_reconnect_delay: Duration::from_millis(50), + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, sends_hello: false, })); cb.configure::("cfg", |reg| { diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs index 99143d0..3e72d44 100644 --- a/aimdb-core/src/session/aimx/codec.rs +++ b/aimdb-core/src/session/aimx/codec.rs @@ -176,6 +176,11 @@ impl EnvelopeCodec for AimxCodec { write_frame(out, &frame) } Outbound::Pong => write_frame(out, &Frame::tagged("pong")), + // AimX has no explicit subscribe ack (the client owns the id; events + // carry it back). `run_session` only emits this when `acks_subscribe` + // is set, which the AimX server leaves off — so this is unreachable on + // the AimX wire. + Outbound::Subscribed { .. } => Err(CodecError::Malformed), } } diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index 0c76c58..5083d9e 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -102,13 +102,19 @@ where }) } - fn subscribe(&mut self, topic: &str) -> Result, RpcError> { + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { // The engine owns the subscription lifecycle (keyed by request id) and // the per-connection cap (SessionLimits); no `generate_subscription_id` - // / `max_subs` bookkeeping here. - let stream = - crate::remote::stream::stream_record_updates(&self.db, topic).map_err(map_db_err)?; - Ok(Box::pin(stream.map(|v| to_payload(&v)))) + // / `max_subs` bookkeeping here. AimX has no async authorization, so this + // is a trivial wrapper. + Box::pin(async move { + let stream = crate::remote::stream::stream_record_updates(&self.db, topic) + .map_err(map_db_err)?; + Ok(Box::pin(stream.map(|v| to_payload(&v))) as BoxStream<'static, Payload>) + }) } fn write<'a>( @@ -410,6 +416,8 @@ where max_subs_per_connection: config.max_subs_per_connection, }, reads_hello: false, + // AimX's subscribe ack stays implicit (events flow); no explicit ack frame. + acks_subscribe: false, }; let dispatch = Arc::new(AimxDispatch::new(db, config)); let listener = UdsListener::new(listener); diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 1d2342b..686be68 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -33,8 +33,29 @@ use crate::{AimDb, RuntimeAdapter}; pub struct ClientConfig { /// Redial after a dropped/failed connection instead of ending the engine. pub reconnect: bool, - /// Delay before each redial when `reconnect` is set. + /// Base delay before the first redial when `reconnect` is set. Subsequent + /// redials grow this exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). pub reconnect_delay: Duration, + /// Upper bound for the exponential reconnect backoff. Defaults to + /// [`reconnect_delay`](Self::reconnect_delay) (i.e. no escalation — a fixed + /// delay, preserving the pre-Phase-4 behavior). + pub max_reconnect_delay: Duration, + /// Maximum redial attempts before the engine gives up. `0` = unlimited + /// (the default). + pub max_reconnect_attempts: usize, + /// If set, send a keepalive `Ping` on this interval while a connection is + /// idle. `None` (default) disables keepalive. + pub keepalive_interval: Option, + /// Cap on caller commands buffered while disconnected; the oldest are dropped + /// past this bound. Defaults to `usize::MAX` (effectively unbounded — the + /// pre-Phase-4 behavior). + pub max_offline_queue: usize, + /// Key the subscription demux by **topic** instead of the engine request id. + /// `false` (default, AimX-style) — events carry the request id back, demux by + /// id. `true` (WS-style) — the wire pushes data keyed by topic with no id, so + /// the codec's `decode_outbound` returns the topic as `Event.sub` and the + /// engine routes by topic. + pub topic_routed_subs: bool, /// Send a Ping handshake on connect and wait for the Pong before accepting /// caller commands (the proactive "handshake-as-caller"). Mirrors the /// server's `reads_hello`; a real protocol swaps Ping/Pong for its Hello. @@ -46,11 +67,31 @@ impl Default for ClientConfig { Self { reconnect: true, reconnect_delay: Duration::from_millis(200), + max_reconnect_delay: Duration::from_millis(200), + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, sends_hello: false, } } } +/// Exponential backoff for the `attempt`-th redial (1-based), capped at +/// [`ClientConfig::max_reconnect_delay`]. Defaults collapse this to a fixed +/// `reconnect_delay` (max == base), preserving pre-Phase-4 behavior. +fn backoff_delay(config: &ClientConfig, attempt: usize) -> Duration { + let base = config.reconnect_delay; + let cap = config.max_reconnect_delay.max(base); + let shift = attempt.saturating_sub(1).min(16) as u32; + base.saturating_mul(1u32 << shift).min(cap) +} + +/// Bound the offline backlog: drop the oldest buffered commands beyond `cap`. +fn bound_offline_queue(cmd_rx: &mut mpsc::UnboundedReceiver, cap: usize) { + while cmd_rx.len() > cap && cmd_rx.try_recv().is_ok() {} +} + /// A cheap-clone handle to a running [`run_client`] engine — the caller-facing /// RPC surface. Every method funnels a command to the engine, which owns the /// pending-call map and the wire. @@ -165,33 +206,62 @@ async fn client_loop( D: Dialer, C: EnvelopeCodec, { + // Consecutive failed attempts since the last successful connection; drives + // exponential backoff and the optional attempt cap. + let mut attempt: usize = 0; loop { let conn = match dialer.connect().await { - Ok(conn) => conn, + Ok(conn) => { + attempt = 0; + conn + } Err(_e) => { #[cfg(feature = "tracing")] tracing::warn!("client dial failed: {:?}", _e); - if config.reconnect { - tokio::time::sleep(config.reconnect_delay).await; - continue; + match reconnect_after(&mut attempt, &config, &mut cmd_rx).await { + true => continue, + false => return, } - return; } }; match drive_connection(conn, &codec, &mut cmd_rx, &config).await { Ended::HandlesDropped => return, Ended::Disconnected => { - if config.reconnect { - tokio::time::sleep(config.reconnect_delay).await; - continue; + match reconnect_after(&mut attempt, &config, &mut cmd_rx).await { + true => continue, + false => return, } - return; } } } } +/// Decide whether to redial: honor `reconnect`, the attempt cap, the offline-queue +/// bound, and the exponential backoff sleep. Returns `true` to retry, `false` to +/// stop the engine. +async fn reconnect_after( + attempt: &mut usize, + config: &ClientConfig, + cmd_rx: &mut mpsc::UnboundedReceiver, +) -> bool { + if !config.reconnect { + return false; + } + *attempt += 1; + if config.max_reconnect_attempts != 0 && *attempt >= config.max_reconnect_attempts { + #[cfg(feature = "tracing")] + tracing::warn!( + "client giving up after {} reconnect attempts", + config.max_reconnect_attempts + ); + return false; + } + bound_offline_queue(cmd_rx, config.max_offline_queue); + tokio::time::sleep(backoff_delay(config, *attempt)).await; + true +} + /// Drive one dialed [`Connection`]: optional handshake, then `biased` demux of /// server frames (resolve `Reply` by `id`, route `Event`/`Snapshot` to their /// subscription channels) interleaved with caller commands. Pending state is @@ -229,6 +299,9 @@ where } } + // Optional keepalive ticker — `None` parks the arm forever (see below). + let mut keepalive = config.keepalive_interval.map(tokio::time::interval); + loop { tokio::select! { biased; @@ -269,10 +342,31 @@ where } } Ok(Outbound::Pong) => {} + // Explicit subscribe ack (WS). Informational — the local + // event sink already exists from the Subscribe command, so + // there is nothing to route; just confirm liveness. + Ok(Outbound::Subscribed { .. }) => {} Err(_e) => continue, // skip a malformed frame, keep the connection } } + // ---- keepalive: send a Ping when the ticker fires -------------- + // With no interval configured the arm parks on `pending()` forever, + // so it never wins the `select!`. + _ = async { + match keepalive.as_mut() { + Some(i) => { i.tick().await; } + None => std::future::pending::<()>().await, + } + } => { + out.clear(); + if codec.encode_inbound(Inbound::Ping, &mut out).is_ok() + && conn.send(&out).await.is_err() + { + return Ended::Disconnected; + } + } + // ---- caller commands from ClientHandle ------------------------- cmd = cmd_rx.recv() => { let cmd = match cmd { @@ -299,7 +393,14 @@ where ClientCmd::Subscribe { topic, events } => { let id = next_id; next_id += 1; - subs.insert(id.to_string(), events); + // Topic-routed (WS): the wire pushes data keyed by topic, + // so demux by topic; id-routed (AimX): events echo the id. + let key = if config.topic_routed_subs { + topic.clone() + } else { + id.to_string() + }; + subs.insert(key, events); out.clear(); let sent = codec .encode_inbound(Inbound::Subscribe { id, topic }, &mut out) diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 297259c..978617f 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -80,22 +80,78 @@ pub type TransportResult = Result; // Supporting types (stubs — sufficient for the signatures to compile) // =========================================================================== -/// Remote-peer metadata carried by a [`Connection`] (remote addr, headers, -/// pre-resolved auth). +/// Remote-peer metadata carried by a [`Connection`] (remote addr, pre-resolved +/// auth). /// -/// Opaque placeholder. The concrete fields — and whether one shape carries both -/// AimX `SecurityPolicy` and WS `Permissions` — are **deferred to Phase 4**. -#[derive(Debug, Clone, Default)] +/// **Phase 4 (resolved — doc 037 auth-context gate).** One shape serves both +/// connectors: a neutral [`peer_addr`](Self::peer_addr) plus a type-erased +/// [`ext`](Self::ext) slot the connector fills with its own resolved identity +/// (WS stuffs `ClientInfo`/`Permissions` at the HTTP upgrade; AimX stuffs its +/// `SecurityPolicy`). Core stays connector-agnostic; each side downcasts `ext`. +#[derive(Clone, Default)] #[non_exhaustive] -pub struct PeerInfo {} +pub struct PeerInfo { + /// Remote address, if the transport exposes one. + pub peer_addr: Option, + /// Connector-resolved identity, type-erased so core need not know the + /// connector's auth types. Downcast with [`ext_as`](Self::ext_as). + pub ext: Option>, +} + +impl PeerInfo { + /// Attach a connector-resolved identity (consumed by [`Dispatch::authenticate`]). + pub fn with_ext(mut self, ext: Arc) -> Self { + self.ext = Some(ext); + self + } + + /// Downcast the [`ext`](Self::ext) identity to a concrete connector type. + pub fn ext_as(&self) -> Option> { + self.ext.clone()?.downcast::().ok() + } +} + +impl core::fmt::Debug for PeerInfo { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PeerInfo") + .field("peer_addr", &self.peer_addr) + .field("ext", &self.ext.as_ref().map(|_| "")) + .finish() + } +} /// The authenticated session context threaded through [`Dispatch`] calls. /// -/// Minimal/opaque placeholder. Auth fields are **deferred to Phase 4** -/// (the auth-context shape gate). -#[derive(Debug, Clone, Default)] +/// **Phase 4 (resolved — doc 037 auth-context gate).** Carries the resolved +/// principal as a type-erased [`ext`](Self::ext) that [`Dispatch::open`] threads +/// into the per-connection [`Session`] for per-operation authorization +/// (`authorize_subscribe`/`authorize_write`). AimX leaves it `None`. +#[derive(Clone, Default)] #[non_exhaustive] -pub struct SessionCtx {} +pub struct SessionCtx { + /// The resolved principal, type-erased. Downcast with [`ext_as`](Self::ext_as). + pub ext: Option>, +} + +impl SessionCtx { + /// Build a context carrying a connector-resolved principal. + pub fn with_ext(ext: Arc) -> Self { + Self { ext: Some(ext) } + } + + /// Downcast the [`ext`](Self::ext) principal to a concrete connector type. + pub fn ext_as(&self) -> Option> { + self.ext.clone()?.downcast::().ok() + } +} + +impl core::fmt::Debug for SessionCtx { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SessionCtx") + .field("ext", &self.ext.as_ref().map(|_| "")) + .finish() + } +} /// Engine-local bounds for a session (consumed by the Phase 2 engines, not by /// the contracts here). @@ -222,6 +278,16 @@ pub enum Outbound<'a> { /// Unparsed record value. data: Payload, }, + /// An explicit acknowledgement that a subscription opened. Emitted by + /// [`run_session`](super::server::run_session) only when + /// [`SessionConfig::acks_subscribe`](super::server::SessionConfig) is set + /// (WS needs it; AimX's ack is implicit, so it leaves the flag off and never + /// emits this). The `sub` is the subscription's routing id — the same value + /// that tags its [`Event`](Outbound::Event)s. + Subscribed { + /// Subscription id that was opened. + sub: &'a str, + }, /// Keepalive response. Pong, } @@ -314,9 +380,25 @@ pub trait Session: Send { /// Defaulted to [`RpcError::NotFound`] so a dispatch with no streaming /// surface need not implement it (doc 037 § the server-port refinement — /// the stream is side-neutral, so it is defaulted here for symmetry). - fn subscribe(&mut self, topic: &str) -> Result, RpcError> { + /// + /// Async (Phase 4): opening a subscription may need to *await* per-operation + /// authorization (e.g. WS `authorize_subscribe`); the engine awaits it. + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { let _ = topic; - Err(RpcError::NotFound) + Box::pin(async { Err(RpcError::NotFound) }) + } + + /// Late-join snapshot: the current value for `topic`, emitted by + /// [`run_session`](super::server::run_session) as an [`Outbound::Snapshot`] + /// right after a successful [`subscribe`](Session::subscribe) and before the + /// first event. Defaulted to `None` (no snapshot) — AimX inherits this; WS + /// overrides it from its `SnapshotProvider`. + fn snapshot(&mut self, topic: &str) -> Option { + let _ = topic; + None } /// Fire-and-forget write: no reply. Routes through the existing @@ -461,7 +543,10 @@ mod tests { ) -> BoxFut<'a, Result> { unimplemented!() } - fn subscribe(&mut self, _topic: &str) -> Result, RpcError> { + fn subscribe<'a>( + &'a mut self, + _topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { unimplemented!() } fn write<'a>( diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 1ca5ec0..491deac 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -35,6 +35,12 @@ pub struct SessionConfig { /// [`PeerInfo`](super::PeerInfo) alone (identity pre-resolved at the HTTP /// upgrade), no frame consumed. pub reads_hello: bool, + /// Emit an explicit [`Outbound::Subscribed`] ack when a subscription opens. + /// - `false` (default, AimX-style) — the ack is implicit; events flow and + /// carry the subscription id back, no ack frame. + /// - `true` (WS-style) — `run_session` emits `Subscribed { sub }` before the + /// first event, restoring the explicit ack WS clients wait on. + pub acks_subscribe: bool, } /// One subscription update on its way back to the connection's send half. @@ -129,8 +135,31 @@ pub async fn run_session( send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await; continue; } - match session.subscribe(&topic) { + match session.subscribe(&topic).await { Ok(stream) => { + // Optional explicit ack (WS-style); AimX leaves + // `acks_subscribe` off so its ack stays implicit. + if config.acks_subscribe { + out.clear(); + if codec + .encode(Outbound::Subscribed { sub: &sub_id }, &mut out) + .is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } + // Optional late-join snapshot, before the first event. + if let Some(data) = session.snapshot(&topic) { + out.clear(); + if codec + .encode(Outbound::Snapshot { topic: &topic, data }, &mut out) + .is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } let (cancel_tx, cancel_rx) = oneshot::channel(); cancels.insert(sub_id.clone(), cancel_tx); subs.push(Box::pin(pump_subscription( diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index 3d806ad..d16650d 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -178,6 +178,7 @@ impl EnvelopeCodec for LineCodec { } Outbound::Snapshot { topic, data } => format!("SNAP\n{}\n{}", topic, utf8(&data)?), Outbound::Pong => "PONG".to_string(), + Outbound::Subscribed { sub } => format!("SUBSCRIBED\n{}", sub), }; out.extend_from_slice(s.as_bytes()); Ok(()) @@ -280,16 +281,22 @@ impl Session for EchoSession { Box::pin(async move { Ok(params) }) } - fn subscribe(&mut self, topic: &str) -> Result, RpcError> { - // Sentinel: let a known topic fail so the subscribe-ack path is testable. - if topic == "bad" { - return Err(RpcError::NotFound); - } - // Three synthetic updates derived from the topic, then end. - let items: Vec = (1..=3) - .map(|i| payload_from(&format!("{topic}#{i}"))) - .collect(); - Ok(Box::pin(futures::stream::iter(items))) + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + let topic = topic.to_string(); + Box::pin(async move { + // Sentinel: let a known topic fail so the subscribe-ack path is testable. + if topic == "bad" { + return Err(RpcError::NotFound); + } + // Three synthetic updates derived from the topic, then end. + let items: Vec = (1..=3) + .map(|i| payload_from(&format!("{topic}#{i}"))) + .collect(); + Ok(Box::pin(futures::stream::iter(items)) as BoxStream<'static, Payload>) + }) } fn write<'a>( @@ -334,6 +341,11 @@ async fn echo_roundtrip_rpc_streaming_and_write() { ClientConfig { reconnect: false, reconnect_delay: Duration::from_millis(10), + max_reconnect_delay: Duration::from_millis(10), + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, sends_hello: true, }, ); @@ -394,6 +406,11 @@ async fn failed_subscribe_ends_stream_via_ack() { ClientConfig { reconnect: false, reconnect_delay: Duration::from_millis(10), + max_reconnect_delay: Duration::from_millis(10), + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, sends_hello: false, }, ); diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/builder.rs index faab78d..f081da5 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/builder.rs @@ -19,20 +19,22 @@ use std::{ net::{SocketAddr, ToSocketAddrs}, pin::Pin, sync::{Arc, Mutex}, + time::Instant, }; use aimdb_data_contracts::Streamable; -use aimdb_core::{router::RouterBuilder, ConnectorBuilder}; +use aimdb_core::{router::RouterBuilder, ConnectorBuilder, Dispatch}; use axum::Router as AxumRouter; use crate::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, connector::WebSocketConnectorImpl, + dispatch::WsDispatch, registry::StreamableRegistry, - server::build_server_future, - session::{NoQuery, NoSnapshot, QueryHandler, SessionContext, SnapshotProvider}, + server::{build_server_future, ServerState}, + session::{NoQuery, NoSnapshot, QueryHandler, SnapshotProvider}, }; use aimdb_ws_protocol::TopicInfo; @@ -312,7 +314,7 @@ where Arc::new(Mutex::new(HashMap::new())); // ── Client manager ──────────────────────────────────── - let client_mgr = ClientManager::new(); + let client_mgr = ClientManager::new(self.raw_payload); // ── Build snapshot provider ────────────────────────── let snapshot_provider: Arc = if self.late_join { @@ -343,33 +345,35 @@ where }) .collect(); - // ── Session context ─────────────────────────────────── - let session_ctx = SessionContext { + // ── Shared dispatch (one Arc per server) ─── + let dispatch: Arc = Arc::new(WsDispatch { client_mgr: client_mgr.clone(), + snapshot_provider, + query_handler: self.query_handler.clone(), router: router.clone(), + known_topics: Arc::new(known_topics), auth: self.auth.clone(), - channel_capacity: self.channel_capacity, late_join: self.late_join, - snapshot_provider, - auto_subscribe_topics: self.auto_subscribe_topics.clone(), - query_handler: self.query_handler.clone(), - known_topics, runtime_ctx: Some(db.runtime_any()), - }; + }); // ── Build connector & collect outbound publishers ─────────────── - let connector = WebSocketConnectorImpl::new(client_mgr, self.raw_payload); + let connector = WebSocketConnectorImpl::new(client_mgr.clone()); let outbound_futures = connector.collect_outbound_futures(db, outbound_routes, snapshot_map); // ── Build Axum server future ────────────────────────── + let state = ServerState { + dispatch, + auth: self.auth.clone(), + client_mgr, + auto_subscribe: Arc::new(self.auto_subscribe_topics.clone()), + max_subs_per_connection: self.max_clients.max(1), + started_at: Instant::now(), + }; let additional = self.additional_routes.clone(); - let server_future = build_server_future( - self.bind_addr, - self.ws_path.clone(), - session_ctx, - additional, - ); + let server_future = + build_server_future(self.bind_addr, self.ws_path.clone(), state, additional); let mut futures: Vec = Vec::with_capacity(1 + outbound_futures.len()); futures.push(server_future); diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index d8bccba..64516e4 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -17,11 +17,13 @@ //! └─ return Vec (drained by AimDbRunner) //! ``` -use std::{pin::Pin, sync::Arc, time::Duration}; +use std::{pin::Pin, time::Duration}; -use aimdb_core::{router::RouterBuilder, ConnectorBuilder}; +use aimdb_core::session::{pump_client, run_client, ClientConfig}; +use aimdb_core::ConnectorBuilder; -use super::connector::WsClientConnectorImpl; +use crate::codec::WsCodec; +use crate::transport::WsDialer; // ════════════════════════════════════════════════════════════════════ // Builder @@ -146,40 +148,14 @@ where ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // ── Inbound routes ────────────────────────────────────── - let inbound_routes = db.collect_inbound_routes("ws-client"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: {} inbound routes collected", - inbound_routes.len() - ); - - let router = Arc::new(RouterBuilder::from_routes(inbound_routes).build()); - - // ── Outbound routes ────────────────────────────────────── - let outbound_routes = db.collect_outbound_routes("ws-client"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: {} outbound routes collected", - outbound_routes.len() - ); - - // ── Resolve subscribe topics ───────────────────────────── - // Merge explicit subscribe_topics with topics derived from inbound routes - let mut topics: Vec = self.subscribe_topics.clone(); - for resource_id in router.resource_ids() { - let topic = resource_id.to_string(); - if !topics.contains(&topic) { - topics.push(topic); - } - } - - // ── Build client config ───────────────────────────────── - let config = super::connector::WsClientConfig { - url: self.url.clone(), - auto_reconnect: self.auto_reconnect, + // ── Engine config from the WS-specific knobs (doc 039 § 5) ── + // Reconnect/keepalive/offline-queue are now `ClientConfig`/engine + // concerns; `topic_routed_subs` keys the demux by topic (the WS wire + // pushes `Data{topic}` with no id). + let config = ClientConfig { + reconnect: self.auto_reconnect, + reconnect_delay: Duration::from_millis(200), + max_reconnect_delay: Duration::from_secs(30), max_reconnect_attempts: self.max_reconnect_attempts, keepalive_interval: if self.keepalive_ms > 0 { Some(Duration::from_millis(self.keepalive_ms)) @@ -187,27 +163,21 @@ where None }, max_offline_queue: self.max_offline_queue, - subscribe_topics: topics, + topic_routed_subs: true, + sends_hello: false, }; - // ── Build the connector and collect its infrastructure future ── - // The connector future owns a `FuturesUnordered` driving the - // write/read/keepalive loops and reconnect watcher; no - // `tokio::spawn` is involved. - let (connector, connector_future) = WsClientConnectorImpl::connect(config, router, db) - .await - .map_err(|e| aimdb_core::DbError::RuntimeError { - message: format!("WS client connect failed: {}", e), - })?; - - // ── Collect outbound publisher futures ─────────────────── - let mut futures = connector.collect_outbound_futures(db, outbound_routes); - - // Prepend the connector's infrastructure future so it gets - // driven alongside the per-route publishers. Order does not - // matter to `FuturesUnordered`, but front-loading the long- - // running infra future keeps logs readable. - futures.insert(0, connector_future); + // ── Drive the shared client engine + record-mirroring pumps ── + // Mirrors `AimxClientConnector`: `run_client` owns demux/reconnect/ + // keepalive over the WS `Dialer` + per-connection `WsCodec`; + // `pump_client` wires `link_to`/`link_from` routes to the handle. + let (handle, engine_fut) = run_client( + WsDialer::new(self.url.clone()), + WsCodec::new(), + config, + ); + let mut futures = pump_client(db, "ws-client", &handle); + futures.push(engine_fut); Ok(futures) }) } diff --git a/aimdb-websocket-connector/src/client/connector.rs b/aimdb-websocket-connector/src/client/connector.rs deleted file mode 100644 index 09eb2a8..0000000 --- a/aimdb-websocket-connector/src/client/connector.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! WebSocket client connector implementation. -//! -//! [`WsClientConnectorImpl`] manages a `tokio-tungstenite` WebSocket connection -//! to a remote AimDB server, with: -//! -//! - **Inbound routing**: `ServerMessage::Data/Snapshot` → `Router::route()` -//! - **Outbound publishing**: `subscribe_any() → recv_any() → Write` message -//! - **Reconnection**: exponential backoff with configurable limits -//! - **Keepalive**: periodic `Ping` messages -//! - **Offline queue**: queued writes during disconnection - -use std::{collections::VecDeque, pin::Pin, sync::Arc, time::Duration}; - -use aimdb_core::{ - router::Router, - transport::{ConnectorConfig, PublishError}, - OutboundRoute, -}; -use aimdb_ws_protocol::{ClientMessage, ServerMessage}; -use futures_util::stream::FuturesUnordered; -use futures_util::{SinkExt, StreamExt}; -use tokio::sync::{mpsc, Mutex}; - -/// Boxed `()`-yielding future used for the connector's nested -/// `FuturesUnordered`. Identical in shape to `aimdb_core::builder::BoxFuture`. -type BoxFuture = Pin + Send + 'static>>; - -/// Aliases for the split halves of the underlying WebSocket stream. -/// Defined once so the reconnect-watcher's `NewLoops` payload type -/// stays readable. -type WsStream = - tokio_tungstenite::WebSocketStream>; -type WsWriteSink = - futures_util::stream::SplitSink; -type WsReadStream = futures_util::stream::SplitStream; - -/// Sent from the reconnect watcher to the outer connector future after a -/// successful reconnect. The outer loop pushes one write-loop future and -/// one read-loop future built from these halves into its -/// `FuturesUnordered`. -struct NewLoops { - write_sink: WsWriteSink, - read_stream: WsReadStream, - write_rx: mpsc::UnboundedReceiver, -} - -// ════════════════════════════════════════════════════════════════════ -// Configuration -// ════════════════════════════════════════════════════════════════════ - -/// Internal configuration for the WS client connector. -pub(crate) struct WsClientConfig { - pub url: String, - pub auto_reconnect: bool, - pub max_reconnect_attempts: usize, - pub keepalive_interval: Option, - pub max_offline_queue: usize, - pub subscribe_topics: Vec, -} - -// ════════════════════════════════════════════════════════════════════ -// Connection status -// ════════════════════════════════════════════════════════════════════ - -/// Connection state of the WS client. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConnectionStatus { - Connecting, - Connected, - Disconnected, - Reconnecting, -} - -// ════════════════════════════════════════════════════════════════════ -// Shared state -// ════════════════════════════════════════════════════════════════════ - -/// Shared mutable state protected by a Mutex. -struct SharedState { - status: ConnectionStatus, - pending_writes: VecDeque, - max_offline_queue: usize, - /// The current write channel sender. Swapped atomically on reconnect so - /// that all producers (outbound publishers, publish(), keepalive) always - /// send through the live connection. - write_tx: mpsc::UnboundedSender, -} - -// ════════════════════════════════════════════════════════════════════ -// Connector implementation -// ════════════════════════════════════════════════════════════════════ - -/// Live WebSocket client connector. -/// -/// Created by [`WsClientConnectorBuilder::build()`]. Manages the connection -/// lifecycle and spawns background tasks for: -/// -/// - Receiving server messages and routing them via `Router` -/// - Sending outbound data from local record changes -/// - Keepalive pings -/// - Automatic reconnection -pub struct WsClientConnectorImpl { - /// Shared state for status, offline queue, and the current write channel. - state: Arc>, - /// Router for inbound data (server → local buffers). - #[allow(dead_code)] - router: Arc, -} - -impl WsClientConnectorImpl { - /// Connect to the remote WebSocket server and return a handle plus - /// the infrastructure future that drives the read/write/keepalive - /// loops and the reconnect watcher. - /// - /// The returned [`BoxFuture`] owns a [`FuturesUnordered`] holding all - /// background loops; dropping it (via the runner being cancelled) - /// terminates every loop in one step. On successful reconnect the - /// watcher sends a [`NewLoops`] message that the outer future - /// translates into two fresh futures pushed onto the set. - pub(crate) async fn connect( - config: WsClientConfig, - router: Arc, - db: &aimdb_core::builder::AimDb, - ) -> Result<(Self, BoxFuture), String> - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - // Connect to the remote server - let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.url) - .await - .map_err(|e| format!("WebSocket connection failed: {e}"))?; - - #[cfg(feature = "tracing")] - tracing::info!("WS client: connected to {}", config.url); - - let (ws_write, ws_read) = ws_stream.split(); - - // Channel for sending text frames from any task to the write loop - let (write_tx, write_rx) = mpsc::unbounded_channel::(); - - let state = Arc::new(Mutex::new(SharedState { - status: ConnectionStatus::Connected, - pending_writes: VecDeque::new(), - max_offline_queue: config.max_offline_queue, - write_tx, - })); - - // ── Send subscribe message ────────────────────────────────── - // The mpsc buffers this until the write loop is first polled by - // the runner; the message is delivered as soon as the outer - // future starts. - if !config.subscribe_topics.is_empty() { - let sub_msg = ClientMessage::Subscribe { - topics: config.subscribe_topics.clone(), - }; - if let Ok(json) = serde_json::to_string(&sub_msg) { - let _ = state.lock().await.write_tx.send(json); - } - } - - let reconnect_url = config.url.clone(); - let reconnect_topics = config.subscribe_topics.clone(); - let auto_reconnect = config.auto_reconnect; - let max_reconnect_attempts = config.max_reconnect_attempts; - let keepalive_interval = config.keepalive_interval; - let runtime_ctx: Arc = db.runtime_any(); - - // Channel from the reconnect watcher to the outer future. The - // watcher sends a `NewLoops` on each successful reconnect; the - // outer future pushes a fresh write+read future onto its set. - let (new_loops_tx, mut new_loops_rx) = mpsc::unbounded_channel::(); - - let state_for_future = state.clone(); - let router_for_future = router.clone(); - let runtime_ctx_for_future = runtime_ctx.clone(); - - let connector_future: BoxFuture = Box::pin(async move { - let mut tasks: FuturesUnordered = FuturesUnordered::new(); - - // Initial write loop. On exit, mark the connection as - // disconnected so the reconnect watcher notices. - { - let state_for_write = state_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_write_loop(ws_write, write_rx).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: write loop ended"); - - state_for_write.lock().await.status = ConnectionStatus::Disconnected; - })); - } - - // Initial read loop. - { - let router_for_read = router_for_future.clone(); - let ctx_for_read = runtime_ctx_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_read_loop(ws_read, &router_for_read, Some(&ctx_for_read)).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: read loop ended"); - })); - } - - // Keepalive. - if let Some(interval) = keepalive_interval { - let ka_state = state_for_future.clone(); - tasks.push(Box::pin(Self::run_keepalive(ka_state, interval))); - } - - // Reconnect watcher. - if auto_reconnect { - let watcher_state = state_for_future.clone(); - let watcher_tx = new_loops_tx.clone(); - tasks.push(Box::pin(Self::run_reconnect_watcher( - watcher_state, - reconnect_url, - reconnect_topics, - max_reconnect_attempts, - watcher_tx, - ))); - } - // Drop the watcher's sender clone we still hold so the - // `new_loops_rx.recv()` returns `None` once the watcher - // task ends, breaking the outer loop cleanly. - drop(new_loops_tx); - - // Drive the set. `biased;` keeps reconnect handling - // (which churns rarely) polled ahead of the drain arm. - loop { - tokio::select! { - biased; - - // Reconnect produced fresh halves — push new read + - // write futures into the set. - maybe_new = new_loops_rx.recv() => match maybe_new { - Some(NewLoops { write_sink, read_stream, write_rx }) => { - let router_for_read = router_for_future.clone(); - let ctx_for_read = runtime_ctx_for_future.clone(); - let state_for_write = state_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_write_loop(write_sink, write_rx).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: (reconnect) write loop ended"); - - state_for_write.lock().await.status = ConnectionStatus::Disconnected; - })); - tasks.push(Box::pin(async move { - Self::run_read_loop( - read_stream, - &router_for_read, - Some(&ctx_for_read), - ) - .await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: (reconnect) read loop ended"); - })); - } - None => { - // Watcher gone (auto_reconnect disabled or - // it gave up after max_attempts). Stop - // listening for new loops; tasks continue - // draining until empty. - break; - } - }, - - // Drain finished child futures. `Some(_) = next()` - // (rather than `select_next_some()`) is the safe form: - // an empty `FuturesUnordered` reports - // `is_terminated() == true`, and `select_next_some` - // panics in that state. With the pattern guard, the - // arm is simply disabled when `next()` resolves to - // `None`; the always-active reconnect arm keeps the - // select alive. - Some(_) = tasks.next() => {} - } - } - - // After the watcher exited: drain remaining children to - // completion so resources release cleanly. - while tasks.next().await.is_some() {} - }); - - Ok((Self { state, router }, connector_future)) - } - - /// Collect one outbound publisher future per route. - /// - /// Each future subscribes to a local record, serializes values, and sends - /// `ClientMessage::Write` to the remote server. Returned futures are appended - /// to the `AimDbRunner` accumulator. - pub(crate) fn collect_outbound_futures( - &self, - db: &aimdb_core::builder::AimDb, - outbound_routes: Vec, - ) -> Vec + Send + 'static>>> - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec + Send + 'static>>> = - Vec::with_capacity(outbound_routes.len()); - - for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { - let state = self.state.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: subscribe failed for '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Resolve topic (dynamic or static) - let topic = topic_provider - .as_ref() - .and_then(|p| p.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Build Write message - let payload: serde_json::Value = match serde_json::from_slice(&bytes) { - Ok(v) => v, - Err(_e) => { - // Fallback: wrap raw bytes as a JSON string - serde_json::Value::String(String::from_utf8_lossy(&bytes).into_owned()) - } - }; - - let msg = ClientMessage::Write { - topic: topic.clone(), - payload, - }; - - if let Ok(json) = serde_json::to_string(&msg) { - let mut s = state.lock().await; - if s.status == ConnectionStatus::Connected { - let _ = s.write_tx.send(json); - } else if s.pending_writes.len() < s.max_offline_queue { - s.pending_writes.push_back(json); - } - // else: drop (overflow policy) - } - } - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } - - futures - } - - // ════════════════════════════════════════════════════════════════ - // Background task implementations - // ════════════════════════════════════════════════════════════════ - - /// Write loop: drains the mpsc channel and sends text frames. - async fn run_write_loop( - mut ws_write: futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - tokio_tungstenite::tungstenite::Message, - >, - mut write_rx: mpsc::UnboundedReceiver, - ) { - while let Some(text) = write_rx.recv().await { - let msg = tokio_tungstenite::tungstenite::Message::Text(text.into()); - if ws_write.send(msg).await.is_err() { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: write failed, closing write loop"); - break; - } - } - } - - /// Read loop: receives server messages and routes them via the Router. - async fn run_read_loop( - mut ws_read: futures_util::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - router: &Router, - runtime_ctx: Option<&Arc>, - ) { - while let Some(Ok(msg)) = ws_read.next().await { - let text = match msg { - tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), - tokio_tungstenite::tungstenite::Message::Close(_) => { - #[cfg(feature = "tracing")] - tracing::info!("WS client: received close frame"); - break; - } - _ => continue, - }; - - let server_msg: ServerMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: failed to parse server message: {}", _e); - continue; - } - }; - - match server_msg { - ServerMessage::Data { topic, payload, .. } - | ServerMessage::Snapshot { topic, payload } => { - if let Some(payload) = payload { - let bytes = match serde_json::to_vec(&payload) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: failed to serialize payload for '{}': {}", - topic, - _e - ); - continue; - } - }; - if let Err(_e) = router.route(&topic, &bytes, runtime_ctx).await { - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: route failed for topic '{}': {:?}", - topic, - _e - ); - } - } - } - ServerMessage::Subscribed { .. } => { - #[cfg(feature = "tracing")] - tracing::debug!("WS client: subscription acknowledged"); - } - ServerMessage::Error { message, topic, .. } => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client: server error{}: {}", - topic - .as_ref() - .map(|t| format!(" on '{}'", t)) - .unwrap_or_default(), - message - ); - let _ = (&message, &topic); - } - ServerMessage::Pong => { - // Keepalive ACK — nothing to do. - } - ServerMessage::QueryResult { .. } => { - // Query results are handled by the WASM bridge; the native - // client connector does not issue queries (yet). - } - ServerMessage::TopicList { .. } => { - // Topic list responses are not used by the native client connector. - } - } - } - } - - /// Keepalive loop: sends periodic Ping messages via the shared state sender. - async fn run_keepalive(state: Arc>, interval: Duration) { - let mut ticker = tokio::time::interval(interval); - ticker.tick().await; // skip first immediate tick - - loop { - ticker.tick().await; - let ping = ClientMessage::Ping; - if let Ok(json) = serde_json::to_string(&ping) { - let s = state.lock().await; - if s.status != ConnectionStatus::Connected { - continue; - } - if s.write_tx.send(json).is_err() { - break; // channel closed, connection gone - } - } - } - } - - /// Reconnect watcher: monitors connection status and reconnects when needed. - /// - /// Uses exponential backoff: 500ms, 1s, 2s, 4s, 8s (capped). On a - /// successful reconnect it sends a [`NewLoops`] to the outer - /// connector future, which translates it into a fresh write- and - /// read-loop future pushed onto the connector's `FuturesUnordered`. - /// The watcher itself never calls `tokio::spawn`. - async fn run_reconnect_watcher( - state: Arc>, - url: String, - subscribe_topics: Vec, - max_attempts: usize, - new_loops_tx: mpsc::UnboundedSender, - ) { - let backoff = [500u64, 1_000, 2_000, 4_000, 8_000]; - let mut attempt = 0usize; - - loop { - // Wait a bit before checking - tokio::time::sleep(Duration::from_millis(1_000)).await; - - let status = state.lock().await.status; - if status == ConnectionStatus::Connected || status == ConnectionStatus::Connecting { - attempt = 0; - continue; - } - - // Disconnected — try to reconnect - if max_attempts > 0 && attempt >= max_attempts { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client: max reconnect attempts ({}) reached, giving up", - max_attempts - ); - break; - } - - let delay_ms = backoff.get(attempt).copied().unwrap_or(8_000); - attempt += 1; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: reconnecting in {}ms (attempt {})", - delay_ms, - attempt - ); - - state.lock().await.status = ConnectionStatus::Reconnecting; - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - - // Guard: status may have changed during sleep - if state.lock().await.status != ConnectionStatus::Reconnecting { - continue; - } - - match tokio_tungstenite::connect_async(&url).await { - Ok((ws_stream, _)) => { - #[cfg(feature = "tracing")] - tracing::info!("WS client: reconnected to {}", url); - - let (ws_write, ws_read) = ws_stream.split(); - - // Create new channel; sender will be swapped into - // shared state; receiver travels to the outer future - // inside `NewLoops`. - let (new_write_tx, new_write_rx) = mpsc::unbounded_channel::(); - - // Re-subscribe before swapping — the new sender is - // still local and other producers cannot reach it - // yet, so this `Subscribe` is guaranteed first in - // the new write channel. - if !subscribe_topics.is_empty() { - let sub = ClientMessage::Subscribe { - topics: subscribe_topics.clone(), - }; - if let Ok(json) = serde_json::to_string(&sub) { - let _ = new_write_tx.send(json); - } - } - - // Swap write_tx and flush pending writes in one - // critical section. All producers (outbound - // publishers, publish(), keepalive) pick up the new - // sender on their next lock acquisition. - { - let mut s = state.lock().await; - s.write_tx = new_write_tx; - while let Some(msg) = s.pending_writes.pop_front() { - let _ = s.write_tx.send(msg); - } - s.status = ConnectionStatus::Connected; - } - - // Hand the new halves to the outer connector future, - // which will push fresh read+write loop futures onto - // its `FuturesUnordered`. - if new_loops_tx - .send(NewLoops { - write_sink: ws_write, - read_stream: ws_read, - write_rx: new_write_rx, - }) - .is_err() - { - // Outer future has gone away — nothing left to - // drive the loops; give up. - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: outer future dropped, stopping reconnect watcher" - ); - break; - } - - attempt = 0; - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: reconnect failed: {}", _e); - state.lock().await.status = ConnectionStatus::Disconnected; - } - } - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Connector trait -// ════════════════════════════════════════════════════════════════════ - -impl aimdb_core::transport::Connector for WsClientConnectorImpl { - /// Send a payload to the remote server as a `Write` message. - /// - /// This is the on-demand publish path used by the `ConnectorConfig` system. - /// Most data flow happens via the outbound publisher tasks instead. - fn publish( - &self, - destination: &str, - _config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - let destination = destination.to_string(); - let payload_owned = payload.to_vec(); - - Box::pin(async move { - let json_payload: serde_json::Value = serde_json::from_slice(&payload_owned) - .map_err(|_| PublishError::MessageTooLarge)?; - - let msg = ClientMessage::Write { - topic: destination, - payload: json_payload, - }; - - let json = serde_json::to_string(&msg).map_err(|_| PublishError::MessageTooLarge)?; - - let mut s = self.state.lock().await; - if s.status == ConnectionStatus::Connected { - s.write_tx - .send(json) - .map_err(|_| PublishError::ConnectionFailed)?; - } else if s.pending_writes.len() < s.max_offline_queue { - s.pending_writes.push_back(json); - } else { - return Err(PublishError::BufferFull); - } - - Ok(()) - }) - } -} diff --git a/aimdb-websocket-connector/src/client/mod.rs b/aimdb-websocket-connector/src/client/mod.rs index c05e00e..05814c8 100644 --- a/aimdb-websocket-connector/src/client/mod.rs +++ b/aimdb-websocket-connector/src/client/mod.rs @@ -23,7 +23,5 @@ //! ``` mod builder; -mod connector; pub use builder::WsClientConnectorBuilder; -pub use connector::WsClientConnectorImpl; diff --git a/aimdb-websocket-connector/src/client_manager.rs b/aimdb-websocket-connector/src/client_manager.rs index ce47b97..2e01698 100644 --- a/aimdb-websocket-connector/src/client_manager.rs +++ b/aimdb-websocket-connector/src/client_manager.rs @@ -1,382 +1,218 @@ -//! Shared client registry and topic-based fan-out. +//! Shared per-topic broadcast bus (Phase 4 — doc 039 § 3). //! -//! [`ClientManager`] tracks all connected WebSocket clients and their topic -//! subscriptions. When an outbound publisher task receives a new value it calls -//! [`ClientManager::broadcast`] which serializes the payload once and delivers it -//! to every client that has a matching subscription pattern. - -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, +//! [`ClientManager`] is the **fan-out bridge** behind `Dispatch::subscribe`: one +//! record update reaches every matching subscription. Each `WsSession::subscribe` +//! registers a per-subscription channel and gets back a [`BoxStream`] of raw +//! record-value [`Payload`]s; the per-connection [`WsCodec`](crate::codec) wraps +//! each into a `ServerMessage::Data` on encode. The outbound record→broadcast +//! tasks ([`crate::connector`]) feed [`broadcast`](ClientManager::broadcast). +//! +//! This replaces the pre-Phase-4 model where the manager owned per-client +//! `mpsc::Sender` channels and formatted `ServerMessage`s itself — that +//! formatting now lives in the codec, and the per-connection send half is owned +//! by `run_session`. + +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, }; -use axum::extract::ws::Message; +use aimdb_core::{BoxStream, Payload}; use dashmap::DashMap; -use serde_json::Value; use tokio::sync::mpsc; use crate::{ - auth::{ClientId, ClientInfo}, - protocol::{topic_matches, ErrorCode, ServerMessage}, + auth::ClientId, + codec::parse_payload, + protocol::{now_ms, topic_matches, ServerMessage}, }; -// ════════════════════════════════════════════════════════════════════ -// Per-client state -// ════════════════════════════════════════════════════════════════════ - -/// State tracked for each connected WebSocket client. -pub(crate) struct ClientState { - pub info: ClientInfo, - /// Channel used to push messages to the client's send loop. - pub sender: mpsc::Sender, - /// Topic patterns this client has subscribed to. - pub subscriptions: Vec, +/// One live subscription: a wildcard pattern + the channel feeding its stream. +struct SubEntry { + pattern: String, + tx: mpsc::UnboundedSender, } -// ════════════════════════════════════════════════════════════════════ -// ClientManager -// ════════════════════════════════════════════════════════════════════ - -/// Shared registry of connected clients with subscription-based fan-out. -/// -/// Cloning this type is cheap — all instances share the same underlying data. +/// Shared per-topic broadcast bus. Cloning is cheap (all clones share state). #[derive(Clone)] pub struct ClientManager { - /// Map from ClientId → per-client state. - /// - /// `DashMap` is used instead of `RwLock` to minimise lock contention - /// when many publisher tasks are broadcasting concurrently. - clients: Arc>, - /// Monotonically-increasing counter for generating unique `ClientId`s. - next_id: Arc, + /// sub-id → subscription entry. + subs: Arc>, + /// Allocator for subscription ids. + next_sub: Arc, + /// Allocator for client ids (assigned at the HTTP upgrade). + next_client: Arc, + /// Live connection count (for the health endpoint). + connections: Arc, + /// Mirrors the builder's `with_raw_payload`: when set, `broadcast` ships the + /// serializer bytes verbatim instead of wrapping them in a `Data` envelope. + raw_payload: bool, } impl ClientManager { - /// Create a new, empty client registry. - pub fn new() -> Self { + /// Create a new, empty bus. `raw_payload` mirrors the builder flag. + pub fn new(raw_payload: bool) -> Self { Self { - clients: Arc::new(DashMap::new()), - next_id: Arc::new(AtomicU64::new(1)), + subs: Arc::new(DashMap::new()), + next_sub: Arc::new(AtomicU64::new(1)), + next_client: Arc::new(AtomicU64::new(1)), + connections: Arc::new(AtomicU64::new(0)), + raw_payload, } } - /// Register a new client and return its id together with the message receiver. - /// - /// The caller (session task) owns the `mpsc::Receiver` and drives the - /// WebSocket send loop. - pub fn register( - &self, - info: ClientInfo, - channel_capacity: usize, - ) -> (ClientId, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(channel_capacity); - let state = ClientState { - info, - sender: tx, - subscriptions: Vec::new(), - }; - let raw_id = state.info.id.0; - self.clients.insert(raw_id, state); - (ClientId(raw_id), rx) - } - - /// Remove a client from the registry (called when the connection closes). - pub fn unregister(&self, id: ClientId) { - self.clients.remove(&id.0); - } - - /// Return the number of currently connected clients. - pub fn client_count(&self) -> usize { - self.clients.len() - } - - /// Allocate a new unique `ClientId`. + /// Allocate a new unique [`ClientId`] (called at the HTTP upgrade). pub fn next_client_id(&self) -> ClientId { - ClientId(self.next_id.fetch_add(1, Ordering::Relaxed)) + ClientId(self.next_client.fetch_add(1, Ordering::Relaxed)) } - // ──────────────────────────────────────────────────────────────── - // Subscription management (called from session recv loop) - // ──────────────────────────────────────────────────────────────── - - /// Add subscription patterns for the given client. - /// - /// Returns only the patterns that were actually new (duplicates are skipped). - pub fn subscribe(&self, id: ClientId, patterns: &[String]) -> Vec { - let mut added = Vec::new(); - if let Some(mut entry) = self.clients.get_mut(&id.0) { - for pat in patterns { - if !entry.subscriptions.contains(pat) { - entry.subscriptions.push(pat.clone()); - added.push(pat.clone()); - } - } - } - added + /// Number of live connections (informational, for `/health`). + pub fn client_count(&self) -> usize { + self.connections.load(Ordering::Relaxed) as usize } - /// Remove subscription patterns for the given client. - pub fn unsubscribe(&self, id: ClientId, patterns: &[String]) { - if let Some(mut entry) = self.clients.get_mut(&id.0) { - entry.subscriptions.retain(|s| !patterns.contains(s)); + /// RAII guard: increments the connection count now, decrements on drop. + pub(crate) fn connection_guard(&self) -> ConnectionGuard { + self.connections.fetch_add(1, Ordering::Relaxed); + ConnectionGuard { + connections: self.connections.clone(), } } - /// Returns `true` if the client has at least one matching subscription for `topic`. - pub fn is_subscribed(&self, id: ClientId, topic: &str) -> bool { - self.clients - .get(&id.0) - .map(|e| e.subscriptions.iter().any(|p| topic_matches(p, topic))) - .unwrap_or(false) - } - - // ──────────────────────────────────────────────────────────────── - // Fan-out - // ──────────────────────────────────────────────────────────────── - - /// Broadcast a serialized `data` payload to all clients subscribed to `topic`. - /// - /// The payload bytes (from the record serializer) are parsed as JSON once; - /// if parsing fails the raw bytes are embedded as a JSON string. + /// Register a subscription for `pattern`; returns its id and the stream of + /// matching record-value payloads. Dropping the stream ends the subscription + /// (the next [`broadcast`](Self::broadcast) prunes the dead entry). + pub fn subscribe(&self, pattern: &str) -> (u64, BoxStream<'static, Payload>) { + let id = self.next_sub.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::unbounded_channel::(); + self.subs.insert( + id, + SubEntry { + pattern: pattern.to_string(), + tx, + }, + ); + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + (id, Box::pin(stream)) + } + + /// Explicitly drop a subscription (on `Unsubscribe`). + pub fn unsubscribe(&self, sub_id: u64) { + self.subs.remove(&sub_id); + } + + /// Fan a serialized record-value out to every subscription whose pattern + /// matches `topic`. Dead subscriptions (dropped streams) are pruned. pub async fn broadcast(&self, topic: &str, payload_bytes: &[u8]) { - let payload = parse_payload(payload_bytes); - let ts = crate::protocol::now_ms(); - - let msg = ServerMessage::Data { - topic: topic.to_string(), - payload: Some(payload), - ts, - }; - - let text = match serde_json::to_string(&msg) { - Ok(t) => t, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize data message for topic '{}': {}", - topic, - _e - ); - return; - } - }; - - let ws_msg = Message::Text(text.into()); - - // Iterate clients without holding a write lock - let ids: Vec = self - .clients - .iter() - .filter_map(|entry| { - if entry.subscriptions.iter().any(|p| topic_matches(p, topic)) { - Some(*entry.key()) - } else { - None - } - }) - .collect(); - - for raw_id in ids { - if let Some(entry) = self.clients.get(&raw_id) { - let _ = entry.sender.try_send(ws_msg.clone()); - } - } - } - - /// Broadcast raw payload bytes directly to all subscribed clients as a - /// WebSocket text frame — **no `ServerMessage` envelope**. - /// - /// Use this (with `raw_payload = true` on the connector builder) when the - /// serializer already produces the complete JSON the client expects. - pub async fn broadcast_raw(&self, topic: &str, payload_bytes: &[u8]) { - let text = match std::str::from_utf8(payload_bytes) { - Ok(s) => s.to_string(), - Err(_) => { - #[cfg(feature = "tracing")] - tracing::error!("broadcast_raw: payload for '{}' is not valid UTF-8", topic); - return; + // Serialize the complete wire frame **once** here — the bus is the only + // place the real topic + value meet, and doing it once (vs once per + // subscriber in the codec) keeps fan-out O(1). The codec writes the + // result verbatim. The same finished bytes are `Arc`-shared to every + // matching subscription (a refcount bump, no per-subscriber copy). + let frame = if self.raw_payload { + payload_bytes.to_vec() + } else { + match serde_json::to_vec(&ServerMessage::Data { + topic: topic.to_string(), + payload: Some(parse_payload(payload_bytes)), + ts: now_ms(), + }) { + Ok(f) => f, + Err(_) => return, } }; - - let ws_msg = Message::Text(text.into()); - - let ids: Vec = self - .clients - .iter() - .filter_map(|entry| { - if entry.subscriptions.iter().any(|p| topic_matches(p, topic)) { - Some(*entry.key()) - } else { - None - } - }) - .collect(); - - for raw_id in ids { - if let Some(entry) = self.clients.get(&raw_id) { - let _ = entry.sender.try_send(ws_msg.clone()); + let payload = Payload::from(frame.as_slice()); + let mut dead: Vec = Vec::new(); + for entry in self.subs.iter() { + if topic_matches(&entry.pattern, topic) && entry.tx.send(payload.clone()).is_err() { + dead.push(*entry.key()); } } - } - - /// Send a snapshot (late-join current value) to a single client. - pub async fn send_snapshot(&self, id: ClientId, topic: &str, payload_bytes: &[u8]) { - let payload = parse_payload(payload_bytes); - let msg = ServerMessage::Snapshot { - topic: topic.to_string(), - payload: Some(payload), - }; - - self.send_to(id, &msg).await; - } - - /// Send an error message to a single client. - pub async fn send_error( - &self, - id: ClientId, - code: ErrorCode, - topic: Option, - message: impl Into, - ) { - let msg = ServerMessage::Error { - code, - topic, - message: message.into(), - }; - self.send_to(id, &msg).await; - } - - /// Send a `subscribed` acknowledgement to a single client. - pub async fn send_subscribed(&self, id: ClientId, topics: Vec) { - let msg = ServerMessage::Subscribed { topics }; - self.send_to(id, &msg).await; - } - - /// Send a `pong` to a single client. - pub async fn send_pong(&self, id: ClientId) { - self.send_to(id, &ServerMessage::Pong).await; - } - - /// Send an arbitrary [`ServerMessage`] to a single client. - /// - /// Used by the query handler to deliver `QueryResult` responses. - pub async fn send_to_client(&self, id: ClientId, msg: &ServerMessage) { - self.send_to(id, msg).await; - } - - // ──────────────────────────────────────────────────────────────── - // Helpers - // ──────────────────────────────────────────────────────────────── - - async fn send_to(&self, id: ClientId, msg: &ServerMessage) { - let text = match serde_json::to_string(msg) { - Ok(t) => t, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to serialize message: {}", _e); - return; - } - }; - - if let Some(entry) = self.clients.get(&id.0) { - let _ = entry.sender.try_send(Message::Text(text.into())); + for id in dead { + self.subs.remove(&id); } } - /// Return the `ClientInfo` for the given id, if still connected. - pub fn client_info(&self, id: ClientId) -> Option { - self.clients.get(&id.0).map(|e| e.info.clone()) - } - - /// Returns a snapshot of (topic, subscribed-client-count) pairs for monitoring. - pub fn subscription_stats(&self) -> HashMap { - let mut stats: HashMap = HashMap::new(); - for entry in self.clients.iter() { - for pat in &entry.subscriptions { - *stats.entry(pat.clone()).or_insert(0) += 1; - } - } - stats + /// Number of live subscriptions (for monitoring/tests). + pub fn subscription_count(&self) -> usize { + self.subs.len() } } impl Default for ClientManager { fn default() -> Self { - Self::new() + Self::new(false) } } -// ════════════════════════════════════════════════════════════════════ -// Helpers -// ════════════════════════════════════════════════════════════════════ - -/// Parse raw bytes as JSON, falling back to a JSON string if parsing fails. -fn parse_payload(bytes: &[u8]) -> Value { - serde_json::from_slice(bytes) - .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned())) +/// Decrements the connection count when dropped (held by `WsSession`). +pub(crate) struct ConnectionGuard { + connections: Arc, } -// ════════════════════════════════════════════════════════════════════ -// Tests -// ════════════════════════════════════════════════════════════════════ +impl Drop for ConnectionGuard { + fn drop(&mut self) { + self.connections.fetch_sub(1, Ordering::Relaxed); + } +} #[cfg(test)] mod tests { use super::*; - use crate::auth::Permissions; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + use futures_util::StreamExt; - fn dummy_info(id: u64) -> ClientInfo { - ClientInfo { - id: ClientId(id), - remote_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234), - permissions: Permissions::allow_all(), + #[tokio::test] + async fn broadcast_reaches_matching_subscriptions() { + let mgr = ClientManager::new(false); + let (_id, mut stream) = mgr.subscribe("sensors/#"); + + mgr.broadcast("sensors/temp/vienna", b"22.5").await; + + // Delivery is the complete, pre-serialized `Data` frame (built once in + // broadcast) carrying the real topic — even for the wildcard sub. + let payload = stream.next().await.expect("should receive"); + match serde_json::from_slice::(&payload).unwrap() { + ServerMessage::Data { topic, payload, .. } => { + assert_eq!(topic, "sensors/temp/vienna"); + assert_eq!(payload, Some(serde_json::json!(22.5))); + } + _ => panic!("expected Data"), } } #[tokio::test] - async fn register_and_unregister() { - let mgr = ClientManager::new(); - let info = dummy_info(1); - let (id, _rx) = mgr.register(info, 16); - assert_eq!(mgr.client_count(), 1); - mgr.unregister(id); - assert_eq!(mgr.client_count(), 0); + async fn non_matching_topic_is_not_delivered() { + use futures_util::FutureExt; + let mgr = ClientManager::new(false); + let (_id, mut stream) = mgr.subscribe("commands/#"); + mgr.broadcast("sensors/temp", b"22.5").await; + // Nothing queued: the next() future is not ready. + assert!(stream.next().now_or_never().is_none()); } #[tokio::test] - async fn subscribe_and_broadcast() { - let mgr = ClientManager::new(); - let info = dummy_info(42); - let (id, mut rx) = mgr.register(info, 16); - mgr.subscribe(id, &["sensors/#".to_string()]); - - mgr.broadcast("sensors/temperature/vienna", b"22.5").await; - - let msg = rx.recv().await.expect("should receive message"); - if let Message::Text(text) = msg { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(v["type"], "data"); - assert_eq!(v["topic"], "sensors/temperature/vienna"); - } else { - panic!("expected text message"); + async fn fan_out_to_n_subscribers() { + let mgr = ClientManager::new(false); + let mut streams: Vec<_> = (0..5).map(|_| mgr.subscribe("#").1).collect(); + mgr.broadcast("any/topic", b"\"v\"").await; + for s in &mut streams { + let frame = s.next().await.unwrap(); + assert!(matches!( + serde_json::from_slice::(&frame).unwrap(), + ServerMessage::Data { topic, .. } if topic == "any/topic" + )); } } #[tokio::test] - async fn no_broadcast_when_not_subscribed() { - let mgr = ClientManager::new(); - let info = dummy_info(7); - let (id, mut rx) = mgr.register(info, 16); - mgr.subscribe(id, &["commands/#".to_string()]); - - // Broadcast to a topic the client is NOT subscribed to - mgr.broadcast("sensors/temperature/vienna", b"22.5").await; - - // Channel should be empty - assert!(rx.try_recv().is_err()); + async fn dropped_stream_is_pruned() { + let mgr = ClientManager::new(false); + let (_id, stream) = mgr.subscribe("#"); + assert_eq!(mgr.subscription_count(), 1); + drop(stream); + mgr.broadcast("t", b"v").await; + assert_eq!(mgr.subscription_count(), 0); } } diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs new file mode 100644 index 0000000..0bd0254 --- /dev/null +++ b/aimdb-websocket-connector/src/codec.rs @@ -0,0 +1,479 @@ +//! Per-connection WS-JSON [`EnvelopeCodec`] (Phase 4 — doc 039 § 2). +//! +//! Maps the WS wire ([`ClientMessage`]/[`ServerMessage`]) onto the engine's +//! [`Inbound`]/[`Outbound`] so `run_session` ([`aimdb_core::session::run_session`]) +//! can drive a WebSocket exactly as it drives AimX. +//! +//! **Why per-connection (not the shared `Arc` that `serve` uses).** `decode` +//! is **1→1**, so it cannot fan a multi-topic `Subscribe` into N +//! `Inbound::Subscribe` — the transport splits the frame instead (see +//! [`crate::transport`]) and the codec synthesizes a `u64` id per topic, which the +//! `Subscribed` ack and `Unsubscribe` map back to a topic. That `id↔topic` +//! bookkeeping is per-connection state a shared `Arc` cannot hold. Option A +//! calls `run_session(conn, &codec, …)` directly (only `serve` shares `Arc`), +//! so each upgrade builds its own `WsCodec` holding the maps behind a `Mutex` (it +//! stays `Send + Sync`; encode/decode take `&self`). +//! +//! **Data frames are pre-serialized by the bus.** The hot fan-out path does *not* +//! pass through the id maps: [`ClientManager::broadcast`](crate::client_manager) +//! serializes the complete `Data` frame **once** (it owns the real topic) and the +//! codec writes it verbatim — O(1) in subscribers. The explicit `Subscribed` ack +//! and late-join `Snapshot` are engine emissions (`Outbound::Subscribed` + +//! `Session::snapshot`, gated by `SessionConfig::acks_subscribe`); the codec maps +//! those to wire frames. + +use std::collections::HashMap; +use std::sync::Mutex; + +use aimdb_core::{CodecError, Inbound, Outbound, Payload, RpcError}; +use serde_json::Value; + +use crate::protocol::{ClientMessage, ErrorCode, ServerMessage}; + +/// Per-connection id bookkeeping (doc 039 § 2). Lives behind a `Mutex` so the +/// `&self` codec methods can mutate it. +#[derive(Default)] +struct WsCodecState { + /// Monotonic id allocator for engine `Subscribe`/`Request` correlation. The + /// WS client never sees these ids — they exist only for the engine's demux. + next_id: u64, + /// WS topic → engine `Subscribe.id` (synthesized on subscribe; consulted by + /// `Unsubscribe`). + topic_to_id: HashMap, + /// Engine `sub` id → wire topic (consulted when encoding `Event`→`Data` and + /// the `Subscribed` ack). + id_to_topic: HashMap, +} + +impl WsCodecState { + /// Allocate (or reuse) the engine subscribe id for `topic`. + fn alloc_sub(&mut self, topic: &str) -> u64 { + if let Some(id) = self.topic_to_id.get(topic) { + return *id; + } + self.next_id += 1; + let id = self.next_id; + self.topic_to_id.insert(topic.to_string(), id); + self.id_to_topic.insert(id, topic.to_string()); + id + } + + /// Allocate a bare correlation id (for `Query`/`ListTopics` requests). + fn alloc_req(&mut self) -> u64 { + self.next_id += 1; + self.next_id + } + + /// Drop the mapping for `topic`, returning its engine id if known. + fn remove_topic(&mut self, topic: &str) -> Option { + let id = self.topic_to_id.remove(topic)?; + self.id_to_topic.remove(&id); + Some(id) + } + + /// Resolve an engine `sub` id (as the engine's `&str` form) back to its topic. + fn topic_of(&self, sub: &str) -> Option { + let id: u64 = sub.parse().ok()?; + self.id_to_topic.get(&id).cloned() + } +} + +/// A per-connection WS-JSON codec. Construct one per accepted connection (server) +/// or per dialed connection (client). +pub struct WsCodec { + state: Mutex, +} + +impl WsCodec { + /// Build a fresh codec with empty id maps. + pub fn new() -> Self { + Self { + state: Mutex::new(WsCodecState::default()), + } + } +} + +impl Default for WsCodec { + fn default() -> Self { + Self::new() + } +} + +/// Parse record-value bytes as JSON, falling back to a JSON string (mirrors the +/// legacy `ClientManager` behavior so the wire is byte-identical). +pub(crate) fn parse_payload(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned())) +} + +/// Map an engine [`RpcError`] to a wire [`ErrorCode`]. +fn rpc_to_code(err: &RpcError) -> ErrorCode { + match err { + RpcError::NotFound => ErrorCode::UnknownTopic, + RpcError::Denied => ErrorCode::Forbidden, + _ => ErrorCode::ServerError, + } +} + +/// Serialize a [`ServerMessage`] into `out` as one WS-JSON text frame. +fn write_server(out: &mut Vec, msg: &ServerMessage) -> Result<(), CodecError> { + let bytes = serde_json::to_vec(msg).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) +} + +impl aimdb_core::EnvelopeCodec for WsCodec { + // ---- server direction: read a ClientMessage, write a ServerMessage ------ + + fn decode(&self, frame: &[u8]) -> Result { + let msg: ClientMessage = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + let mut st = self.state.lock().unwrap(); + match msg { + // The transport splits multi-topic frames, so exactly one topic here. + ClientMessage::Subscribe { topics } => { + let topic = topics.into_iter().next().ok_or(CodecError::Malformed)?; + let id = st.alloc_sub(&topic); + Ok(Inbound::Subscribe { id, topic }) + } + ClientMessage::Unsubscribe { topics } => { + let topic = topics.into_iter().next().ok_or(CodecError::Malformed)?; + let id = st.remove_topic(&topic).ok_or(CodecError::Malformed)?; + Ok(Inbound::Unsubscribe { + sub: id.to_string(), + }) + } + ClientMessage::Write { topic, payload } => { + let bytes = serde_json::to_vec(&payload).map_err(|_| CodecError::Malformed)?; + Ok(Inbound::Write { + topic, + payload: Payload::from(bytes.as_slice()), + }) + } + ClientMessage::Ping => Ok(Inbound::Ping), + // The whole frame (incl. the WS `String` correlation id) rides as + // `params`; the dispatch parses it and the id round-trips in the + // response, so no per-request id map is needed. + ClientMessage::Query { .. } => Ok(Inbound::Request { + id: st.alloc_req(), + method: "query".to_string(), + params: Payload::from(frame), + }), + ClientMessage::ListTopics { .. } => Ok(Inbound::Request { + id: st.alloc_req(), + method: "list_topics".to_string(), + params: Payload::from(frame), + }), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError> { + match msg { + // The bus pre-serializes the complete `Data` frame **once** per + // broadcast (it knows the real topic + raw-payload mode), so fan-out + // is O(1) in subscribers, not O(N) re-serializations — see + // `ClientManager::broadcast`. The codec just writes it verbatim. + Outbound::Event { data, .. } => { + out.extend_from_slice(&data); + Ok(()) + } + Outbound::Snapshot { topic, data } => write_server( + out, + &ServerMessage::Snapshot { + topic: topic.to_string(), + payload: Some(parse_payload(&data)), + }, + ), + Outbound::Subscribed { sub } => { + let topic = self + .state + .lock() + .unwrap() + .topic_of(sub) + .ok_or(CodecError::Malformed)?; + write_server(out, &ServerMessage::Subscribed { topics: vec![topic] }) + } + Outbound::Pong => write_server(out, &ServerMessage::Pong), + // `Reply::Ok` payloads are already a complete `ServerMessage` JSON + // (`QueryResult`/`TopicList`/`Error`) built by the dispatch with the + // client's `String` id spliced in — write them verbatim. + Outbound::Reply { result, .. } => match result { + Ok(payload) => { + out.extend_from_slice(&payload); + Ok(()) + } + Err(e) => write_server( + out, + &ServerMessage::Error { + code: rpc_to_code(&e), + topic: None, + message: format!("{e:?}"), + }, + ), + }, + } + } + + // ---- client direction: write a ClientMessage, read a ServerMessage ------ + // Used by the WS client port (`run_client`, Workstream D). The client engine + // is configured topic-routed, so `Event.sub` carries the topic. + + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + let client_msg = match msg { + Inbound::Subscribe { id, topic } => { + let mut st = self.state.lock().unwrap(); + st.topic_to_id.insert(topic.clone(), id); + st.id_to_topic.insert(id, topic.clone()); + ClientMessage::Subscribe { + topics: vec![topic], + } + } + Inbound::Unsubscribe { sub } => { + let topic = { + let mut st = self.state.lock().unwrap(); + st.topic_of(&sub) + .inspect(|t| { + st.remove_topic(t); + }) + .ok_or(CodecError::Malformed)? + }; + ClientMessage::Unsubscribe { + topics: vec![topic], + } + } + Inbound::Write { topic, payload } => ClientMessage::Write { + topic, + payload: parse_payload(&payload), + }, + Inbound::Ping => ClientMessage::Ping, + // `params` is already a complete `ClientMessage` JSON (`Query`/ + // `ListTopics`) — write it verbatim. + Inbound::Request { params, .. } => { + out.extend_from_slice(¶ms); + return Ok(()); + } + }; + let bytes = serde_json::to_vec(&client_msg).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + // `Outbound::Event`/`Snapshot` borrow `topic` as `&'a str` from the frame. + // serde's internally-tagged enums can't borrow (they buffer), so we peek + // the `type` tag, then deserialize the matching struct that borrows the + // topic slice **zero-copy** (no interning, no leak). Topics are record + // keys without JSON escapes, so the borrow always succeeds. + let tag: TagOnly = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match tag.ty { + "data" => { + let d: TopicValueRef = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + Ok(Outbound::Event { + sub: d.topic, + seq: 0, + data: value_to_payload(d.payload), + }) + } + "snapshot" => { + let d: TopicValueRef = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + Ok(Outbound::Snapshot { + topic: d.topic, + data: value_to_payload(d.payload), + }) + } + // Informational on the client; the engine ignores `Subscribed`, so the + // `sub` value is irrelevant — hand back an empty borrow (no leak). + "subscribed" => Ok(Outbound::Subscribed { sub: "" }), + "pong" => Ok(Outbound::Pong), + // Query/list responses + errors are RPC replies; the caller-RPC path + // is not wired on the WS client (records mirror via Data/Snapshot), + // so map them to a benign Pong. + _ => Ok(Outbound::Pong), + } + } +} + +/// Zero-copy peek at the `"type"` discriminant (the tag is short ASCII — no escapes). +#[derive(serde::Deserialize)] +struct TagOnly<'a> { + #[serde(rename = "type", borrow)] + ty: &'a str, +} + +/// Zero-copy view of a `Data`/`Snapshot` frame: the `topic` borrows the frame. +#[derive(serde::Deserialize)] +struct TopicValueRef<'a> { + #[serde(borrow)] + topic: &'a str, + payload: Option, +} + +/// Serialize a WS payload `Value` back to record-value bytes. +fn value_to_payload(payload: Option) -> Payload { + let bytes = payload + .as_ref() + .and_then(|v| serde_json::to_vec(v).ok()) + .unwrap_or_default(); + Payload::from(bytes.as_slice()) +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_core::EnvelopeCodec; + + fn sub(codec: &WsCodec, topic: &str) -> u64 { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec![topic.to_string()], + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Subscribe { id, topic: t } => { + assert_eq!(t, topic); + id + } + _ => panic!("expected Subscribe"), + } + } + + #[test] + fn event_is_written_verbatim() { + // The bus pre-serializes the complete Data frame; encode passes it through + // (O(1) fan-out). `sub`/`seq` are irrelevant on this path. + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: "sensors/temp/vienna".into(), + payload: Some(serde_json::json!(22.5)), + ts: 123, + }) + .unwrap(); + let mut out = Vec::new(); + codec + .encode( + Outbound::Event { + sub: "ignored", + seq: 7, + data: Payload::from(frame.as_slice()), + }, + &mut out, + ) + .unwrap(); + assert_eq!(out, frame); + } + + #[test] + fn decode_outbound_borrows_topic_without_leaking() { + // Client direction: a Data frame decodes to a topic-routed Event whose + // `sub` is the topic, borrowed zero-copy from the frame. + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: "weather/vienna".into(), + payload: Some(serde_json::json!("sunny")), + ts: 0, + }) + .unwrap(); + match codec.decode_outbound(&frame).unwrap() { + Outbound::Event { sub, data, .. } => { + assert_eq!(sub, "weather/vienna"); + assert_eq!(&data[..], b"\"sunny\""); + } + _ => panic!("expected Event"), + } + } + + #[test] + fn subscribed_ack_maps_to_topic() { + let codec = WsCodec::new(); + let id = sub(&codec, "a/b"); + let mut out = Vec::new(); + codec + .encode(Outbound::Subscribed { sub: &id.to_string() }, &mut out) + .unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert_eq!(v, ServerMessage::Subscribed { topics: vec!["a/b".into()] }); + } + + #[test] + fn unsubscribe_resolves_topic_to_id() { + let codec = WsCodec::new(); + let id = sub(&codec, "x/y"); + let frame = serde_json::to_vec(&ClientMessage::Unsubscribe { + topics: vec!["x/y".to_string()], + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Unsubscribe { sub } => assert_eq!(sub, id.to_string()), + _ => panic!("expected Unsubscribe"), + } + } + + #[test] + fn write_carries_payload() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Write { + topic: "cmd/set".to_string(), + payload: serde_json::json!({"v": 1}), + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Write { topic, payload } => { + assert_eq!(topic, "cmd/set"); + let v: Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(v, serde_json::json!({"v": 1})); + } + _ => panic!("expected Write"), + } + } + + #[test] + fn query_passes_frame_as_params_and_reply_writes_verbatim() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Query { + id: "q1".to_string(), + pattern: "#".to_string(), + from: None, + to: None, + limit: None, + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Request { method, params, .. } => { + assert_eq!(method, "query"); + assert_eq!(¶ms[..], &frame[..]); + } + _ => panic!("expected Request"), + } + + // A dispatch reply (already a full ServerMessage) is written verbatim. + let reply = serde_json::to_vec(&ServerMessage::QueryResult { + id: "q1".to_string(), + records: vec![], + total: 0, + }) + .unwrap(); + let mut out = Vec::new(); + codec + .encode( + Outbound::Reply { + id: 7, + result: Ok(Payload::from(reply.as_slice())), + }, + &mut out, + ) + .unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert!(matches!(v, ServerMessage::QueryResult { id, .. } if id == "q1")); + } + + #[test] + fn ping_pong() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Ping).unwrap(); + assert!(matches!(codec.decode(&frame).unwrap(), Inbound::Ping)); + let mut out = Vec::new(); + codec.encode(Outbound::Pong, &mut out).unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert_eq!(v, ServerMessage::Pong); + } +} diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/connector.rs index 9189095..77d84e8 100644 --- a/aimdb-websocket-connector/src/connector.rs +++ b/aimdb-websocket-connector/src/connector.rs @@ -26,20 +26,16 @@ use crate::client_manager::ClientManager; type BoxFuture = Pin + Send + 'static>>; -/// Live WebSocket connector returned by `build()`. +/// Live WebSocket connector returned by `build()` — owns the outbound publisher +/// tasks that feed the [`ClientManager`] bus. (Envelope vs raw-payload framing is +/// now the per-connection [`WsCodec`](crate::codec)'s job, not the broadcaster's.) pub struct WebSocketConnectorImpl { pub(crate) client_mgr: ClientManager, - /// When `true`, outbound data bypasses the `ServerMessage::Data` envelope - /// and sends the serializer bytes directly as a WebSocket text frame. - pub(crate) raw_payload: bool, } impl WebSocketConnectorImpl { - pub(crate) fn new(client_mgr: ClientManager, raw_payload: bool) -> Self { - Self { - client_mgr, - raw_payload, - } + pub(crate) fn new(client_mgr: ClientManager) -> Self { + Self { client_mgr } } /// Collects one outbound publisher future per route. @@ -58,7 +54,6 @@ impl WebSocketConnectorImpl { where R: aimdb_executor::RuntimeAdapter + 'static, { - let raw_payload = self.raw_payload; let runtime_ctx: Arc = db.runtime_any(); let mut futures: Vec = Vec::with_capacity(outbound_routes.len()); @@ -131,12 +126,10 @@ impl WebSocketConnectorImpl { map.insert(topic.clone(), bytes.clone()); } - // Fan-out to subscribed clients - if raw_payload { - client_mgr.broadcast_raw(&topic, &bytes).await; - } else { - client_mgr.broadcast(&topic, &bytes).await; - } + // Fan-out to subscribed clients via the bus. The per-connection + // `WsCodec` applies the `Data` envelope (or, in raw mode, sends + // the bytes verbatim) — so the bus always carries raw bytes. + client_mgr.broadcast(&topic, &bytes).await; } #[cfg(feature = "tracing")] diff --git a/aimdb-websocket-connector/src/dispatch.rs b/aimdb-websocket-connector/src/dispatch.rs new file mode 100644 index 0000000..ebb61d7 --- /dev/null +++ b/aimdb-websocket-connector/src/dispatch.rs @@ -0,0 +1,388 @@ +//! WS server [`Dispatch`] + [`Session`] (Phase 4 — doc 039 § 3). +//! +//! [`WsDispatch`] is the shared half (one `Arc` per server): +//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (carried in +//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`]. The session +//! threads `&mut self` from `run_session` and homes the application surface — the +//! [`ClientManager`] bus handle, the auth principal, the query handler — exactly +//! as `AimxSession` homes `drain_readers`. +//! +//! The id↔topic bookkeeping does **not** live here — it lives in the +//! per-connection [`WsCodec`](crate::codec) (doc 039 § 2). The explicit +//! `Subscribed` ack and late-join `Snapshot` are engine emissions +//! (`acks_subscribe` + `Session::snapshot`); this session only supplies the +//! snapshot bytes and the filtered subscription stream. + +use std::any::Any; +use std::sync::Arc; + +use aimdb_core::session::Session; +use aimdb_core::{AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, SessionCtx}; +use aimdb_ws_protocol::TopicInfo; + +use crate::{ + auth::{AuthHandler, ClientId, ClientInfo, Permissions}, + client_manager::{ClientManager, ConnectionGuard}, + protocol::{ClientMessage, ErrorCode, ServerMessage}, + session::{QueryHandler, Router, SnapshotProvider}, +}; + +/// The shared WS dispatch — one `Arc` per server. +pub struct WsDispatch { + pub(crate) client_mgr: ClientManager, + pub(crate) snapshot_provider: Arc, + pub(crate) query_handler: Arc, + pub(crate) router: Arc, + pub(crate) known_topics: Arc>, + pub(crate) auth: Arc, + pub(crate) late_join: bool, + pub(crate) runtime_ctx: Option>, +} + +impl Dispatch for WsDispatch { + fn authenticate<'a>( + &'a self, + peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo` + // as a `ClientInfo` (WS-style `reads_hello:false`, doc 039 § 4). + let info = peer.ext_as::(); + Box::pin(async move { + match info { + Some(info) => Ok(SessionCtx::with_ext(info)), + None => Err(AuthError::Unauthorized), + } + }) + } + + fn open(&self, ctx: &SessionCtx) -> Box { + let info = ctx.ext_as::().unwrap_or_else(|| { + // Should not happen (authenticate populates it); deny-all fallback. + Arc::new(ClientInfo { + id: ClientId(0), + remote_addr: ([0, 0, 0, 0], 0).into(), + permissions: Permissions::default(), + }) + }); + Box::new(WsSession { + client_mgr: self.client_mgr.clone(), + snapshot_provider: self.snapshot_provider.clone(), + query_handler: self.query_handler.clone(), + router: self.router.clone(), + known_topics: self.known_topics.clone(), + auth: self.auth.clone(), + late_join: self.late_join, + runtime_ctx: self.runtime_ctx.clone(), + info, + _conn_guard: self.client_mgr.connection_guard(), + }) + } +} + +/// One connection's per-session state (owned by the engine, `&mut`-threaded). +struct WsSession { + client_mgr: ClientManager, + snapshot_provider: Arc, + query_handler: Arc, + router: Arc, + known_topics: Arc>, + auth: Arc, + late_join: bool, + runtime_ctx: Option>, + info: Arc, + /// Decrements the live-connection count on drop. + _conn_guard: ConnectionGuard, +} + +impl Session for WsSession { + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + Box::pin(async move { + let msg: ClientMessage = + serde_json::from_slice(¶ms).map_err(|_| RpcError::Internal)?; + let response = match (method, msg) { + ( + "query", + ClientMessage::Query { + id, + pattern, + from, + to, + limit, + }, + ) => match self + .query_handler + .handle_query(&pattern, from, to, limit) + .await + { + Ok((records, total)) => ServerMessage::QueryResult { + id, + records, + total, + }, + Err(message) => ServerMessage::Error { + code: ErrorCode::ServerError, + topic: None, + message, + }, + }, + ("list_topics", ClientMessage::ListTopics { id }) => ServerMessage::TopicList { + id, + topics: (*self.known_topics).clone(), + }, + _ => return Err(RpcError::NotFound), + }; + // The codec writes this complete `ServerMessage` verbatim (doc 039 § 2). + let bytes = serde_json::to_vec(&response).map_err(|_| RpcError::Internal)?; + Ok(Payload::from(bytes.as_slice())) + }) + } + + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + Box::pin(async move { + // Per-operation authorization — the full async `AuthHandler` hook, so + // a custom `authorize_subscribe` (per-topic ACL, token introspection) + // is honored, not just the static permission set. + if !self.auth.authorize_subscribe(&self.info, topic).await { + return Err(RpcError::Denied); + } + // Register on the shared bus; the engine owns the returned stream and + // drops it on Unsubscribe/teardown (the bus prunes the dead entry). + let (_sub_id, stream) = self.client_mgr.subscribe(topic); + Ok(stream) + }) + } + + fn snapshot(&mut self, topic: &str) -> Option { + if !self.late_join { + return None; + } + self.snapshot_provider + .snapshot(topic) + .map(|bytes| Payload::from(bytes.as_slice())) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + Box::pin(async move { + if !self.auth.authorize_write(&self.info, topic).await { + return Err(RpcError::Denied); + } + self.router + .route(topic, &payload, self.runtime_ctx.as_ref()) + .await + .map_err(|_| RpcError::Internal) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + use std::time::Duration; + + use aimdb_core::session::{run_session, SessionConfig}; + use aimdb_core::router::RouterBuilder; + use aimdb_core::{Connection, SessionLimits, TransportResult}; + use tokio::sync::mpsc; + + use crate::auth::{NoAuth, Permissions}; + use crate::codec::WsCodec; + use crate::session::NoQuery; + + /// A mock [`Connection`]: inbound frames arrive on a channel, outbound frames + /// are captured. Closing the channel ends the session. + struct MockConn { + rx: mpsc::UnboundedReceiver>, + out: Arc>>>, + peer: PeerInfo, + } + + impl Connection for MockConn { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { Ok(self.rx.recv().await) }) + } + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + let out = self.out.clone(); + let frame = frame.to_vec(); + Box::pin(async move { + out.lock().unwrap().push(frame); + Ok(()) + }) + } + fn peer(&self) -> &PeerInfo { + &self.peer + } + } + + struct OneSnap(String, Vec); + impl SnapshotProvider for OneSnap { + fn snapshot(&self, topic: &str) -> Option> { + (topic == self.0).then(|| self.1.clone()) + } + } + + fn dispatch_with(snapshot: Arc) -> Arc { + Arc::new(WsDispatch { + client_mgr: ClientManager::new(false), + snapshot_provider: snapshot, + query_handler: Arc::new(NoQuery), + router: Arc::new(RouterBuilder::from_routes(Vec::new()).build()), + known_topics: Arc::new(Vec::new()), + auth: Arc::new(NoAuth), + late_join: true, + runtime_ctx: None, + }) + } + + fn allow_all_peer() -> PeerInfo { + PeerInfo::default().with_ext(Arc::new(ClientInfo { + id: ClientId(1), + remote_addr: ([127, 0, 0, 1], 0).into(), + permissions: Permissions::allow_all(), + })) + } + + fn parse(out: &Arc>>>) -> Vec { + out.lock() + .unwrap() + .iter() + .map(|f| serde_json::from_slice(f).unwrap()) + .collect() + } + + // run_session drives the real codec + dispatch: subscribe → ack + late-join + // snapshot, then a bus broadcast fans out as a Data frame. + #[tokio::test] + async fn subscribe_ack_snapshot_and_fanout() { + let dispatch = + dispatch_with(Arc::new(OneSnap("sensors/temp".into(), b"\"last\"".to_vec()))); + let mgr = dispatch.client_mgr.clone(); + + let (tx, rx) = mpsc::unbounded_channel::>(); + let out = Arc::new(Mutex::new(Vec::new())); + let conn = MockConn { + rx, + out: out.clone(), + peer: allow_all_peer(), + }; + + let task = { + let dispatch = dispatch.clone(); + tokio::spawn(async move { + let codec = WsCodec::new(); + let config = SessionConfig { + limits: SessionLimits::default(), + reads_hello: false, + acks_subscribe: true, + }; + run_session(Box::new(conn), &codec, dispatch.as_ref(), &config).await; + }) + }; + + // Subscribe to an exact topic so the snapshot provider matches. + tx.send( + serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["sensors/temp".into()], + }) + .unwrap(), + ) + .unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + // Ack + snapshot should have been emitted, in order. + let msgs = parse(&out); + assert!(matches!(&msgs[0], ServerMessage::Subscribed { topics } if topics == &vec!["sensors/temp".to_string()])); + assert!(matches!(&msgs[1], ServerMessage::Snapshot { topic, .. } if topic == "sensors/temp")); + + // A bus broadcast now fans out to this subscription as a Data frame. + mgr.broadcast("sensors/temp", b"\"22.5\"").await; + tokio::time::sleep(Duration::from_millis(50)).await; + let msgs = parse(&out); + let data = msgs + .iter() + .find_map(|m| match m { + ServerMessage::Data { topic, payload, .. } => Some((topic.clone(), payload.clone())), + _ => None, + }) + .expect("a Data frame"); + assert_eq!(data.0, "sensors/temp"); + assert_eq!(data.1, Some(serde_json::json!("22.5"))); + + drop(tx); // close the connection → end the session + let _ = task.await; + } + + // One broadcast reaches N subscribed connections (the fan-out bridge). + #[tokio::test] + async fn fanout_to_multiple_connections() { + let dispatch = dispatch_with(Arc::new(crate::session::NoSnapshot)); + let mgr = dispatch.client_mgr.clone(); + + let mut conns = Vec::new(); + for _ in 0..3 { + let (tx, rx) = mpsc::unbounded_channel::>(); + let out = Arc::new(Mutex::new(Vec::new())); + let conn = MockConn { + rx, + out: out.clone(), + peer: allow_all_peer(), + }; + let dispatch = dispatch.clone(); + let task = tokio::spawn(async move { + let codec = WsCodec::new(); + let config = SessionConfig { + limits: SessionLimits::default(), + reads_hello: false, + acks_subscribe: true, + }; + run_session(Box::new(conn), &codec, dispatch.as_ref(), &config).await; + }); + tx.send( + serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["weather/#".into()], + }) + .unwrap(), + ) + .unwrap(); + conns.push((tx, out, task)); + } + // Wait until all three subscriptions have registered on the bus. + for _ in 0..50 { + if mgr.subscription_count() == 3 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(mgr.subscription_count(), 3); + + mgr.broadcast("weather/vienna", b"\"sunny\"").await; + + for (tx, out, task) in conns { + let mut got_data = false; + for _ in 0..50 { + got_data = parse(&out).iter().any( + |m| matches!(m, ServerMessage::Data { topic, .. } if topic == "weather/vienna"), + ); + if got_data { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert!(got_data, "each connection should receive the broadcast"); + drop(tx); + let _ = task.await; + } + } +} diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 1561313..2f7c7af 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -82,6 +82,8 @@ pub mod client_manager; #[cfg(feature = "server")] pub mod connector; #[cfg(feature = "server")] +pub(crate) mod dispatch; +#[cfg(feature = "server")] pub(crate) mod registry; #[cfg(feature = "server")] pub(crate) mod server; @@ -95,6 +97,20 @@ pub(crate) mod session; #[cfg(feature = "client")] pub mod client; +// ════════════════════════════════════════════════════════════════════ +// Shared session-engine glue (Phase 4 — server and/or client) +// ════════════════════════════════════════════════════════════════════ + +/// Per-connection WS-JSON [`EnvelopeCodec`](aimdb_core::EnvelopeCodec) shared by +/// the server (`run_session`) and client (`run_client`) ports. +#[cfg(any(feature = "server", feature = "client"))] +pub mod codec; + +/// WS transport adapters ([`Connection`](aimdb_core::Connection)/`Dialer`) over a +/// real WebSocket. +#[cfg(any(feature = "server", feature = "client"))] +pub mod transport; + // ════════════════════════════════════════════════════════════════════ // Protocol (always available) // ════════════════════════════════════════════════════════════════════ diff --git a/aimdb-websocket-connector/src/server.rs b/aimdb-websocket-connector/src/server.rs index fa3062e..601abf5 100644 --- a/aimdb-websocket-connector/src/server.rs +++ b/aimdb-websocket-connector/src/server.rs @@ -11,8 +11,12 @@ //! { "status": "ok", "clients": 3, "uptime_secs": 120 } //! ``` -use std::{collections::HashMap, net::SocketAddr, time::Instant}; +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Instant}; +use aimdb_core::{ + session::{run_session, SessionConfig}, + Connection, Dispatch, PeerInfo, SessionLimits, +}; use axum::{ extract::{ ws::{WebSocket, WebSocketUpgrade}, @@ -26,17 +30,31 @@ use axum::{ use tower_http::cors::CorsLayer; use crate::{ - auth::{AuthError, AuthRequest, ClientInfo}, - session::{run_session, SessionContext}, + auth::{AuthError, AuthRequest, ClientInfo, DynAuthHandler}, + client_manager::ClientManager, + codec::WsCodec, + transport::WsServerConnection, }; // ════════════════════════════════════════════════════════════════════ // Shared server state // ════════════════════════════════════════════════════════════════════ +/// State shared across upgrade/health handlers. The per-connection session engine +/// (`run_session`) is driven from [`ws_upgrade_handler`]; only the *accept* loop +/// stays axum's (Option A, doc 039 § 6). #[derive(Clone)] pub(crate) struct ServerState { - pub session_ctx: SessionContext, + /// Shared application dispatch (one `Arc` per server). + pub dispatch: Arc, + /// HTTP-upgrade authenticator (resolves identity before the engine runs). + pub auth: DynAuthHandler, + /// Bus + connection counter (for client-id allocation and `/health`). + pub client_mgr: ClientManager, + /// Patterns to auto-subscribe each client to on connect. + pub auto_subscribe: Arc>, + /// Per-connection subscription cap. + pub max_subs_per_connection: usize, pub started_at: Instant, } @@ -63,14 +81,9 @@ type BoxFuture = std::pin::Pin + Send pub(crate) fn build_server_future( bind_addr: SocketAddr, ws_path: String, - session_ctx: SessionContext, + state: ServerState, additional_routes: Option, ) -> BoxFuture { - let state = ServerState { - session_ctx, - started_at: Instant::now(), - }; - // Apply state first so the router becomes `Router<()>`, which can then be // merged with user-supplied `additional_routes: Router<()>` without a // type-parameter mismatch. @@ -132,8 +145,8 @@ async fn ws_upgrade_handler( remote_addr, }; - // Authenticate — returns permissions or rejects - let permissions = match state.session_ctx.auth.authenticate(&auth_req).await { + // Authenticate at the HTTP upgrade — returns permissions or rejects (401). + let permissions = match state.auth.authenticate(&auth_req).await { Ok(p) => p, Err(AuthError { message }) => { #[cfg(feature = "tracing")] @@ -142,8 +155,9 @@ async fn ws_upgrade_handler( } }; - // Allocate a client id before upgrading so it's available synchronously - let id = state.session_ctx.client_mgr.next_client_id(); + // Resolve identity synchronously, before the upgrade, and carry it into the + // engine via `PeerInfo::ext` (WS-style `reads_hello:false`, doc 039 § 4). + let id = state.client_mgr.next_client_id(); let info = ClientInfo { id, remote_addr, @@ -157,16 +171,32 @@ async fn ws_upgrade_handler( remote_addr ); - let ctx = state.session_ctx.clone(); + let dispatch = state.dispatch.clone(); + let auto_subscribe = state.auto_subscribe.clone(); + let config = SessionConfig { + limits: SessionLimits { + max_connections: usize::MAX, // axum owns the accept loop (Option A) + max_subs_per_connection: state.max_subs_per_connection, + }, + reads_hello: false, + acks_subscribe: true, + }; - ws.on_upgrade(move |socket: WebSocket| run_session(socket, info, ctx)) - .into_response() + ws.on_upgrade(move |socket: WebSocket| async move { + let peer = PeerInfo::default().with_ext(Arc::new(info)); + let conn: Box = + Box::new(WsServerConnection::new(socket, peer, &auto_subscribe)); + let codec = WsCodec::new(); + // Per-connection codec + run_session drive this socket (doc 039 § 6). + run_session(conn, &codec, dispatch.as_ref(), &config).await; + }) + .into_response() } /// Health check endpoint. async fn health_handler(State(state): State) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); - let clients = state.session_ctx.client_mgr.client_count(); + let clients = state.client_mgr.client_count(); Json(serde_json::json!({ "status": "ok", diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/session.rs index 74c8cbd..af1e49b 100644 --- a/aimdb-websocket-connector/src/session.rs +++ b/aimdb-websocket-connector/src/session.rs @@ -1,33 +1,19 @@ -//! Per-client WebSocket session management. +//! Reusable handler traits for the WebSocket dispatch (Phase 4). //! -//! Each accepted connection spawns three cooperating tasks: +//! The hand-rolled per-connection recv/send loops that used to live here were +//! retired in Phase 4 — the WS server now rides `run_session` +//! ([`aimdb_core::session::run_session`]) via [`crate::dispatch`]. What survives +//! is the pluggable application surface the dispatch consumes: //! -//! 1. **Send loop** — drains the per-client `mpsc` channel and writes frames to -//! the WebSocket. -//! 2. **Recv loop** — reads frames from the WebSocket and dispatches -//! `subscribe`, `unsubscribe`, `write`, `ping`, and `query` messages. -//! 3. A **cleanup** fence — unregisters the client from the [`ClientManager`] -//! when either loop finishes. -//! -//! The session receives an already-authenticated [`ClientInfo`] and the shared -//! [`ClientManager`] / inbound [`Router`] from the server. - -use std::sync::Arc; +//! - [`QueryHandler`] — answers client `query` messages from a persistence backend; +//! - [`SnapshotProvider`] — supplies the late-join current value for a topic. use core::future::Future; use core::pin::Pin; -use axum::extract::ws::{Message, WebSocket}; -use futures_util::{SinkExt, StreamExt}; -use tokio::sync::mpsc; - -use crate::{ - auth::{AuthHandler, ClientId, ClientInfo}, - client_manager::ClientManager, - protocol::{ClientMessage, ErrorCode, QueryRecord, TopicInfo}, -}; +use crate::protocol::QueryRecord; -// Re-export so server.rs can use it easily. +// Re-export so the builder/dispatch can use it easily. pub use aimdb_core::router::Router; // ════════════════════════════════════════════════════════════════════ @@ -42,27 +28,6 @@ pub type QueryFuture<'a> = /// /// Implementations typically query a persistence backend and return matching /// records. The trait is async to support database I/O. -/// -/// # Example -/// -/// ```rust,ignore -/// struct MyQueryHandler { db: Arc } -/// -/// impl QueryHandler for MyQueryHandler { -/// fn handle_query<'a>( -/// &'a self, -/// pattern: &'a str, -/// from: Option, -/// to: Option, -/// limit: Option, -/// ) -> QueryFuture<'a> { -/// Box::pin(async move { -/// // query your persistence layer … -/// Ok((records, total)) -/// }) -/// } -/// } -/// ``` pub trait QueryHandler: Send + Sync + 'static { /// Execute a history query and return `(records, total_count)`. /// @@ -97,347 +62,20 @@ impl QueryHandler for NoQuery { } // ════════════════════════════════════════════════════════════════════ -// Session context +// Snapshot provider (late-join) // ════════════════════════════════════════════════════════════════════ -/// Shared context injected into every session. -#[derive(Clone)] -pub(crate) struct SessionContext { - pub client_mgr: ClientManager, - /// Inbound router: maps WebSocket topics → AimDB producers. - pub router: Arc, - pub auth: Arc, - /// Channel capacity used when registering a new client. - pub channel_capacity: usize, - /// Whether to send current values on subscribe (late-join). - pub late_join: bool, - /// Snapshot provider: topic → serialized current value. - /// - /// Set by the connector builder after collecting outbound routes. - pub snapshot_provider: Arc, - /// Topics to subscribe every new client to automatically on connect. - /// - /// Use `["#"]` to push all data to all clients without requiring an - /// explicit `{"type":"subscribe"}` message from the client. - pub auto_subscribe_topics: Vec, - /// Handler for `Query` messages (historical record retrieval). - pub query_handler: Arc, - /// All outbound topics served by this endpoint, returned on `list_topics`. - pub known_topics: Vec, - /// Type-erased runtime for context-aware deserializers. - pub runtime_ctx: Option>, -} - /// Provides the current serialized value of a record for late-join snapshots. -pub(crate) trait SnapshotProvider: Send + Sync + 'static { +pub trait SnapshotProvider: Send + Sync + 'static { /// Return the latest serialized value for the given topic, if available. fn snapshot(&self, topic: &str) -> Option>; } -/// A snapshot provider that always returns `None` (used when late-join is disabled -/// or no snapshot data is available). -pub(crate) struct NoSnapshot; +/// A snapshot provider that always returns `None` (late-join disabled or no data). +pub struct NoSnapshot; impl SnapshotProvider for NoSnapshot { fn snapshot(&self, _topic: &str) -> Option> { None } } - -/// A snapshot provider backed by a `HashMap`. -#[cfg(test)] -#[allow(dead_code)] -pub(crate) struct MapSnapshot(pub std::collections::HashMap>); - -#[cfg(test)] -impl SnapshotProvider for MapSnapshot { - fn snapshot(&self, topic: &str) -> Option> { - self.0.get(topic).cloned() - } -} - -// ════════════════════════════════════════════════════════════════════ -// Session entry point -// ════════════════════════════════════════════════════════════════════ - -/// Drive a single WebSocket connection to completion. -/// -/// This function is `await`ed inside `tokio::spawn` by the Axum upgrade handler. -pub(crate) async fn run_session(socket: WebSocket, info: ClientInfo, ctx: SessionContext) { - let id = info.id; - - // Register client and obtain the per-client receiver - let (_, rx) = ctx.client_mgr.register(info, ctx.channel_capacity); - - // Auto-subscribe: subscribe all clients to the configured topics immediately - // on connect, without requiring a Subscribe message from the client. - if !ctx.auto_subscribe_topics.is_empty() { - ctx.client_mgr.subscribe(id, &ctx.auto_subscribe_topics); - } - - #[cfg(feature = "tracing")] - tracing::debug!("{}: session started", id); - - let (ws_sender, ws_receiver) = socket.split(); - - // Spawn the send loop (mpsc receiver → WebSocket sender) - let mgr_send = ctx.client_mgr.clone(); - let send_handle = tokio::spawn(send_loop(ws_sender, rx, id)); - - // Run the receive loop in-place (WebSocket receiver → router/subscriptions) - recv_loop(ws_receiver, id, ctx).await; - - // Receiving finished; abort sender and unregister - send_handle.abort(); - mgr_send.unregister(id); - - #[cfg(feature = "tracing")] - tracing::debug!("{}: session ended", id); -} - -// ════════════════════════════════════════════════════════════════════ -// Send loop -// ════════════════════════════════════════════════════════════════════ - -async fn send_loop( - mut ws_sender: futures_util::stream::SplitSink, - mut rx: mpsc::Receiver, - #[allow(unused_variables)] id: ClientId, -) { - while let Some(msg) = rx.recv().await { - if ws_sender.send(msg).await.is_err() { - #[cfg(feature = "tracing")] - tracing::debug!("{}: send failed — closing", id); - break; - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Receive loop -// ════════════════════════════════════════════════════════════════════ - -async fn recv_loop( - mut ws_receiver: futures_util::stream::SplitStream, - id: ClientId, - ctx: SessionContext, -) { - while let Some(result) = ws_receiver.next().await { - let raw = match result { - Ok(msg) => msg, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::debug!("{}: recv error: {}", id, _e); - break; - } - }; - - match raw { - Message::Text(text) => { - handle_text(id, text.as_str(), &ctx).await; - } - Message::Binary(bytes) => { - handle_text(id, &String::from_utf8_lossy(&bytes), &ctx).await; - } - Message::Close(_) => { - #[cfg(feature = "tracing")] - tracing::debug!("{}: received close frame", id); - break; - } - // WebSocket ping/pong frames are handled transparently by axum. - _ => {} - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Message dispatch -// ════════════════════════════════════════════════════════════════════ - -async fn handle_text(id: ClientId, text: &str, ctx: &SessionContext) { - let msg: ClientMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("{}: invalid JSON from client: {}", id, _e); - ctx.client_mgr - .send_error( - id, - ErrorCode::SerializationError, - None, - "Invalid JSON message", - ) - .await; - return; - } - }; - - match msg { - ClientMessage::Subscribe { topics } => handle_subscribe(id, topics, ctx).await, - ClientMessage::Unsubscribe { topics } => { - ctx.client_mgr.unsubscribe(id, &topics); - } - ClientMessage::Write { topic, payload } => handle_write(id, topic, payload, ctx).await, - ClientMessage::Ping => { - ctx.client_mgr.send_pong(id).await; - } - ClientMessage::Query { - id: query_id, - pattern, - from, - to, - limit, - } => { - handle_query(id, query_id, pattern, from, to, limit, ctx).await; - } - ClientMessage::ListTopics { id: req_id } => { - handle_list_topics(id, req_id, ctx).await; - } - } -} - -async fn handle_subscribe(id: ClientId, topics: Vec, ctx: &SessionContext) { - // Authorise each requested pattern - let client_info = match ctx.client_mgr.client_info(id) { - Some(i) => i, - None => return, - }; - - let mut allowed = Vec::new(); - - for topic in &topics { - if ctx.auth.authorize_subscribe(&client_info, topic).await { - allowed.push(topic.clone()); - } else { - ctx.client_mgr - .send_error( - id, - ErrorCode::Forbidden, - Some(topic.clone()), - "Not authorised to subscribe to this topic", - ) - .await; - } - } - - if allowed.is_empty() { - return; - } - - // Register subscriptions - let confirmed = ctx.client_mgr.subscribe(id, &allowed); - - // Send acknowledgement - ctx.client_mgr.send_subscribed(id, confirmed.clone()).await; - - // Late-join: send current values for each exact topic pattern that resolves - if ctx.late_join { - for pattern in confirmed { - if let Some(bytes) = ctx.snapshot_provider.snapshot(&pattern) { - ctx.client_mgr.send_snapshot(id, &pattern, &bytes).await; - } - } - } -} - -async fn handle_write( - id: ClientId, - topic: String, - payload: serde_json::Value, - ctx: &SessionContext, -) { - // Authorise - let client_info = match ctx.client_mgr.client_info(id) { - Some(i) => i, - None => return, - }; - - if !ctx.auth.authorize_write(&client_info, &topic).await { - ctx.client_mgr - .send_error( - id, - ErrorCode::Forbidden, - Some(topic.clone()), - "Write permission denied", - ) - .await; - return; - } - - // Serialize payload back to bytes for the router - let bytes = match serde_json::to_vec(&payload) { - Ok(b) => b, - Err(_e) => { - ctx.client_mgr - .send_error( - id, - ErrorCode::SerializationError, - Some(topic.clone()), - "Failed to re-serialize payload", - ) - .await; - return; - } - }; - - // Dispatch through the inbound router - if let Err(_e) = ctx - .router - .route(&topic, &bytes, ctx.runtime_ctx.as_ref()) - .await - { - #[cfg(feature = "tracing")] - tracing::warn!("{}: write routing failed for '{}': {}", id, topic, _e); - - ctx.client_mgr - .send_error( - id, - ErrorCode::UnknownTopic, - Some(topic), - "No inbound route for this topic", - ) - .await; - } -} - -async fn handle_list_topics(id: ClientId, req_id: String, ctx: &SessionContext) { - use crate::protocol::ServerMessage; - - let result = ServerMessage::TopicList { - id: req_id, - topics: ctx.known_topics.clone(), - }; - ctx.client_mgr.send_to_client(id, &result).await; -} - -async fn handle_query( - id: ClientId, - query_id: String, - pattern: String, - from: Option, - to: Option, - limit: Option, - ctx: &SessionContext, -) { - use crate::protocol::ServerMessage; - - match ctx - .query_handler - .handle_query(&pattern, from, to, limit) - .await - { - Ok((records, total)) => { - let result = ServerMessage::QueryResult { - id: query_id, - records, - total, - }; - ctx.client_mgr.send_to_client(id, &result).await; - } - Err(msg) => { - ctx.client_mgr - .send_error(id, ErrorCode::ServerError, None, &msg) - .await; - } - } -} diff --git a/aimdb-websocket-connector/src/transport.rs b/aimdb-websocket-connector/src/transport.rs new file mode 100644 index 0000000..9d091aa --- /dev/null +++ b/aimdb-websocket-connector/src/transport.rs @@ -0,0 +1,237 @@ +//! WS transport adapters — [`Connection`](aimdb_core::Connection) (and, for the +//! client, `Dialer`) over a real WebSocket so the shared session engines drive it +//! (Phase 4 — doc 039 § 6). +//! +//! The **server** side ([`WsServerConnection`]) wraps axum's upgraded +//! [`WebSocket`]; the upgrade handler hands it to `run_session` +//! ([`aimdb_core::session::run_session`]). It also performs the **multi-topic +//! split** (doc 039 § 2 / issue.md § 1a): a `Subscribe`/`Unsubscribe` frame +//! carrying N topics is yielded as N single-topic logical frames so the codec's +//! `decode` stays 1→1. + +#[cfg(feature = "server")] +use std::collections::VecDeque; + +#[cfg(any(feature = "server", feature = "client"))] +use aimdb_core::{BoxFut, Connection, PeerInfo, TransportError, TransportResult}; +#[cfg(feature = "server")] +use axum::extract::ws::{Message, WebSocket}; + +#[cfg(feature = "server")] +use crate::protocol::ClientMessage; + +/// A server-side [`Connection`] over an upgraded axum [`WebSocket`]. +#[cfg(feature = "server")] +pub struct WsServerConnection { + ws: WebSocket, + peer: PeerInfo, + /// Single-topic frames split off a multi-topic `Subscribe`/`Unsubscribe`, + /// drained before reading the next WS message. + pending: VecDeque>, +} + +#[cfg(feature = "server")] +impl WsServerConnection { + /// Wrap an upgraded socket with its pre-resolved peer identity. + /// + /// `auto_subscribe` seeds synthetic single-topic `Subscribe` frames so the + /// engine subscribes the client to those patterns on connect (replacing the + /// legacy `ClientManager::subscribe`-on-connect path) without a client message. + pub fn new(ws: WebSocket, peer: PeerInfo, auto_subscribe: &[String]) -> Self { + let pending = auto_subscribe + .iter() + .filter_map(|t| { + serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec![t.clone()], + }) + .ok() + }) + .collect(); + Self { ws, peer, pending } + } + + /// Read one logical frame, expanding a multi-topic `Subscribe`/`Unsubscribe` + /// into one single-topic frame per call (the rest are queued in `pending`). + async fn next_logical(&mut self) -> TransportResult>> { + loop { + if let Some(frame) = self.pending.pop_front() { + return Ok(Some(frame)); + } + let bytes = match self.ws.recv().await { + Some(Ok(Message::Text(t))) => t.as_str().as_bytes().to_vec(), + Some(Ok(Message::Binary(b))) => b.to_vec(), + // axum answers Ping transparently; ignore Pong; Close ends the stream. + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Err(_)) => return Err(TransportError::Io), + }; + if let Some(split) = split_multi_topic(&bytes) { + self.pending.extend(split); + continue; + } + return Ok(Some(bytes)); + } + } +} + +#[cfg(feature = "server")] +impl Connection for WsServerConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(self.next_logical()) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + Box::pin(async move { + let text = String::from_utf8_lossy(frame).into_owned(); + self.ws + .send(Message::Text(text.into())) + .await + .map_err(|_| TransportError::Io) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// If `bytes` is a multi-topic `Subscribe`/`Unsubscribe`, return one re-serialized +/// single-topic frame per topic; otherwise `None` (the frame passes through). +#[cfg(feature = "server")] +fn split_multi_topic(bytes: &[u8]) -> Option>> { + let msg: ClientMessage = serde_json::from_slice(bytes).ok()?; + let (topics, is_sub) = match msg { + ClientMessage::Subscribe { topics } if topics.len() > 1 => (topics, true), + ClientMessage::Unsubscribe { topics } if topics.len() > 1 => (topics, false), + _ => return None, + }; + Some( + topics + .into_iter() + .filter_map(|t| { + let one = if is_sub { + ClientMessage::Subscribe { topics: vec![t] } + } else { + ClientMessage::Unsubscribe { topics: vec![t] } + }; + serde_json::to_vec(&one).ok() + }) + .collect(), + ) +} + +// ════════════════════════════════════════════════════════════════════ +// Client side — Dialer + Connection over tokio-tungstenite +// ════════════════════════════════════════════════════════════════════ + +/// A [`Dialer`](aimdb_core::Dialer) that opens a `tokio-tungstenite` connection +/// to a remote WS server. `run_client` ([`aimdb_core::session::run_client`]) calls +/// [`connect`](aimdb_core::Dialer::connect) on each (re)dial. +#[cfg(feature = "client")] +pub struct WsDialer { + url: String, +} + +#[cfg(feature = "client")] +impl WsDialer { + /// Dial the WS server at `url` (e.g. `wss://host/ws`). + pub fn new(url: impl Into) -> Self { + Self { url: url.into() } + } +} + +#[cfg(feature = "client")] +impl aimdb_core::Dialer for WsDialer { + fn connect(&self) -> aimdb_core::BoxFut<'_, aimdb_core::TransportResult>> { + Box::pin(async move { + let (ws, _resp) = tokio_tungstenite::connect_async(&self.url) + .await + .map_err(|_| aimdb_core::TransportError::Io)?; + Ok(Box::new(WsClientConnection { + ws, + peer: aimdb_core::PeerInfo::default(), + }) as Box) + }) + } +} + +/// A client-side [`Connection`] over a `tokio-tungstenite` stream. +#[cfg(feature = "client")] +pub struct WsClientConnection { + ws: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + peer: aimdb_core::PeerInfo, +} + +#[cfg(feature = "client")] +impl Connection for WsClientConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + use futures_util::StreamExt; + use tokio_tungstenite::tungstenite::Message; + Box::pin(async move { + loop { + return match self.ws.next().await { + Some(Ok(Message::Text(t))) => Ok(Some(t.as_bytes().to_vec())), + Some(Ok(Message::Binary(b))) => Ok(Some(b.to_vec())), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + Some(Ok(Message::Close(_))) | None => Ok(None), + Some(Ok(_)) => continue, // Frame — ignore + Some(Err(_)) => Err(TransportError::Io), + }; + } + }) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + use futures_util::SinkExt; + use tokio_tungstenite::tungstenite::Message; + Box::pin(async move { + let text = String::from_utf8_lossy(frame).into_owned(); + self.ws + .send(Message::Text(text.into())) + .await + .map_err(|_| TransportError::Io) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn splits_multi_topic_subscribe() { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["a".into(), "b".into(), "c".into()], + }) + .unwrap(); + let split = split_multi_topic(&frame).expect("should split"); + assert_eq!(split.len(), 3); + for (i, t) in ["a", "b", "c"].iter().enumerate() { + match serde_json::from_slice::(&split[i]).unwrap() { + ClientMessage::Subscribe { topics } => assert_eq!(topics, vec![t.to_string()]), + _ => panic!("expected single-topic Subscribe"), + } + } + } + + #[test] + fn single_topic_passes_through() { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["only".into()], + }) + .unwrap(); + assert!(split_multi_topic(&frame).is_none()); + } + + #[test] + fn non_subscribe_passes_through() { + let frame = serde_json::to_vec(&ClientMessage::Ping).unwrap(); + assert!(split_multi_topic(&frame).is_none()); + } +} From 85b23deee454bd450a4515440b710a05f761ffe2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 11:54:22 +0000 Subject: [PATCH 10/34] refactor: clean up code formatting and improve readability in WebSocket client and codec --- .../src/client/builder.rs | 7 ++--- aimdb-websocket-connector/src/codec.rs | 24 ++++++++++++++--- aimdb-websocket-connector/src/dispatch.rs | 26 +++++++++++-------- aimdb-websocket-connector/src/transport.rs | 4 ++- 4 files changed, 40 insertions(+), 21 deletions(-) diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 64516e4..57c381a 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -171,11 +171,8 @@ where // Mirrors `AimxClientConnector`: `run_client` owns demux/reconnect/ // keepalive over the WS `Dialer` + per-connection `WsCodec`; // `pump_client` wires `link_to`/`link_from` routes to the handle. - let (handle, engine_fut) = run_client( - WsDialer::new(self.url.clone()), - WsCodec::new(), - config, - ); + let (handle, engine_fut) = + run_client(WsDialer::new(self.url.clone()), WsCodec::new(), config); let mut futures = pump_client(db, "ws-client", &handle); futures.push(engine_fut); Ok(futures) diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs index 0bd0254..f9cef4d 100644 --- a/aimdb-websocket-connector/src/codec.rs +++ b/aimdb-websocket-connector/src/codec.rs @@ -126,7 +126,8 @@ impl aimdb_core::EnvelopeCodec for WsCodec { // ---- server direction: read a ClientMessage, write a ServerMessage ------ fn decode(&self, frame: &[u8]) -> Result { - let msg: ClientMessage = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + let msg: ClientMessage = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; let mut st = self.state.lock().unwrap(); match msg { // The transport splits multi-topic frames, so exactly one topic here. @@ -190,7 +191,12 @@ impl aimdb_core::EnvelopeCodec for WsCodec { .unwrap() .topic_of(sub) .ok_or(CodecError::Malformed)?; - write_server(out, &ServerMessage::Subscribed { topics: vec![topic] }) + write_server( + out, + &ServerMessage::Subscribed { + topics: vec![topic], + }, + ) } Outbound::Pong => write_server(out, &ServerMessage::Pong), // `Reply::Ok` payloads are already a complete `ServerMessage` JSON @@ -388,10 +394,20 @@ mod tests { let id = sub(&codec, "a/b"); let mut out = Vec::new(); codec - .encode(Outbound::Subscribed { sub: &id.to_string() }, &mut out) + .encode( + Outbound::Subscribed { + sub: &id.to_string(), + }, + &mut out, + ) .unwrap(); let v: ServerMessage = serde_json::from_slice(&out).unwrap(); - assert_eq!(v, ServerMessage::Subscribed { topics: vec!["a/b".into()] }); + assert_eq!( + v, + ServerMessage::Subscribed { + topics: vec!["a/b".into()] + } + ); } #[test] diff --git a/aimdb-websocket-connector/src/dispatch.rs b/aimdb-websocket-connector/src/dispatch.rs index ebb61d7..4857a7b 100644 --- a/aimdb-websocket-connector/src/dispatch.rs +++ b/aimdb-websocket-connector/src/dispatch.rs @@ -119,11 +119,7 @@ impl Session for WsSession { .handle_query(&pattern, from, to, limit) .await { - Ok((records, total)) => ServerMessage::QueryResult { - id, - records, - total, - }, + Ok((records, total)) => ServerMessage::QueryResult { id, records, total }, Err(message) => ServerMessage::Error { code: ErrorCode::ServerError, topic: None, @@ -192,8 +188,8 @@ mod tests { use std::sync::Mutex; use std::time::Duration; - use aimdb_core::session::{run_session, SessionConfig}; use aimdb_core::router::RouterBuilder; + use aimdb_core::session::{run_session, SessionConfig}; use aimdb_core::{Connection, SessionLimits, TransportResult}; use tokio::sync::mpsc; @@ -266,8 +262,10 @@ mod tests { // snapshot, then a bus broadcast fans out as a Data frame. #[tokio::test] async fn subscribe_ack_snapshot_and_fanout() { - let dispatch = - dispatch_with(Arc::new(OneSnap("sensors/temp".into(), b"\"last\"".to_vec()))); + let dispatch = dispatch_with(Arc::new(OneSnap( + "sensors/temp".into(), + b"\"last\"".to_vec(), + ))); let mgr = dispatch.client_mgr.clone(); let (tx, rx) = mpsc::unbounded_channel::>(); @@ -303,8 +301,12 @@ mod tests { // Ack + snapshot should have been emitted, in order. let msgs = parse(&out); - assert!(matches!(&msgs[0], ServerMessage::Subscribed { topics } if topics == &vec!["sensors/temp".to_string()])); - assert!(matches!(&msgs[1], ServerMessage::Snapshot { topic, .. } if topic == "sensors/temp")); + assert!( + matches!(&msgs[0], ServerMessage::Subscribed { topics } if topics == &vec!["sensors/temp".to_string()]) + ); + assert!( + matches!(&msgs[1], ServerMessage::Snapshot { topic, .. } if topic == "sensors/temp") + ); // A bus broadcast now fans out to this subscription as a Data frame. mgr.broadcast("sensors/temp", b"\"22.5\"").await; @@ -313,7 +315,9 @@ mod tests { let data = msgs .iter() .find_map(|m| match m { - ServerMessage::Data { topic, payload, .. } => Some((topic.clone(), payload.clone())), + ServerMessage::Data { topic, payload, .. } => { + Some((topic.clone(), payload.clone())) + } _ => None, }) .expect("a Data frame"); diff --git a/aimdb-websocket-connector/src/transport.rs b/aimdb-websocket-connector/src/transport.rs index 9d091aa..b313ade 100644 --- a/aimdb-websocket-connector/src/transport.rs +++ b/aimdb-websocket-connector/src/transport.rs @@ -142,7 +142,9 @@ impl WsDialer { #[cfg(feature = "client")] impl aimdb_core::Dialer for WsDialer { - fn connect(&self) -> aimdb_core::BoxFut<'_, aimdb_core::TransportResult>> { + fn connect( + &self, + ) -> aimdb_core::BoxFut<'_, aimdb_core::TransportResult>> { Box::pin(async move { let (ws, _resp) = tokio_tungstenite::connect_async(&self.url) .await From 6f0ec89e68f33d049ac4f6934ab0c9a7d048b294 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 16:46:13 +0000 Subject: [PATCH 11/34] feat: enable remote access for embedded no_std environments with tokio adapter integration --- Cargo.lock | 1 + aimdb-core/src/session/server.rs | 24 +- aimdb-websocket-connector/Cargo.toml | 1 + .../src/client_manager.rs | 17 + aimdb-websocket-connector/src/codec.rs | 21 + aimdb-websocket-connector/src/e2e.rs | 606 ++++++++++++++++++ aimdb-websocket-connector/src/lib.rs | 4 + aimdb-websocket-connector/src/server.rs | 31 +- .../tests/ws_roundtrip.rs | 131 ++++ 9 files changed, 821 insertions(+), 15 deletions(-) create mode 100644 aimdb-websocket-connector/src/e2e.rs create mode 100644 aimdb-websocket-connector/tests/ws_roundtrip.rs diff --git a/Cargo.lock b/Cargo.lock index c6fa289..af9afba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ "aimdb-core", "aimdb-data-contracts", "aimdb-executor", + "aimdb-tokio-adapter", "aimdb-ws-protocol", "axum", "dashmap", diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 491deac..55ade79 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -43,6 +43,11 @@ pub struct SessionConfig { pub acks_subscribe: bool, } +/// Bound for the per-connection event funnel — caps how many pending outbound +/// updates a single connection may buffer before pumps start dropping (matches +/// the hand-rolled WS server's default per-client channel capacity). +const EVENT_BUFFER: usize = 256; + /// One subscription update on its way back to the connection's send half. struct SubEvent { sub: String, @@ -91,8 +96,12 @@ pub async fn run_session( let mut session = dispatch.open(&ctx); // Event funnel: every per-subscription pump sends its updates here; the main - // loop is the sole writer to the connection. - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); + // loop is the sole writer to the connection. **Bounded** so a slow client + // (one whose socket is backpressured, stalling the main loop) cannot grow the + // funnel without limit — the pumps drop on overflow rather than accumulate + // (events carry a monotonic `seq`, so a client can detect the gap). This + // restores the bounded-buffer slow-client protection the hand-rolled loops had. + let (event_tx, mut event_rx) = mpsc::channel::(EVENT_BUFFER); // Per-connection subscription pumps; the engine future is their sole owner. let mut subs: FuturesUnordered> = FuturesUnordered::new(); // sub-id → cancel handle (dropping/sending the oneshot cancels the pump, @@ -248,9 +257,10 @@ async fn send_reply_err( async fn pump_subscription( sub_id: String, mut stream: BoxStream<'static, Payload>, - tx: mpsc::UnboundedSender, + tx: mpsc::Sender, mut cancel: oneshot::Receiver<()>, ) { + use tokio::sync::mpsc::error::TrySendError; let mut seq: u64 = 0; loop { tokio::select! { @@ -260,8 +270,12 @@ async fn pump_subscription( next = stream.next() => match next { Some(data) => { seq += 1; - if tx.send(SubEvent { sub: sub_id.clone(), seq, data }).is_err() { - break; // funnel closed → connection gone + // `try_send` keeps the pump non-blocking: a backpressured + // funnel drops this update (slow-client protection) rather + // than stalling the bus; only a closed funnel ends the pump. + match tx.try_send(SubEvent { sub: sub_id.clone(), seq, data }) { + Ok(()) | Err(TrySendError::Full(_)) => {} + Err(TrySendError::Closed(_)) => break, // connection gone } } None => break, // stream exhausted diff --git a/aimdb-websocket-connector/Cargo.toml b/aimdb-websocket-connector/Cargo.toml index b18add5..22f40fc 100644 --- a/aimdb-websocket-connector/Cargo.toml +++ b/aimdb-websocket-connector/Cargo.toml @@ -71,3 +71,4 @@ tracing = { version = "0.1", optional = true } [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } tokio-tungstenite = "0.26" +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } diff --git a/aimdb-websocket-connector/src/client_manager.rs b/aimdb-websocket-connector/src/client_manager.rs index 2e01698..ec0e598 100644 --- a/aimdb-websocket-connector/src/client_manager.rs +++ b/aimdb-websocket-connector/src/client_manager.rs @@ -215,4 +215,21 @@ mod tests { mgr.broadcast("t", b"v").await; assert_eq!(mgr.subscription_count(), 0); } + + // Layer 2.2 (#2): one broadcast → N subscribers all receive the *same* + // pre-serialized bytes (a shared `Arc`), evidencing a single serialization + // regardless of subscriber count (O(1) fan-out, not O(N)). + #[tokio::test] + async fn broadcast_serializes_once_and_shares_to_all() { + let mgr = ClientManager::new(false); + let mut streams: Vec<_> = (0..8).map(|_| mgr.subscribe("#").1).collect(); + mgr.broadcast("t", b"123").await; + let mut frames = Vec::new(); + for s in &mut streams { + frames.push(s.next().await.unwrap()); + } + // Every subscriber got byte-identical content (the one serialized frame). + let first = &frames[0]; + assert!(frames.iter().all(|f| f.as_ref() == first.as_ref())); + } } diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs index f9cef4d..6db9ac0 100644 --- a/aimdb-websocket-connector/src/codec.rs +++ b/aimdb-websocket-connector/src/codec.rs @@ -424,6 +424,27 @@ mod tests { } } + // Layer 2.3 (#1): decoding many *distinct*-topic Data frames must not + // accumulate any process-lifetime state (the old `leak_topic` interner would + // have grown one `&'static str` per topic here). The borrow is zero-copy. + #[test] + fn decode_outbound_high_cardinality_no_static_growth() { + let codec = WsCodec::new(); + for i in 0..10_000 { + let topic = format!("sensors/dev-{i}"); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: topic.clone(), + payload: Some(serde_json::json!(i)), + ts: 0, + }) + .unwrap(); + match codec.decode_outbound(&frame).unwrap() { + Outbound::Event { sub, .. } => assert_eq!(sub, topic), + _ => panic!("expected Event"), + } + } + } + #[test] fn write_carries_payload() { let codec = WsCodec::new(); diff --git a/aimdb-websocket-connector/src/e2e.rs b/aimdb-websocket-connector/src/e2e.rs new file mode 100644 index 0000000..5c2473b --- /dev/null +++ b/aimdb-websocket-connector/src/e2e.rs @@ -0,0 +1,606 @@ +//! Layer 1 real-socket integration tests (doc 039-validation). +//! +//! These run the **real** stack over a real TCP socket: a tungstenite client (or +//! the WS client engine) ↔ an axum server driving `run_session` + `WsCodec` + +//! `WsDispatch` + the `ClientManager` bus. Gated on `test` + both features so the +//! per-connection path that unit tests can only mock is actually exercised. + +use core::future::Future; +use core::pin::Pin; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use aimdb_core::router::RouterBuilder; +use aimdb_core::session::{run_client, ClientConfig}; +use aimdb_core::Dispatch; +use aimdb_ws_protocol::{QueryRecord, TopicInfo}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite::Message; + +use crate::auth::{AuthError, AuthHandler, AuthRequest, NoAuth, Permissions}; +use crate::client_manager::ClientManager; +use crate::codec::WsCodec; +use crate::dispatch::WsDispatch; +use crate::protocol::{ClientMessage, ServerMessage}; +use crate::server::{build_app, ServerState}; +use crate::session::{NoQuery, NoSnapshot, QueryFuture, QueryHandler, SnapshotProvider}; +use crate::transport::WsDialer; + +// ── Test fixtures ──────────────────────────────────────────────────── + +struct OneSnap(&'static str, &'static [u8]); +impl SnapshotProvider for OneSnap { + fn snapshot(&self, topic: &str) -> Option> { + (topic == self.0).then(|| self.1.to_vec()) + } +} + +struct OneRecordQuery; +impl QueryHandler for OneRecordQuery { + fn handle_query<'a>( + &'a self, + _pattern: &'a str, + _from: Option, + _to: Option, + _limit: Option, + ) -> QueryFuture<'a> { + Box::pin(async { + Ok(( + vec![QueryRecord { + topic: "temp".into(), + payload: serde_json::json!(21.0), + ts: 7, + }], + 1, + )) + }) + } +} + +struct DenyAuth; +impl AuthHandler for DenyAuth { + fn authenticate<'a>( + &'a self, + _request: &'a AuthRequest, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Err(AuthError::new("denied")) }) + } +} + +/// Allows the connection with **allow-all** permissions, but asynchronously +/// **denies** subscribing to `secret/*` via `authorize_subscribe`. If the engine +/// only consulted the static permission set (the pre-fix sync path), `secret` +/// would be allowed — so this fixture proves the async hook actually gates (#3). +struct AsyncTopicAuth; +impl AuthHandler for AsyncTopicAuth { + fn authenticate<'a>( + &'a self, + _request: &'a AuthRequest, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(Permissions::allow_all()) }) + } + fn authorize_subscribe<'a>( + &'a self, + _client: &'a crate::auth::ClientInfo, + topic: &'a str, + ) -> Pin + Send + 'a>> { + let denied = topic.starts_with("secret"); + // Simulate an async ACL lookup. + Box::pin(async move { + tokio::task::yield_now().await; + !denied + }) + } +} + +/// Knobs for the spawned server; defaults give an allow-all, no-snapshot server. +struct Opts { + snapshot: Arc, + query: Arc, + known_topics: Vec, + auth: Arc, +} + +impl Default for Opts { + fn default() -> Self { + Self { + snapshot: Arc::new(NoSnapshot), + query: Arc::new(NoQuery), + known_topics: Vec::new(), + auth: Arc::new(NoAuth), + } + } +} + +/// Bring up the real axum server on an ephemeral port; return its address and the +/// shared bus (so the test can `broadcast`, simulating an outbound record update). +async fn spawn(opts: Opts) -> (SocketAddr, ClientManager) { + let client_mgr = ClientManager::new(false); + let dispatch: Arc = Arc::new(WsDispatch { + client_mgr: client_mgr.clone(), + snapshot_provider: opts.snapshot, + query_handler: opts.query, + router: Arc::new(RouterBuilder::from_routes(Vec::new()).build()), + known_topics: Arc::new(opts.known_topics), + auth: opts.auth.clone(), + late_join: true, + runtime_ctx: None, + }); + let state = ServerState { + dispatch, + auth: opts.auth, + client_mgr: client_mgr.clone(), + auto_subscribe: Arc::new(Vec::new()), + max_subs_per_connection: 64, + started_at: Instant::now(), + }; + let app = build_app("/ws", state, None); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + (addr, client_mgr) +} + +type WsClient = + tokio_tungstenite::WebSocketStream>; + +async fn ws_connect(addr: SocketAddr) -> WsClient { + tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect") + .0 +} + +async fn ws_send(c: &mut WsClient, m: ClientMessage) { + c.send(Message::Text(serde_json::to_string(&m).unwrap().into())) + .await + .unwrap(); +} + +/// Read the next `ServerMessage`, with a timeout so a hang fails loudly. +async fn ws_recv(c: &mut WsClient) -> ServerMessage { + loop { + match tokio::time::timeout(Duration::from_secs(3), c.next()) + .await + .expect("recv timed out") + { + Some(Ok(Message::Text(t))) => return serde_json::from_str(&t).unwrap(), + Some(Ok(Message::Binary(b))) => return serde_json::from_slice(&b).unwrap(), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + other => panic!("unexpected ws frame: {other:?}"), + } + } +} + +// ── 1.1 Server e2e ─────────────────────────────────────────────────── + +#[tokio::test] +async fn server_subscribe_ack_and_wildcard_fanout() { + let (addr, bus) = spawn(Opts::default()).await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["sensors/#".into()], + }, + ) + .await; + assert!( + matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { topics } if topics == vec!["sensors/#".to_string()]) + ); + + // An outbound record update fans out as a Data frame with the *real* topic. + bus.broadcast("sensors/temp/vienna", b"22.5").await; + match ws_recv(&mut c).await { + ServerMessage::Data { topic, payload, .. } => { + assert_eq!(topic, "sensors/temp/vienna"); + assert_eq!(payload, Some(serde_json::json!(22.5))); + } + other => panic!("expected Data, got {other:?}"), + } +} + +#[tokio::test] +async fn server_multi_topic_subscribe_and_unsubscribe() { + let (addr, bus) = spawn(Opts::default()).await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["a".into(), "b".into()], + }, + ) + .await; + // Per-topic acks (the documented wire nuance). + let mut acked = Vec::new(); + for _ in 0..2 { + if let ServerMessage::Subscribed { topics } = ws_recv(&mut c).await { + acked.extend(topics); + } + } + acked.sort(); + assert_eq!(acked, vec!["a".to_string(), "b".to_string()]); + + bus.broadcast("a", b"1").await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "a")); + + // Unsubscribe from "a"; a later broadcast to "a" must not arrive, but "b" still does. + ws_send( + &mut c, + ClientMessage::Unsubscribe { + topics: vec!["a".into()], + }, + ) + .await; + // Give the engine a moment to process the unsubscribe. + tokio::time::sleep(Duration::from_millis(100)).await; + bus.broadcast("a", b"2").await; + bus.broadcast("b", b"3").await; + match ws_recv(&mut c).await { + ServerMessage::Data { topic, .. } => assert_eq!(topic, "b", "only 'b' should arrive"), + other => panic!("expected Data on b, got {other:?}"), + } +} + +#[tokio::test] +async fn server_late_join_snapshot() { + let (addr, _bus) = spawn(Opts { + snapshot: Arc::new(OneSnap("sensors/temp", b"99")), + ..Opts::default() + }) + .await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["sensors/temp".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + match ws_recv(&mut c).await { + ServerMessage::Snapshot { topic, payload } => { + assert_eq!(topic, "sensors/temp"); + assert_eq!(payload, Some(serde_json::json!(99))); + } + other => panic!("expected Snapshot, got {other:?}"), + } +} + +#[tokio::test] +async fn server_query_and_list_topics() { + let (addr, _bus) = spawn(Opts { + query: Arc::new(OneRecordQuery), + known_topics: vec![TopicInfo { + name: "temp".into(), + schema_type: Some("temperature".into()), + entity: None, + }], + ..Opts::default() + }) + .await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Query { + id: "q1".into(), + pattern: "#".into(), + from: None, + to: None, + limit: None, + }, + ) + .await; + match ws_recv(&mut c).await { + ServerMessage::QueryResult { id, records, total } => { + assert_eq!(id, "q1"); // the String id round-trips + assert_eq!(total, 1); + assert_eq!(records.len(), 1); + } + other => panic!("expected QueryResult, got {other:?}"), + } + + ws_send(&mut c, ClientMessage::ListTopics { id: "l1".into() }).await; + match ws_recv(&mut c).await { + ServerMessage::TopicList { id, topics } => { + assert_eq!(id, "l1"); + assert_eq!(topics.len(), 1); + assert_eq!(topics[0].name, "temp"); + } + other => panic!("expected TopicList, got {other:?}"), + } +} + +#[tokio::test] +async fn server_ping_pong() { + let (addr, _bus) = spawn(Opts::default()).await; + let mut c = ws_connect(addr).await; + ws_send(&mut c, ClientMessage::Ping).await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Pong)); +} + +#[tokio::test] +async fn server_rejects_unauthenticated_upgrade() { + let (addr, _bus) = spawn(Opts { + auth: Arc::new(DenyAuth), + ..Opts::default() + }) + .await; + // The upgrade must be refused with HTTP 401 → the WS handshake fails. + let result = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")).await; + assert!(result.is_err(), "auth-rejected upgrade should not connect"); +} + +#[tokio::test] +async fn server_survives_malformed_frame() { + let (addr, bus) = spawn(Opts::default()).await; + let mut c = ws_connect(addr).await; + + // Garbage that is not a ClientMessage — the session must skip it, not die. + c.send(Message::Text("{not valid".to_string().into())) + .await + .unwrap(); + // The connection is still usable afterwards. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + bus.broadcast("x", b"1").await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "x")); +} + +// ── 1.2 Client engine e2e (run_client + WsDialer over a real socket) ── + +#[tokio::test] +async fn client_engine_receives_broadcast_over_real_socket() { + let (addr, bus) = spawn(Opts::default()).await; + + let config = ClientConfig { + topic_routed_subs: true, + reconnect: false, + ..ClientConfig::default() + }; + let (handle, engine) = run_client( + WsDialer::new(format!("ws://{addr}/ws")), + WsCodec::new(), + config, + ); + let driver = tokio::spawn(engine); + + // Subscribe through the client engine; the dialer opens a real WebSocket. + let mut stream = handle.subscribe("sensors/temp").unwrap(); + + // Wait for the subscription to register on the server bus, then broadcast. + for _ in 0..50 { + if bus.subscription_count() == 1 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!( + bus.subscription_count(), + 1, + "client subscribe should reach the server" + ); + + bus.broadcast("sensors/temp", b"42").await; + + let item = tokio::time::timeout(Duration::from_secs(3), stream.next()) + .await + .expect("stream timed out") + .expect("a value"); + // The record value round-trips: Data{payload:42} → engine stream yields b"42". + assert_eq!(&item[..], b"42"); + + drop(handle); + drop(stream); + let _ = driver.await; +} + +// ── Layer 4 — concurrency / resource cleanup / backpressure ────────── + +/// Poll `pred` until true or fail loudly. +async fn wait_until(mut pred: impl FnMut() -> bool, label: &str) { + for _ in 0..300 { + if pred() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("timed out waiting for: {label}"); +} + +#[tokio::test] +async fn many_clients_fanout_and_resource_cleanup() { + let (addr, bus) = spawn(Opts::default()).await; + + let mut clients = Vec::new(); + for _ in 0..20 { + let mut c = ws_connect(addr).await; + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["evt/#".into()], + }, + ) + .await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { .. })); + clients.push(c); + } + wait_until(|| bus.subscription_count() == 20, "20 subscriptions").await; + assert_eq!(bus.client_count(), 20); + + // One broadcast reaches all 20. + bus.broadcast("evt/x", b"1").await; + for c in &mut clients { + assert!(matches!(ws_recv(c).await, ServerMessage::Data { topic, .. } if topic == "evt/x")); + } + + // Disconnect everyone; the live-connection count returns to zero… + for mut c in clients.drain(..) { + let _ = c.close(None).await; + } + wait_until(|| bus.client_count() == 0, "0 connections").await; + // …and a subsequent broadcast prunes the now-dead subscriptions. + bus.broadcast("evt/x", b"2").await; + wait_until(|| bus.subscription_count() == 0, "0 subscriptions").await; +} + +#[tokio::test] +async fn stalled_client_does_not_block_a_healthy_one() { + let (addr, bus) = spawn(Opts::default()).await; + + // Stalled: subscribes but never reads — its socket backpressures and its + // bounded funnel fills, but it must not stall the server for others. + let mut stalled = ws_connect(addr).await; + ws_send( + &mut stalled, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + + let mut healthy = ws_connect(addr).await; + ws_send( + &mut healthy, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + assert!(matches!(ws_recv(&mut healthy).await, ServerMessage::Subscribed { .. })); + wait_until(|| bus.subscription_count() == 2, "2 subscriptions").await; + + // Flood well past the bounded funnel (256). The stalled client's pump drops + // on overflow rather than growing without bound; the healthy client keeps up. + for i in 0..2000u32 { + bus.broadcast("x", i.to_string().as_bytes()).await; + } + + // The healthy client still receives data despite its stalled peer. + assert!( + matches!(ws_recv(&mut healthy).await, ServerMessage::Data { topic, .. } if topic == "x"), + "healthy client must keep receiving past a stalled peer", + ); + let _ = stalled.close(None).await; +} + +// ── Layer 3.1 — golden wire frames (the masterplan wire-capture gate) ─ + +/// Receive the next text frame and parse it to a `Value`, normalizing the +/// time-dependent `ts` field to `0` so the shape can be asserted exactly. +async fn recv_value(c: &mut WsClient) -> serde_json::Value { + loop { + match tokio::time::timeout(Duration::from_secs(3), c.next()) + .await + .expect("recv timed out") + { + Some(Ok(Message::Text(t))) => { + let mut v: serde_json::Value = serde_json::from_str(&t).unwrap(); + if let Some(ts) = v.get_mut("ts") { + *ts = serde_json::json!(0); + } + return v; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + other => panic!("unexpected ws frame: {other:?}"), + } + } +} + +/// Locks the exact on-the-wire JSON shape (tag + field names + value types) the +/// server emits, so any accidental wire change is caught. Frame *shape* is what a +/// browser/wasm client depends on (key order is irrelevant to JSON). +#[tokio::test] +async fn golden_wire_frames() { + use serde_json::json; + let (addr, bus) = spawn(Opts { + snapshot: Arc::new(OneSnap("t", b"5")), + ..Opts::default() + }) + .await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["t".into()], + }, + ) + .await; + assert_eq!(recv_value(&mut c).await, json!({"type": "subscribed", "topics": ["t"]})); + assert_eq!(recv_value(&mut c).await, json!({"type": "snapshot", "topic": "t", "payload": 5})); + + bus.broadcast("t", b"42").await; + assert_eq!( + recv_value(&mut c).await, + json!({"type": "data", "topic": "t", "payload": 42, "ts": 0}) + ); + + ws_send(&mut c, ClientMessage::Ping).await; + assert_eq!(recv_value(&mut c).await, json!({"type": "pong"})); +} + +// ── Layer 2.1 — the async-authz fix (#3) over a real socket ────────── + +#[tokio::test] +async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { + let (addr, bus) = spawn(Opts { + auth: Arc::new(AsyncTopicAuth), + ..Opts::default() + }) + .await; + let mut c = ws_connect(addr).await; + + // Denied topic: permissions are allow-all, but the *async* hook says no. + // Pre-fix (sync permission check) this would have been allowed → the test + // would see a `Subscribed` ack instead of an error. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["secret/x".into()], + }, + ) + .await; + match ws_recv(&mut c).await { + ServerMessage::Error { code, .. } => { + assert!(matches!(code, crate::protocol::ErrorCode::Forbidden)); + } + other => panic!("expected Forbidden Error for denied subscribe, got {other:?}"), + } + + // An allowed topic still works end-to-end. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["public/x".into()], + }, + ) + .await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { .. })); + bus.broadcast("public/x", b"1").await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "public/x")); +} diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 2f7c7af..3cda91b 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -111,6 +111,10 @@ pub mod codec; #[cfg(any(feature = "server", feature = "client"))] pub mod transport; +/// Layer 1 real-socket integration tests (doc 039-validation) — needs both halves. +#[cfg(all(test, feature = "server", feature = "client"))] +mod e2e; + // ════════════════════════════════════════════════════════════════════ // Protocol (always available) // ════════════════════════════════════════════════════════════════════ diff --git a/aimdb-websocket-connector/src/server.rs b/aimdb-websocket-connector/src/server.rs index 601abf5..565b817 100644 --- a/aimdb-websocket-connector/src/server.rs +++ b/aimdb-websocket-connector/src/server.rs @@ -78,26 +78,37 @@ type BoxFuture = std::pin::Pin + Send /// * `session_ctx` — Shared session context (auth, router, client manager, …). /// * `additional_routes` — Optional user-supplied Axum `Router` that is merged /// into the server (useful for REST + WebSocket on the same port). -pub(crate) fn build_server_future( - bind_addr: SocketAddr, - ws_path: String, +/// Build the axum application (WS upgrade + health, plus any extra routes). +/// +/// Extracted so tests can serve the **real** app on a known ephemeral port +/// (`build_server_future` binds internally and does not surface the port). +pub(crate) fn build_app( + ws_path: &str, state: ServerState, additional_routes: Option, -) -> BoxFuture { +) -> Router { // Apply state first so the router becomes `Router<()>`, which can then be // merged with user-supplied `additional_routes: Router<()>` without a // type-parameter mismatch. let ws_app = Router::new() - .route(&ws_path, get(ws_upgrade_handler)) + .route(ws_path, get(ws_upgrade_handler)) .route("/health", get(health_handler)) .with_state(state) .layer(CorsLayer::permissive()); - let app = if let Some(extra) = additional_routes { - ws_app.merge(extra) - } else { - ws_app - }; + match additional_routes { + Some(extra) => ws_app.merge(extra), + None => ws_app, + } +} + +pub(crate) fn build_server_future( + bind_addr: SocketAddr, + ws_path: String, + state: ServerState, + additional_routes: Option, +) -> BoxFuture { + let app = build_app(&ws_path, state, additional_routes); Box::pin(async move { let listener = match tokio::net::TcpListener::bind(bind_addr).await { diff --git a/aimdb-websocket-connector/tests/ws_roundtrip.rs b/aimdb-websocket-connector/tests/ws_roundtrip.rs new file mode 100644 index 0000000..00888a1 --- /dev/null +++ b/aimdb-websocket-connector/tests/ws_roundtrip.rs @@ -0,0 +1,131 @@ +//! Layer 1.3 (doc 039-validation) — full **AimDB ↔ AimDB over a real WebSocket**. +//! +//! A server `AimDb` (served by `WebSocketConnector`) and a client `AimDb` (whose +//! records carry `ws-client://` links via `WsClientConnector`). This exercises the +//! whole public path both directions over a real socket: +//! - **client → server**: producing the client's `cfg` record writes it to the +//! server (`link_to("ws-client://cfg")` → `Write` → server `link_from("ws://cfg")`). +//! - **server → client**: updating the server's `tele` record streams it back +//! (`link_to("ws://tele")` broadcast → client subscription → client `tele`). + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::{WebSocketConnector, WsClientConnector}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Msg { + v: u64, +} + +/// Grab a probably-free ephemeral port (the WS builder binds internally and does +/// not surface `:0`'s assigned port, so we pick one up front). +fn free_addr() -> SocketAddr { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let a = l.local_addr().unwrap(); + drop(l); + a +} + +/// Re-assert `db.` reaches `want`, re-driving `push` each tick so the test is +/// robust against subscription-registration timing. +async fn mirror_reaches( + db: &Arc>, + key: &str, + want: &serde_json::Value, + mut push: impl FnMut(), +) -> bool { + for _ in 0..100 { + push(); + tokio::time::sleep(Duration::from_millis(25)).await; + if db.try_latest_as_json(key).as_ref() == Some(want) { + return true; + } + } + false +} + +#[tokio::test] +async fn ws_mirrors_record_both_directions() { + let addr = free_addr(); + + // --- server: tele (broadcast source) + cfg (inbound write target) -------- + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WebSocketConnector::new().bind(addr).path("/ws")); + sb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://tele") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + sb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws://cfg") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + let (server_db, server_runner) = sb.build().await.expect("build server db"); + let server_db = Arc::new(server_db); + tokio::spawn(server_runner.run()); + + // Give the server a moment to bind before the client dials. + tokio::time::sleep(Duration::from_millis(100)).await; + + // --- client: cfg links *to* the server, tele links *from* it ------------- + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WsClientConnector::new(format!("ws://{addr}/ws"))); + cb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws-client://cfg") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + cb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws-client://tele") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + let (client_db, client_runner) = cb.build().await.expect("build client db"); + let client_db = Arc::new(client_db); + tokio::spawn(client_runner.run()); + + // server → client: updating server `tele` mirrors to client `tele`. + let want_tele = json!({ "v": 9 }); + let mirrored_in = mirror_reaches(&client_db, "tele", &want_tele, || { + server_db + .set_record_from_json("tele", json!({ "v": 9 })) + .expect("set server tele"); + }) + .await; + assert!(mirrored_in, "server→client mirror did not reach the client"); + + // client → server: producing client `cfg` mirrors to server `cfg`. + let want_cfg = json!({ "v": 7 }); + let mirrored_out = mirror_reaches(&server_db, "cfg", &want_cfg, || { + client_db + .set_record_from_json("cfg", json!({ "v": 7 })) + .expect("set client cfg"); + }) + .await; + assert!( + mirrored_out, + "client→server mirror did not reach the server" + ); +} From 0e6c185485b2086d751c8a5d3e736537df366e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 19:35:52 +0000 Subject: [PATCH 12/34] feat: enhance WebSocket connector build and test processes for server and client integration --- Makefile | 10 ++- .../examples/ws_server.rs | 89 +++++++++++++++++++ aimdb-websocket-connector/src/e2e.rs | 29 ++++-- 3 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 aimdb-websocket-connector/examples/ws_server.rs diff --git a/Makefile b/Makefile index 1d7a7d9..df30a15 100644 --- a/Makefile +++ b/Makefile @@ -93,8 +93,8 @@ build: cargo build --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Building WS protocol$(NC)\n" cargo build --package aimdb-ws-protocol - @printf "$(YELLOW) → Building WebSocket connector$(NC)\n" - cargo build --package aimdb-websocket-connector --features "tokio-runtime" + @printf "$(YELLOW) → Building WebSocket connector (server + client)$(NC)\n" + cargo build --package aimdb-websocket-connector --features "server,client" @printf "$(YELLOW) → Building WASM adapter$(NC)\n" cargo build --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" @@ -150,8 +150,10 @@ test: cargo test --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Testing WS protocol$(NC)\n" cargo test --package aimdb-ws-protocol - @printf "$(YELLOW) → Testing WebSocket connector$(NC)\n" - cargo test --package aimdb-websocket-connector --features "tokio-runtime" + @printf "$(YELLOW) → Testing WebSocket connector (server + client: unit, real-socket e2e, AimDB round-trip)$(NC)\n" + cargo test --package aimdb-websocket-connector --features "server,client" + @printf "$(YELLOW) → Testing WebSocket connector client-only build$(NC)\n" + cargo test --package aimdb-websocket-connector --no-default-features --features "client" --lib fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" diff --git a/aimdb-websocket-connector/examples/ws_server.rs b/aimdb-websocket-connector/examples/ws_server.rs new file mode 100644 index 0000000..94913f9 --- /dev/null +++ b/aimdb-websocket-connector/examples/ws_server.rs @@ -0,0 +1,89 @@ +//! Minimal runnable WebSocket **server** demo (doc 039-validation Layer 5) — the +//! first real consumer of the connector and a manual-smoke vehicle. +//! +//! Run: +//! ```text +//! cargo run -p aimdb-websocket-connector --example ws_server +//! ``` +//! Then connect a client and subscribe to the ticking `counter` record: +//! ```text +//! wscat -c ws://127.0.0.1:8080/ws +//! > {"type":"subscribe","topics":["counter"]} +//! < {"type":"subscribed","topics":["counter"]} +//! < {"type":"data","topic":"counter","payload":{"n":1},"ts":...} +//! ``` +//! Or write to the inbound `echo` record: +//! ```text +//! > {"type":"write","topic":"echo","payload":{"msg":"hi"}} +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::AimDbBuilder; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::WebSocketConnector; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Counter { + n: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Echo { + msg: String, +} + +#[tokio::main] +async fn main() { + let addr = "127.0.0.1:8080"; + + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector( + WebSocketConnector::new() + .bind(addr) + .path("/ws") + .with_late_join(true), + ); + + // Outbound: pushed to every subscribed client. + sb.configure::("counter", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://counter") + .with_serializer_raw(|c: &Counter| Ok(serde_json::to_vec(c).unwrap())) + .finish(); + }); + // Inbound: clients may `write` to it. + sb.configure::("echo", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws://echo") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + + let (db, runner) = sb.build().await.expect("build db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + println!("WS server listening on ws://{addr}/ws"); + println!(" subscribe: wscat -c ws://{addr}/ws → {{\"type\":\"subscribe\",\"topics\":[\"counter\"]}}"); + + let mut n = 0u64; + loop { + n += 1; + db.set_record_from_json("counter", json!({ "n": n })) + .expect("set counter"); + if let Some(echo) = db.try_latest_as_json("echo") { + println!("echo record = {echo}"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +} diff --git a/aimdb-websocket-connector/src/e2e.rs b/aimdb-websocket-connector/src/e2e.rs index 5c2473b..63a504a 100644 --- a/aimdb-websocket-connector/src/e2e.rs +++ b/aimdb-websocket-connector/src/e2e.rs @@ -446,7 +446,10 @@ async fn many_clients_fanout_and_resource_cleanup() { }, ) .await; - assert!(matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { .. })); + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); clients.push(c); } wait_until(|| bus.subscription_count() == 20, "20 subscriptions").await; @@ -491,7 +494,10 @@ async fn stalled_client_does_not_block_a_healthy_one() { }, ) .await; - assert!(matches!(ws_recv(&mut healthy).await, ServerMessage::Subscribed { .. })); + assert!(matches!( + ws_recv(&mut healthy).await, + ServerMessage::Subscribed { .. } + )); wait_until(|| bus.subscription_count() == 2, "2 subscriptions").await; // Flood well past the bounded funnel (256). The stalled client's pump drops @@ -551,8 +557,14 @@ async fn golden_wire_frames() { }, ) .await; - assert_eq!(recv_value(&mut c).await, json!({"type": "subscribed", "topics": ["t"]})); - assert_eq!(recv_value(&mut c).await, json!({"type": "snapshot", "topic": "t", "payload": 5})); + assert_eq!( + recv_value(&mut c).await, + json!({"type": "subscribed", "topics": ["t"]}) + ); + assert_eq!( + recv_value(&mut c).await, + json!({"type": "snapshot", "topic": "t", "payload": 5}) + ); bus.broadcast("t", b"42").await; assert_eq!( @@ -600,7 +612,12 @@ async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { }, ) .await; - assert!(matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { .. })); + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); bus.broadcast("public/x", b"1").await; - assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "public/x")); + assert!( + matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "public/x") + ); } From d85e2a84b7775b8ce4e54255299ec2143da432ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 30 May 2026 19:48:43 +0000 Subject: [PATCH 13/34] refactor: update clippy command to include 'client' feature and clean up documentation in server.rs --- Makefile | 2 +- aimdb-websocket-connector/src/server.rs | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/Makefile b/Makefile index df30a15..8212ca0 100644 --- a/Makefile +++ b/Makefile @@ -224,7 +224,7 @@ clippy: @printf "$(YELLOW) → Clippy on WS protocol$(NC)\n" cargo clippy --package aimdb-ws-protocol --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WebSocket connector$(NC)\n" - cargo clippy --package aimdb-websocket-connector --features "tokio-runtime" --all-targets -- -D warnings + cargo clippy --package aimdb-websocket-connector --features "tokio-runtime,client" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings diff --git a/aimdb-websocket-connector/src/server.rs b/aimdb-websocket-connector/src/server.rs index 565b817..3f71e19 100644 --- a/aimdb-websocket-connector/src/server.rs +++ b/aimdb-websocket-connector/src/server.rs @@ -76,9 +76,6 @@ type BoxFuture = std::pin::Pin + Send /// * `bind_addr` — TCP address to listen on. /// * `ws_path` — URL path for the WebSocket endpoint (e.g., `"/ws"`). /// * `session_ctx` — Shared session context (auth, router, client manager, …). -/// * `additional_routes` — Optional user-supplied Axum `Router` that is merged -/// into the server (useful for REST + WebSocket on the same port). -/// Build the axum application (WS upgrade + health, plus any extra routes). /// /// Extracted so tests can serve the **real** app on a known ephemeral port /// (`build_server_future` binds internally and does not surface the port). From 2b8c1f4fa829721024d04493665587937b6ec353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 04:09:42 +0000 Subject: [PATCH 14/34] Refactor session engine to be runtime-neutral and integrate TimeOps - Updated the client and server engines to use `async-channel` instead of `tokio` channels, enabling a no_std compatible design. - Changed duration fields in `ClientConfig` from `Duration` to `u64` milliseconds for better compatibility with no_std. - Introduced a `TimeOps` trait for runtime-specific time operations, allowing the engines to remain agnostic of the underlying runtime. - Modified the `run_client` and `run_session` functions to accept a `TimeOps` parameter, facilitating reconnect backoff and keepalive functionality. - Added a minimal `TestClock` implementation for testing purposes, ensuring the engine can be driven without a full runtime. - Created a new smoke test for the Embassy adapter to validate the runtime-neutral client engine. - Updated WebSocket connector to utilize the new `TimeOps` interface for reconnect and keepalive configurations. --- Cargo.lock | 43 ++++ Makefile | 2 + aimdb-client/Cargo.toml | 3 + aimdb-client/src/engine.rs | 5 +- aimdb-client/tests/pump_client.rs | 4 +- aimdb-core/Cargo.toml | 16 ++ aimdb-core/src/builder.rs | 9 + .../src/session/aimx/client_connector.rs | 7 +- aimdb-core/src/session/client.rs | 227 +++++++++++------ aimdb-core/src/session/mod.rs | 22 +- aimdb-core/src/session/server.rs | 241 ++++++++++++------ aimdb-core/tests/session_engine.rs | 50 +++- aimdb-embassy-adapter/Cargo.toml | 9 + aimdb-embassy-adapter/tests/session_smoke.rs | 143 +++++++++++ .../src/client/builder.rs | 19 +- aimdb-websocket-connector/src/e2e.rs | 1 + 16 files changed, 609 insertions(+), 192 deletions(-) create mode 100644 aimdb-embassy-adapter/tests/session_smoke.rs diff --git a/Cargo.lock b/Cargo.lock index af9afba..1dd9725 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,8 +80,10 @@ dependencies = [ "aimdb-derive", "aimdb-executor", "anyhow", + "async-channel", "defmt 1.0.1", "futures", + "futures-channel", "futures-core", "futures-util", "hashbrown 0.15.5", @@ -408,6 +410,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -679,6 +693,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-default" version = "1.0.0" @@ -1346,6 +1369,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" diff --git a/Makefile b/Makefile index 8212ca0..511978a 100644 --- a/Makefile +++ b/Makefile @@ -285,6 +285,8 @@ test-embedded: cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,json-serialize" + @printf "$(YELLOW) → Checking aimdb-core session engines (no_std + connector-session) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session" @printf "$(YELLOW) → Checking aimdb-core (no_std/embassy) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-embassy-adapter on thumbv7em-none-eabihf target$(NC)\n" diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index 3d1f91a..a7a6cfa 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -29,6 +29,9 @@ serde_json = "1" # Async runtime (must match AimDB runtime) tokio = { version = "1", features = ["net", "io-util", "macros", "fs"] } futures = { version = "0.3", default-features = false, features = ["alloc"] } +# Supplies the `TimeOps` clock the engine-based client hands to `run_client` +# (reconnect backoff / keepalive). This client always drives the engine on tokio. +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } # Error handling anyhow = "1" diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index ab176b0..3eaf9aa 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -20,8 +20,11 @@ use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::task::JoinHandle; +use std::sync::Arc; + use aimdb_core::session::aimx::{AimxCodec, UdsDialer}; use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; +use aimdb_tokio_adapter::TokioAdapter; use crate::error::{ClientError, ClientResult}; use crate::protocol::{RecordMetadata, WelcomeMessage}; @@ -63,7 +66,7 @@ impl AimxConnection { sends_hello: false, ..ClientConfig::default() }; - let (handle, engine_fut) = run_client(dialer, AimxCodec, config); + let (handle, engine_fut) = run_client(dialer, AimxCodec, config, Arc::new(TokioAdapter)); let engine = tokio::spawn(engine_fut); // Handshake-as-RPC: the server replies with its Welcome. diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index d02e7e9..7264472 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -78,8 +78,8 @@ async fn pump_client_mirrors_record_both_directions() { .runtime(Arc::new(TokioAdapter)) .with_connector(AimxClientConnector::new(&sock).with_config(ClientConfig { reconnect: true, - reconnect_delay: Duration::from_millis(50), - max_reconnect_delay: Duration::from_millis(50), + reconnect_delay: 50, + max_reconnect_delay: 50, max_reconnect_attempts: 0, keepalive_interval: None, max_offline_queue: usize::MAX, diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index b8064c7..35abe26 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -73,9 +73,25 @@ aimdb-executor = { version = "0.2.0", path = "../aimdb-executor", default-featur # Stream trait for bidirectional connectors (minimal, no_std compatible) futures-core = { version = "0.3", default-features = false } +# `async-await-macro` enables the `select_biased!` macro the runtime-neutral +# session engines use (Phase 5; it pulls in `futures-macro` + `async-await`); +# `alloc` covers the combinators (`fuse`, `select_next_some`). All no_std- +# compatible — no `std`/`tokio` enters the engine compile path. futures-util = { version = "0.3", default-features = false, features = [ "alloc", + "async-await-macro", ] } +# Runtime-neutral `oneshot` for the session engines (alloc-backed, no_std-ready). +# `futures-channel`'s `mpsc` is std-only, so the engines use `async-channel` for +# that (below); only its `oneshot` is used here. +futures-channel = { version = "0.3", default-features = false, features = [ + "alloc", +] } +# Runtime-neutral mpsc (bounded + unbounded) for the session engines — one +# alloc-backed implementation for every runtime (tokio + Embassy). Owned, +# cloneable `Arc`-based senders (so the `'static` per-connection/-subscription +# pump futures can hold them) and a `Receiver: Stream` with `len()`. no_std + alloc. +async-channel = { version = "2", default-features = false } # Serialization (optional) serde = { workspace = true, optional = true } diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index ee3da9e..5bdcd04 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1249,6 +1249,15 @@ impl AimDb { &self.runtime } + /// Returns an owned `Arc` handle to the runtime adapter. + /// + /// Connectors that hand the runtime to a `'static` engine future (e.g. the + /// session client engine, which needs the adapter's [`TimeOps`](aimdb_executor::TimeOps) + /// clock for reconnect backoff/keepalive) clone it through here. + pub fn runtime_arc(&self) -> Arc { + self.runtime.clone() + } + /// Returns the runtime as a type-erased `Arc` /// /// Used by connectors to provide `RuntimeContext` to context-aware diff --git a/aimdb-core/src/session/aimx/client_connector.rs b/aimdb-core/src/session/aimx/client_connector.rs index bcc9025..d5a6bc3 100644 --- a/aimdb-core/src/session/aimx/client_connector.rs +++ b/aimdb-core/src/session/aimx/client_connector.rs @@ -12,10 +12,12 @@ use std::future::Future; use std::path::PathBuf; use std::pin::Pin; +use aimdb_executor::TimeOps; + use crate::builder::AimDb; use crate::connector::ConnectorBuilder; use crate::session::{pump_client, run_client, ClientConfig}; -use crate::{DbResult, RuntimeAdapter}; +use crate::DbResult; use super::{AimxCodec, UdsDialer}; @@ -48,7 +50,7 @@ impl AimxClientConnector { impl ConnectorBuilder for AimxClientConnector where - R: RuntimeAdapter + 'static, + R: TimeOps + 'static, { fn build<'a>( &'a self, @@ -59,6 +61,7 @@ where UdsDialer::new(self.socket_path.clone()), AimxCodec, self.config.clone(), + db.runtime_arc(), ); // One pump future per route; they hold `ClientHandle` clones, so the // engine stays alive as long as any mirror runs. `handle` drops here. diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 686be68..511fe0a 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -1,6 +1,6 @@ //! Phase 2 **client** engine — the proactive half of the shared session //! substrate (doc 034 § "shared with a client engine"; doc 035 Client -//! capability). Std-only, the dual of [`server`](super::server): it *dials* a +//! capability). The dual of [`server`](super::server): it *dials* a //! [`Connection`] via a [`Dialer`] instead of accepting one, *sends* [`Inbound`] //! and *receives* [`Outbound`] (roles swapped vs the server), and demultiplexes //! replies by `id`. @@ -14,12 +14,28 @@ //! //! Spawn-free: [`run_client`] returns the engine future for the runner to drive; //! it never spawns. +//! +//! **Runtime-neutral (Phase 5).** The only runtime-specific primitive this engine +//! touches is *time* (reconnect backoff + keepalive), so it is parametrized over +//! the adapter's [`TimeOps`] clock; everything else is `futures` channels + +//! `select_biased!`. No `tokio`/`embassy-*` here — the runtime split lives in the +//! adapter crates' `TimeOps` impls. +//! +//! Like the server, the demux loop uses an **extract-then-act** shape: the +//! `select_biased!` block only computes a small [`ClientStep`] (it must not touch +//! `conn` while a sibling arm's future still borrows it), then the loop acts on +//! it once the borrows release. -use std::collections::HashMap; -use std::sync::Arc; -use std::time::Duration; +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; -use tokio::sync::{mpsc, oneshot}; +use aimdb_executor::TimeOps; +use async_channel::{Receiver, Sender}; +use futures_channel::oneshot; +use futures_util::{select_biased, FutureExt, StreamExt}; +use hashbrown::HashMap; use super::{ BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, @@ -28,24 +44,28 @@ use crate::connector::SerializerKind; use crate::router::RouterBuilder; use crate::{AimDb, RuntimeAdapter}; -/// Client engine knobs. +/// Client engine knobs. Durations are **milliseconds** (`u64`) rather than +/// `std::time::Duration` so the engine stays `no_std`-clean and runtime-neutral — +/// the adapter's [`TimeOps`] turns them into its native `Duration` at the call. #[derive(Debug, Clone)] pub struct ClientConfig { /// Redial after a dropped/failed connection instead of ending the engine. pub reconnect: bool, - /// Base delay before the first redial when `reconnect` is set. Subsequent + /// Base delay (ms) before the first redial when `reconnect` is set. Subsequent /// redials grow this exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). - pub reconnect_delay: Duration, - /// Upper bound for the exponential reconnect backoff. Defaults to + pub reconnect_delay: u64, + /// Upper bound (ms) for the exponential reconnect backoff. Defaults to /// [`reconnect_delay`](Self::reconnect_delay) (i.e. no escalation — a fixed /// delay, preserving the pre-Phase-4 behavior). - pub max_reconnect_delay: Duration, + pub max_reconnect_delay: u64, /// Maximum redial attempts before the engine gives up. `0` = unlimited /// (the default). pub max_reconnect_attempts: usize, - /// If set, send a keepalive `Ping` on this interval while a connection is - /// idle. `None` (default) disables keepalive. - pub keepalive_interval: Option, + /// If set, send a keepalive `Ping` after this many milliseconds of an + /// otherwise-idle connection. `None` (default) disables keepalive. (Phase 5: + /// the timer re-arms each loop iteration, so this is an *idle* keepalive — + /// inbound/outbound traffic resets it, which only suppresses redundant pings.) + pub keepalive_interval: Option, /// Cap on caller commands buffered while disconnected; the oldest are dropped /// past this bound. Defaults to `usize::MAX` (effectively unbounded — the /// pre-Phase-4 behavior). @@ -66,8 +86,8 @@ impl Default for ClientConfig { fn default() -> Self { Self { reconnect: true, - reconnect_delay: Duration::from_millis(200), - max_reconnect_delay: Duration::from_millis(200), + reconnect_delay: 200, + max_reconnect_delay: 200, max_reconnect_attempts: 0, keepalive_interval: None, max_offline_queue: usize::MAX, @@ -77,18 +97,18 @@ impl Default for ClientConfig { } } -/// Exponential backoff for the `attempt`-th redial (1-based), capped at +/// Exponential backoff (ms) for the `attempt`-th redial (1-based), capped at /// [`ClientConfig::max_reconnect_delay`]. Defaults collapse this to a fixed /// `reconnect_delay` (max == base), preserving pre-Phase-4 behavior. -fn backoff_delay(config: &ClientConfig, attempt: usize) -> Duration { +fn backoff_delay(config: &ClientConfig, attempt: usize) -> u64 { let base = config.reconnect_delay; let cap = config.max_reconnect_delay.max(base); let shift = attempt.saturating_sub(1).min(16) as u32; - base.saturating_mul(1u32 << shift).min(cap) + base.saturating_mul(1u64 << shift).min(cap) } /// Bound the offline backlog: drop the oldest buffered commands beyond `cap`. -fn bound_offline_queue(cmd_rx: &mut mpsc::UnboundedReceiver, cap: usize) { +fn bound_offline_queue(cmd_rx: &Receiver, cap: usize) { while cmd_rx.len() > cap && cmd_rx.try_recv().is_ok() {} } @@ -97,7 +117,7 @@ fn bound_offline_queue(cmd_rx: &mut mpsc::UnboundedReceiver, cap: usi /// pending-call map and the wire. #[derive(Clone)] pub struct ClientHandle { - cmd_tx: mpsc::UnboundedSender, + cmd_tx: Sender, } /// Commands the [`ClientHandle`] funnels to the engine (the engine assigns the @@ -110,7 +130,7 @@ enum ClientCmd { }, Subscribe { topic: String, - events: mpsc::UnboundedSender, + events: Sender, }, Write { topic: String, @@ -119,6 +139,12 @@ enum ClientCmd { } impl ClientHandle { + /// Funnel a command to the engine. The channel is unbounded, so `try_send` + /// never blocks and only fails once the engine has stopped (receiver closed). + fn enqueue(&self, cmd: ClientCmd) -> Result<(), RpcError> { + self.cmd_tx.try_send(cmd).map_err(|_| RpcError::Internal) + } + /// One-shot RPC: send a request and await its single reply. Returns /// [`RpcError::Internal`] if the engine has stopped or the connection drops /// before the reply arrives. @@ -128,13 +154,11 @@ impl ClientHandle { params: Payload, ) -> Result { let (reply, rx) = oneshot::channel(); - self.cmd_tx - .send(ClientCmd::Call { - method: method.into(), - params, - reply, - }) - .map_err(|_| RpcError::Internal)?; + self.enqueue(ClientCmd::Call { + method: method.into(), + params, + reply, + })?; rx.await.map_err(|_| RpcError::Internal)? } @@ -146,27 +170,21 @@ impl ClientHandle { &self, topic: impl Into, ) -> Result, RpcError> { - let (events, rx) = mpsc::unbounded_channel::(); - self.cmd_tx - .send(ClientCmd::Subscribe { - topic: topic.into(), - events, - }) - .map_err(|_| RpcError::Internal)?; - let stream = futures_util::stream::unfold(rx, |mut rx| async move { - rx.recv().await.map(|item| (item, rx)) - }); - Ok(Box::pin(stream)) + let (events, rx) = async_channel::unbounded::(); + self.enqueue(ClientCmd::Subscribe { + topic: topic.into(), + events, + })?; + // The receiver is itself a `Stream`. + Ok(Box::pin(rx)) } /// Fire-and-forget write to a remote topic (no reply). pub fn write(&self, topic: impl Into, payload: Payload) -> Result<(), RpcError> { - self.cmd_tx - .send(ClientCmd::Write { - topic: topic.into(), - payload, - }) - .map_err(|_| RpcError::Internal) + self.enqueue(ClientCmd::Write { + topic: topic.into(), + payload, + }) } } @@ -174,18 +192,24 @@ impl ClientHandle { /// engine future to drive on the runner (spawn-free). The future runs until all /// `ClientHandle` clones are dropped (graceful stop) — or, with /// [`ClientConfig::reconnect`] off, until the first disconnect. -pub fn run_client( +/// +/// `clock` is the adapter's [`TimeOps`] runtime (e.g. `db.runtime_arc()`); the +/// engine uses it for the reconnect backoff and keepalive — the *only* runtime +/// dependency, so the rest of the engine is runtime-neutral. +pub fn run_client( dialer: D, codec: C, config: ClientConfig, + clock: Arc, ) -> (ClientHandle, BoxFut<'static, ()>) where D: Dialer + 'static, C: EnvelopeCodec + 'static, + R: TimeOps + 'static, { - let (cmd_tx, cmd_rx) = mpsc::unbounded_channel(); + let (cmd_tx, cmd_rx) = async_channel::unbounded(); let handle = ClientHandle { cmd_tx }; - let fut = Box::pin(client_loop(dialer, codec, config, cmd_rx)); + let fut = Box::pin(client_loop(dialer, codec, config, cmd_rx, clock)); (handle, fut) } @@ -197,14 +221,28 @@ enum Ended { HandlesDropped, } -async fn client_loop( +/// What [`drive_connection`]'s `select_biased!` decided this iteration. Extracted +/// so the connection work runs *after* the select's arm futures (and their borrow +/// of `conn`) are dropped — see the module note. +enum ClientStep { + /// A frame (or close/error) arrived from the server. + Inbound(super::TransportResult>>), + /// The keepalive timer fired — send a `Ping`. + Keepalive, + /// A caller command (or `None` = all handles dropped). + Cmd(Option), +} + +async fn client_loop( dialer: D, codec: C, config: ClientConfig, - mut cmd_rx: mpsc::UnboundedReceiver, + cmd_rx: Receiver, + clock: Arc, ) where D: Dialer, C: EnvelopeCodec, + R: TimeOps, { // Consecutive failed attempts since the last successful connection; drives // exponential backoff and the optional attempt cap. @@ -218,17 +256,17 @@ async fn client_loop( Err(_e) => { #[cfg(feature = "tracing")] tracing::warn!("client dial failed: {:?}", _e); - match reconnect_after(&mut attempt, &config, &mut cmd_rx).await { + match reconnect_after(&mut attempt, &config, &cmd_rx, &*clock).await { true => continue, false => return, } } }; - match drive_connection(conn, &codec, &mut cmd_rx, &config).await { + match drive_connection(conn, &codec, &cmd_rx, &config, &*clock).await { Ended::HandlesDropped => return, Ended::Disconnected => { - match reconnect_after(&mut attempt, &config, &mut cmd_rx).await { + match reconnect_after(&mut attempt, &config, &cmd_rx, &*clock).await { true => continue, false => return, } @@ -238,12 +276,13 @@ async fn client_loop( } /// Decide whether to redial: honor `reconnect`, the attempt cap, the offline-queue -/// bound, and the exponential backoff sleep. Returns `true` to retry, `false` to -/// stop the engine. -async fn reconnect_after( +/// bound, and the exponential backoff sleep (via the runtime clock). Returns +/// `true` to retry, `false` to stop the engine. +async fn reconnect_after( attempt: &mut usize, config: &ClientConfig, - cmd_rx: &mut mpsc::UnboundedReceiver, + cmd_rx: &Receiver, + clock: &R, ) -> bool { if !config.reconnect { return false; @@ -258,7 +297,9 @@ async fn reconnect_after( return false; } bound_offline_queue(cmd_rx, config.max_offline_queue); - tokio::time::sleep(backoff_delay(config, *attempt)).await; + clock + .sleep(clock.millis(backoff_delay(config, *attempt))) + .await; true } @@ -267,21 +308,24 @@ async fn reconnect_after( /// subscription channels) interleaved with caller commands. Pending state is /// per-connection: a disconnect fails outstanding calls (their `oneshot` /// senders drop → callers see [`RpcError::Internal`]). -async fn drive_connection( +async fn drive_connection( mut conn: Box, codec: &C, - cmd_rx: &mut mpsc::UnboundedReceiver, + cmd_rx: &Receiver, config: &ClientConfig, + clock: &R, ) -> Ended where C: EnvelopeCodec + ?Sized, + R: TimeOps, { let mut next_id: u64 = 1; let mut pending: HashMap>> = HashMap::new(); // sub-id → event sink. The sub-id is `id.to_string()` of the opening // request, matching the server's derivation so `Event.sub` routes back. - let mut subs: HashMap> = HashMap::new(); + let mut subs: HashMap> = HashMap::new(); let mut out = Vec::new(); + let keepalive_ms = config.keepalive_interval; // Handshake-as-caller: prove the link with Ping/Pong before serving commands. if config.sends_hello { @@ -299,15 +343,37 @@ where } } - // Optional keepalive ticker — `None` parks the arm forever (see below). - let mut keepalive = config.keepalive_interval.map(tokio::time::interval); - loop { - tokio::select! { - biased; + // `biased`, server-read first. The select only *decides* the next step; + // it must not touch `conn` while the `recv` arm still borrows it. + let step = { + let mut recv = conn.recv().fuse(); + // Idle keepalive: re-armed each iteration. With no interval configured + // the arm parks on `pending()` forever, so it never wins the select. + // The async block is `!Unpin`, so pin it for the select arm. + let mut keepalive = core::pin::pin!(async { + match keepalive_ms { + Some(ms) => clock.sleep(clock.millis(ms)).await, + None => core::future::pending::<()>().await, + } + } + .fuse()); + // async-channel's `recv()` is `!Unpin` (holds a pinned listener), so + // pin it in place for the arm. + let mut cmd = core::pin::pin!(cmd_rx.recv().fuse()); + select_biased! { + // ---- inbound from server: Reply / Event / Snapshot / Pong -- + r = recv => ClientStep::Inbound(r), + // ---- keepalive: send a Ping when the idle timer fires ------ + _ = keepalive => ClientStep::Keepalive, + // ---- caller commands from ClientHandle --------------------- + // `recv()` errors only when every `ClientHandle` is dropped → `None`. + c = cmd => ClientStep::Cmd(c.ok()), + } + }; - // ---- inbound from server: Reply / Event / Snapshot / Pong ------ - recv = conn.recv() => { + match step { + ClientStep::Inbound(recv) => { let frame = match recv { Ok(Some(frame)) => frame, Ok(None) | Err(_) => return Ended::Disconnected, @@ -329,7 +395,7 @@ where } Ok(Outbound::Event { sub, seq: _, data }) => { let dead = match subs.get(sub) { - Some(tx) => tx.send(data).is_err(), + Some(tx) => tx.try_send(data).is_err(), None => false, // late event for a dropped sub — ignore }; if dead { @@ -338,7 +404,7 @@ where } Ok(Outbound::Snapshot { topic, data }) => { if let Some(tx) = subs.get(topic) { - let _ = tx.send(data); + let _ = tx.try_send(data); } } Ok(Outbound::Pong) => {} @@ -350,15 +416,7 @@ where } } - // ---- keepalive: send a Ping when the ticker fires -------------- - // With no interval configured the arm parks on `pending()` forever, - // so it never wins the `select!`. - _ = async { - match keepalive.as_mut() { - Some(i) => { i.tick().await; } - None => std::future::pending::<()>().await, - } - } => { + ClientStep::Keepalive => { out.clear(); if codec.encode_inbound(Inbound::Ping, &mut out).is_ok() && conn.send(&out).await.is_err() @@ -367,14 +425,17 @@ where } } - // ---- caller commands from ClientHandle ------------------------- - cmd = cmd_rx.recv() => { + ClientStep::Cmd(cmd) => { let cmd = match cmd { Some(cmd) => cmd, None => return Ended::HandlesDropped, // all handles dropped }; match cmd { - ClientCmd::Call { method, params, reply } => { + ClientCmd::Call { + method, + params, + reply, + } => { let id = next_id; next_id += 1; pending.insert(id, reply); @@ -448,8 +509,6 @@ pub fn pump_client( where R: RuntimeAdapter + 'static, { - use futures_util::StreamExt; - // The type-erased runtime context for context-aware (de)serializers. let ctx = db.runtime_any(); let mut pumps: Vec> = Vec::new(); diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 978617f..6a272fb 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -26,18 +26,18 @@ use futures_core::Stream; use crate::transport::{ConnectorConfig, PublishError}; // --------------------------------------------------------------------------- -// Phase 2 engines (std-only). The frozen contracts above stay `no_std + alloc`; -// the reactive `serve`/`run_session` (server) and proactive `run_client`/ -// `pump_client` (client) engines need `tokio` and therefore gate on `std`. This -// keeps the Phase 0 acceptance criterion intact: `--features connector-session` -// still cross-compiles to `thumbv7em` because the no_std build sees only the -// contracts, never the engines. Phase 5 is where the engines themselves go -// `no_std` (Embassy/heapless). See docs/design/detailed/036/037. +// Phase 2 engines. **Phase 5 made these runtime-neutral** (`futures` channels + +// `select_biased!` + the adapter's `TimeOps` clock — no `tokio`/`embassy-*`), so +// they now gate on `connector-session` (`alloc`) rather than `std` and +// cross-compile to `thumbv7em-none-eabihf`. The frozen contracts above stay +// `no_std + alloc` as before. Only the concrete AimX substrate below (UDS + +// NDJSON) is still std-only until its embedded transport lands in Phase 6. +// See docs/design/detailed/036/037/040. // --------------------------------------------------------------------------- -#[cfg(feature = "std")] +#[cfg(feature = "connector-session")] mod client; -#[cfg(feature = "std")] +#[cfg(feature = "connector-session")] mod server; // Concrete AimX-v2 substrate (UDS transport + NDJSON codec), std-only. Phase 3 @@ -45,9 +45,9 @@ mod server; #[cfg(feature = "std")] pub mod aimx; -#[cfg(feature = "std")] +#[cfg(feature = "connector-session")] pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; -#[cfg(feature = "std")] +#[cfg(feature = "connector-session")] pub use server::{run_session, serve, SessionConfig}; // =========================================================================== diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 55ade79..6d45f65 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -1,6 +1,6 @@ //! Phase 2 **server** engine — the reactive half of the shared session -//! substrate (doc 034 § Layer 2). Written once here, std-only; it generalizes -//! the two hand-rolled loops it will replace in Phases 3–4: +//! substrate (doc 034 § Layer 2). Written once here; it generalizes the two +//! hand-rolled loops it will replace in Phases 3–4: //! //! - [`run_session`] = `remote/handler.rs`'s biased `select!` per-connection loop //! (RPC + streaming + writes over one [`Connection`]), transport-erased. @@ -10,12 +10,28 @@ //! Spawn-free: every per-connection and per-subscription task lives in a //! [`FuturesUnordered`] owned by the engine future the runner drives — no //! `tokio::spawn`. +//! +//! **Runtime-neutral (Phase 5).** This engine is purely reactive — it touches no +//! timer — so it carries *zero* runtime knowledge: `futures` channels + a +//! `select_biased!` over the wire, the event funnel, and the subscription task +//! set. No `tokio`/`embassy-*` here; it runs unchanged on both via the adapters. +//! +//! The loops use an **extract-then-act** shape: `select_biased!` only computes a +//! small [`Step`]/action value (it must not touch `conn`/`subs` while a sibling +//! arm's future still borrows them — unlike `tokio::select!`, `futures`' macro +//! keeps the non-selected futures alive across the handler), then the loop acts +//! on that value once the borrows release. -use std::collections::HashMap; -use std::sync::Arc; +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use async_channel::Sender; +use futures_channel::oneshot; use futures_util::stream::{FuturesUnordered, StreamExt}; -use tokio::sync::{mpsc, oneshot}; +use futures_util::{select_biased, FutureExt}; +use hashbrown::HashMap; use super::{ BoxFut, BoxStream, Connection, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, @@ -55,6 +71,20 @@ struct SubEvent { data: Payload, } +/// What [`run_session`]'s `select_biased!` decided this iteration. Extracted so +/// the connection/subscription work runs *after* the select's arm futures (and +/// their borrows of `conn`/`subs`) are dropped — see the module note. +enum Step { + /// A logical frame arrived from the peer (decode + dispatch). + Frame(Vec), + /// Peer closed or the transport errored — end the session. + Closed, + /// A subscription update to encode and forward to the peer. + Event(SubEvent), + /// A subscription pump finished — nothing to do but reap it. + SubDrained, +} + /// Drive one accepted [`Connection`] until it closes. /// /// Authenticates once, then interleaves — `biased`, request-read first so a @@ -101,7 +131,7 @@ pub async fn run_session( // funnel without limit — the pumps drop on overflow rather than accumulate // (events carry a monotonic `seq`, so a client can detect the gap). This // restores the bounded-buffer slow-client protection the hand-rolled loops had. - let (event_tx, mut event_rx) = mpsc::channel::(EVENT_BUFFER); + let (event_tx, event_rx) = async_channel::bounded::(EVENT_BUFFER); // Per-connection subscription pumps; the engine future is their sole owner. let mut subs: FuturesUnordered> = FuturesUnordered::new(); // sub-id → cancel handle (dropping/sending the oneshot cancels the pump, @@ -111,25 +141,68 @@ pub async fn run_session( let mut out = Vec::new(); loop { - tokio::select! { - biased; + // `biased`, request-read first so a chatty subscription cannot starve the + // RPC path. The `select_biased!` block only *decides* the next step — it + // must not touch `conn`/`subs` while a sibling arm's future still borrows + // them; the work happens after, in the `match`. + let step = { + // Per-iteration futures, fused for the `select_biased!` arms. The + // channel `recv()` is `!Unpin` (async-channel holds a pinned + // listener), so it is pinned in place; `subs` is a `FuturesUnordered` + // (`Unpin` + `FusedStream`), so `select_next_some` parks on the empty + // set and the always-active `recv` arm keeps the select alive. + let mut recv = conn.recv().fuse(); + let mut event = core::pin::pin!(event_rx.recv().fuse()); + select_biased! { + // ---- inbound: one logical frame from the peer -------------- + r = recv => match r { + Ok(Some(frame)) => Step::Frame(frame), + Ok(None) | Err(_) => Step::Closed, // peer closed / transport error + }, + // ---- outbound: a subscription update to forward ------------ + ev = event => match ev { + Ok(ev) => Step::Event(ev), + Err(_) => Step::SubDrained, // funnel closed (tx held, so unreachable) + }, + // ---- drain finished subscription pumps --------------------- + () = subs.select_next_some() => Step::SubDrained, + } + }; - // ---- inbound: one logical frame from the peer ------------------ - recv = conn.recv() => { - let frame = match recv { - Ok(Some(frame)) => frame, - Ok(None) => break, // peer closed - Err(_e) => break, // transport error - }; + match step { + Step::Closed => break, + Step::SubDrained => {} + + Step::Event(ev) => { + out.clear(); + let encoded = codec + .encode( + Outbound::Event { + sub: &ev.sub, + seq: ev.seq, + data: ev.data, + }, + &mut out, + ) + .is_ok(); + if encoded && conn.send(&out).await.is_err() { + break; + } + } + + Step::Frame(frame) => { let msg = match codec.decode(&frame) { Ok(msg) => msg, - Err(_e) => continue, // skip a malformed frame, keep the session + Err(_e) => continue, // skip a malformed frame, keep the session }; match msg { Inbound::Request { id, method, params } => { let result = session.call(&method, params).await; out.clear(); - if codec.encode(Outbound::Reply { id, result }, &mut out).is_err() { + if codec + .encode(Outbound::Reply { id, result }, &mut out) + .is_err() + { continue; } if conn.send(&out).await.is_err() { @@ -162,7 +235,13 @@ pub async fn run_session( if let Some(data) = session.snapshot(&topic) { out.clear(); if codec - .encode(Outbound::Snapshot { topic: &topic, data }, &mut out) + .encode( + Outbound::Snapshot { + topic: &topic, + data, + }, + &mut out, + ) .is_ok() && conn.send(&out).await.is_err() { @@ -201,23 +280,6 @@ pub async fn run_session( } } } - - // ---- outbound: a subscription update to forward ---------------- - Some(ev) = event_rx.recv() => { - out.clear(); - let encoded = codec - .encode(Outbound::Event { sub: &ev.sub, seq: ev.seq, data: ev.data }, &mut out) - .is_ok(); - if encoded && conn.send(&out).await.is_err() { - break; - } - } - - // ---- drain finished subscription pumps ------------------------- - // `Some(_) =` guards against the empty-`FuturesUnordered` panic - // (it reports `is_terminated()`); the always-active `recv` arm - // keeps the select alive. - Some(()) = subs.next() => {} } } @@ -257,29 +319,40 @@ async fn send_reply_err( async fn pump_subscription( sub_id: String, mut stream: BoxStream<'static, Payload>, - tx: mpsc::Sender, - mut cancel: oneshot::Receiver<()>, + tx: Sender, + cancel: oneshot::Receiver<()>, ) { - use tokio::sync::mpsc::error::TrySendError; + // `oneshot::Receiver` reports `is_terminated() == true` once its sender drops + // (the cancel signal!), and `select_biased!` *skips* terminated arms — so a + // bare `cancel` arm would never fire on Unsubscribe. Fuse it once: `Fuse`'s + // `is_terminated` stays false until the fused future itself yields `Ready`, so + // the arm is polled and the cancellation is observed. + let mut cancel = cancel.fuse(); let mut seq: u64 = 0; loop { - tokio::select! { - biased; + // `cancel` and `stream` are independent, and neither handler touches the + // other's borrow, so this stays a direct `select_biased!`. + let data = select_biased! { // Resolves on explicit Unsubscribe (send) or on sender drop. - _ = &mut cancel => break, - next = stream.next() => match next { - Some(data) => { - seq += 1; - // `try_send` keeps the pump non-blocking: a backpressured - // funnel drops this update (slow-client protection) rather - // than stalling the bus; only a closed funnel ends the pump. - match tx.try_send(SubEvent { sub: sub_id.clone(), seq, data }) { - Ok(()) | Err(TrySendError::Full(_)) => {} - Err(TrySendError::Closed(_)) => break, // connection gone - } - } + _ = cancel => break, + // `BoxStream` is not `FusedStream`, so fuse the per-iteration `next`. + next = stream.next().fuse() => match next { + Some(data) => data, None => break, // stream exhausted - } + }, + }; + seq += 1; + // `try_send` keeps the pump non-blocking: a backpressured funnel drops + // this update (slow-client protection) rather than stalling the bus; only + // a disconnected funnel ends the pump. + match tx.try_send(SubEvent { + sub: sub_id.clone(), + seq, + data, + }) { + Ok(()) => {} + Err(e) if e.is_full() => {} // drop on overflow + Err(_) => break, // funnel disconnected — connection gone } } } @@ -297,37 +370,43 @@ where let mut conns: FuturesUnordered> = FuturesUnordered::new(); loop { - tokio::select! { - biased; + // Extract the accept result first (the accept future borrows `listener`, + // and pushing/reaping borrows `conns` — keep them apart), then act. + let accept = { + let mut accept = listener.accept().fuse(); + select_biased! { + a = accept => a, + // `select_next_some` parks on the empty-`FuturesUnordered` case, + // so the accept arm keeps the select alive without a guard. + () = conns.select_next_some() => continue, + } + }; - accept = listener.accept() => match accept { - Ok(conn) => { - // Soft cap; `len()` is conservative (a completed-but-undrained - // future still counts), which only ever refuses one extra. - if conns.len() >= config.limits.max_connections { - #[cfg(feature = "tracing")] - tracing::warn!( - "max_connections={} reached, refusing client", - config.limits.max_connections - ); - drop(conn); - continue; - } - let codec = codec.clone(); - let dispatch = dispatch.clone(); - let cfg = config.clone(); - conns.push(Box::pin(async move { - run_session(conn, codec.as_ref(), dispatch.as_ref(), &cfg).await; - })); - } - Err(_e) => { + match accept { + Ok(conn) => { + // Soft cap; `len()` is conservative (a completed-but-undrained + // future still counts), which only ever refuses one extra. + if conns.len() >= config.limits.max_connections { #[cfg(feature = "tracing")] - tracing::error!("accept failed: {:?}", _e); - // Keep serving existing connections despite a transient accept error. + tracing::warn!( + "max_connections={} reached, refusing client", + config.limits.max_connections + ); + drop(conn); + continue; } - }, - - Some(()) = conns.next() => {} + let codec = codec.clone(); + let dispatch = dispatch.clone(); + let cfg = config.clone(); + conns.push(Box::pin(async move { + run_session(conn, codec.as_ref(), dispatch.as_ref(), &cfg).await; + })); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("accept failed: {:?}", _e); + // Keep serving existing connections despite a transient accept error. + } } } } diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index d16650d..89df744 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -22,6 +22,46 @@ use aimdb_core::session::{ SessionConfig, SessionCtx, TransportError, TransportResult, }; +/// Minimal [`TimeOps`](aimdb_executor::TimeOps) clock for the engine tests +/// (aimdb-core can't depend on a runtime adapter — that would be a cycle). +/// Backs the reconnect/keepalive seam with `tokio::time`; these tests run with +/// `reconnect: false` and no keepalive, so it is never actually awaited. +#[derive(Clone, Copy)] +struct TestClock; + +impl aimdb_executor::RuntimeAdapter for TestClock { + fn runtime_name() -> &'static str { + "test-clock" + } +} + +impl aimdb_executor::TimeOps for TestClock { + type Instant = std::time::Instant; + type Duration = Duration; + + fn now(&self) -> Self::Instant { + std::time::Instant::now() + } + fn duration_since(&self, later: Self::Instant, earlier: Self::Instant) -> Option { + later.checked_duration_since(earlier) + } + fn millis(&self, ms: u64) -> Duration { + Duration::from_millis(ms) + } + fn secs(&self, secs: u64) -> Duration { + Duration::from_secs(secs) + } + fn micros(&self, micros: u64) -> Duration { + Duration::from_micros(micros) + } + fn sleep(&self, duration: Duration) -> impl core::future::Future + Send { + tokio::time::sleep(duration) + } + fn duration_as_nanos(&self, duration: Duration) -> u64 { + duration.as_nanos().min(u64::MAX as u128) as u64 + } +} + // =========================================================================== // Channel-backed transport (Layer 1) // =========================================================================== @@ -340,14 +380,15 @@ async fn echo_roundtrip_rpc_streaming_and_write() { LineCodec, ClientConfig { reconnect: false, - reconnect_delay: Duration::from_millis(10), - max_reconnect_delay: Duration::from_millis(10), + reconnect_delay: 10, + max_reconnect_delay: 10, max_reconnect_attempts: 0, keepalive_interval: None, max_offline_queue: usize::MAX, topic_routed_subs: false, sends_hello: true, }, + Arc::new(TestClock), ); let client = tokio::spawn(client_fut); @@ -405,14 +446,15 @@ async fn failed_subscribe_ends_stream_via_ack() { LineCodec, ClientConfig { reconnect: false, - reconnect_delay: Duration::from_millis(10), - max_reconnect_delay: Duration::from_millis(10), + reconnect_delay: 10, + max_reconnect_delay: 10, max_reconnect_attempts: 0, keepalive_interval: None, max_offline_queue: usize::MAX, topic_routed_subs: false, sends_hello: false, }, + Arc::new(TestClock), ); let client = tokio::spawn(client_fut); diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index 4d72351..023db4c 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -69,6 +69,15 @@ defmt = { workspace = true } tracing = { workspace = true, optional = true, default-features = false } [dev-dependencies] +# Phase 5 session smoke (`tests/session_smoke.rs`) drives the runtime-neutral +# `run_client` engine on the EmbassyAdapter clock — pull in aimdb-core's +# `connector-session` gate. Dev-only, so the normal no_std lib build (and the +# thumbv7em checks) stay `alloc`-only. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false, features = [ + "alloc", + "connector-session", +] } + # For testing on embedded targets heapless = "0.9.1" diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs new file mode 100644 index 0000000..8c17f5d --- /dev/null +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -0,0 +1,143 @@ +//! Phase 5 Embassy smoke — the runtime-neutral session **client engine** runs on +//! the Embassy adapter's [`TimeOps`](aimdb_executor::TimeOps) clock. +//! +//! `run_client` is parametrized over the runtime clock (its only runtime +//! dependency, for reconnect backoff / keepalive). This test instantiates it with +//! [`EmbassyAdapter`] — the same monomorphization an MCU build uses — and drives +//! the returned spawn-free engine future over a stub loopback transport, +//! round-tripping one record (an RPC `call` whose reply echoes the params). It +//! validates the Embassy seam (engine future + `EmbassyAdapter::sleep` clock) +//! without needing `embassy-executor`, which does not build on the host; the +//! spawn-free `BoxFut` is driven by `futures::executor::block_on`. +//! +//! Runs under the same host feature set as the other embassy-adapter host tests +//! (`alloc,embassy-sync,embassy-time`); `connector-session` is pulled in for the +//! engine via this crate's dev-dependency on `aimdb-core`. + +#![cfg(feature = "embassy-time")] + +use std::sync::Arc; + +use aimdb_core::session::{ + run_client, BoxFut, ClientConfig, CodecError, Connection, Dialer, EnvelopeCodec, Inbound, + Outbound, Payload, PeerInfo, TransportError, TransportResult, +}; +use aimdb_embassy_adapter::EmbassyAdapter; + +// Trivial host time driver so `embassy_time` links (the happy path never awaits +// `clock.sleep`, so `now`/`schedule_wake` are never actually exercised). +struct TestTimeDriver; +impl embassy_time_driver::Driver for TestTimeDriver { + fn now(&self) -> u64 { + 0 + } + fn schedule_wake(&self, _at: u64, _waker: &core::task::Waker) {} +} +embassy_time_driver::time_driver_impl!(static TEST_TIME_DRIVER: TestTimeDriver = TestTimeDriver); + +/// Minimal echo wire: a `Request` is `[id:8][params]`; the loopback returns those +/// bytes verbatim, which `decode_outbound` reads back as `Reply { id, Ok(params) }`. +struct EchoCodec; + +impl EnvelopeCodec for EchoCodec { + fn decode(&self, _frame: &[u8]) -> Result { + Err(CodecError::Malformed) // server direction unused by this client smoke + } + fn encode(&self, _msg: Outbound<'_>, _out: &mut Vec) -> Result<(), CodecError> { + Err(CodecError::Malformed) + } + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + match msg { + Inbound::Request { + id, + method: _, + params, + } => { + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(¶ms); + Ok(()) + } + _ => Err(CodecError::Malformed), + } + } + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + if frame.len() < 8 { + return Err(CodecError::Malformed); + } + let id = u64::from_be_bytes(frame[0..8].try_into().unwrap()); + Ok(Outbound::Reply { + id, + result: Ok(Payload::from(&frame[8..])), + }) + } +} + +/// A loopback connection: every `send` echoes the frame straight back to `recv`. +struct Loopback { + tx: futures::channel::mpsc::UnboundedSender>, + rx: futures::channel::mpsc::UnboundedReceiver>, + peer: PeerInfo, +} + +impl Connection for Loopback { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { + use futures::StreamExt; + Ok(self.rx.next().await) // `None` once every sender drops + }) + } + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + let tx = self.tx.clone(); + let bytes = frame.to_vec(); + Box::pin(async move { tx.unbounded_send(bytes).map_err(|_| TransportError::Closed) }) + } + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// Dials a fresh loopback connection. +struct StubDialer; + +impl Dialer for StubDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + Box::pin(async { + let (tx, rx) = futures::channel::mpsc::unbounded(); + Ok(Box::new(Loopback { + tx, + rx, + peer: PeerInfo::default(), + }) as Box) + }) + } +} + +#[test] +fn embassy_clock_drives_client_engine_rpc() { + use futures::executor::block_on; + use futures::future::{select, Either}; + + // The exact `run_client<_, _, EmbassyAdapter>` monomorphization an MCU uses. + let clock = Arc::new(EmbassyAdapter::default()); + let config = ClientConfig { + reconnect: false, + sends_hello: false, + ..ClientConfig::default() + }; + let (handle, engine_fut) = run_client(StubDialer, EchoCodec, config, clock); + + block_on(async move { + futures::pin_mut!(engine_fut); + let call = handle.call("echo", Payload::from(&b"ping"[..])); + futures::pin_mut!(call); + + // Drive the engine concurrently with the call; the engine must reach the + // reply, not end first. + match select(call, engine_fut).await { + Either::Left((reply, _engine)) => { + assert_eq!(&*reply.expect("call should resolve"), b"ping"); + } + Either::Right(_) => panic!("engine ended before the reply arrived"), + } + }); +} diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 57c381a..054cc7b 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -17,7 +17,7 @@ //! └─ return Vec (drained by AimDbRunner) //! ``` -use std::{pin::Pin, time::Duration}; +use std::pin::Pin; use aimdb_core::session::{pump_client, run_client, ClientConfig}; use aimdb_core::ConnectorBuilder; @@ -136,7 +136,7 @@ type BoxFuture = Pin + Send + 'static> impl ConnectorBuilder for WsClientConnectorBuilder where - R: aimdb_executor::RuntimeAdapter + 'static, + R: aimdb_executor::TimeOps + 'static, { fn scheme(&self) -> &str { "ws-client" @@ -154,11 +154,11 @@ where // pushes `Data{topic}` with no id). let config = ClientConfig { reconnect: self.auto_reconnect, - reconnect_delay: Duration::from_millis(200), - max_reconnect_delay: Duration::from_secs(30), + reconnect_delay: 200, + max_reconnect_delay: 30_000, max_reconnect_attempts: self.max_reconnect_attempts, keepalive_interval: if self.keepalive_ms > 0 { - Some(Duration::from_millis(self.keepalive_ms)) + Some(self.keepalive_ms) } else { None }, @@ -171,8 +171,13 @@ where // Mirrors `AimxClientConnector`: `run_client` owns demux/reconnect/ // keepalive over the WS `Dialer` + per-connection `WsCodec`; // `pump_client` wires `link_to`/`link_from` routes to the handle. - let (handle, engine_fut) = - run_client(WsDialer::new(self.url.clone()), WsCodec::new(), config); + // The runtime's `TimeOps` clock drives reconnect backoff/keepalive. + let (handle, engine_fut) = run_client( + WsDialer::new(self.url.clone()), + WsCodec::new(), + config, + db.runtime_arc(), + ); let mut futures = pump_client(db, "ws-client", &handle); futures.push(engine_fut); Ok(futures) diff --git a/aimdb-websocket-connector/src/e2e.rs b/aimdb-websocket-connector/src/e2e.rs index 63a504a..feef03f 100644 --- a/aimdb-websocket-connector/src/e2e.rs +++ b/aimdb-websocket-connector/src/e2e.rs @@ -386,6 +386,7 @@ async fn client_engine_receives_broadcast_over_real_socket() { WsDialer::new(format!("ws://{addr}/ws")), WsCodec::new(), config, + Arc::new(aimdb_tokio_adapter::TokioAdapter), ); let driver = tokio::spawn(engine); From 3cc6d278c089271561e7535856d89d78fb4eae1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 04:56:39 +0000 Subject: [PATCH 15/34] feat: implement connection timeout handling and cleanup on engine exit --- Makefile | 5 ++ aimdb-client/src/discovery.rs | 14 ++---- aimdb-client/src/engine.rs | 84 +++++++++++++++++++++++--------- aimdb-core/src/session/client.rs | 21 ++++++++ 4 files changed, 91 insertions(+), 33 deletions(-) diff --git a/Makefile b/Makefile index 511978a..bb5941e 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,11 @@ # `make clean-embedded`. EMBEDDED_CHECK_TARGET_DIR := target/embedded-check +# Disable incremental compilation to avoid "Stale file handle" linker errors +# on Docker overlay filesystems when many cargo invocations run in sequence +# with different feature sets (as the test/check targets do). +export CARGO_INCREMENTAL := 0 + # Colors for output GREEN := \033[0;32m YELLOW := \033[0;33m diff --git a/aimdb-client/src/discovery.rs b/aimdb-client/src/discovery.rs index c682620..3489b04 100644 --- a/aimdb-client/src/discovery.rs +++ b/aimdb-client/src/discovery.rs @@ -75,17 +75,11 @@ async fn scan_directory(mut entries: tokio::fs::ReadDir) -> Vec { /// Try to connect to a socket and get instance information async fn probe_instance(socket_path: &PathBuf) -> ClientResult { - // Try to connect with a short timeout + // `connect_with_timeout` bounds the whole handshake (dial + hello), so a stale + // socket whose peer accepts but never replies fails fast instead of hanging — + // no need to wrap a second timeout around `connect`. let connect_timeout = Duration::from_millis(500); - - let client = tokio::time::timeout(connect_timeout, AimxConnection::connect(socket_path)) - .await - .map_err(|_| { - ClientError::connection_failed( - socket_path.display().to_string(), - "timeout during discovery probe", - ) - })??; + let client = AimxConnection::connect_with_timeout(socket_path, connect_timeout).await?; let welcome = client.server_info().clone(); diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 3eaf9aa..2208114 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -14,13 +14,14 @@ //! connection drops the handle, which stops the engine gracefully. use std::path::Path; +use std::sync::Arc; +use std::time::Duration; use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::json; use tokio::task::JoinHandle; - -use std::sync::Arc; +use tokio::time::timeout; use aimdb_core::session::aimx::{AimxCodec, UdsDialer}; use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; @@ -29,6 +30,11 @@ use aimdb_tokio_adapter::TokioAdapter; use crate::error::{ClientError, ClientResult}; use crate::protocol::{RecordMetadata, WelcomeMessage}; +/// Default deadline for the connect handshake (dial + `hello`/Welcome). Bounds +/// the case where a peer accepts the socket but never replies — the engine has +/// no handshake timeout of its own, so the wait would otherwise be unbounded. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + /// Response from a `record.drain` call: the values accumulated since the /// previous drain for this connection's per-record cursor. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -53,14 +59,30 @@ pub struct AimxConnection { } impl AimxConnection { - /// Dial `socket_path`, start the engine, and complete the `hello` handshake. + /// Dial `socket_path`, start the engine, and complete the `hello` handshake, + /// bounded by [`DEFAULT_CONNECT_TIMEOUT`]. /// /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than /// a privileged frame — the reshaped wire's deliberate simplification. A dial /// failure surfaces here as the `hello` call failing (the engine runs with - /// reconnect off so connect-time errors are prompt). + /// reconnect off so connect-time errors are prompt); a peer that accepts but + /// never replies surfaces as a timeout (see [`connect_with_timeout`](Self::connect_with_timeout)). pub async fn connect(socket_path: impl AsRef) -> ClientResult { - let dialer = UdsDialer::new(socket_path.as_ref()); + Self::connect_with_timeout(socket_path, DEFAULT_CONNECT_TIMEOUT).await + } + + /// Like [`connect`](Self::connect), but with an explicit handshake deadline. + /// + /// The deadline covers the whole handshake — dial *and* the `hello`/Welcome + /// exchange — so a silent or unresponsive peer cannot block the caller + /// indefinitely. On timeout (or any failure) the engine task is aborted so it + /// does not linger blocked on a stalled connection. + pub async fn connect_with_timeout( + socket_path: impl AsRef, + connect_timeout: Duration, + ) -> ClientResult { + let path = socket_path.as_ref(); + let dialer = UdsDialer::new(path); let config = ClientConfig { reconnect: false, sends_hello: false, @@ -69,24 +91,40 @@ impl AimxConnection { let (handle, engine_fut) = run_client(dialer, AimxCodec, config, Arc::new(TokioAdapter)); let engine = tokio::spawn(engine_fut); - // Handshake-as-RPC: the server replies with its Welcome. - let hello = json!({ "client": "aimdb-client" }); - let reply = handle - .call("hello", to_payload(&hello)?) - .await - .map_err(|_| { - ClientError::connection_failed( - socket_path.as_ref().display().to_string(), - "handshake failed (engine could not reach server)", - ) - })?; - let server_info: WelcomeMessage = from_payload(&reply)?; - - Ok(Self { - handle, - engine, - server_info, - }) + // Handshake-as-RPC: the server replies with its Welcome. Bounded so an + // accepted-but-silent peer times out instead of hanging forever. + let server_info = async { + let hello = json!({ "client": "aimdb-client" }); + let reply = timeout(connect_timeout, handle.call("hello", to_payload(&hello)?)) + .await + .map_err(|_| { + ClientError::connection_failed( + path.display().to_string(), + "handshake timed out", + ) + })? + .map_err(|_| { + ClientError::connection_failed( + path.display().to_string(), + "handshake failed (engine could not reach server)", + ) + })?; + from_payload::(&reply) + } + .await; + + match server_info { + Ok(server_info) => Ok(Self { + handle, + engine, + server_info, + }), + Err(e) => { + // Don't leave the engine task blocked on a stalled dial/connection. + engine.abort(); + Err(e) + } + } } /// The raw engine handle — `call` / `subscribe` / `write` for methods the diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 511fe0a..56d346a 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -221,6 +221,25 @@ enum Ended { HandlesDropped, } +/// On engine exit (any path), **close and drain** the command channel so buffered +/// or in-flight caller commands are dropped — each `ClientCmd::Call` drops with +/// its `reply` oneshot sender, so a waiting [`ClientHandle::call`] resolves with +/// [`RpcError::Internal`] instead of hanging forever. +/// +/// This is required because `async-channel` keeps buffered items alive as long as +/// *any* `Sender` exists (a live `ClientHandle` does), and a dropped `Receiver` +/// only *closes* the queue without draining it — unlike `tokio::mpsc`, whose +/// receiver-drop discarded the backlog. `close()` first stops new sends; the +/// drain then releases the backlog. +struct DrainOnExit<'a>(&'a Receiver); + +impl Drop for DrainOnExit<'_> { + fn drop(&mut self) { + self.0.close(); + while self.0.try_recv().is_ok() {} + } +} + /// What [`drive_connection`]'s `select_biased!` decided this iteration. Extracted /// so the connection work runs *after* the select's arm futures (and their borrow /// of `conn`) are dropped — see the module note. @@ -244,6 +263,8 @@ async fn client_loop( C: EnvelopeCodec, R: TimeOps, { + // Whenever the engine returns, fail any buffered/in-flight calls (see guard). + let _drain = DrainOnExit(&cmd_rx); // Consecutive failed attempts since the last successful connection; drives // exponential backoff and the optional attempt cap. let mut attempt: usize = 0; From 332272f4cb2f070f483a6683e21a5b8cf63c347c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 09:23:19 +0000 Subject: [PATCH 16/34] Refactor AimX transport and codec for UDS integration - Introduced `aimdb-uds-connector` crate for Unix-domain socket transport. - Replaced legacy remote access implementation with `SessionClientConnector` and `SessionServerConnector`. - Updated `AimDbBuilder` to use `UdsClient` and `UdsServer` for remote access configuration. - Enhanced `AimxCodec` to support `no_std + alloc` features. - Refined server dispatch and connection handling to improve modularity and maintainability. - Updated integration tests to utilize the new UDS connector. --- Cargo.lock | 14 + Cargo.toml | 1 + Makefile | 8 + aimdb-client/Cargo.toml | 6 +- aimdb-client/src/engine.rs | 3 +- aimdb-client/tests/aimx_session.rs | 7 +- aimdb-client/tests/pump_client.rs | 7 +- aimdb-core/src/builder.rs | 130 +++----- aimdb-core/src/remote/mod.rs | 19 +- .../src/session/aimx/client_connector.rs | 77 ----- aimdb-core/src/session/aimx/codec.rs | 4 +- aimdb-core/src/session/aimx/dispatch.rs | 103 +----- aimdb-core/src/session/aimx/mod.rs | 35 +- aimdb-core/src/session/connector.rs | 188 +++++++++++ aimdb-core/src/session/mod.rs | 14 +- aimdb-core/src/session/server.rs | 5 +- aimdb-tokio-adapter/Cargo.toml | 2 + .../tests/drain_integration_tests.rs | 7 +- aimdb-uds-connector/Cargo.toml | 32 ++ aimdb-uds-connector/src/lib.rs | 308 ++++++++++++++++++ .../src}/transport.rs | 26 +- examples/remote-access-demo/Cargo.toml | 1 + examples/remote-access-demo/src/client.rs | 5 +- examples/remote-access-demo/src/server.rs | 7 +- .../weather-station-gamma/src/main.rs | 6 +- 25 files changed, 717 insertions(+), 298 deletions(-) delete mode 100644 aimdb-core/src/session/aimx/client_connector.rs create mode 100644 aimdb-core/src/session/connector.rs create mode 100644 aimdb-uds-connector/Cargo.toml create mode 100644 aimdb-uds-connector/src/lib.rs rename {aimdb-core/src/session/aimx => aimdb-uds-connector/src}/transport.rs (84%) diff --git a/Cargo.lock b/Cargo.lock index 1dd9725..c4c37a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -51,6 +51,7 @@ version = "0.6.0" dependencies = [ "aimdb-core", "aimdb-tokio-adapter", + "aimdb-uds-connector", "anyhow", "futures", "serde", @@ -271,6 +272,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-executor", + "aimdb-uds-connector", "futures", "serde", "serde_json", @@ -279,6 +281,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "aimdb-uds-connector" +version = "0.1.0" +dependencies = [ + "aimdb-core", + "aimdb-executor", + "aimdb-tokio-adapter", + "tokio", + "tracing", +] + [[package]] name = "aimdb-wasm-adapter" version = "0.2.0" @@ -2608,6 +2621,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-tokio-adapter", + "aimdb-uds-connector", "futures", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 88a54d7..435d3cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "aimdb-mqtt-connector", "aimdb-knx-connector", "aimdb-websocket-connector", + "aimdb-uds-connector", "aimdb-ws-protocol", "aimdb-wasm-adapter", "tools/aimdb-cli", diff --git a/Makefile b/Makefile index bb5941e..17e4ca8 100644 --- a/Makefile +++ b/Makefile @@ -100,6 +100,8 @@ build: cargo build --package aimdb-ws-protocol @printf "$(YELLOW) → Building WebSocket connector (server + client)$(NC)\n" cargo build --package aimdb-websocket-connector --features "server,client" + @printf "$(YELLOW) → Building UDS connector$(NC)\n" + cargo build --package aimdb-uds-connector @printf "$(YELLOW) → Building WASM adapter$(NC)\n" cargo build --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" @@ -159,6 +161,8 @@ test: cargo test --package aimdb-websocket-connector --features "server,client" @printf "$(YELLOW) → Testing WebSocket connector client-only build$(NC)\n" cargo test --package aimdb-websocket-connector --no-default-features --features "client" --lib + @printf "$(YELLOW) → Testing UDS connector$(NC)\n" + cargo test --package aimdb-uds-connector fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -230,6 +234,8 @@ clippy: cargo clippy --package aimdb-ws-protocol --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WebSocket connector$(NC)\n" cargo clippy --package aimdb-websocket-connector --features "tokio-runtime,client" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on UDS connector$(NC)\n" + cargo clippy --package aimdb-uds-connector --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @@ -292,6 +298,8 @@ test-embedded: cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,json-serialize" @printf "$(YELLOW) → Checking aimdb-core session engines (no_std + connector-session) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session" + @printf "$(YELLOW) → Checking aimdb-core AimX codec (no_std + connector-session + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session,json-serialize" @printf "$(YELLOW) → Checking aimdb-core (no_std/embassy) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-embassy-adapter on thumbv7em-none-eabihf target$(NC)\n" diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index a7a6cfa..e97d6e1 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -16,12 +16,16 @@ profiling = ["aimdb-core/profiling"] [dependencies] # Core dependencies - protocol types from aimdb-core. `connector-session` # exposes the shared session engine (`run_client`/`ClientHandle`) plus the AimX -# UDS transport + codec that the engine-based client (`crate::engine`) builds on. +# codec that the engine-based client (`crate::engine`) builds on. aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ "std", "connector-session", ] } +# The UDS transport (`UdsDialer`) relocated out of core in Phase 6; the +# engine-based client dials over it. +aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector" } + # Serialization serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 2208114..157fb47 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -23,9 +23,10 @@ use serde_json::json; use tokio::task::JoinHandle; use tokio::time::timeout; -use aimdb_core::session::aimx::{AimxCodec, UdsDialer}; +use aimdb_core::session::aimx::AimxCodec; use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; use aimdb_tokio_adapter::TokioAdapter; +use aimdb_uds_connector::UdsDialer; use crate::error::{ClientError, ClientResult}; use crate::protocol::{RecordMetadata, WelcomeMessage}; diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index dd31073..89613b6 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -8,6 +8,11 @@ //! This swaps the client-half milestone's test-local `UdsListener` + //! `TestDispatch` for real server code standing up an actual `AimDb`, proving //! the reshaped wire end-to-end through the shared session engine. +//! +//! Exercises the back-compat `build_aimx_server` alias (relocated to +//! `aimdb-uds-connector` in Phase 6); hence the crate-level `allow(deprecated)`. + +#![allow(deprecated)] use std::sync::Arc; use std::time::Duration; @@ -15,9 +20,9 @@ use std::time::Duration; use aimdb_client::AimxConnection; use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; -use aimdb_core::session::aimx::build_aimx_server; use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::build_aimx_server; use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 7264472..0515e75 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -10,16 +10,21 @@ //! server via `ClientHandle::write` → the server's `record.set` path. //! - **server → client**: updating the server's `tele` record streams it back //! through a subscription → the client's inbound producer (arbiter path). +//! +//! Exercises the back-compat `build_aimx_server`/`AimxClientConnector` aliases +//! (relocated to `aimdb-uds-connector` in Phase 6); hence `allow(deprecated)`. + +#![allow(deprecated)] use std::sync::Arc; use std::time::Duration; use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; -use aimdb_core::session::aimx::{build_aimx_server, AimxClientConnector}; use aimdb_core::session::ClientConfig; use aimdb_core::{AimDb, AimDbBuilder}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::{build_aimx_server, AimxClientConnector}; use serde::{Deserialize, Serialize}; use serde_json::json; diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 5bdcd04..c3212b3 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -335,10 +335,6 @@ pub struct AimDbBuilder { /// Moved into AimDbInner during build() so it can be read on the live AimDb handle. extensions: Extensions, - /// Remote access configuration (std only) - #[cfg(feature = "std")] - remote_config: Option, - /// PhantomData to track the runtime type parameter _phantom: PhantomData, } @@ -355,8 +351,6 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: Vec::new(), extensions: Extensions::new(), - #[cfg(feature = "std")] - remote_config: None, _phantom: PhantomData, } } @@ -384,8 +378,8 @@ impl AimDbBuilder { /// .with_connector(connector) // ← Now available /// ``` /// - /// The `records` and `remote_config` are preserved across the transition since they - /// are not parameterized by the runtime type. + /// The `records` are preserved across the transition since they are not + /// parameterized by the runtime type. pub fn runtime(self, rt: Arc) -> AimDbBuilder where R: aimdb_executor::RuntimeAdapter + 'static, @@ -397,8 +391,6 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: self.start_fns, extensions: self.extensions, - #[cfg(feature = "std")] - remote_config: self.remote_config, _phantom: PhantomData, } } @@ -452,25 +444,44 @@ where self } - /// Registers a connector builder that will be invoked during `build()` + /// Registers a connector builder, invoked during [`build`](Self::build). /// - /// The connector builder will be called after the database is constructed, - /// allowing it to collect routes and initialize the connector properly. + /// This single entry point registers **two kinds** of connector — both + /// implement [`ConnectorBuilder`](crate::connector::ConnectorBuilder) and are + /// driven the same way: /// - /// # Arguments - /// * `builder` - A connector builder that implements `ConnectorBuilder` + /// 1. **Data-plane links** (MQTT / KNX / WebSocket): a record opts in with + /// `link_to(\"://\")` / `link_from(...)`, and the connector + /// mirrors that record to/from the external topic. The connector's + /// [`scheme`](crate::connector::ConnectorBuilder::scheme) is what those + /// links match against. + /// 2. **Remote-access session connectors** (UDS / serial / TCP): these expose + /// AimDB itself over a transport so peers can introspect/subscribe/write. + /// - The **client** half (e.g. `UdsClient`) dials a peer and *does* use + /// `link_to`/`link_from` under its scheme — just like (1), the scheme is + /// `\"remote\"` by default instead of `\"mqtt\"`. + /// - The **server** half (e.g. `UdsServer`) *accepts* connections and takes + /// **no links** — registering it is how a server stands up remote access + /// (this replaces the old `with_remote_access(config)`). /// - /// # Example + /// # Examples /// /// ```rust,ignore - /// use aimdb_mqtt_connector::MqttConnector; + /// // (1) data-plane link to an MQTT topic + /// AimDbBuilder::new().runtime(rt) + /// .with_connector(MqttConnector::new(\"mqtt://broker.local:1883\")) + /// .configure::(|r| { r.link_from(\"mqtt://commands/temp\")...; }) + /// .build().await?; /// - /// let db = AimDbBuilder::new() - /// .runtime(runtime) - /// .with_connector(MqttConnector::new("mqtt://broker.local:1883")) - /// .configure::(|reg| { - /// reg.link_from("mqtt://commands/temp")... - /// }) + /// // (2a) remote-access SERVER — no links, just expose this db over UDS + /// AimDbBuilder::new().runtime(rt) + /// .with_connector(UdsServer::from_config(remote_config)) + /// .build().await?; + /// + /// // (2b) remote-access CLIENT — mirror a record to a peer over UDS + /// AimDbBuilder::new().runtime(rt) + /// .with_connector(UdsClient::new(\"/run/aimdb.sock\")) + /// .configure::(|r| { r.with_remote_access().link_to(\"remote://temp\")...; }) /// .build().await?; /// ``` pub fn with_connector( @@ -481,35 +492,16 @@ where self } - /// Enables remote access via AimX protocol (std only) - /// - /// Configures the database to accept remote connections over a Unix domain socket, - /// allowing external clients to introspect records, subscribe to updates, and - /// (optionally) write data. - /// - /// The remote access supervisor will be spawned automatically during `build()`. - /// - /// # Arguments - /// * `config` - Remote access configuration (socket path, security policy, etc.) - /// - /// # Example - /// - /// ```rust,ignore - /// use aimdb_core::remote::{AimxConfig, SecurityPolicy}; - /// - /// let config = AimxConfig::new("/tmp/aimdb.sock") - /// .with_security(SecurityPolicy::read_only()); - /// - /// let db = AimDbBuilder::new() - /// .runtime(runtime) - /// .with_remote_access(config) - /// .build()?; - /// ``` - #[cfg(feature = "std")] - pub fn with_remote_access(mut self, config: crate::remote::AimxConfig) -> Self { - self.remote_config = Some(config); - self - } + // NOTE: the former `with_remote_access(config: AimxConfig)` builder method was + // removed in the Phase-6 connector convergence. A remote-access **server** is + // now registered like any other connector: + // + // .with_connector(aimdb_uds_connector::UdsServer::from_config(config)) + // + // This unifies the server onto the `with_connector` spine (see its docs) and + // lets the transport be swapped (UDS / serial / TCP) without touching the + // builder. The per-record `TypedRecord::with_remote_access()` is unrelated and + // unchanged. /// Configures a record type manually with a unique key /// @@ -876,35 +868,11 @@ where #[cfg(feature = "tracing")] tracing::info!("Record future collection complete"); - // Collect the AimX remote-access server future, if configured (std only). - // The server now rides the shared session engine (`session::aimx`), - // replacing the hand-rolled supervisor/handler loops. - #[cfg(feature = "std")] - if let Some(remote_cfg) = self.remote_config { - #[cfg(feature = "tracing")] - tracing::info!( - "Building AimX remote-access server for socket: {}", - remote_cfg.socket_path.display() - ); - - // Apply security policy to mark writable records (so `record.list` - // reports the `writable` flag; the server also enforces the policy). - let writable_keys = remote_cfg.security_policy.writable_records(); - for key_str in writable_keys { - if let Some(id) = inner.resolve_str(&key_str) { - #[cfg(feature = "tracing")] - tracing::debug!("Marking record '{}' as writable", key_str); - - inner.storages[id.index()].set_writable_erased(true); - } - } - - let server_future = crate::session::aimx::build_aimx_server(db.clone(), remote_cfg)?; - futures_acc.push(server_future); - - #[cfg(feature = "tracing")] - tracing::info!("AimX remote-access server future collected"); - } + // AimX remote-access servers are no longer stood up here: register a + // session connector (`UdsServer::from_config(...)`) via `with_connector` + // instead — it collects below like any other connector, binds its + // transport, applies the security policy's writable marking, and drives + // the shared session engine. See `with_connector`'s docs. // Collect connector futures. After issue #88 connector builders return // a `Vec` instead of an `Arc` (which previously diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index b9f48b4..eda763c 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -18,18 +18,23 @@ //! //! # Usage //! +//! Remote access is registered like any other connector — via `with_connector` +//! using `aimdb_uds_connector::UdsServer` (this replaced the former +//! `AimDbBuilder::with_remote_access(config)`): +//! //! ```rust,ignore //! use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +//! use aimdb_uds_connector::UdsServer; +//! +//! let config = AimxConfig::uds_default() +//! .socket_path("/var/run/aimdb/aimdb.sock") +//! .security_policy(SecurityPolicy::ReadOnly) +//! .max_connections(16) +//! .max_subs_per_connection(32); //! //! let db = AimDbBuilder::new() //! .runtime(tokio_adapter) -//! .with_remote_access( -//! AimxConfig::uds_default() -//! .socket_path("/var/run/aimdb/aimdb.sock") -//! .security_policy(SecurityPolicy::ReadOnly) -//! .max_connections(16) -//! .max_subs_per_connection(32) -//! ) +//! .with_connector(UdsServer::from_config(config)) //! .build()?; //! ``` diff --git a/aimdb-core/src/session/aimx/client_connector.rs b/aimdb-core/src/session/aimx/client_connector.rs deleted file mode 100644 index d5a6bc3..0000000 --- a/aimdb-core/src/session/aimx/client_connector.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! AimX **client** connector (Phase 3 server port, std-only) — registers the -//! `aimx://` scheme so records can `.link_to`/`.link_from` an AimX peer, and on -//! build dials that peer and drives the mirroring pumps. -//! -//! This is the registerable wrapper around [`pump_client`](crate::session::pump_client): -//! `build` opens the connection with [`run_client`](crate::session::run_client) -//! and returns one spawn-free future per route (plus the engine future) for the -//! runner to drive — collapsing the AimX *client* onto the shared session engine -//! the same way [`build_aimx_server`](super::build_aimx_server) does the server. - -use std::future::Future; -use std::path::PathBuf; -use std::pin::Pin; - -use aimdb_executor::TimeOps; - -use crate::builder::AimDb; -use crate::connector::ConnectorBuilder; -use crate::session::{pump_client, run_client, ClientConfig}; -use crate::DbResult; - -use super::{AimxCodec, UdsDialer}; - -type BoxFuture = Pin + Send + 'static>>; - -/// A connector that mirrors records to/from an AimX peer over a Unix-domain -/// socket. Register it with [`AimDbBuilder::with_connector`](crate::AimDbBuilder::with_connector) -/// so `aimx://` links validate; its `build` wires every collected route -/// to the connection. -pub struct AimxClientConnector { - socket_path: PathBuf, - config: ClientConfig, -} - -impl AimxClientConnector { - /// Mirror records over the AimX peer listening at `socket_path`. - pub fn new(socket_path: impl Into) -> Self { - Self { - socket_path: socket_path.into(), - config: ClientConfig::default(), - } - } - - /// Override the client engine config (reconnect policy, etc.). - pub fn with_config(mut self, config: ClientConfig) -> Self { - self.config = config; - self - } -} - -impl ConnectorBuilder for AimxClientConnector -where - R: TimeOps + 'static, -{ - fn build<'a>( - &'a self, - db: &'a AimDb, - ) -> Pin>> + Send + 'a>> { - Box::pin(async move { - let (handle, engine_fut) = run_client( - UdsDialer::new(self.socket_path.clone()), - AimxCodec, - self.config.clone(), - db.runtime_arc(), - ); - // One pump future per route; they hold `ClientHandle` clones, so the - // engine stays alive as long as any mirror runs. `handle` drops here. - let mut futures = pump_client(db, "aimx", &handle); - futures.push(engine_fut); - Ok(futures) - }) - } - - fn scheme(&self) -> &str { - "aimx" - } -} diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs index 3e72d44..d750f5c 100644 --- a/aimdb-core/src/session/aimx/codec.rs +++ b/aimdb-core/src/session/aimx/codec.rs @@ -1,4 +1,5 @@ -//! AimX-v2 NDJSON envelope codec (Phase 3, std-only). +//! AimX-v2 NDJSON envelope codec (`no_std + alloc`, feature `connector-session` +//! + `json-serialize`). //! //! The reshaped AimX wire: one JSON object per line, tagged by a `"t"` field, //! mapping verbatim onto the engine's role-neutral [`Inbound`]/[`Outbound`] @@ -28,6 +29,7 @@ use serde_json::value::RawValue; use crate::session::{CodecError, EnvelopeCodec, Inbound, Outbound, Payload, RpcError}; /// The zero-sized AimX-v2 NDJSON codec. +#[derive(Clone, Copy, Default)] pub struct AimxCodec; /// One wire frame. A single flat, all-optional struct (rather than an internally diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index 5083d9e..e00aa46 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -1,6 +1,12 @@ -//! AimX server dispatch (Phase 3 server port, std-only) — the method semantics -//! of AimX remote access, ported off the hand-rolled `remote/handler.rs` loop -//! onto the shared session engine ([`serve`]/[`run_session`]). +//! AimX server dispatch (std-only) — the method semantics of AimX remote access, +//! ported off the hand-rolled `remote/handler.rs` loop onto the shared session +//! engine (`serve`/`run_session`). +//! +//! Still `std`-gated: the dispatch reaches into core's `record.list`/JSON API +//! (the `AnyRecord` JSON + metadata methods), which remain `#[cfg(std)]` until +//! their own no_std port. A transport (UDS today) pairs this dispatch with the +//! generic [`SessionServerConnector`](crate::session::SessionServerConnector) — +//! see `aimdb-uds-connector`'s `UdsServer`. //! //! The dispatch role is split per the Phase-3 server-port refinement (doc 037): //! - [`AimxDispatch`] is the **shared** half (one `Arc` per server): peer-only @@ -16,22 +22,17 @@ //! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`. use std::collections::HashMap; -use std::os::unix::fs::PermissionsExt; use std::sync::Arc; use futures_util::StreamExt; use serde_json::{json, Value}; -use tokio::net::UnixListener; use crate::buffer::JsonBufferReader; -use crate::builder::BoxFuture; use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage}; -use crate::session::aimx::{AimxCodec, UdsListener}; use crate::session::{ - serve, AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, - SessionConfig, SessionCtx, SessionLimits, + AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, SessionCtx, }; -use crate::{AimDb, DbError, DbResult, RuntimeAdapter}; +use crate::{AimDb, DbError, RuntimeAdapter}; /// The shared AimX dispatch — `authenticate` (peer-only) + the [`AimxSession`] /// factory. One `Arc` is shared across every accepted connection. @@ -347,85 +348,3 @@ fn map_db_err(e: DbError) -> RpcError { _ => RpcError::Internal, } } - -/// Build the AimX **server** future: bind the Unix-domain socket (remove a stale -/// socket file, `bind`, `set_permissions`) — synchronously, so bind errors -/// surface from `build()` — then return the spawn-free [`serve`] engine driving -/// [`AimxDispatch`] over [`AimxCodec`]. Replaces the legacy -/// `remote/supervisor.rs` accept loop; the `max_connections` cap moves into -/// [`SessionLimits`]. -pub fn build_aimx_server(db: Arc>, config: AimxConfig) -> DbResult -where - R: RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::info!( - "Initializing AimX server on socket: {}", - config.socket_path.display() - ); - - // Remove an existing socket file if present. - if config.socket_path.exists() { - std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to remove existing socket file {}", - config.socket_path.display() - ), - source: e, - })?; - } - - let listener = UnixListener::bind(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to bind Unix socket at {}", - config.socket_path.display() - ), - source: e, - })?; - - // Set socket file permissions. - let permissions = config.socket_permissions.unwrap_or(0o600); - let mut perms = std::fs::metadata(&config.socket_path) - .map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to read socket metadata for {}", - config.socket_path.display() - ), - source: e, - })? - .permissions(); - perms.set_mode(permissions); - std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to set socket permissions for {}", - config.socket_path.display() - ), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!( - "AimX socket bound at {} (mode {:o})", - config.socket_path.display(), - permissions - ); - - let session_config = SessionConfig { - limits: SessionLimits { - max_connections: config.max_connections, - max_subs_per_connection: config.max_subs_per_connection, - }, - reads_hello: false, - // AimX's subscribe ack stays implicit (events flow); no explicit ack frame. - acks_subscribe: false, - }; - let dispatch = Arc::new(AimxDispatch::new(db, config)); - let listener = UdsListener::new(listener); - - Ok(Box::pin(serve( - listener, - Arc::new(AimxCodec), - dispatch, - session_config, - ))) -} diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs index 2e9d339..bac4cfb 100644 --- a/aimdb-core/src/session/aimx/mod.rs +++ b/aimdb-core/src/session/aimx/mod.rs @@ -1,17 +1,26 @@ -//! AimX-v2 transport + codec + dispatch (Phase 3, std-only) — the concrete -//! substrate the shared session engine rides for AimX remote access. +//! AimX-v2 codec + dispatch (the concrete substrate the shared session engine +//! rides for AimX remote access). //! -//! Role-neutral substrate ([`UdsConnection`] + the symmetric [`AimxCodec`]) plus -//! both sides: the dialing transport ([`UdsDialer`]) that drives `run_client`, -//! and the accepting transport ([`UdsListener`]) + server [`AimxDispatch`] that -//! [`build_aimx_server`] drives via `serve`. +//! Split by capability so the embedded *client* (a sensor dialing a gateway over +//! a real transport) gets the codec on `no_std + alloc`, while the *server* +//! dispatch stays `std`-gated until its own no_std port (Phase 6 follow-up): +//! +//! - [`AimxCodec`] — the symmetric NDJSON [`EnvelopeCodec`](crate::session::EnvelopeCodec), +//! `no_std + alloc` (features `connector-session` + `json-serialize`). Both the +//! proactive `run_client` and the reactive `serve` engine ride it. +//! - [`AimxDispatch`] — the server method semantics, **`std`-only** for now (it +//! reaches into core's `record.list`/JSON API, which is still std-gated). +//! +//! The concrete **transport** (UDS dialer/listener + socket setup) no longer +//! lives here — a transport is a swappable connector crate (see +//! `aimdb-uds-connector`). Core keeps only the protocol (codec + dispatch) and +//! the generic [`SessionClientConnector`](crate::session::SessionClientConnector) / +//! [`SessionServerConnector`](crate::session::SessionServerConnector) spine. -mod client_connector; mod codec; -mod dispatch; -mod transport; - -pub use client_connector::AimxClientConnector; pub use codec::AimxCodec; -pub use dispatch::{build_aimx_server, AimxDispatch}; -pub use transport::{UdsConnection, UdsDialer, UdsListener}; + +#[cfg(feature = "std")] +mod dispatch; +#[cfg(feature = "std")] +pub use dispatch::AimxDispatch; diff --git a/aimdb-core/src/session/connector.rs b/aimdb-core/src/session/connector.rs new file mode 100644 index 0000000..087403e --- /dev/null +++ b/aimdb-core/src/session/connector.rs @@ -0,0 +1,188 @@ +//! Generic, transport-agnostic session connectors — the reusable spine every +//! transport crate (`aimdb-uds-connector`, and later serial/TCP) wraps. +//! +//! A transport contributes only a [`Dialer`]/[`Listener`]/[`Connection`] triple +//! (doc 037 Layer 1) and an [`EnvelopeCodec`]; the engine wiring (reconnect, +//! pumps, accept loop, fan-out) is inherited here verbatim. So a new transport +//! is a thin crate, and swapping one never ripples into record/link code. +//! +//! - [`SessionClientConnector`] generalizes the dialing half: on `build` it +//! opens [`run_client`] over the injected dialer/codec and drives +//! [`pump_client`] for every route under its **scheme**. Generalized from the +//! old `AimxClientConnector` (which hardcoded a UDS dialer + the AimX codec). +//! - [`SessionServerConnector`] generalizes the accepting half: it binds a +//! [`Listener`] (behind a factory, so bind errors surface synchronously from +//! `build`) and drives [`serve`] with an injected dispatch + codec. +//! Generalized from the old in-core `build_aimx_server`. +//! +//! The **scheme** is a constructor argument (default `"remote"`): it decouples +//! the logical routing key from the transport, so the same scheme can be backed +//! by any transport and two transports can coexist under different schemes. + +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use core::future::Future; +use core::pin::Pin; + +use aimdb_executor::{RuntimeAdapter, TimeOps}; + +use crate::builder::AimDb; +use crate::connector::ConnectorBuilder; +use crate::session::{ + pump_client, run_client, serve, ClientConfig, Dialer, Dispatch, EnvelopeCodec, Listener, + SessionConfig, +}; +use crate::DbResult; + +/// The default scheme a session connector registers when none is given. +pub const DEFAULT_SCHEME: &str = "remote"; + +type BoxFuture = Pin + Send + 'static>>; +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +// =========================================================================== +// Client — dials a peer, mirrors records under `scheme`. +// =========================================================================== + +/// Mirrors records to/from a peer reached via the dialer `D`, speaking codec `C`, +/// under a logical [`scheme`](ConnectorBuilder::scheme). The transport-agnostic +/// generalization of the old `AimxClientConnector`; a transport crate wraps it in +/// a one-line sugar constructor (e.g. `UdsClient`). +pub struct SessionClientConnector { + scheme: String, + dialer: D, + codec: C, + config: ClientConfig, +} + +impl SessionClientConnector { + /// Mirror records over `dialer`, framing messages with `codec`. The scheme + /// defaults to [`DEFAULT_SCHEME`] (`"remote"`). + pub fn new(dialer: D, codec: C) -> Self { + Self { + scheme: DEFAULT_SCHEME.to_string(), + dialer, + codec, + config: ClientConfig::default(), + } + } + + /// Override the scheme this connector registers (so `://` + /// links validate and route here). + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } + + /// Override the client engine config (reconnect policy, keepalive, etc.). + pub fn with_config(mut self, config: ClientConfig) -> Self { + self.config = config; + self + } +} + +impl ConnectorBuilder for SessionClientConnector +where + R: TimeOps + 'static, + D: Dialer + Clone + Send + Sync + 'static, + C: EnvelopeCodec + Clone + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + Box::pin(async move { + let (handle, engine_fut) = run_client( + self.dialer.clone(), + self.codec.clone(), + self.config.clone(), + db.runtime_arc(), + ); + // One pump future per route; each holds a `ClientHandle` clone, so the + // engine stays alive as long as any mirror runs. `handle` drops here. + let mut futures = pump_client(db, &self.scheme, &handle); + futures.push(engine_fut); + Ok(futures) + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} + +// =========================================================================== +// Server — accepts connections, serves a dispatch under `scheme`. +// =========================================================================== + +/// Accepts connections from a [`Listener`] `L` and serves them with a dispatch, +/// speaking codec `C`, under a logical [`scheme`](ConnectorBuilder::scheme). The +/// transport-agnostic generalization of the old in-core `build_aimx_server`. +/// +/// Two factories keep this transport- and protocol-agnostic: +/// - `listener_factory` runs at `build` time and returns `DbResult`, so the +/// bind (remove-stale / `bind` / `set_permissions` for UDS) happens there and +/// any error surfaces synchronously from `build`, exactly as the legacy +/// supervisor's synchronous bind did. +/// - `dispatch_factory` turns the live `&AimDb` into an `Arc` +/// (e.g. an `AimxDispatch`), so the spine never names a concrete protocol. +pub struct SessionServerConnector { + scheme: String, + listener_factory: LF, + codec: C, + dispatch_factory: DF, + config: SessionConfig, +} + +impl SessionServerConnector { + /// Build a server connector. `listener_factory` binds the listener at + /// `build` time; `dispatch_factory` produces the per-server dispatch from the + /// live db. The scheme defaults to [`DEFAULT_SCHEME`] (`"remote"`). + pub fn new( + listener_factory: LF, + codec: C, + dispatch_factory: DF, + config: SessionConfig, + ) -> Self { + Self { + scheme: DEFAULT_SCHEME.to_string(), + listener_factory, + codec, + dispatch_factory, + config, + } + } + + /// Override the scheme this connector registers. + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } +} + +impl ConnectorBuilder for SessionServerConnector +where + R: RuntimeAdapter + 'static, + L: Listener + 'static, + C: EnvelopeCodec + Clone + 'static, + LF: Fn() -> DbResult + Send + Sync + 'static, + DF: Fn(&AimDb) -> Arc + Send + Sync + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + // Bind synchronously so a bind error surfaces from `build` (not from a + // spawned future), mirroring the legacy supervisor. + let listener = (self.listener_factory)(); + let dispatch = (self.dispatch_factory)(db); + let codec = Arc::new(self.codec.clone()); + let config = self.config.clone(); + Box::pin(async move { + let listener = listener?; + let fut: BoxFuture = Box::pin(serve(listener, codec, dispatch, config)); + Ok(vec![fut]) + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 6a272fb..b42307f 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -38,16 +38,24 @@ use crate::transport::{ConnectorConfig, PublishError}; #[cfg(feature = "connector-session")] mod client; #[cfg(feature = "connector-session")] +mod connector; +#[cfg(feature = "connector-session")] mod server; -// Concrete AimX-v2 substrate (UDS transport + NDJSON codec), std-only. Phase 3 -// client-first: the dialing half + symmetric codec that `run_client` drives. -#[cfg(feature = "std")] +// Concrete AimX-v2 protocol substrate (NDJSON codec + server dispatch). The +// codec is `no_std + alloc` (the embedded *client* rides it over a real +// transport); the dispatch stays `std`-gated inside the module until its own +// no_std port. The transport itself is no longer here — it is a swappable +// connector crate (`aimdb-uds-connector`); core keeps the protocol + the +// generic [`SessionClientConnector`] / [`SessionServerConnector`] spine. +#[cfg(all(feature = "connector-session", feature = "json-serialize"))] pub mod aimx; #[cfg(feature = "connector-session")] pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; #[cfg(feature = "connector-session")] +pub use connector::{SessionClientConnector, SessionServerConnector}; +#[cfg(feature = "connector-session")] pub use server::{run_session, serve, SessionConfig}; // =========================================================================== diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 6d45f65..dcb2bce 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -365,7 +365,10 @@ pub async fn serve(mut listener: L, codec: Arc, dispatch: Arc, co where L: Listener, C: EnvelopeCodec + 'static, - D: Dispatch + 'static, + // `?Sized` so a caller can serve an `Arc` (the generic + // `SessionServerConnector` does, to stay protocol-agnostic). `run_session` + // already accepts `?Sized`; `serve` only uses `dispatch` via `clone`/`as_ref`. + D: Dispatch + 'static + ?Sized, { let mut conns: FuturesUnordered> = FuturesUnordered::new(); diff --git a/aimdb-tokio-adapter/Cargo.toml b/aimdb-tokio-adapter/Cargo.toml index 7827782..4d2f1a8 100644 --- a/aimdb-tokio-adapter/Cargo.toml +++ b/aimdb-tokio-adapter/Cargo.toml @@ -57,5 +57,7 @@ futures = { workspace = true } # For drain integration tests aimdb-client = { version = "0.6.0", path = "../aimdb-client" } +# Stands up the AimX UDS server (`UdsServer`) the drain tests connect to. +aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/aimdb-tokio-adapter/tests/drain_integration_tests.rs b/aimdb-tokio-adapter/tests/drain_integration_tests.rs index a9132ed..bcc6d92 100644 --- a/aimdb-tokio-adapter/tests/drain_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/drain_integration_tests.rs @@ -14,6 +14,7 @@ use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -52,7 +53,7 @@ async fn setup_test_server(socket_path: &str) -> aimdb_core::AimDb let mut builder = AimDbBuilder::new() .runtime(adapter) - .with_remote_access(remote_config); + .with_connector(UdsServer::from_config(remote_config)); // SpmcRing for drain testing (capacity 20) builder.configure::("test::Temperature", |reg| { @@ -83,7 +84,7 @@ async fn setup_small_ring_server(socket_path: &str) -> aimdb_core::AimDb("test::Counter", |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 4 }) @@ -108,7 +109,7 @@ async fn setup_no_remote_access_server(socket_path: &str) -> aimdb_core::AimDb("test::Counter", |reg| { diff --git a/aimdb-uds-connector/Cargo.toml b/aimdb-uds-connector/Cargo.toml new file mode 100644 index 0000000..3c85c7e --- /dev/null +++ b/aimdb-uds-connector/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "aimdb-uds-connector" +version = "0.1.0" +edition = "2021" +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Unix-domain-socket transport connector for AimDB remote access (AimX over UDS)" +keywords = ["aimdb", "connector", "uds", "unix-socket", "remote"] +categories = ["network-programming", "database"] + +[features] +default = [] +tracing = ["dep:tracing", "aimdb-core/tracing"] + +[dependencies] +# AimX protocol (codec + dispatch) and the generic session connectors live in +# core; this crate contributes only the UDS transport triple + sugar. UDS is +# Linux/std-only, so there is no Embassy half. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ + "std", + "connector-session", +] } +aimdb-executor = { version = "0.2.0", path = "../aimdb-executor", default-features = false } + +tokio = { version = "1", features = ["net", "io-util"] } + +tracing = { version = "0.1", optional = true } + +[dev-dependencies] +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs new file mode 100644 index 0000000..8747962 --- /dev/null +++ b/aimdb-uds-connector/src/lib.rs @@ -0,0 +1,308 @@ +//! Unix-domain-socket transport connector for AimDB remote access. +//! +//! A transport is a thin, swappable connector crate (doc 041): it contributes +//! only the [`Dialer`]/[`Listener`]/[`Connection`] triple ([`UdsConnection`] / +//! [`UdsDialer`] / [`UdsListener`]); the AimX codec + dispatch and the engine +//! wiring are reused verbatim from `aimdb-core`. Two ergonomic constructors wrap +//! the generic core connectors: +//! +//! - [`UdsClient`] — dials a peer over UDS and mirrors records under a scheme +//! (`"remote"` by default). It uses `link_to`/`link_from` like any data-plane +//! connector. Sugar over [`SessionClientConnector`]``. +//! - [`UdsServer`] — *accepts* connections and serves the AimX toolset over UDS. +//! Register it with `with_connector` to stand up remote access (this replaces +//! the old `AimDbBuilder::with_remote_access(config)`). Sugar over +//! [`SessionServerConnector`]. +//! +//! ```rust,ignore +//! use aimdb_uds_connector::{UdsClient, UdsServer}; +//! +//! // server: expose this db over a socket (no links) +//! AimDbBuilder::new().runtime(rt) +//! .with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) +//! .build().await?; +//! +//! // client: mirror a record to a peer over the socket +//! AimDbBuilder::new().runtime(rt) +//! .with_connector(UdsClient::new("/run/aimdb.sock")) +//! .configure::("temp", |r| { r.with_remote_access().link_to("remote://temp")...; }) +//! .build().await?; +//! ``` + +mod transport; + +pub use transport::{UdsConnection, UdsDialer, UdsListener}; + +use std::future::Future; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::remote::AimxConfig; +use aimdb_core::session::aimx::{AimxCodec, AimxDispatch}; +use aimdb_core::session::{ + serve, Dispatch, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, +}; +use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; + +type BoxFuture = Pin + Send + 'static>>; +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +/// The default scheme `UdsClient`/`UdsServer` register when none is given. +pub const DEFAULT_SCHEME: &str = "remote"; + +// =========================================================================== +// Client sugar +// =========================================================================== + +/// Constructs a [`SessionClientConnector`] that dials an AimX peer over a +/// Unix-domain socket. `UdsClient::new(path)` is sugar; chain `.scheme(...)` / +/// `.with_config(...)` on the returned connector. +pub struct UdsClient; + +impl UdsClient { + /// Mirror records to/from the AimX peer listening at `socket_path` (scheme + /// defaults to [`DEFAULT_SCHEME`]). + // Sugar constructor: intentionally returns the generic connector, not `Self`. + #[allow(clippy::new_ret_no_self)] + pub fn new(socket_path: impl Into) -> SessionClientConnector { + SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec) + } +} + +// =========================================================================== +// Server sugar +// =========================================================================== + +/// Accepts AimX connections over a Unix-domain socket and serves the full AimX +/// toolset. Register it via `with_connector` to stand up remote access: +/// +/// ```rust,ignore +/// builder.with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) +/// ``` +/// +/// Unlike a data-plane connector, a server takes **no** `link_to`/`link_from` — +/// it answers introspection/subscribe/write for whatever records exist. +pub struct UdsServer { + config: AimxConfig, + scheme: String, +} + +impl UdsServer { + /// Serve AimX over the socket at `socket_path`, with default limits/policy. + pub fn new(socket_path: impl Into) -> Self { + Self { + config: AimxConfig::uds_default().socket_path(socket_path), + scheme: DEFAULT_SCHEME.to_string(), + } + } + + /// Build from a full [`AimxConfig`] — the one-line migration for code that + /// used the old `AimDbBuilder::with_remote_access(config)`. + pub fn from_config(config: AimxConfig) -> Self { + Self { + config, + scheme: DEFAULT_SCHEME.to_string(), + } + } + + /// Maximum concurrently served connections. + pub fn max_connections(mut self, max: usize) -> Self { + self.config = self.config.max_connections(max); + self + } + + /// Maximum live subscriptions per connection. + pub fn max_subs_per_connection(mut self, max: usize) -> Self { + self.config = self.config.max_subs_per_connection(max); + self + } + + /// Socket file permissions (octal mode, e.g. `0o600`). + pub fn socket_permissions(mut self, mode: u32) -> Self { + self.config = self.config.socket_permissions(mode); + self + } + + /// Override the scheme this connector registers. + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } +} + +impl ConnectorBuilder for UdsServer +where + R: RuntimeAdapter + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + let config = self.config.clone(); + let scheme = self.scheme.clone(); + Box::pin(async move { + let session_config = SessionConfig { + limits: SessionLimits { + max_connections: config.max_connections, + max_subs_per_connection: config.max_subs_per_connection, + }, + reads_hello: false, + // AimX's subscribe ack stays implicit (events flow); no ack frame. + acks_subscribe: false, + }; + let bind_config = config.clone(); + let dispatch_config = config; + // Reuse the generic spine: bind (errors surface synchronously) + AimX + // dispatch over the AimX codec. + let connector = SessionServerConnector::new( + move || bind_uds_listener(&bind_config), + AimxCodec, + move |db: &AimDb| -> Arc { + // Apply the security policy's writable marking so `record.list` + // reports the `writable` flag (the dispatch also enforces it). + apply_writable(db, &dispatch_config); + Arc::new(AimxDispatch::new( + Arc::new(db.clone()), + dispatch_config.clone(), + )) + }, + session_config, + ) + .scheme(scheme); + connector.build(db).await + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} + +// =========================================================================== +// Shared bind / writable helpers +// =========================================================================== + +/// Bind the Unix-domain socket synchronously (remove a stale socket file, +/// `bind`, `set_permissions`) so bind errors surface from `build`. Relocated out +/// of core's `build_aimx_server`. +fn bind_uds_listener(config: &AimxConfig) -> DbResult { + #[cfg(feature = "tracing")] + tracing::info!( + "Initializing AimX UDS server on socket: {}", + config.socket_path.display() + ); + + if config.socket_path.exists() { + std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to remove existing socket file {}", + config.socket_path.display() + ), + source: e, + })?; + } + + let listener = tokio::net::UnixListener::bind(&config.socket_path).map_err(|e| { + DbError::IoWithContext { + context: format!( + "Failed to bind Unix socket at {}", + config.socket_path.display() + ), + source: e, + } + })?; + + let permissions = config.socket_permissions.unwrap_or(0o600); + let mut perms = std::fs::metadata(&config.socket_path) + .map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to read socket metadata for {}", + config.socket_path.display() + ), + source: e, + })? + .permissions(); + perms.set_mode(permissions); + std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to set socket permissions for {}", + config.socket_path.display() + ), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!( + "AimX socket bound at {} (mode {:o})", + config.socket_path.display(), + permissions + ); + + Ok(UdsListener::new(listener)) +} + +/// Mark each record named in the policy's writable set as writable, so +/// `record.list` advertises the `writable` flag. +fn apply_writable(db: &AimDb, config: &AimxConfig) +where + R: RuntimeAdapter + 'static, +{ + for key in config.security_policy.writable_records() { + if let Some(id) = db.inner().resolve_str(&key) { + if let Some(storage) = db.inner().storage(id) { + storage.set_writable_erased(true); + } + } + } +} + +// =========================================================================== +// Deprecated back-compat aliases (the types relocated here from core). +// =========================================================================== + +/// Deprecated alias for [`UdsClient`] that defaults the scheme to `"aimx"` +/// (preserving the pre-Phase-6 behavior of `AimxClientConnector`). +#[deprecated( + since = "0.1.0", + note = "use `UdsClient::new(path)` (scheme defaults to \"remote\"); pass `.scheme(\"aimx\")` for the old scheme" +)] +pub struct AimxClientConnector; + +#[allow(deprecated)] +impl AimxClientConnector { + /// Mirror records over the AimX peer at `socket_path`, under scheme `"aimx"`. + #[allow(clippy::new_ret_no_self)] + pub fn new(socket_path: impl Into) -> SessionClientConnector { + SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec).scheme("aimx") + } +} + +/// Deprecated free-standing AimX server builder. Prefer registering +/// [`UdsServer::from_config`] via `with_connector`; this returns the single +/// `serve` future directly for callers that spawn it by hand. +#[deprecated( + since = "0.1.0", + note = "register `UdsServer::from_config(config)` via `with_connector` instead" +)] +pub fn build_aimx_server(db: Arc>, config: AimxConfig) -> DbResult +where + R: RuntimeAdapter + 'static, +{ + let listener = bind_uds_listener(&config)?; + apply_writable(&db, &config); + let session_config = SessionConfig { + limits: SessionLimits { + max_connections: config.max_connections, + max_subs_per_connection: config.max_subs_per_connection, + }, + reads_hello: false, + acks_subscribe: false, + }; + let dispatch = Arc::new(AimxDispatch::new(db, config)); + Ok(Box::pin(serve( + listener, + Arc::new(AimxCodec), + dispatch, + session_config, + ))) +} diff --git a/aimdb-core/src/session/aimx/transport.rs b/aimdb-uds-connector/src/transport.rs similarity index 84% rename from aimdb-core/src/session/aimx/transport.rs rename to aimdb-uds-connector/src/transport.rs index 710c3d4..8f7b266 100644 --- a/aimdb-core/src/session/aimx/transport.rs +++ b/aimdb-uds-connector/src/transport.rs @@ -1,10 +1,15 @@ -//! AimX UDS transport (Phase 3, std-only) — a [`Connection`] over a Unix-domain -//! socket with NDJSON framing in the transport: one line == one logical frame. +//! AimX UDS transport — a [`Connection`] over a Unix-domain socket with NDJSON +//! framing in the transport: one line == one logical frame. +//! +//! Relocated out of `aimdb-core` in Phase 6: a transport is a swappable +//! connector crate that contributes only the [`Dialer`]/[`Listener`]/ +//! [`Connection`] triple (doc 037 Layer 1). The engine, codec, and dispatch are +//! reused verbatim from core. //! //! Both transport roles ride the same role-neutral [`UdsConnection`]: the //! dialing half ([`UdsDialer`]) that the proactive `run_client` engine drives, //! and the accepting half ([`UdsListener`]) that the reactive `serve` engine -//! drives (added with the server port). +//! drives. use std::path::PathBuf; @@ -12,7 +17,7 @@ use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; use tokio::net::{UnixListener, UnixStream}; -use crate::session::{ +use aimdb_core::session::{ BoxFut, Connection, Dialer, Listener, PeerInfo, TransportError, TransportResult, }; @@ -26,8 +31,8 @@ pub struct UdsConnection { } impl UdsConnection { - /// Wrap an already-connected [`UnixStream`] (used by both the dialer here and - /// the future server-side listener). + /// Wrap an already-connected [`UnixStream`] (used by both the dialer and the + /// server-side listener). pub fn new(stream: UnixStream) -> Self { let (read_half, write_half) = stream.into_split(); Self { @@ -80,8 +85,9 @@ impl Connection for UdsConnection { } /// The initiating (client) side: dials a Unix-domain socket and yields a -/// [`UdsConnection`]. Cheap to clone the path, so `run_client` can redial on -/// reconnect. +/// [`UdsConnection`]. Cheap to clone (just the path), so `run_client` can redial +/// on reconnect and the generic `SessionClientConnector` can hold it. +#[derive(Clone)] pub struct UdsDialer { socket_path: PathBuf, } @@ -109,8 +115,8 @@ impl Dialer for UdsDialer { /// The accepting (server) side: wraps an already-bound [`UnixListener`] and /// yields a [`UdsConnection`] per accepted client. The dual of [`UdsDialer`]; /// `serve` drives it. Socket setup (remove-stale / `bind` / `set_permissions`) -/// happens once in [`build_aimx_server`](super::build_aimx_server) before the -/// listener is handed here, mirroring the legacy supervisor's synchronous bind. +/// happens once in [`UdsServer`](crate::UdsServer)'s bind step before the +/// listener is handed here. pub struct UdsListener { inner: UnixListener, } diff --git a/examples/remote-access-demo/Cargo.toml b/examples/remote-access-demo/Cargo.toml index 53fb889..ccc5e97 100644 --- a/examples/remote-access-demo/Cargo.toml +++ b/examples/remote-access-demo/Cargo.toml @@ -26,6 +26,7 @@ aimdb-tokio-adapter = { path = "../../aimdb-tokio-adapter", features = [ "metrics", ] } aimdb-client = { path = "../../aimdb-client" } +aimdb-uds-connector = { path = "../../aimdb-uds-connector" } tokio = { version = "1.48", features = ["full"] } futures = "0.3" tracing = "0.1" diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs index 3c45646..174e078 100644 --- a/examples/remote-access-demo/src/client.rs +++ b/examples/remote-access-demo/src/client.rs @@ -91,7 +91,10 @@ async fn main() -> Result<(), Box> { println!("📤 Verifying update..."); match conn.get_record("server::AppSettings").await { - Ok(v) => println!("✔️ AppSettings after update:\n{}\n", serde_json::to_string_pretty(&v)?), + Ok(v) => println!( + "✔️ AppSettings after update:\n{}\n", + serde_json::to_string_pretty(&v)? + ), Err(e) => println!("❌ Error: {e}"), } diff --git a/examples/remote-access-demo/src/server.rs b/examples/remote-access-demo/src/server.rs index d5e3d33..3995663 100644 --- a/examples/remote-access-demo/src/server.rs +++ b/examples/remote-access-demo/src/server.rs @@ -15,6 +15,7 @@ use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::{buffer::BufferCfg, AimDbBuilder, Consumer, Producer, RuntimeContext}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::info; @@ -90,10 +91,12 @@ async fn main() -> Result<(), Box> { info!("🔒 Security policy: ReadWrite"); info!("✍️ Writable records: AppSettings"); - // Build database with remote access enabled + // Build database with remote access enabled. Remote access is now just a + // connector: register the UDS *server* via `with_connector` (the client + // side, by contrast, uses `UdsClient` + `link_to`/`link_from`). let mut builder = AimDbBuilder::new() .runtime(adapter) - .with_remote_access(remote_config); + .with_connector(UdsServer::from_config(remote_config)); // Configure records // Use SpmcRing for Temperature and SystemStatus to support record.drain history. diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index 962c704..2ebb213 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -336,9 +336,9 @@ async fn main(spawner: Spawner) { let sign = if neg { "-" } else { "" }; info!("📊 DewPoint: {}{}.{}°C", sign, whole, frac); producer.produce(DewPoint { - celsius: dew_point, - timestamp, - }); + celsius: dew_point, + timestamp, + }); } } }) From a050be2ca3ebbd5066d91f8a58ed7f7842019d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 17:56:32 +0000 Subject: [PATCH 17/34] feat: Enhance MQTT connector with query-based configuration and refactor - Added `from_query` method to `ConnectorConfig` for building configurations from URL query parameters, allowing for dynamic setting of `timeout_ms` and passing other options verbatim. - Updated `Cargo.toml` to include `aimdb-core/connector-session` in the `std` feature for improved functionality. - Refactored `MqttConnectorImpl` to utilize `pump_sink` and `pump_source` for handling inbound and outbound data, streamlining the connection and subscription process. - Introduced `MqttSink` and `MqttEventLoopSource` to encapsulate publishing and event loop handling, respectively, improving code organization and clarity. - Removed deprecated methods and unnecessary complexity in the MQTT connector implementation. - Deleted outdated design document on frozen connector-session contracts and added a new design document outlining the architecture for remote access via connectors. --- aimdb-core/src/lib.rs | 8 +- aimdb-core/src/session/aimx/codec.rs | 6 +- aimdb-core/src/session/mod.rs | 46 +-- aimdb-core/src/session/pump.rs | 178 +++++++++ aimdb-core/src/transport.rs | 33 ++ aimdb-mqtt-connector/Cargo.toml | 4 +- aimdb-mqtt-connector/src/tokio_client.rs | 384 ++++++------------- docs/design/detailed/037-phase0-contracts.md | 112 ------ docs/design/remote-access-via-connectors.md | 73 ++++ 9 files changed, 417 insertions(+), 427 deletions(-) create mode 100644 aimdb-core/src/session/pump.rs delete mode 100644 docs/design/detailed/037-phase0-contracts.md create mode 100644 docs/design/remote-access-via-connectors.md diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index e813f5c..d503526 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -90,12 +90,12 @@ pub use codec::{JsonCodec, RemoteSerialize, SerdeJsonCodec}; // Phase 0 connector-session contracts (feature `connector-session`, no_std + // alloc compatible). Frozen trait skeletons only — see -// docs/design/detailed/037-phase0-contracts.md. +// docs/design/remote-access-via-connectors.md. #[cfg(feature = "connector-session")] pub use session::{ - AuthError, BoxFut, BoxStream, CodecError, Connection, Dialer, Dispatch, EnvelopeCodec, Inbound, - Listener, Outbound, Payload, PeerInfo, RpcError, SessionCtx, SessionLimits, Sink, Source, - TransportError, TransportResult, + pump_sink, pump_source, AuthError, BoxFut, BoxStream, CodecError, Connection, Dialer, Dispatch, + EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, SessionCtx, + SessionLimits, Source, TransportError, TransportResult, }; // Stage profiling exports (feature-gated) diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs index d750f5c..ab0ca48 100644 --- a/aimdb-core/src/session/aimx/codec.rs +++ b/aimdb-core/src/session/aimx/codec.rs @@ -6,7 +6,7 @@ //! message set. This is **not** backward-compatible with the legacy AimX wire — //! the no-compat decision lets the wire follow the engine's clean model instead //! of the engine bending to preserve the old framing (see -//! `docs/design/detailed/038-phase3-aimx-client.md`): +//! `docs/design/remote-access-via-connectors.md`, Phase 3): //! //! - `record.subscribe` is an engine-native [`Inbound::Subscribe`] keyed by the //! request `id`; there is **no** `{"subscription_id":"sub-N"}` ack and events @@ -16,8 +16,8 @@ //! - the Hello/Welcome handshake is a normal `call("hello", …)` over the client //! handle, so `authenticate` stays peer-only — no privileged handshake frame. //! -//! Per [037](../../../../docs/design/detailed/037-phase0-contracts.md) -//! Decision 1 the record-value `Payload` is spliced into / sliced out of the +//! Per [the design](../../../../docs/design/remote-access-via-connectors.md) +//! decision 1 the record-value `Payload` is spliced into / sliced out of the //! textual envelope verbatim via [`serde_json::value::RawValue`] — no //! intermediate `Value` tree, no re-escaping, one serde pass. diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index b42307f..9a00747 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -6,10 +6,10 @@ //! is `unimplemented!()`. The engines (`run_session` / `serve` / `run_client`), //! the pump helpers, and the transport/dispatch impls all arrive in Phases 1–6. //! -//! See [`docs/design/detailed/037-phase0-contracts.md`] for the decision record, -//! and the canonical signature sketches this module copies verbatim: -//! - transport + [`EnvelopeCodec`] + [`Dispatch`]: doc 034 (§ The three layers) -//! - [`Sink`] / [`Source`] / [`Dialer`]: doc 035 (§ The toolkit) +//! See [`docs/design/remote-access-via-connectors.md`] for the design — the +//! decisions, the three-layer substrate (transport + [`EnvelopeCodec`] + +//! [`Dispatch`]), and the capability model. `Sink` is now the canonical +//! [`Connector`](crate::transport::Connector); [`Source`] / [`Dialer`] are here. //! //! Everything here is `dyn`-safe and compiles on `std` **and** `no_std + alloc` //! (boxed-future pattern throughout, no `std`/`tokio`/`serde_json` at the @@ -23,8 +23,6 @@ use core::pin::Pin; use futures_core::Stream; -use crate::transport::{ConnectorConfig, PublishError}; - // --------------------------------------------------------------------------- // Phase 2 engines. **Phase 5 made these runtime-neutral** (`futures` channels + // `select_biased!` + the adapter's `TimeOps` clock — no `tokio`/`embassy-*`), so @@ -32,7 +30,7 @@ use crate::transport::{ConnectorConfig, PublishError}; // cross-compile to `thumbv7em-none-eabihf`. The frozen contracts above stay // `no_std + alloc` as before. Only the concrete AimX substrate below (UDS + // NDJSON) is still std-only until its embedded transport lands in Phase 6. -// See docs/design/detailed/036/037/040. +// See docs/design/remote-access-via-connectors.md (Phases 0/2/5). // --------------------------------------------------------------------------- #[cfg(feature = "connector-session")] @@ -40,6 +38,8 @@ mod client; #[cfg(feature = "connector-session")] mod connector; #[cfg(feature = "connector-session")] +mod pump; +#[cfg(feature = "connector-session")] mod server; // Concrete AimX-v2 protocol substrate (NDJSON codec + server dispatch). The @@ -56,6 +56,8 @@ pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; #[cfg(feature = "connector-session")] pub use connector::{SessionClientConnector, SessionServerConnector}; #[cfg(feature = "connector-session")] +pub use pump::{pump_sink, pump_source}; +#[cfg(feature = "connector-session")] pub use server::{run_session, serve, SessionConfig}; // =========================================================================== @@ -456,19 +458,9 @@ pub trait EnvelopeCodec: Send + Sync { // library owns any session. // =========================================================================== -/// AimDB → external data-plane (Decision 3: `publish` stays a **sibling** -/// capability). This is today's [`Connector`](crate::transport::Connector) -/// contract verbatim — no rename or migration here; reconciling the two names -/// is Phase 1. -pub trait Sink: Send + Sync { - /// Publish a serialized record value to a protocol-specific destination. - fn publish( - &self, - dest: &str, - cfg: &ConnectorConfig, - bytes: &[u8], - ) -> BoxFut<'_, Result<(), PublishError>>; -} +// AimDB → external data-plane (the `Sink` capability) is the canonical +// [`Connector`](crate::transport::Connector) trait verbatim — Phase 1 collapsed +// the Phase-0 `Sink` skeleton onto it. `pump_sink` takes `Arc`. /// External → AimDB data-plane — a stream of inbound frames (the one genuinely /// new data-plane trait; replaces the hand-rolled read loop). @@ -492,7 +484,6 @@ fn _assert_object_safe( _dispatch: &dyn Dispatch, _session: &dyn Session, _codec: &dyn EnvelopeCodec, - _sink: &dyn Sink, _source: &dyn Source, ) { } @@ -582,18 +573,6 @@ mod tests { } } - struct MockSink; - impl Sink for MockSink { - fn publish( - &self, - _dest: &str, - _cfg: &ConnectorConfig, - _bytes: &[u8], - ) -> BoxFut<'_, Result<(), PublishError>> { - unimplemented!() - } - } - struct MockSource; impl Source for MockSource { fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { @@ -610,7 +589,6 @@ mod tests { let _dispatch: Box = Box::new(MockDispatch); let _session: Box = Box::new(MockSession); let _codec: Box = Box::new(MockCodec); - let _sink: Box = Box::new(MockSink); let _source: Box = Box::new(MockSource); } } diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs new file mode 100644 index 0000000..7af69d2 --- /dev/null +++ b/aimdb-core/src/session/pump.rs @@ -0,0 +1,178 @@ +//! Data-plane pump helpers (doc 035 § The toolkit, masterplan 036 Phase 1). +//! +//! Two free functions that own the boilerplate every data-plane connector used +//! to hand-roll, layered on top of the [`ConnectorBuilder::build -> Vec`] +//! spine. A connector author writes only the pure I/O adapter — a +//! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`] +//! (inbound) — and composes the helpers in `build()`: +//! +//! ```rust,ignore +//! let mut f = pump_sink(db, "redis", self.sink().await?); // outbound +//! f.extend(pump_source(db, "redis", self.subscription().await?)); // inbound +//! Ok(f) +//! ``` +//! +//! Both helpers are `no_std + alloc`-native (boxed futures, no `tokio`) and gate +//! on `connector-session` alongside the session engines, so they cross-compile to +//! `thumbv7em-none-eabihf`. The spine stays the universal contract and escape +//! hatch — a connector that fits no helper still implements `build()` directly. + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; + +use super::Source; +use crate::builder::{AimDb, BoxFuture}; +use crate::connector::SerializerKind; +use crate::router::RouterBuilder; +use crate::transport::{Connector, ConnectorConfig}; + +/// Outbound pump: one publisher future per outbound route on `scheme`. +/// +/// Extracts the consume-and-publish loop a data-plane connector used to write by +/// hand. For each route from [`collect_outbound_routes`](AimDb::collect_outbound_routes), +/// the returned future subscribes to the record (type-erased), serializes each +/// value with the route's [`SerializerKind`], resolves the destination via the +/// route's optional topic provider (falling back to the URL-derived default), and +/// publishes through `sink`. Per-route configuration (`qos`/`retain`/…) is built +/// once from the route's URL query via [`ConnectorConfig::from_query`]. +/// +/// The publisher future terminates when its subscription yields an error (e.g. the +/// record buffer closed), matching the legacy hand-rolled loop. +pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec +where + R: aimdb_executor::RuntimeAdapter + 'static, +{ + let routes = db.collect_outbound_routes(scheme); + let mut futures: Vec = Vec::with_capacity(routes.len()); + + for (default_topic, consumer, serializer, config, topic_provider) in routes { + let sink = sink.clone(); + let runtime_ctx = db.runtime_any(); + let cfg = ConnectorConfig::from_query(&config); + + futures.push(Box::pin(async move { + // Subscribe to typed values (type-erased). + let mut reader = match consumer.subscribe_any().await { + Ok(r) => r, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to subscribe for destination '{}': {:?}", + default_topic, + _e + ); + return; + } + }; + + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher started for destination: {}", + default_topic + ); + + while let Ok(value_any) = reader.recv_any().await { + // Resolve destination: dynamic (from provider) or default (from URL). + let dest = topic_provider + .as_ref() + .and_then(|provider| provider.topic_any(&*value_any)) + .unwrap_or_else(|| default_topic.clone()); + + // Serialize the type-erased value. + let bytes = match &serializer { + SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to serialize for destination '{}': {:?}", + dest, + _e + ); + continue; + } + }, + SerializerKind::Context(ser) => match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to serialize for destination '{}': {:?}", + dest, + _e + ); + continue; + } + }, + }; + + // Publish through the connector's pure I/O adapter. + if let Err(_e) = sink.publish(&dest, &cfg, &bytes).await { + #[cfg(feature = "tracing")] + tracing::error!("pump_sink: failed to publish to '{}': {:?}", dest, _e); + } else { + #[cfg(feature = "tracing")] + tracing::debug!("pump_sink: published to: {}", dest); + } + } + + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher stopped for destination: {}", + default_topic + ); + })); + } + + futures +} + +/// Inbound pump: a single multiplexed reader future for `scheme`. +/// +/// Drives one [`Source`] — never one task per topic (doc 035 Decision 2) — fanning +/// each `(topic, payload)` out to the matching producers via a [`Router`] built +/// from [`collect_inbound_routes`](AimDb::collect_inbound_routes). Replaces the +/// hand-rolled read+route loop. +/// +/// Backpressure (doc 035 Decision 3): [`Router::route`] drops + logs on a full +/// producer buffer rather than blocking, so one slow record never stalls the +/// shared source. Route errors are non-fatal and never propagate. +/// +/// [`Router`]: crate::router::Router +/// [`Router::route`]: crate::router::Router::route +pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> Vec +where + R: aimdb_executor::RuntimeAdapter + 'static, +{ + let routes = db.collect_inbound_routes(scheme); + let router = Arc::new(RouterBuilder::from_routes(routes).build()); + let ctx = db.runtime_any(); + + vec![Box::pin(async move { + #[cfg(feature = "tracing")] + tracing::info!( + "pump_source: reader started ({} topics)", + router.resource_ids().len() + ); + + while let Some((topic, payload)) = src.next().await { + // `route` deserializes and fans out to producers; it drops + logs on a + // full producer buffer and never returns a fatal error. + if let Err(_e) = router.route(&topic, &payload, Some(&ctx)).await { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_source: failed to route message on '{}': {}", + topic, + _e + ); + } + } + + #[cfg(feature = "tracing")] + tracing::info!("pump_source: reader stopped"); + })] +} diff --git a/aimdb-core/src/transport.rs b/aimdb-core/src/transport.rs index 87bec87..35ea369 100644 --- a/aimdb-core/src/transport.rs +++ b/aimdb-core/src/transport.rs @@ -55,6 +55,39 @@ impl Default for ConnectorConfig { } } +impl ConnectorConfig { + /// Build a config from a route's URL-query key/value pairs. + /// + /// This is the shared seam the data-plane `pump_sink` helper uses to thread + /// per-route configuration through to [`Connector::publish`] without changing + /// the `publish` signature. + /// + /// Only the protocol-agnostic `timeout_ms` is lifted into a typed field. The + /// `qos`/`retain` *meaning* differs per protocol (an MQTT QoS level vs. a + /// Kafka `acks` setting vs. an HTTP retry count — see the type docs), and a + /// `u8`/`bool` field cannot represent "unspecified", so these — and every + /// other key — are passed through verbatim in [`protocol_options`] for the + /// connector to interpret with its own defaults. The typed `qos`/`retain` + /// fields therefore keep their [`Default`] values here; they remain available + /// for callers that construct a [`ConnectorConfig`] directly. + /// + /// [`protocol_options`]: ConnectorConfig::protocol_options + pub fn from_query(query: &[(String, String)]) -> ConnectorConfig { + let mut cfg = ConnectorConfig::default(); + for (k, v) in query { + match k.as_str() { + "timeout_ms" => { + if let Ok(n) = v.parse::() { + cfg.timeout_ms = Some(n); + } + } + _ => cfg.protocol_options.push((k.clone(), v.clone())), + } + } + cfg + } +} + /// Error that can occur during connector publishing /// /// Uses an enum instead of String for better performance in `no_std` environments diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index a37b704..fa2e8e3 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -12,7 +12,9 @@ categories = ["network-programming", "embedded", "asynchronous"] [features] default = ["aimdb-core/alloc"] -std = ["aimdb-core/std", "aimdb-core/alloc", "thiserror"] +# `aimdb-core/connector-session` provides the data-plane `pump_sink`/`pump_source` +# helpers the tokio client builds on (re-exported there; `std` implies it too). +std = ["aimdb-core/std", "aimdb-core/alloc", "aimdb-core/connector-session", "thiserror"] tokio-runtime = [ "std", "tokio", diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 42863f2..9f5af65 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -8,8 +8,9 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; -use aimdb_core::ConnectorBuilder; -use rumqttc::{AsyncClient, EventLoop, MqttOptions, Packet}; +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; +use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; +use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -97,60 +98,49 @@ impl ConnectorBuilder for MqttCo db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // Collect inbound routes from database + // Build a router from the inbound routes purely to drive the MQTT + // subscriptions + channel-capacity sizing in `build_internal`. The + // routing `Router` that fans incoming frames out to producers is + // (re)built by `pump_source` from the same `collect_inbound_routes`. let inbound_routes = db.collect_inbound_routes("mqtt"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} inbound routes for MQTT connector", - inbound_routes.len() - ); - - // Convert routes to Router let router = RouterBuilder::from_routes(inbound_routes).build(); #[cfg(feature = "tracing")] - tracing::info!("MQTT router has {} topics", router.resource_ids().len()); - - // Build the client + event-loop future - let runtime_ctx = db.runtime_any(); - let (client, event_loop_future) = MqttConnectorImpl::build_internal( - &self.broker_url, - self.client_id.clone(), - router, - Some(runtime_ctx), - ) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build MQTT connector: {}", e).into(), - } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; + tracing::info!("MQTT subscribing to {} topics", router.resource_ids().len()); + + // Connect, subscribe, and hand back the raw event loop. + let (client, event_loop) = + MqttConnectorImpl::build_internal(&self.broker_url, self.client_id.clone(), router) + .await + .map_err(|_e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build MQTT connector: {}", _e), + } + } + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; - let outbound_routes = db.collect_outbound_routes("mqtt"); + let mut futures: Vec = Vec::new(); - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} outbound routes for MQTT connector", - outbound_routes.len() - ); - - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(1 + outbound_routes.len()); - futures.push(event_loop_future); - futures.extend(MqttConnectorImpl::collect_outbound_futures( - client, - runtime_ctx, - outbound_routes, + // Inbound: one multiplexed reader future fanning publishes out to producers. + futures.extend(pump_source( + db, + "mqtt", + MqttEventLoopSource { + event_loop, + #[cfg(feature = "tracing")] + broker_key: self.broker_url.clone(), + }, )); + // Outbound: one publisher future per outbound route. + futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); + Ok(futures) }) } @@ -160,31 +150,31 @@ impl ConnectorBuilder for MqttCo } } -/// Internal MQTT connector implementation +/// Internal MQTT connector build helpers. /// -/// This is the actual connector created after collecting routes from the database. -pub struct MqttConnectorImpl { - client: Arc, - router: Arc, -} +/// A namespace for the broker-connection setup invoked from +/// [`MqttConnectorBuilder::build`]; the data-plane loops themselves live in the +/// reusable `pump_sink` / `pump_source` helpers + the [`MqttSink`] / +/// [`MqttEventLoopSource`] adapters below. +pub struct MqttConnectorImpl; impl MqttConnectorImpl { - /// Create a new MQTT connector with pre-configured router (internal) + /// Connect to the broker and subscribe to all configured topics (internal). /// - /// Creates a connection to the MQTT broker and subscribes to all topics - /// defined in the router. The event loop is spawned automatically. + /// Creates the MQTT client, sizes the send-channel from the route count, and + /// subscribes to every topic in `router`. Returns the shared client (for the + /// outbound `pump_sink`) plus the raw event loop (handed to a + /// [`MqttEventLoopSource`] for the inbound `pump_source`). /// /// # Arguments /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) /// * `client_id` - Optional client ID (if None, generates UUID-based ID) - /// * `router` - Pre-configured router with all routes - /// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers + /// * `router` - Routes used only for the subscription list + capacity sizing async fn build_internal( broker_url: &str, client_id: Option, router: Router, - runtime_ctx: Option>, - ) -> Result<(Arc, BoxFuture), String> { + ) -> Result<(Arc, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic let mut url = broker_url.to_string(); @@ -205,10 +195,8 @@ impl MqttConnectorImpl { } }); - let broker_key = format!("{}:{}", host, port); - #[cfg(feature = "tracing")] - tracing::info!("Creating MQTT client for {}", broker_key); + tracing::info!("Creating MQTT client for {}:{}", host, port); // Use provided client_id or generate a UUID-based one let client_id = client_id.unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); @@ -251,12 +239,6 @@ impl MqttConnectorImpl { let (client, event_loop) = AsyncClient::new(mqtt_opts, channel_capacity); let client_arc = Arc::new(client); - // Build the event-loop future (returned to the caller for the runner to - // drive). Per design 028 §"Connector futures", the event loop runs - // concurrently with outbound publishers under one `FuturesUnordered`. - let event_loop_future = - build_event_loop_future(event_loop, broker_key, router_arc.clone(), runtime_ctx); - let topics = router_arc.resource_ids(); #[cfg(feature = "tracing")] @@ -275,173 +257,48 @@ impl MqttConnectorImpl { #[cfg(feature = "tracing")] tracing::info!("MQTT subscriptions complete"); - Ok((client_arc, event_loop_future)) + Ok((client_arc, event_loop)) } +} - /// Get list of all MQTT topics this connector is subscribed to - /// - /// Returns the unique topics from the router configuration. - /// Useful for debugging and monitoring. - pub fn topics(&self) -> Vec> { - self.router.resource_ids() - } - - /// Get the number of routes configured in this connector - /// - /// Each route represents a (topic, type) mapping. - /// Multiple routes can exist for the same topic if different types subscribe to it. - pub fn route_count(&self) -> usize { - self.router.route_count() - } - - /// Collects outbound publisher futures for all configured routes (internal). - /// - /// Called automatically during `build()` to construct the per-route - /// publisher futures. Each subscribes to its record (type-erased), serializes - /// values, and publishes them to the MQTT broker. Returned futures are - /// appended to the `AimDbRunner` accumulator. - fn collect_outbound_futures( - client: Arc, - runtime_ctx: Arc, - routes: Vec, - ) -> Vec { - let mut futures: Vec = Vec::with_capacity(routes.len()); - - for (default_topic, consumer, serializer, config, topic_provider) in routes { - let client = client.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - // Parse config options - let mut qos = rumqttc::QoS::AtLeastOnce; // Default - let mut retain = false; - - for (key, value) in &config { - match key.as_str() { - "qos" => { - if let Ok(qos_val) = value.parse::() { - qos = match qos_val { - 0 => rumqttc::QoS::AtMostOnce, - 1 => rumqttc::QoS::AtLeastOnce, - 2 => rumqttc::QoS::ExactlyOnce, - _ => rumqttc::QoS::AtLeastOnce, - }; - } - } - "retain" => { - if let Ok(retain_val) = value.parse::() { - retain = retain_val; - } - } - _ => {} - } - } - - futures.push(Box::pin(async move { - // Subscribe to typed values (type-erased) - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to subscribe for outbound topic '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "MQTT outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Determine topic: dynamic (from provider) or default (from URL) - let topic = topic_provider - .as_ref() - .and_then(|provider| provider.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize the type-erased value - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for topic '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for topic '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Publish to MQTT with protocol-specific config - if let Err(_e) = client.publish(&topic, qos, retain, bytes).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to publish to MQTT topic '{}': {:?}", topic, _e); - } else { - #[cfg(feature = "tracing")] - tracing::debug!("Published to MQTT topic: {}", topic); - } - } - - #[cfg(feature = "tracing")] - tracing::info!( - "MQTT outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } +/// Pure outbound publish adapter driven by `pump_sink`. +/// +/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's protocol +/// options (threaded through by `pump_sink` via [`ConnectorConfig::from_query`]), +/// interpreted with MQTT's legacy defaults — **QoS 1 (`AtLeastOnce`)** when +/// unspecified, no retain — so the wire stays byte-identical to the old loop. +struct MqttSink { + client: Arc, +} - futures +impl MqttSink { + /// Look up a protocol option by key and parse it. + fn opt(config: &ConnectorConfig, key: &str) -> Option { + config + .protocol_options + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| v.parse().ok()) } } -// Implement the connector trait from aimdb-core -impl aimdb_core::transport::Connector for MqttConnectorImpl { +impl Connector for MqttSink { fn publish( &self, destination: &str, - config: &aimdb_core::transport::ConnectorConfig, + config: &ConnectorConfig, payload: &[u8], - ) -> core::pin::Pin< - Box< - dyn core::future::Future> - + Send - + '_, - >, - > { - use aimdb_core::transport::PublishError; - - // Destination is already the MQTT topic (from ConnectorUrl::resource_id()) + ) -> Pin> + Send + '_>> { + // Legacy defaults: QoS 1 when no `qos` query option, no retain. + let qos = Self::opt::(config, "qos").unwrap_or(1); + let retain = Self::opt::(config, "retain").unwrap_or(false); + + // Destination is already the MQTT topic (from ConnectorUrl::resource_id()). let topic = destination.to_string(); let payload_owned = payload.to_vec(); - let qos = config.qos; - let retain = config.retain; let client = self.client.clone(); Box::pin(async move { - // Determine QoS let qos_level = match qos { 0 => rumqttc::QoS::AtMostOnce, 1 => rumqttc::QoS::AtLeastOnce, @@ -449,7 +306,6 @@ impl aimdb_core::transport::Connector for MqttConnectorImpl { _ => return Err(PublishError::UnsupportedQoS), }; - // Publish the message #[cfg(feature = "tracing")] let topic_for_log = topic.clone(); @@ -468,43 +324,29 @@ impl aimdb_core::transport::Connector for MqttConnectorImpl { Ok(()) }) } - - // Note: subscribe() method removed in v0.2.0 - // Inbound routing now uses the MqttRouter passed to new() } -/// Builds the MQTT event-loop future with router-based dispatch. -/// -/// The event loop is required by rumqttc to handle: -/// - Network I/O (reading/writing packets) -/// - Reconnection logic -/// - QoS handshakes -/// - Routing incoming publishes to AimDB producers -/// -/// Returns a `BoxFuture` that is appended to the `AimDbRunner` accumulator. +/// Inbound frame source driven by `pump_source`. /// -/// # Arguments -/// * `event_loop` - The rumqttc EventLoop to run -/// * `_broker_key` - Broker identifier for logging (unused in release builds) -/// * `router` - Router for dispatching messages to producers -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers -fn build_event_loop_future( - mut event_loop: EventLoop, - _broker_key: String, - router: Arc, - runtime_ctx: Option>, -) -> BoxFuture { - Box::pin(async move { - #[cfg(feature = "tracing")] - tracing::debug!("MQTT event loop started for {}", _broker_key); +/// Yields `(topic, payload)` for each incoming MQTT publish. The inner poll loop +/// discards non-publish packets — keeping QoS handshakes and keepalive flowing — +/// and backs off 5s on a connection error before retrying, reproducing the old +/// hand-rolled event-loop future exactly. It never yields `None`: the reader runs +/// for the lifetime of the connector. +struct MqttEventLoopSource { + event_loop: EventLoop, + #[cfg(feature = "tracing")] + broker_key: String, +} - loop { - match event_loop.poll().await { - Ok(notification) => { - // Route incoming publishes via the router - if let rumqttc::Event::Incoming(Packet::Publish(publish)) = notification { +impl Source for MqttEventLoopSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { + loop { + match self.event_loop.poll().await { + Ok(Event::Incoming(Packet::Publish(publish))) => { let topic = publish.topic.clone(); - let payload = publish.payload.to_vec(); + let payload: Payload = Arc::from(publish.payload.as_ref()); #[cfg(feature = "tracing")] tracing::debug!( @@ -513,24 +355,21 @@ fn build_event_loop_future( payload.len() ); - // Route to appropriate producer(s) - if let Err(_e) = router.route(&topic, &payload, runtime_ctx.as_ref()).await - { - #[cfg(feature = "tracing")] - tracing::error!("Failed to route message on topic '{}': {}", topic, _e); - } + return Some((topic, payload)); } - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("MQTT event loop error for {}: {:?}", _broker_key, _e); + // Non-publish packets (PUBACK/PINGRESP/…) keep driving the protocol. + Ok(_) => continue, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("MQTT event loop error for {}: {:?}", self.broker_key, _e); - // Wait before reconnecting - tokio::time::sleep(Duration::from_secs(5)).await; + // Wait before reconnecting. + tokio::time::sleep(Duration::from_secs(5)).await; + } } } - } - }) + }) + } } #[cfg(test)] @@ -542,7 +381,7 @@ mod tests { async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router, None).await; + MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router).await; assert!(connector.is_ok()); } @@ -550,15 +389,14 @@ mod tests { async fn test_connector_with_port() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router, None).await; + MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_invalid_url() { let router = RouterBuilder::new().build(); - let connector = - MqttConnectorImpl::build_internal("not-a-valid-url", None, router, None).await; + let connector = MqttConnectorImpl::build_internal("not-a-valid-url", None, router).await; assert!(connector.is_err()); } } diff --git a/docs/design/detailed/037-phase0-contracts.md b/docs/design/detailed/037-phase0-contracts.md deleted file mode 100644 index 52405db..0000000 --- a/docs/design/detailed/037-phase0-contracts.md +++ /dev/null @@ -1,112 +0,0 @@ -# Phase 0 — frozen connector-session contracts (decision record) - -**Version:** 1.0 (ratified) -**Status:** 🟢 Decided -**Realizes:** [036 — Masterplan](036-remote-access-masterplan.md) Phase 0 -**Depends on:** [033 — Remote access as a connector](033-remote-access-as-connector.md), [034 — Server-connection trait](034-server-connection-trait.md), [035 — Connector capability model](035-connector-capability-model.md), [M16 — AimX JSON codec](../032-M16-aimx-json-codec.md) -**Implements:** [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs) (feature `connector-session`) -**Last Updated:** May 29, 2026 -**Milestone:** M17+ (connector convergence) - ---- - -## TL;DR - -This record **freezes the cross-cutting contracts** every later phase consumes, so Phases 2–6 never rework signatures mid-flight. It ships two things: - -1. **Four cross-cutting decisions** (below), each resolved with rationale and explicit deferrals. -2. **`dyn`-safe trait skeletons** — signatures only, `unimplemented!()` bodies — in [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs) behind the `connector-session` feature, compiling on `std` **and** `no_std + alloc` (`thumbv7em-none-eabihf`). - -No engine logic, no pumps, no transport impls, no wire-protocol changes — **contracts, not behavior**. - ---- - -## Decisions - -### Decision 1 — `Payload` seam type → **raw bytes** ✅ - -`Payload = Arc<[u8]>` — opaque serialized bytes, the interchange between the outer `EnvelopeCodec` (protocol frame) and the inner M16 record-value `JsonCodec` (record value). - -**Rationale.** Cheap-clone (refcount bump) for WS fan-out, `no_std + alloc`-native, no new dependency. The alternative (`serde_json::Value`) is zero-churn for std ports but couples the envelope to `serde_json` and forces a `Value` tree on the hot paths. Raw bytes keep **one** serde pass on the hot paths; a JSON tree materializes only where an RPC handler inspects structure. - -**Convert-boundary rule** (the discipline this type enforces): -- *emit:* `T → bytes` once via `serde_json::to_vec` / `serde-json-core` — no intermediate `Value` tree. -- *ingest:* `bytes → T` once via `from_slice`. -- *pass-through:* streaming a record to subscribers and routing source→producer never touch the payload. -- *structured:* a `serde_json::Value` materializes only inside RPC handlers that inspect structure (query filters, graph introspection) — never on the streaming/produce loops. - -**EnvelopeCodec implication.** `decode` yields `params`/`data` as an *unparsed* `Payload` (a slice of the frame); `encode` splices a `Payload` in verbatim. Embedding raw JSON bytes inside a textual NDJSON/WS-JSON envelope will need `serde_json::value::RawValue` (std) or a manual fixed-buffer splice (`serde-json-core`) to avoid re-escaping — an implementation concern for the Phase 2+ codecs, not a contract change. - -**Future option.** `bytes::Bytes` only if cheap sub-slicing / zero-copy binary framing is later needed; `Arc<[u8]>` is the no-dependency default until then. - -### Decision 2 — RPC + streaming unify in one `Dispatch` → **one trait** ✅ - -A single `Dispatch` trait carries all three reply cardinalities: `call` (one reply) / `subscribe` (many) / `write` (none). They differ only by return type, not by abstraction. - -**Rationale.** Both existing stacks (AimX-remote, WS connector) already interleave RPC and streaming over **one** connection via a `biased select!`. A subscription is just a method whose reply is a `Stream` rather than a single value. Forking into separate RPC and streaming traits only pays off with separate transports, which AimDB does not have. - -### Decision 3 — `publish` placement → **sibling capability** ✅ (ratify) - -`publish` stays a sibling data-plane capability, **not** absorbed into the session trait. `Sink` = today's [`Connector`](../../../aimdb-core/src/transport.rs#L159) contract **verbatim** — no rename, no migration in Phase 0. - -**Rationale.** Already decided in [033 §5](033-remote-access-as-connector.md); `publish` is the client/sink role, the session trait is the server role. MQTT *inbound* is the degenerate session case (a single `Connection`, no `Listener`). Reconciling the `Sink`/`Connector` names is **Phase 1**, not here. - -### Decision 4 — embedded engine order → **client-first** ✅ - -The embedded push target is *an MCU dials a gateway and pushes records*, so the **Client** path (`Dialer` + `run_client`) is the embedded-critical one. - -**Rationale.** The smallest engine — one connection, no accept loop, no fan-out — gives the best odds against #39's ~60–100 KB / ≥256 KB RAM budget. This orders the later phases, not the Phase 0 contracts: Phases 2–4 still build **both** sides (the substrate is role-neutral); Phase 5 does substrate-first then `run_client`; Phase 6 ships the `Dialer` half first. The frozen substrate (`Connection` / `EnvelopeCodec` / `Inbound` / `Outbound`) is deliberately role-neutral so server and client share it. - ---- - -## Deferred — recorded here, resolved elsewhere - -| deferral | lands in | -|---|---| -| Auth-context shape — one `SessionCtx` for AimX `SecurityPolicy` + WS `Permissions`? (`SessionCtx`/`PeerInfo` are opaque placeholders for now) | **Phase 4** | -| `Source` cardinality + backpressure (one multiplexed `Source` per scheme; block vs drop+count) | **Phase 1** | -| Client surface — `pump_client` mirroring-only vs caller RPC (`request_id_counter`) | ✅ **Phase 2** — *resolved: one engine, both. `run_client`+`ClientHandle` shipped; the caller-RPC half landed in **Phase 3** as `AimxConnection` ([038](038-phase3-aimx-client.md)); `pump_client` mirroring deferred to the server port (its route-collection deps share the server `Dispatch` machinery)* | -| Envelope wire convergence — NDJSON vs WS-JSON (the codec is pluggable) | deferrable past **Phase 4** | -| Bounded-resource policy — `heapless`/const-generic vs runtime config (`SessionLimits` is a stub) | **Phase 5** | -| Memory budget validation against #39 target | **Phase 7** | - ---- - -## The frozen contract sheet - -All in [`aimdb-core/src/session.rs`](../../../aimdb-core/src/session.rs), feature `connector-session`. Signatures copied verbatim from their canonical sketches — transport + `EnvelopeCodec` + `Dispatch` from [034 § The three layers](034-server-connection-trait.md), `Sink` / `Source` / `Dialer` from [035 § The toolkit](035-connector-capability-model.md). - -**Shared aliases & types** -- `BoxFut<'a, T> = Pin + Send + 'a>>`, `BoxStream<'a, T> = Pin + Send + 'a>>` -- `Payload = Arc<[u8]>` (Decision 1) -- `SessionCtx`, `PeerInfo` — opaque placeholders (auth deferred to Phase 4); `SessionLimits` — stub (Phase 5) -- `Inbound` / `Outbound<'a>` — role-neutral logical message set (field types align with the existing AimX wire in `remote::protocol`: `id`/`seq` are `u64`, `topic`/`sub`/`method` are `String`/`&str`) -- supporting errors: `TransportError` (+ `TransportResult`), `CodecError`, `RpcError`, `AuthError` - -**Transport (Layer 1)** — `Connection` (`recv`/`send`/`peer`), `Listener` (`accept`), `Dialer` (`connect`, the dual of `Listener`). - -**Dispatch (Layer 3)** — `Dispatch` (`authenticate`/`open`) + `Session` (`call`/`subscribe`/`write`), `EnvelopeCodec` (`decode`/`encode`). - -> **Phase 3 server-port refinement (additive).** The dispatch role was **split** into a shared `Dispatch` (`Send + Sync`, one `Arc` per server: `authenticate` + an `open(&SessionCtx) -> Box` factory) and a per-connection `Session` (`Send`, one `Box` per accepted connection: `call`/`subscribe`/`write` on `&mut self`). The engine (`run_session`) owns the `Box` and threads `&mut` into it, so a connection can hold mutable dispatch state — `record.drain`'s lazy per-record cursors today, per-session auth identity in Phase 4 — without a lock. This is the **one seam the AimX wire reshape did not dissolve** (it is about `Dispatch` being a shared `&self`, not the wire). `Session::subscribe` is **defaulted** to `Err(RpcError::NotFound)` since its stream is `'static` (it captures cloned handles) and so is side-neutral. The split is additive and object-safe (the Phase-0 `Box` object-safety tests still hold, now covering `Session`); it mirrors the precedent of the Phase-2 `encode_inbound`/`decode_outbound` addition below — made when the server port was built, the phase where per-connection dispatch state is nailed down. - -> **Phase 2 refinement (additive).** `EnvelopeCodec` gained its *client* direction — `encode_inbound` + `decode_outbound` — so the proactive `run_client` engine reuses the **same** codec object as the reactive server (the role-neutral-substrate invariant). The two Phase 0 server-direction signatures are unchanged; this is a pure addition, made when the client engine was built (the engine phase is where the client codec direction is nailed down). - -**Data-plane (035)** — `Sink` (`publish` = today's `Connector` verbatim), `Source` (`next`). - -### Faithful-translation notes (where a verbatim copy needed a concrete choice) - -- **Transport frame type.** 034 sketches `Connection::recv -> Option`. Per Decision 1 (no `bytes` crate) the owned transport frame is `Vec`; `EnvelopeCodec::decode(&[u8])` borrows it. `Payload = Arc<[u8]>` is reserved for the *record-value* seam, not raw transport frames. -- **`Sink::publish` lifetimes.** Match today's `Connector::publish` exactly: the returned `BoxFut<'_, …>` is tied to `&self` only (params get independent elided lifetimes). The new session traits keep 034's `<'a>` form where the future borrows params (`call`/`write`/`authenticate`/`send`). -- **`: Send` on `Dialer` / `Source`.** Added for consistency with the rest of the boxed-future family (they are driven inside the runner's `FuturesUnordered`); 035's sketch omitted the bound. - ---- - -## Acceptance criteria — status - -- [x] `037-phase0-contracts.md` exists, with a resolution + rationale for Decisions 1–4 and a "deferred to Phase N" line per deferral. -- [x] Trait skeletons for all Scope types compile on `std` (`cargo check -p aimdb-core --features connector-session`). -- [x] Same skeletons compile for `no_std + alloc` (`cargo check -p aimdb-core --target thumbv7em-none-eabihf --no-default-features --features "alloc,connector-session"`). -- [x] Every trait is object-safe — `_assert_object_safe(&dyn …)` (all targets) + `traits_are_object_safe` test building `Box` per trait. -- [x] Skeletons are unimplemented (`unimplemented!()`) — contracts, not behavior. -- [x] [036](036-remote-access-masterplan.md)'s decision-gate table marks the Phase-0 rows resolved. -``` diff --git a/docs/design/remote-access-via-connectors.md b/docs/design/remote-access-via-connectors.md new file mode 100644 index 0000000..cc68cae --- /dev/null +++ b/docs/design/remote-access-via-connectors.md @@ -0,0 +1,73 @@ +# Remote access via connectors + +**Issue:** aimdb-dev/aimdb#39 — *Enable remote access for embedded / `no_std` environments* +**Status:** 🟢 Architecture settled; Phases 0–6 landed in code, Phase 7 (on-target validation) open. +**Scope:** This is the single design doc for the connector-convergence initiative. It captures the *what* and *why* and the load-bearing decisions — not implementation detail (read the code under [`aimdb-core/src/session/`](../../aimdb-core/src/session/) for that). + +--- + +## The problem + +Issue #39 wants AimX remote access to run on **embedded** (`no_std`/Embassy). The naive path — a new `RemoteTransport` trait with UDS/serial/TCP impls — would bolt a second I/O abstraction next to the connector framework that *already* crosses the std/Embassy boundary, and leave AimDB maintaining **four hand-rolled networking stacks**: an AimX server + client and a WebSocket server + client, each re-implementing bind/accept/connect, sessions, framing, and RPC. + +So the real task is **not** "add a transport." It is: **converge those four stacks onto one shared, runtime-agnostic session abstraction in the connector layer**, after which embedded remote access — and any future transport — falls out for free. + +## The decision + +Pursue **convergence**, not a parallel transport trait. The connector layer already solves cross-runtime I/O and spawn-free execution; remote access rides it. + +A connector is a **composition of capabilities** over the existing, kind-agnostic [`ConnectorBuilder::build -> Vec`](../../aimdb-core/src/connector.rs) spine (driven by one `FuturesUnordered`, no `tokio::spawn`). The framework owns every loop; an author implements only a small I/O adapter per capability and composes them in `build()`. + +| capability | role | framework provides | author writes | +|---|---|---|---| +| **Sink** | data out (MQTT-pub, HTTP) | consume loop (`pump_sink`) | `publish()` — today's `Connector` | +| **Source** | data in (MQTT-sub) | read+route loop (`pump_source`) | `next() -> (topic, bytes)` | +| **Server** | accept sessions (AimX, WS) | accept + `run_session`/`serve` (reactive) | `Listener` + `Dispatch` + codec | +| **Client** | dial sessions (AimX, WS, sensor MCU) | `run_client`/`pump_client` (proactive: handshake, RPC demux, reconnect) | `Dialer` + codec | + +**Server and Client share one substrate** — a framed `Connection`, an `EnvelopeCodec` (outer protocol frame), and one role-neutral logical message set (`Inbound`/`Outbound`) — so they are two engines over shared parts, not two stacks. The substrate is split across three layers: **transport** (`Connection`/`Listener`/`Dialer`), **codec** (`EnvelopeCodec`), and **dispatch** (`Dispatch` factory → per-connection `Session` with `call`/`subscribe`/`write`). The `EnvelopeCodec` *nests* the existing [M16 record-value `JsonCodec`](032-M16-aimx-json-codec.md) rather than replacing it. + +## Load-bearing decisions + +1. **`Payload = Arc<[u8]>` (raw bytes)**, not `serde_json::Value`. Cheap-clone for fan-out, `no_std+alloc`-native; one serde pass on hot paths — a JSON tree materializes only inside RPC handlers that inspect structure. +2. **One `Dispatch` trait** carries all reply cardinalities: `call` (one) / `subscribe` (stream) / `write` (none). A subscription is just a method whose reply is a `Stream`; both existing stacks already interleave RPC + streaming over one connection. +3. **`publish` is a sibling capability**, not absorbed into the session trait. `Sink` is today's `Connector` verbatim (Phase 1 collapsed the skeleton onto it — `pump_sink` takes `Arc`). +4. **Client-first embedded order.** A sensor MCU *dials a gateway and pushes records*, so `Dialer` + `run_client` is the embedded-critical, smallest-footprint path (one connection, no accept/fan-out) against #39's ~60–100 KB / ≥256 KB RAM budget. The substrate stays role-neutral so server and client share it. + +## Invariants (hold across every phase) + +- **Behavior-preserving ports** — porting a stack changes *no* wire protocol; wire-capture/golden tests gate each. +- **One engine per role** — server (reactive) and client (proactive) are distinct, each proven in `std` then `no_std`-ed once; never fork a role into parallel std/no_std engines or re-implement the shared substrate per role. +- **Toolkit is additive** — the `ConnectorBuilder -> Vec` escape hatch always works; no connector is forced through a capability that doesn't fit. +- **Single-writer-per-key** stays intact — `Session::write` routes through the existing producer/arbiter path; remote clients are request streams, never direct co-writers. +- **Spawn-free** — every phase only appends `BoxFuture`s to the runner. + +## Roadmap & status + +Std-first, behavior-preserving, incremental: build the abstraction in `std`, port the four stacks onto it wire-identically, *then* `no_std` the engines and add embedded transports. Each phase ships standalone value. + +| # | Phase | Ships | Status | +|---|---|---|---| +| 0 | Freeze `dyn`-safe contracts (the decisions above + trait skeletons) | signature record | ✅ | +| 1 | Data-plane toolkit: `Source` + `pump_sink`/`pump_source`; migrate MQTT | easier third-party connectors | ✅ | +| 2 | Session substrate + server (`run_session`/`serve`) & client (`run_client`/`pump_client`) engines, std | the shared machinery | ✅ | +| 3 | Port AimX (server + client) onto the engines | AimX on shared engine; legacy loops deleted | ✅ | +| 4 | Port WebSocket (server + client) onto the engines | four stacks → two engines | ✅ landed (on-socket validation ongoing) | +| 5 | Make the engines runtime-neutral (`futures` channels + adapter `TimeOps`, no tokio/embassy) | engines cross-compile to `thumbv7em` | ✅ | +| 6 | Embedded transport crates (`Listener`+`Dialer`) | remote access over real links | 🟡 `aimdb-uds-connector` landed (UDS, both halves); serial/TCP + the no_std AimX *server* port are tracked follow-ups (below) | +| 7 | Validate on target MCU — memory budget, #39 acceptance | **#39 delivered** | ⬜ open — tracked by #13 under umbrella #39 | + +**Value milestones:** after Phase 1, third-party connectors are a few lines; after Phase 4, all four hand-rolled stacks collapse onto two shared engines (shippable end state even if #39 is deprioritized); after Phase 7, embedded remote access on the connector framework. + +**Tracked follow-ups (open issues):** +- **#120** — no_std AimX *server* (dispatch) port: cross-cutting `AnyRecord` + `RecordMetadataTracker` de-std. The AimX codec is already `no_std+alloc`; only `AimxDispatch` is std-gated. +- **#121** — `aimdb-tcp-connector` (tokio + embassy-net); **#122** — `aimdb-serial-connector` (COBS over `tokio-serial` + `embedded-io-async`). The two remaining Phase-6 transports. +- **#123** — transport-agnostic host client + `cli`/`mcp` `--connect ` resolver. +- **#41** — dropped-event tracking for remote-access subscriptions. +- **#13** — performance validation & benchmarking (the Phase-7 gate). + +## Foundations reused (not rebuilt) + +- Spawn-free runner + `ConnectorBuilder` spine ([028](028-M13-remove-spawn-trait.md)) and the spawn-free AimX supervisor ([030](030-M13-aimx-remote-spawn-free.md)). +- The `no_std`-capable record-value codec ([032](032-M16-aimx-json-codec.md)) — nested by the `EnvelopeCodec`. +- Record binding: `collect_inbound_routes` / `collect_outbound_routes` + `ProducerTrait` / `ConsumerTrait`. From 98420e7398cf1b7ddaa13fe64296850e05a25574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 18:12:28 +0000 Subject: [PATCH 18/34] feat: Enhance subscription management to free cap slots on natural stream completion --- aimdb-core/src/session/server.rs | 31 ++++++++--- aimdb-core/tests/session_engine.rs | 89 +++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 8 deletions(-) diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index dcb2bce..168b31b 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -81,8 +81,9 @@ enum Step { Closed, /// A subscription update to encode and forward to the peer. Event(SubEvent), - /// A subscription pump finished — nothing to do but reap it. - SubDrained, + /// A subscription pump finished on its own (stream exhausted) — reap it and + /// prune its `cancels` entry by the carried sub id. + SubDrained(String), } /// Drive one accepted [`Connection`] until it closes. @@ -133,7 +134,10 @@ pub async fn run_session( // restores the bounded-buffer slow-client protection the hand-rolled loops had. let (event_tx, event_rx) = async_channel::bounded::(EVENT_BUFFER); // Per-connection subscription pumps; the engine future is their sole owner. - let mut subs: FuturesUnordered> = FuturesUnordered::new(); + // Each pump resolves to its own sub id on completion, so the loop can prune + // the matching `cancels` entry (a pump ended via Unsubscribe was already + // pruned, making the redundant remove a harmless no-op). + let mut subs: FuturesUnordered> = FuturesUnordered::new(); // sub-id → cancel handle (dropping/sending the oneshot cancels the pump, // race-free unlike a bare `Notify`). let mut cancels: HashMap> = HashMap::new(); @@ -162,16 +166,24 @@ pub async fn run_session( // ---- outbound: a subscription update to forward ------------ ev = event => match ev { Ok(ev) => Step::Event(ev), - Err(_) => Step::SubDrained, // funnel closed (tx held, so unreachable) + // Funnel closed — only if every sender dropped, which can't + // happen while the loop holds `event_tx`, so this is + // unreachable; end the session defensively if it ever does. + Err(_) => Step::Closed, }, // ---- drain finished subscription pumps --------------------- - () = subs.select_next_some() => Step::SubDrained, + sub_id = subs.select_next_some() => Step::SubDrained(sub_id), } }; match step { Step::Closed => break, - Step::SubDrained => {} + // A pump finished on its own (stream exhausted), not via Unsubscribe; + // drop its cancel handle so the ended subscription neither leaks nor + // keeps counting against `max_subs_per_connection`. + Step::SubDrained(sub_id) => { + cancels.remove(&sub_id); + } Step::Event(ev) => { out.clear(); @@ -316,12 +328,16 @@ async fn send_reply_err( /// Pump one `Session::subscribe` stream into the connection's event funnel, /// tagging each update with a monotonic `seq`. Ends when the stream finishes or /// the cancel handle is dropped/fired (Unsubscribe or connection teardown). +/// +/// Returns its `sub_id` so [`run_session`] can prune the `cancels` entry for a +/// pump that ended on its own; the Unsubscribe path already removed it, so that +/// later prune is a no-op. async fn pump_subscription( sub_id: String, mut stream: BoxStream<'static, Payload>, tx: Sender, cancel: oneshot::Receiver<()>, -) { +) -> String { // `oneshot::Receiver` reports `is_terminated() == true` once its sender drops // (the cancel signal!), and `select_biased!` *skips* terminated arms — so a // bare `cancel` arm would never fire on Unsubscribe. Fuse it once: `Fuse`'s @@ -355,6 +371,7 @@ async fn pump_subscription( Err(_) => break, // funnel disconnected — connection gone } } + sub_id } /// Accept connections from `listener` and serve each with [`run_session`], diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index 89df744..78d6fe0 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -19,7 +19,7 @@ use futures::StreamExt; use aimdb_core::session::{ run_client, serve, AuthError, BoxFut, BoxStream, ClientConfig, CodecError, Connection, Dialer, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, Session, - SessionConfig, SessionCtx, TransportError, TransportResult, + SessionConfig, SessionCtx, SessionLimits, TransportError, TransportResult, }; /// Minimal [`TimeOps`](aimdb_executor::TimeOps) clock for the engine tests @@ -471,3 +471,90 @@ async fn failed_subscribe_ends_stream_via_ack() { let _ = client.await; server.abort(); } + +/// A subscription whose source stream *ends on its own* (the echo yields three +/// updates, then completes) must free its slot against +/// `max_subs_per_connection` once its pump drains. Regression for the bug where +/// `run_session` pruned `cancels` only on an explicit Unsubscribe, so a +/// naturally-ended subscription lingered in the map — leaking memory and, worse, +/// permanently counting against the per-connection cap until a long-lived +/// connection that churned subscriptions could open no more. +#[tokio::test] +async fn ended_subscription_frees_its_cap_slot() { + let (listener, dialer) = transport_pair(); + let dispatch = Arc::new(EchoDispatch { + writes: Arc::new(Mutex::new(Vec::new())), + }); + // Cap of 2: with the leak, the third subscribe is refused even though the + // first two have already ended. + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig { + limits: SessionLimits { + max_connections: 16, + max_subs_per_connection: 2, + }, + reads_hello: false, + acks_subscribe: false, + }, + )); + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: 10, + max_reconnect_delay: 10, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: false, + }, + Arc::new(TestClock), + ); + let client = tokio::spawn(client_fut); + + // Drain a subscription's three echo updates; the server-side stream then + // ends, so its pump finishes and is reaped. + async fn drain_three(stream: &mut BoxStream<'static, Payload>, topic: &str) { + for i in 1..=3 { + let ev = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("event should arrive") + .expect("an accepted subscription must yield its events"); + assert_eq!(&*ev, format!("{topic}#{i}").as_bytes()); + } + } + + // Open and fully consume two subscriptions (both fit under the cap of 2). + let mut a = handle.subscribe("a").unwrap(); + drain_three(&mut a, "a").await; + let mut b = handle.subscribe("b").unwrap(); + drain_three(&mut b, "b").await; + + // Let the server forward the last events and reap both finished pumps. + tokio::time::sleep(Duration::from_millis(100)).await; + + // A third subscribe must still be accepted — the two ended subs freed their + // slots. Pre-fix their `cancels` entries lingered, the cap stayed full, and + // this subscribe was refused (surfacing as an immediately-ended stream). + let mut c = handle.subscribe("c").unwrap(); + let first = tokio::time::timeout(Duration::from_secs(2), c.next()) + .await + .expect("third subscribe must not hang"); + assert_eq!( + first.as_deref(), + Some(&b"c#1"[..]), + "an ended subscription must free its cap slot; the third subscribe was refused" + ); + + drop(handle); + drop(a); + drop(b); + drop(c); + let _ = client.await; + server.abort(); +} From f07f16b21eecda6df00ffba96e3b8bd24bb0c865 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 18:19:15 +0000 Subject: [PATCH 19/34] feat: Enable remote access for embedded no_std environments with UDS connector and session engine integration --- CHANGELOG.md | 3 +++ aimdb-client/CHANGELOG.md | 7 +++++++ aimdb-core/CHANGELOG.md | 11 +++++++++++ aimdb-embassy-adapter/CHANGELOG.md | 1 + aimdb-mqtt-connector/CHANGELOG.md | 4 ++++ aimdb-tokio-adapter/CHANGELOG.md | 1 + aimdb-uds-connector/CHANGELOG.md | 15 +++++++++++++++ aimdb-websocket-connector/CHANGELOG.md | 1 + tools/aimdb-cli/CHANGELOG.md | 4 +++- tools/aimdb-mcp/CHANGELOG.md | 4 ++++ 10 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 aimdb-uds-connector/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a24751..c3873c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > - [aimdb-mqtt-connector/CHANGELOG.md](aimdb-mqtt-connector/CHANGELOG.md) > - [aimdb-knx-connector/CHANGELOG.md](aimdb-knx-connector/CHANGELOG.md) > - [aimdb-websocket-connector/CHANGELOG.md](aimdb-websocket-connector/CHANGELOG.md) +> - [aimdb-uds-connector/CHANGELOG.md](aimdb-uds-connector/CHANGELOG.md) > - [aimdb-ws-protocol/CHANGELOG.md](aimdb-ws-protocol/CHANGELOG.md) > - [aimdb-wasm-adapter/CHANGELOG.md](aimdb-wasm-adapter/CHANGELOG.md) > - [aimdb-sync/CHANGELOG.md](aimdb-sync/CHANGELOG.md) @@ -29,11 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Remote access via connectors — Phases 0–6: converge four hand-rolled networking stacks onto two shared engines (Issue #39, [design doc](docs/design/remote-access-via-connectors.md)).** AimX remote access (and any future transport) now rides the connector layer instead of a bespoke I/O abstraction. New, runtime-neutral `aimdb-core::session` module (feature `connector-session`, `no_std + alloc`): the three-layer substrate (`Connection`/`Listener`/`Dialer` + `EnvelopeCodec` + `Dispatch`/`Session`), the reactive **server** engine (`serve`/`run_session`) and proactive **client** engine (`run_client`/`pump_client`), the `pump_sink`/`pump_source` data-plane toolkit, and the transport-agnostic `SessionClientConnector`/`SessionServerConnector`. The AimX-v2 NDJSON protocol (`session::aimx`: `AimxCodec` + `AimxDispatch`) and the WebSocket connector are ports onto this substrate, so the AimX server/client and WS server/client stacks collapse onto the two engines. New **`aimdb-uds-connector`** crate carries the UDS transport (`UdsClient`/`UdsServer`). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) - **M16 — JSON codec extracted behind the `json-serialize` feature; `RecordValue::as_json()` now works on `no_std + alloc`, not just `std` ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** New `aimdb-core::codec` module: `RemoteSerialize` (blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec`, and the zero-sized `SerdeJsonCodec`. `serde_json` runs on `alloc`, so embedded targets can opt in; `std` enables the feature transitively, so std builds are unaffected. ([aimdb-core](aimdb-core/CHANGELOG.md)) - **Embassy buffer + join-queue tests now run in CI (Issue #85).** The join-queue tests previously sat behind `embassy-runtime`, which pulls `embassy-executor`'s cortex-m assembly and can't compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions were never caught. The `join_queue` module is now gated on `embassy-sync`, and `make test` runs the embassy adapter's unit tests + doctests on the host (no executor). Also adds `EmbassyBuffer::peek()` and fixes a stale `EmbassyBuffer` doc example. ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) ### Changed (breaking) +- **`AimDbBuilder::with_remote_access(config)` removed — remote-access servers are now registered like any other connector (Issue #39).** Replace `.with_remote_access(config)` with `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The AimX wire was reshaped to **v2** (NDJSON tagged frames mapping onto the engine's role-neutral message set) and is **not** backward-compatible with the legacy AimX v1 framing; the bundled `aimdb-client` / CLI / MCP speak v2. The UDS transport types moved out of `aimdb-core` into `aimdb-uds-connector`. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md)) - **M15 — `latest_snapshot` removed; point-in-time reads go through the new buffer-native `DynBuffer::peek()` ([Design 031](docs/design/031-M15-remove-latest-snapshot.md)).** `TypedRecord::latest()` and AimX `record.get` read the buffer directly instead of a per-record snapshot mutex (one lock + clone off the `produce()` hot path). Consequences: a `.with_remote_access()` record with **no buffer** now fails `build()` (was a silent runtime no-op); `record.get` / `latest()` on an `SpmcRing` record returns `not_found` / `None` (rings have no canonical latest — use `record.drain` / `record.subscribe`); `SingleLatest` and `Mailbox` are unaffected. `TypedRecord::produce` is removed — all writes go through `WriteHandle::push`. Adapters implement `peek()` per buffer type. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) - **M16 — `with_remote_access()` now requires the `json-serialize` feature (transitively enabled by `std`); `with_read_only_serialization()` removed ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** The stored serializer/deserializer closures are replaced by a type-erased `Arc>`. A `Serialize`-only record can no longer be exposed read-only over remote access. ([aimdb-core](aimdb-core/CHANGELOG.md)) diff --git a/aimdb-client/CHANGELOG.md b/aimdb-client/CHANGELOG.md index a786b23..0bb0b55 100644 --- a/aimdb-client/CHANGELOG.md +++ b/aimdb-client/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **`AimxClient` → `AimxConnection`, rebuilt on the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The synchronous `connection::AimxClient` is retired; the new `engine::AimxConnection` drives `aimdb-core`'s `run_client` engine over `aimdb-uds-connector`'s `UdsDialer` and speaks the reshaped **AimX-v2** protocol. Both the type and the module are re-exported from the crate root (`aimdb_client::AimxConnection`); the `connection` module is replaced by `engine`. `connect()` performs the `hello` handshake, and the full tool surface (list/get/set/subscribe/drain/graph/query) is available. + - `subscribe(record_name)` now returns a `Stream` of updates directly — the engine routes events back by request id, so there is **no** server-allocated subscription id to track (the old `(subscription_id, queue_size)` handshake is gone). + - New `connect_with_timeout(path, timeout)` bounds the whole dial + handshake (used by discovery probing). + - New dependencies: `aimdb-uds-connector` (the UDS transport), `aimdb-tokio-adapter` (the `TimeOps` clock handed to `run_client`), and `futures`. `aimdb-core` is now pulled in with the `connector-session` feature. + ## [0.6.0] - 2026-05-22 ### Added diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 16accd3..7d5541d 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,11 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`connector-session` feature + `session` module — the shared, runtime-neutral session substrate (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A new `crate::session` module (gated `connector-session`, `no_std + alloc`, also enabled transitively by `std`) carrying the connector-convergence machinery: + - **Substrate traits** — `Connection`/`Listener`/`Dialer` (Layer 1 transport, framing-in-transport), `EnvelopeCodec` (Layer 2, symmetric: `decode`/`encode` + the client-direction `encode_inbound`/`decode_outbound`), and `Dispatch` (shared, `Send + Sync`) + `Session` (per-connection, `&mut`-threaded) for Layer 3. Plus the role-neutral `Inbound`/`Outbound` message set, `Payload = Arc<[u8]>` (raw bytes — one serde pass on hot paths), `PeerInfo`/`SessionCtx` (type-erased auth `ext` slots), `Source`, and the `SessionLimits`/`Transport`/`Codec`/`Rpc`/`Auth` error enums. All `dyn`-safe on `std` and `no_std + alloc`. + - **Server engine** — `serve` (accept loop, honors `SessionLimits::max_connections`) + `run_session` (per-connection biased `select_biased!` loop: RPC + streaming subscriptions funneled through a bounded per-connection event channel + fire-and-forget writes). Spawn-free (one `FuturesUnordered` per engine future); honors `SessionLimits::max_subs_per_connection`, with subscriptions reaped — and their cap slot freed — when a stream ends or on Unsubscribe. `SessionConfig` knobs: `reads_hello`, `acks_subscribe`. + - **Client engine** — `run_client` returns a cheap-clone `ClientHandle` (`call`/`subscribe`/`write`) + the engine future; demuxes replies by `id`, supports id- or topic-routed subscriptions, exponential reconnect backoff, idle keepalive, and a bounded offline command queue (`ClientConfig`). The only runtime dependency is the adapter's `TimeOps` clock; everything else is `futures` channels + `async-channel`. + - **Data-plane toolkit** — `pump_sink` (outbound: consume-serialize-publish via `Connector`) and `pump_source` (inbound: one multiplexed `Source` reader → `Router` fan-out), extracting the boilerplate every data-plane connector hand-rolled. + - **Generic connectors** — `SessionClientConnector` and `SessionServerConnector` wrap the engines onto the `ConnectorBuilder` spine so a transport crate contributes only its `Dialer`/`Listener`/`Connection` triple under a configurable scheme (default `"remote"`). +- **`session::aimx` — the AimX-v2 protocol substrate (gated `connector-session` + `json-serialize`).** `AimxCodec`, the symmetric NDJSON `EnvelopeCodec` (`no_std + alloc`; splices an already-serialized record-value `Payload` into the JSON envelope verbatim via `serde_json`'s `RawValue`, no re-escaping). `AimxDispatch`/`AimxSession` (still `std`-gated — it reaches into core's `record.list`/JSON API), porting the method semantics (`hello`, `record.{list,get,set,drain,query}`, `graph.*`, `*.reset`) off the deleted hand-rolled handler onto `Session`. The drain cursors live in the per-connection session; the AimX subscribe ack stays implicit (events carry the request id back). +- **`AimDb::runtime_arc(&self) -> Arc`.** An owned runtime-adapter handle for connectors that hand the runtime to a `'static` engine future (the session client engine clones it for its `TimeOps` clock). +- **`ConnectorConfig::from_query(&[(String, String)])`.** Builds a per-route `ConnectorConfig` from a link URL's query pairs (`timeout_ms` lifted to the typed field, everything else passed through in `protocol_options`); the seam `pump_sink` uses to thread per-route config to `Connector::publish`. - **`json-serialize` feature + `codec` module (M16, Design 032).** New `crate::codec` module with `RemoteSerialize` (capability trait, blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec` storage trait, and the zero-sized `SerdeJsonCodec`. All three are re-exported from the crate root. The feature is `no_std + alloc` compatible (`serde_json` runs on `alloc`), so `RecordValue::as_json()` now works on embedded targets, not just `std`. `std` enables `json-serialize` transitively, so existing std builds are unaffected. - **`DynBuffer::peek(&self) -> Option` (M15, Design 031).** Non-destructive, buffer-native point-in-time read; the default impl returns `None` (correct for buffers with no canonical latest, e.g. broadcast/SPMC rings). AimX `record.get` and `TypedRecord::latest()` now route through it. Adapters implement it per buffer type — see the tokio/embassy adapter changelogs. ### Internal refactors +- **AimX server/client ported onto the shared session engine; the hand-rolled loops deleted (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Building on the spawn-free work below, `remote/handler.rs` (the per-connection `select!` loop) and `remote/supervisor.rs` (the accept loop) are **removed** — their behavior is now `run_session` + `serve` in `session`, driven by the AimX-v2 `AimxDispatch`/`AimxCodec`. `remote/stream.rs`'s `stream_record_updates` survives and is reused by `AimxSession::subscribe`. The UDS transport (socket bind/connect, NDJSON framing) relocated out of core into the new `aimdb-uds-connector` crate; core keeps only the protocol (codec + dispatch) and the generic session connectors. The query handler type-erasure moved to `remote/query.rs` (`QueryHandlerFn`/`QueryHandlerParams`). New dependencies: `async-channel` (runtime-neutral mpsc), `futures-channel` (oneshot), `futures-util`'s `async-await-macro` (`select_biased!`), and `serde_json`'s `raw_value` feature — all `no_std + alloc`-compatible, none entering the no_std contracts build. - **AimX remote-access path is now spawn-free (Issue #114, Design 030).** Every remaining `tokio::spawn` in `aimdb-core/src/remote/` was removed; the supervisor's accept loop and each connection handler now own their own `FuturesUnordered` driven by `tokio::select! { biased; }`. Cancellation collapsed to one mechanism — dropping the future. - New `aimdb-core/src/remote/stream.rs` exports a `pub(crate) stream_record_updates` helper that adapts a record's `JsonBufferReader` into a `Stream` via `futures_util::stream::unfold`. No task, no channel — drop the stream to cancel. - `AimDb::subscribe_record_updates` **deleted**. The method had no out-of-tree callers (the only caller was the AimX handler); replaced by `stream_record_updates` above. @@ -22,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`AimDbBuilder::with_remote_access(config)` removed (Issue #39).** Register a session connector instead: `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The connector binds its transport at `build` time (bind errors surface synchronously, as before), applies the security policy's writable-record marking, and drives the shared `serve` engine. The builder's private `remote_config` field is gone; the per-record `TypedRecord::with_remote_access()` is unrelated and unchanged. The reshaped **AimX-v2** wire is not backward-compatible with the legacy v1 framing. - **`latest_snapshot` removed from `TypedRecord`; `latest()` / AimX `record.get` read the buffer via `peek()` (M15, Design 031).** Eliminates one snapshot-mutex lock + `Option` clone per `produce()` on the hot path. Behavioural consequences: - A record configured with `.with_remote_access()` but **no buffer** now fails `build()` with a clear error (previously a silent runtime no-op — reads returned `not_found`, writes were discarded). Add a buffer, e.g. `.buffer(BufferCfg::SingleLatest)`. - `record.get` / `latest()` on an `SpmcRing` record now returns `not_found` / `None` — a ring keeps per-consumer history with no canonical latest. Use `record.drain` (history) or `record.subscribe` (live). `SingleLatest` and `Mailbox` are unaffected. diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index a08e5c7..e8d3d74 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Session-engine smoke test on the Embassy clock (Issue #39, Phase 5, [design doc](../docs/design/remote-access-via-connectors.md)).** New `tests/session_smoke.rs` drives `aimdb-core`'s runtime-neutral `run_client` engine using the `EmbassyAdapter`'s `TimeOps` clock for reconnect backoff / keepalive — proving the shared session engines run on Embassy, not just Tokio. Dev-only: pulls in `aimdb-core` with the `connector-session` feature, so the normal `no_std` lib build and the `thumbv7em` cross-checks stay `alloc`-only. - **`EmbassyBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read matching the Tokio adapter's semantics: `SingleLatest` (`Watch`) via `Watch::try_get()`, `Mailbox` (`Channel<_, T, 1>`) via `Channel::try_peek()`, `SpmcRing` (`PubSubChannel`) returns `None`. Neither path consumes a receiver slot or advances a cursor. - **Embassy buffer + join-queue unit tests now run in CI on the host (Issue #85).** Previously the join-queue tests sat behind `feature = "embassy-runtime"`, which transitively pulls `embassy-executor`'s `platform-cortex-m` ARM assembly and fails to compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions went uncaught. The `join_queue` module is now gated on `embassy-sync` instead (the `JoinFanInRuntime for EmbassyAdapter` impl keeps its own `embassy-runtime` gate), and `make test` runs `cargo test -p aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time"` (15 unit tests + doctests). A test-only no-op `#[defmt::global_logger]` / `#[defmt::panic_handler]` and a trivial `embassy-time-driver` satisfy the host link targets that `defmt` + `defmt-timestamp-uptime` would otherwise leave undefined. - **`embassy-time-driver` dev-dependency** — provides the trivial host time driver above (no tick feature, so it unifies with the workspace `tick-hz-32_768` rather than forcing `mock-driver`/`std`'s conflicting rate). diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 3f4863c..40fd432 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers (the connector now writes only its `Connector`/`Source` I/O adapters and composes the pumps in `build()`). Per-route configuration (`qos` / `retain` / `timeout_ms` / …) is threaded from each link URL's query via `ConnectorConfig::from_query`. `std` now enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. + ### Changed (breaking) - **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index e1fa651..aa40c5e 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Notes +- **Drain integration tests now stand up the AimX server via `aimdb-uds-connector::UdsServer`** (new dev-dependency) instead of the removed `AimDbBuilder::with_remote_access(config)`, and connect with the engine-based `aimdb-client::AimxConnection` (Issue #39). Test-only; no production change. - `BufferOps::spawn_dispatcher` (a test-only utility) is unchanged — it calls `tokio::spawn` directly and does not depend on the deleted `Spawn` trait. - `tests/stage_profiling.rs` dropped the `avg == total / call_count` assertion: it is a tautology (`avg_time_ns()` is *defined* as that quotient) and racy while the source task is still producing. No coverage lost. diff --git a/aimdb-uds-connector/CHANGELOG.md b/aimdb-uds-connector/CHANGELOG.md new file mode 100644 index 0000000..a3abf18 --- /dev/null +++ b/aimdb-uds-connector/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog - aimdb-uds-connector + +All notable changes to the `aimdb-uds-connector` crate will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **New crate — the Unix-domain-socket transport for AimDB remote access (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A thin, swappable transport that rides the shared session engine in `aimdb-core::session`: it contributes only the `Dialer`/`Listener`/`Connection` triple (`UdsDialer` / `UdsListener` / `UdsConnection`, NDJSON framing in the transport — one line per logical frame); the AimX codec + dispatch and the engine wiring are reused verbatim from core. Two ergonomic constructors: + - **`UdsServer`** — accepts connections and serves the full AimX toolset over a socket. Register it via `with_connector` to stand up remote access (this replaces `aimdb-core`'s removed `AimDbBuilder::with_remote_access(config)`). Sugar over `SessionServerConnector`; `UdsServer::from_config(config)` is the one-line migration, plus `new`/`max_connections`/`max_subs_per_connection`/`socket_permissions`/`scheme` builders. Binds synchronously (remove-stale → `bind` → `set_permissions`) so bind errors surface from `build()`, and applies the security policy's writable-record marking. + - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"remote"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. +- **Deprecated back-compat shims** for the types that relocated here from `aimdb-core`: `AimxClientConnector::new(path)` (defaults the scheme to `"aimx"`, preserving pre-Phase-6 behavior) and the free-standing `build_aimx_server(db, config)` (returns the `serve` future directly). Prefer `UdsClient` / `UdsServer`. diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 806a5c5..33bfe30 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal refactors +- **WebSocket server + client ported onto the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Behavior-preserving (wire-identical, gated by a round-trip test): the WS server now runs on `aimdb-core`'s `serve`/`run_session` and the client on `run_client`, so the two hand-rolled WS stacks collapse onto the same engines as AimX. New modules: `codec` (`WsCodec`, the per-connection WS-JSON `EnvelopeCodec` — id↔topic bookkeeping, O(1) fan-out by writing the bus-pre-serialized `Data` frame verbatim, zero-copy `decode_outbound` replacing the old `&'static` topic interner), `transport` (`WsServerConnection`/`WsClientConnection`/`WsDialer` over axum / tokio-tungstenite, including the multi-topic `Subscribe`/`Unsubscribe` split), and `dispatch` (`WsDispatch`/`WsSession` homing the `ClientManager` bus + auth + query/snapshot). The hand-rolled `client/connector.rs` loop is removed; `client_manager`/`session` slim down to a fan-out bus + snapshot/query providers. Public `WebSocketConnectorBuilder` / `WsClientConnectorBuilder` surfaces are unchanged (the client builder now bounds `R: TimeOps` for the engine clock). Added `examples/ws_server.rs`, `tests/ws_roundtrip.rs`, and a dev-dep on `aimdb-tokio-adapter`. - **WS client connector is now spawn-free (Issue #114, Design 030).** All six `tokio::spawn` call sites in the client connector (initial write/read/keepalive/reconnect-watcher plus the watcher's per-reconnect read/write loops) collapsed into one infrastructure future that owns a `FuturesUnordered` driven by `tokio::select! { biased; }`. The reconnect watcher no longer spawns; on a successful reconnect it sends a `NewLoops { write_sink, read_stream, write_rx }` over an mpsc to the outer future, which pushes fresh read- and write-loop futures onto the set. - `WsClientConnectorImpl::connect()` return type changed from `Result` to `Result<(Self, BoxFuture), String>` — the second element is the infrastructure future; the builder prepends it to the outbound publisher futures before returning to `AimDbBuilder`. - Internal-only API change; no impact on the public `WsClientConnectorBuilder` or `ConnectorBuilder` surfaces. diff --git a/tools/aimdb-cli/CHANGELOG.md b/tools/aimdb-cli/CHANGELOG.md index e9700fa..5c31360 100644 --- a/tools/aimdb-cli/CHANGELOG.md +++ b/tools/aimdb-cli/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** All commands (`watch`, `record`, `graph`) now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. `aimdb watch` subscribes via the engine, which streams updates routed by request id — there is no server-allocated subscription id to display, and `--queue-size` is accepted for compatibility but no longer meaningful (queue sizing is now an engine concern). ## [0.6.0] - 2026-03-11 diff --git a/tools/aimdb-mcp/CHANGELOG.md b/tools/aimdb-mcp/CHANGELOG.md index 85a0975..e7f0975 100644 --- a/tools/aimdb-mcp/CHANGELOG.md +++ b/tools/aimdb-mcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** The connection pool and every tool now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. Internal-only; no tool surface or behavior change (drain clients are still pooled per socket behind an `Arc>`). + ## [0.8.0] - 2026-05-22 ### Added From 7e39a31503fddc22f19c1dfb5259bfbce49f2a1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 31 May 2026 19:16:18 +0000 Subject: [PATCH 20/34] Refactor documentation and improve clarity across various modules - Updated comments in `pump.rs` to enhance clarity and remove redundant references to documentation sections. - Simplified and clarified comments in `server.rs`, focusing on the reactive server engine and its components. - Improved documentation in `join.rs` to provide clearer references to functions and their usage. - Clarified comments in `typed_record.rs` regarding the producer service function. - Enhanced documentation in `lib.rs` of the UDS connector to better explain its purpose and functionality. - Streamlined comments in `transport.rs` of the UDS connector, emphasizing the role of the connection and listener. - Improved clarity in authentication comments in `auth.rs` of the WebSocket connector. - Refined comments in `codec.rs` of the WebSocket connector to clarify the per-connection codec's functionality. - Updated comments in `connector.rs` of the WebSocket connector to clarify the routing of inbound writes. - Enhanced documentation in `dispatch.rs` of the WebSocket connector to clarify the dispatch and session handling. - Improved clarity in `lib.rs` of the WebSocket connector regarding the shared codec and transport modules. - Streamlined comments in `transport.rs` of the WebSocket connector to clarify the purpose of transport adapters. --- aimdb-client/src/engine.rs | 6 +- aimdb-core/src/builder.rs | 14 +- aimdb-core/src/connector.rs | 20 +- aimdb-core/src/extensions.rs | 2 +- aimdb-core/src/router.rs | 10 +- aimdb-core/src/session/aimx/codec.rs | 37 ++-- aimdb-core/src/session/aimx/dispatch.rs | 43 ++-- aimdb-core/src/session/aimx/mod.rs | 23 +-- aimdb-core/src/session/client.rs | 139 +++++-------- aimdb-core/src/session/connector.rs | 47 ++--- aimdb-core/src/session/mod.rs | 216 ++++++++------------- aimdb-core/src/session/pump.rs | 25 +-- aimdb-core/src/session/server.rs | 152 ++++++--------- aimdb-core/src/transform/join.rs | 4 +- aimdb-core/src/typed_record.rs | 2 +- aimdb-uds-connector/src/lib.rs | 22 +-- aimdb-uds-connector/src/transport.rs | 12 +- aimdb-websocket-connector/src/auth.rs | 4 +- aimdb-websocket-connector/src/codec.rs | 66 +++---- aimdb-websocket-connector/src/connector.rs | 2 +- aimdb-websocket-connector/src/dispatch.rs | 31 ++- aimdb-websocket-connector/src/lib.rs | 9 +- aimdb-websocket-connector/src/transport.rs | 18 +- 23 files changed, 351 insertions(+), 553 deletions(-) diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 157fb47..4fb6bf8 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -4,9 +4,9 @@ //! symmetric [`AimxCodec`] drive [`run_client`], which owns the wire, the //! request-id demux, and (optionally) reconnect. The public surface is the //! cheap-clone [`ClientHandle`] plus typed convenience wrappers and -//! per-subscription [`futures::Stream`]s — a deliberate **break** from the old -//! synchronous [`crate::connection::AimxClient`] (`&mut self`, single global -//! `receive_event()` queue), which stays until the server port retires it. +//! per-subscription [`futures::Stream`]s — a deliberate **break** from the +//! retired synchronous `AimxClient` (`&mut self`, single global +//! `receive_event()` queue). //! //! `run_client` is itself spawn-free (it returns a future for a runner to //! drive); this convenience layer is a *client application*, so it drives the diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index c3212b3..238a8a5 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -327,7 +327,7 @@ pub struct AimDbBuilder { spawn_fns: Vec<(StringKey, Box)>, /// Startup tasks registered via on_start() — spawned after build() completes. - /// Stored type-erased (Box) -> BoxFuture<…>>>) to allow + /// Stored type-erased (`Box) -> BoxFuture<…>>>`) to allow /// the field to exist on the unparameterised NoRuntime builder too. start_fns: Vec>, @@ -451,7 +451,7 @@ where /// driven the same way: /// /// 1. **Data-plane links** (MQTT / KNX / WebSocket): a record opts in with - /// `link_to(\"://\")` / `link_from(...)`, and the connector + /// `link_to("://")` / `link_from(...)`, and the connector /// mirrors that record to/from the external topic. The connector's /// [`scheme`](crate::connector::ConnectorBuilder::scheme) is what those /// links match against. @@ -459,7 +459,7 @@ where /// AimDB itself over a transport so peers can introspect/subscribe/write. /// - The **client** half (e.g. `UdsClient`) dials a peer and *does* use /// `link_to`/`link_from` under its scheme — just like (1), the scheme is - /// `\"remote\"` by default instead of `\"mqtt\"`. + /// `"remote"` by default instead of `"mqtt"`. /// - The **server** half (e.g. `UdsServer`) *accepts* connections and takes /// **no links** — registering it is how a server stands up remote access /// (this replaces the old `with_remote_access(config)`). @@ -469,8 +469,8 @@ where /// ```rust,ignore /// // (1) data-plane link to an MQTT topic /// AimDbBuilder::new().runtime(rt) - /// .with_connector(MqttConnector::new(\"mqtt://broker.local:1883\")) - /// .configure::(|r| { r.link_from(\"mqtt://commands/temp\")...; }) + /// .with_connector(MqttConnector::new("mqtt://broker.local:1883")) + /// .configure::(|r| { r.link_from("mqtt://commands/temp"); }) /// .build().await?; /// /// // (2a) remote-access SERVER — no links, just expose this db over UDS @@ -480,8 +480,8 @@ where /// /// // (2b) remote-access CLIENT — mirror a record to a peer over UDS /// AimDbBuilder::new().runtime(rt) - /// .with_connector(UdsClient::new(\"/run/aimdb.sock\")) - /// .configure::(|r| { r.with_remote_access().link_to(\"remote://temp\")...; }) + /// .with_connector(UdsClient::new("/run/aimdb.sock")) + /// .configure::(|r| { r.with_remote_access().link_to("remote://temp"); }) /// .build().await?; /// ``` pub fn with_connector( diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 68d7dec..d68ee11 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -473,7 +473,7 @@ pub struct ConnectorLink { /// Consumer factory callback (alloc feature) /// - /// Creates ConsumerTrait from Arc> to enable type-safe subscription. + /// Creates `ConsumerTrait` from `Arc>` to enable type-safe subscription. /// The factory captures the record type T at link_to() configuration time, /// allowing the connector to subscribe without knowing T at compile time. /// @@ -536,7 +536,7 @@ impl ConnectorLink { /// Creates a consumer using the stored factory (alloc feature) /// - /// Takes an Arc (which should contain Arc>) and invokes + /// Takes an `Arc` (which should contain `Arc>`) and invokes /// the consumer factory to create a ConsumerTrait instance. /// /// Returns None if no factory is configured. @@ -587,7 +587,7 @@ pub enum DeserializerKind { /// Type alias for producer factory callback (alloc feature) /// -/// Takes Arc (which contains AimDb) and returns a boxed ProducerTrait. +/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ProducerTrait`. /// This allows capturing the record type T at link_from() time while storing /// the factory in a type-erased InboundConnectorLink. /// @@ -616,7 +616,7 @@ pub type TopicResolverFn = Arc Option + Send + Sync>; /// Type-erased producer trait for MQTT router /// /// Allows the router to call produce() on different record types without knowing -/// the concrete type at compile time. The value is passed as Box and +/// the concrete type at compile time. The value is passed as `Box` and /// downcast to the correct type inside the implementation. /// /// # Implementation Note @@ -627,7 +627,7 @@ pub type TopicResolverFn = Arc Option + Send + Sync>; pub trait ProducerTrait: Send + Sync { /// Produce a value into the record's buffer /// - /// The value must be passed as Box and will be downcast to the correct type. + /// The value must be passed as `Box` and will be downcast to the correct type. /// Returns an error if the downcast fails or if production fails. fn produce_any<'a>( &'a self, @@ -637,7 +637,7 @@ pub trait ProducerTrait: Send + Sync { /// Type alias for consumer factory callback (alloc feature) /// -/// Takes Arc (which contains AimDb) and returns a boxed ConsumerTrait. +/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ConsumerTrait`. /// This allows capturing the record type T at link_to() time while storing /// the factory in a type-erased ConnectorLink. /// @@ -659,7 +659,7 @@ pub type ConsumerFactoryFn = pub trait ConsumerTrait: Send + Sync { /// Subscribe to typed values from this record /// - /// Returns a type-erased reader that can be polled for Box values. + /// Returns a type-erased reader that can be polled for `Box` values. /// The connector will downcast to the expected type after deserialization. fn subscribe_any<'a>(&'a self) -> SubscribeAnyFuture<'a>; } @@ -675,11 +675,11 @@ type RecvAnyFuture<'a> = /// Helper trait for type-erased reading /// /// Allows reading values from a buffer without knowing the concrete type at compile time. -/// The value is returned as Box and must be downcast by the caller. +/// The value is returned as `Box` and must be downcast by the caller. pub trait AnyReader: Send { /// Receive a type-erased value from the buffer /// - /// Returns Box which must be downcast to the concrete type. + /// Returns `Box` which must be downcast to the concrete type. /// Returns an error if the buffer is closed or an I/O error occurs. fn recv_any<'a>(&'a mut self) -> RecvAnyFuture<'a>; } @@ -707,7 +707,7 @@ pub struct InboundConnectorLink { /// Producer creation callback (alloc feature) /// - /// Takes Arc> and returns Box. + /// Takes `Arc>` and returns `Box`. /// Captures the record type T at link_from() call time. /// /// Available in both `std` and `no_std + alloc` environments. diff --git a/aimdb-core/src/extensions.rs b/aimdb-core/src/extensions.rs index 9103adf..86fbde1 100644 --- a/aimdb-core/src/extensions.rs +++ b/aimdb-core/src/extensions.rs @@ -1,4 +1,4 @@ -//! Generic extension storage for [`AimDbBuilder`] and [`AimDb`]. +//! Generic extension storage for [`AimDbBuilder`](crate::AimDbBuilder) and [`AimDb`](crate::AimDb). //! //! External crates store typed state here during builder configuration //! and retrieve it during record setup or at query time. This is the diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 0a69ae5..9b590c2 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -38,7 +38,7 @@ pub struct Route { /// /// Examples: MQTT topic, Kafka topic, HTTP path, DDS topic, shmem segment /// - /// Uses Arc instead of &'static str to avoid memory leaks from Box::leak(). + /// Uses `Arc` instead of `&'static str` to avoid memory leaks from `Box::leak()`. /// This adds ~8 bytes overhead per route (Arc control block) but enables proper cleanup. pub resource_id: Arc, @@ -262,7 +262,7 @@ impl RouterBuilder { /// /// This is a convenience method for automatic router construction from /// `AimDb::collect_inbound_routes()`. The resource_ids are converted to - /// Arc for proper memory management. + /// `Arc` for proper memory management. /// /// # Arguments /// * `routes` - Vector of (resource_id, producer, deserializer) tuples @@ -286,13 +286,13 @@ impl RouterBuilder { /// Add a route to the router /// /// # Arguments - /// * `resource_id` - Resource identifier to match (as Arc) + /// * `resource_id` - Resource identifier to match (as `Arc`) /// * `producer` - Producer that implements ProducerTrait /// * `deserializer` - Deserializer variant (raw or context-aware) /// /// # Resource ID Memory Management - /// The resource_id is stored as Arc for proper reference counting and cleanup. - /// You can create an Arc from: + /// The resource_id is stored as `Arc` for proper reference counting and cleanup. + /// You can create an `Arc` from: /// - String literal: `Arc::from("sensors/temperature")` /// - Owned String: `Arc::from(string.as_str())` pub fn add_route( diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs index ab0ca48..fd5db4a 100644 --- a/aimdb-core/src/session/aimx/codec.rs +++ b/aimdb-core/src/session/aimx/codec.rs @@ -1,25 +1,20 @@ -//! AimX-v2 NDJSON envelope codec (`no_std + alloc`, feature `connector-session` +//! AimX-v2 NDJSON envelope codec (`no_std + alloc`, features `connector-session` //! + `json-serialize`). //! -//! The reshaped AimX wire: one JSON object per line, tagged by a `"t"` field, -//! mapping verbatim onto the engine's role-neutral [`Inbound`]/[`Outbound`] -//! message set. This is **not** backward-compatible with the legacy AimX wire — -//! the no-compat decision lets the wire follow the engine's clean model instead -//! of the engine bending to preserve the old framing (see -//! `docs/design/remote-access-via-connectors.md`, Phase 3): +//! One JSON object per line, tagged by a `"t"` field, mapping onto the engine's +//! role-neutral [`Inbound`]/[`Outbound`] message set. This is **not** +//! backward-compatible with the legacy AimX wire: //! -//! - `record.subscribe` is an engine-native [`Inbound::Subscribe`] keyed by the -//! request `id`; there is **no** `{"subscription_id":"sub-N"}` ack and events -//! carry that `id` back as [`Outbound::Event::sub`] — the client owns the id. -//! - events carry only `{sub, seq, data}`; the legacy server-side `timestamp` / -//! `dropped` fields are gone (a client stamps on receipt if it cares). -//! - the Hello/Welcome handshake is a normal `call("hello", …)` over the client -//! handle, so `authenticate` stays peer-only — no privileged handshake frame. +//! - `record.subscribe` is an [`Inbound::Subscribe`] keyed by the request `id`; +//! there is no `subscription_id` ack — events carry the `id` back as +//! [`Outbound::Event::sub`]. +//! - events carry only `{sub, seq, data}` (no server-side `timestamp`/`dropped`). +//! - the Hello/Welcome handshake is a normal `call("hello", …)`, so +//! `authenticate` stays peer-only. //! -//! Per [the design](../../../../docs/design/remote-access-via-connectors.md) -//! decision 1 the record-value `Payload` is spliced into / sliced out of the -//! textual envelope verbatim via [`serde_json::value::RawValue`] — no -//! intermediate `Value` tree, no re-escaping, one serde pass. +//! The record-value `Payload` is spliced into / sliced out of the envelope +//! verbatim via [`serde_json::value::RawValue`] — no intermediate `Value` tree, +//! no re-escaping. use alloc::sync::Arc; @@ -178,10 +173,8 @@ impl EnvelopeCodec for AimxCodec { write_frame(out, &frame) } Outbound::Pong => write_frame(out, &Frame::tagged("pong")), - // AimX has no explicit subscribe ack (the client owns the id; events - // carry it back). `run_session` only emits this when `acks_subscribe` - // is set, which the AimX server leaves off — so this is unreachable on - // the AimX wire. + // AimX has no explicit subscribe ack; `run_session` only emits this + // when `acks_subscribe` is set, which the AimX server leaves off. Outbound::Subscribed { .. } => Err(CodecError::Malformed), } } diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index e00aa46..735da8f 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -1,24 +1,18 @@ -//! AimX server dispatch (std-only) — the method semantics of AimX remote access, -//! ported off the hand-rolled `remote/handler.rs` loop onto the shared session -//! engine (`serve`/`run_session`). +//! AimX server dispatch (`std`-only) — the method semantics of AimX remote +//! access, served on the shared session engine (`serve`/`run_session`). //! -//! Still `std`-gated: the dispatch reaches into core's `record.list`/JSON API -//! (the `AnyRecord` JSON + metadata methods), which remain `#[cfg(std)]` until -//! their own no_std port. A transport (UDS today) pairs this dispatch with the +//! `std`-gated because it reaches into core's `record.list` / JSON API (the +//! `AnyRecord` JSON + metadata methods). A transport pairs this dispatch with the //! generic [`SessionServerConnector`](crate::session::SessionServerConnector) — //! see `aimdb-uds-connector`'s `UdsServer`. //! -//! The dispatch role is split per the Phase-3 server-port refinement (doc 037): -//! - [`AimxDispatch`] is the **shared** half (one `Arc` per server): peer-only +//! The role is split in two: +//! - [`AimxDispatch`] — the shared half (one `Arc` per server): peer-only //! `authenticate` + an `open` factory. -//! - [`AimxSession`] is the **per-connection** half the engine owns by value, so -//! `record.drain`'s lazy per-record cursors live in it (`drain_readers`) — the -//! one seam the AimX wire reshape did not dissolve. +//! - `AimxSession` — the per-connection half the engine owns by value, homing +//! `record.drain`'s lazy per-record cursors (`drain_readers`). //! -//! Method bodies are ported verbatim from `remote/handler.rs`, reusing the same -//! db introspection helpers; only the reply shape changes (the reshaped AimX-v2 -//! wire's `Result` instead of the legacy rich `Response`). -//! Param shapes follow the v2 client ([`aimdb_client::AimxConnection`]): +//! Param shapes follow the client ([`aimdb_client::AimxConnection`]): //! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`. use std::collections::HashMap; @@ -34,7 +28,7 @@ use crate::session::{ }; use crate::{AimDb, DbError, RuntimeAdapter}; -/// The shared AimX dispatch — `authenticate` (peer-only) + the [`AimxSession`] +/// The shared AimX dispatch — `authenticate` (peer-only) + the `AimxSession` /// factory. One `Arc` is shared across every accepted connection. pub struct AimxDispatch { db: Arc>, @@ -61,9 +55,8 @@ where _first: Option<&'a [u8]>, ) -> BoxFut<'a, Result> { // Peer-only: AimX over UDS relies on socket file permissions for access - // control; the `auth_token` identity is not yet threaded into per-call - // checks (Phase 4 — the auth-context-shape gate). Permission *policy* - // (ReadOnly / writable_records) is enforced per-call from the config. + // control. Permission policy (ReadOnly / writable_records) is enforced + // per-call from the config. Box::pin(async { Ok(SessionCtx::default()) }) } @@ -107,10 +100,8 @@ where &'a mut self, topic: &'a str, ) -> BoxFut<'a, Result, RpcError>> { - // The engine owns the subscription lifecycle (keyed by request id) and - // the per-connection cap (SessionLimits); no `generate_subscription_id` - // / `max_subs` bookkeeping here. AimX has no async authorization, so this - // is a trivial wrapper. + // The engine owns the subscription lifecycle and the per-connection cap; + // AimX has no async authorization, so this is a trivial wrapper. Box::pin(async move { let stream = crate::remote::stream::stream_record_updates(&self.db, topic) .map_err(map_db_err)?; @@ -276,10 +267,8 @@ where /// Build the `Welcome` from the security policy + writable records. /// - /// `writable_records` is derived from the policy directly (not the per-record - /// `writable` marking the builder applies) so the server is self-contained - /// when `build_aimx_server` is used standalone; it is intersected with the - /// records that actually exist so the server never advertises a phantom key. + /// `writable_records` is derived from the policy directly and intersected with + /// the records that actually exist, so the server never advertises a phantom key. fn welcome(&self) -> Value { let (permissions, writable_records) = match &self.config.security_policy { SecurityPolicy::ReadOnly => (vec!["read".to_string()], Vec::new()), diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs index bac4cfb..9a6f5db 100644 --- a/aimdb-core/src/session/aimx/mod.rs +++ b/aimdb-core/src/session/aimx/mod.rs @@ -1,20 +1,15 @@ -//! AimX-v2 codec + dispatch (the concrete substrate the shared session engine -//! rides for AimX remote access). -//! -//! Split by capability so the embedded *client* (a sensor dialing a gateway over -//! a real transport) gets the codec on `no_std + alloc`, while the *server* -//! dispatch stays `std`-gated until its own no_std port (Phase 6 follow-up): +//! AimX codec + dispatch — the concrete protocol substrate the session engines +//! ride for AimX remote access. //! //! - [`AimxCodec`] — the symmetric NDJSON [`EnvelopeCodec`](crate::session::EnvelopeCodec), -//! `no_std + alloc` (features `connector-session` + `json-serialize`). Both the -//! proactive `run_client` and the reactive `serve` engine ride it. -//! - [`AimxDispatch`] — the server method semantics, **`std`-only** for now (it -//! reaches into core's `record.list`/JSON API, which is still std-gated). +//! `no_std + alloc` (features `connector-session` + `json-serialize`); used by +//! both the `run_client` and `serve` engines. +//! - [`AimxDispatch`] — the server method semantics, `std`-only (it reaches into +//! core's `record.list` / JSON API). //! -//! The concrete **transport** (UDS dialer/listener + socket setup) no longer -//! lives here — a transport is a swappable connector crate (see -//! `aimdb-uds-connector`). Core keeps only the protocol (codec + dispatch) and -//! the generic [`SessionClientConnector`](crate::session::SessionClientConnector) / +//! The transport (UDS) lives in a separate connector crate +//! (`aimdb-uds-connector`); core keeps only the protocol plus the generic +//! [`SessionClientConnector`](crate::session::SessionClientConnector) / //! [`SessionServerConnector`](crate::session::SessionServerConnector) spine. mod codec; diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 56d346a..67d1af7 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -1,30 +1,16 @@ -//! Phase 2 **client** engine — the proactive half of the shared session -//! substrate (doc 034 § "shared with a client engine"; doc 035 Client -//! capability). The dual of [`server`](super::server): it *dials* a -//! [`Connection`] via a [`Dialer`] instead of accepting one, *sends* [`Inbound`] -//! and *receives* [`Outbound`] (roles swapped vs the server), and demultiplexes -//! replies by `id`. +//! The proactive **client** engine of the session substrate — the dual of the +//! [`server`](super::server): it *dials* a [`Connection`] via a [`Dialer`], +//! *sends* [`Inbound`] / *receives* [`Outbound`], and demultiplexes replies by `id`. //! -//! Per the Phase 2 client-surface gate (resolved: **one engine, both -//! surfaces**), [`run_client`] owns the demux-by-`id` core and returns a -//! [`ClientHandle`] exposing caller-initiated RPC (`call`/`subscribe`/`write`). -//! Record *mirroring* (`pump_client(db, scheme, …)`) is a thin wrapper that -//! lands in **Phase 3** alongside the AimX route collection it needs — it will -//! drive this same engine, not a second one. +//! [`run_client`] owns the demux core and returns a [`ClientHandle`] for +//! caller-initiated RPC (`call`/`subscribe`/`write`) plus the engine future for +//! the runner to drive (spawn-free). [`pump_client`] is a thin wrapper that +//! mirrors records over the same engine. //! -//! Spawn-free: [`run_client`] returns the engine future for the runner to drive; -//! it never spawns. -//! -//! **Runtime-neutral (Phase 5).** The only runtime-specific primitive this engine -//! touches is *time* (reconnect backoff + keepalive), so it is parametrized over -//! the adapter's [`TimeOps`] clock; everything else is `futures` channels + -//! `select_biased!`. No `tokio`/`embassy-*` here — the runtime split lives in the -//! adapter crates' `TimeOps` impls. -//! -//! Like the server, the demux loop uses an **extract-then-act** shape: the -//! `select_biased!` block only computes a small [`ClientStep`] (it must not touch -//! `conn` while a sibling arm's future still borrows it), then the loop acts on -//! it once the borrows release. +//! Runtime-neutral: the only runtime-specific primitive is *time* (reconnect +//! backoff + keepalive), via the adapter's [`TimeOps`] clock; everything else is +//! `futures` channels. The demux loop uses the same **extract-then-act** shape as +//! the server (compute a [`ClientStep`], then act once the arm borrows release). use alloc::boxed::Box; use alloc::string::{String, ToString}; @@ -44,41 +30,32 @@ use crate::connector::SerializerKind; use crate::router::RouterBuilder; use crate::{AimDb, RuntimeAdapter}; -/// Client engine knobs. Durations are **milliseconds** (`u64`) rather than -/// `std::time::Duration` so the engine stays `no_std`-clean and runtime-neutral — -/// the adapter's [`TimeOps`] turns them into its native `Duration` at the call. +/// Client engine knobs. Durations are in **milliseconds** so the engine stays +/// `no_std`-clean; the adapter's [`TimeOps`] turns them into its native `Duration`. #[derive(Debug, Clone)] pub struct ClientConfig { /// Redial after a dropped/failed connection instead of ending the engine. pub reconnect: bool, - /// Base delay (ms) before the first redial when `reconnect` is set. Subsequent - /// redials grow this exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). + /// Base delay (ms) before the first redial; subsequent redials grow + /// exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). pub reconnect_delay: u64, - /// Upper bound (ms) for the exponential reconnect backoff. Defaults to - /// [`reconnect_delay`](Self::reconnect_delay) (i.e. no escalation — a fixed - /// delay, preserving the pre-Phase-4 behavior). + /// Upper bound (ms) for the reconnect backoff. Defaults to + /// [`reconnect_delay`](Self::reconnect_delay) (a fixed delay). pub max_reconnect_delay: u64, - /// Maximum redial attempts before the engine gives up. `0` = unlimited - /// (the default). + /// Maximum redial attempts before giving up. `0` = unlimited (default). pub max_reconnect_attempts: usize, - /// If set, send a keepalive `Ping` after this many milliseconds of an - /// otherwise-idle connection. `None` (default) disables keepalive. (Phase 5: - /// the timer re-arms each loop iteration, so this is an *idle* keepalive — - /// inbound/outbound traffic resets it, which only suppresses redundant pings.) + /// Send a keepalive `Ping` after this many ms of an idle connection; the timer + /// re-arms each iteration, so traffic resets it. `None` (default) disables it. pub keepalive_interval: Option, - /// Cap on caller commands buffered while disconnected; the oldest are dropped - /// past this bound. Defaults to `usize::MAX` (effectively unbounded — the - /// pre-Phase-4 behavior). + /// Cap on caller commands buffered while disconnected (oldest dropped past it). + /// Defaults to `usize::MAX` (unbounded). pub max_offline_queue: usize, - /// Key the subscription demux by **topic** instead of the engine request id. - /// `false` (default, AimX-style) — events carry the request id back, demux by - /// id. `true` (WS-style) — the wire pushes data keyed by topic with no id, so - /// the codec's `decode_outbound` returns the topic as `Event.sub` and the - /// engine routes by topic. + /// Key the subscription demux by **topic** instead of the request `id`. + /// `false` (default): events echo the id. `true`: the wire pushes data keyed + /// by topic, so `decode_outbound` returns the topic as `Event.sub`. pub topic_routed_subs: bool, - /// Send a Ping handshake on connect and wait for the Pong before accepting - /// caller commands (the proactive "handshake-as-caller"). Mirrors the - /// server's `reads_hello`; a real protocol swaps Ping/Pong for its Hello. + /// Send a Ping handshake on connect and await the Pong before serving caller + /// commands. A real protocol swaps Ping/Pong for its Hello. pub sends_hello: bool, } @@ -98,8 +75,7 @@ impl Default for ClientConfig { } /// Exponential backoff (ms) for the `attempt`-th redial (1-based), capped at -/// [`ClientConfig::max_reconnect_delay`]. Defaults collapse this to a fixed -/// `reconnect_delay` (max == base), preserving pre-Phase-4 behavior. +/// [`ClientConfig::max_reconnect_delay`]. fn backoff_delay(config: &ClientConfig, attempt: usize) -> u64 { let base = config.reconnect_delay; let cap = config.max_reconnect_delay.max(base); @@ -162,10 +138,9 @@ impl ClientHandle { rx.await.map_err(|_| RpcError::Internal)? } - /// Open a subscription; returns a stream of updates immediately (the - /// `Subscribe` request is sent to the server asynchronously by the engine). - /// Dropping the stream stops local delivery; an explicit remote Unsubscribe - /// is left to Phase 3 (the connector mirroring path). + /// Open a subscription; returns the stream of updates immediately (the engine + /// sends the `Subscribe` request asynchronously). Dropping the stream stops + /// local delivery. pub fn subscribe( &self, topic: impl Into, @@ -221,16 +196,13 @@ enum Ended { HandlesDropped, } -/// On engine exit (any path), **close and drain** the command channel so buffered -/// or in-flight caller commands are dropped — each `ClientCmd::Call` drops with -/// its `reply` oneshot sender, so a waiting [`ClientHandle::call`] resolves with -/// [`RpcError::Internal`] instead of hanging forever. +/// On engine exit, close and drain the command channel so buffered/in-flight +/// commands are dropped — each `ClientCmd::Call` drops its `reply` sender, so a +/// waiting [`ClientHandle::call`] resolves with [`RpcError::Internal`] instead of +/// hanging. /// -/// This is required because `async-channel` keeps buffered items alive as long as -/// *any* `Sender` exists (a live `ClientHandle` does), and a dropped `Receiver` -/// only *closes* the queue without draining it — unlike `tokio::mpsc`, whose -/// receiver-drop discarded the backlog. `close()` first stops new sends; the -/// drain then releases the backlog. +/// Needed because `async-channel` keeps buffered items alive while any `Sender` +/// exists, and dropping the `Receiver` only closes the queue without draining it. struct DrainOnExit<'a>(&'a Receiver); impl Drop for DrainOnExit<'_> { @@ -240,9 +212,8 @@ impl Drop for DrainOnExit<'_> { } } -/// What [`drive_connection`]'s `select_biased!` decided this iteration. Extracted -/// so the connection work runs *after* the select's arm futures (and their borrow -/// of `conn`) are dropped — see the module note. +/// What [`drive_connection`]'s `select_biased!` decided this iteration — extracted +/// so the work runs after the arm futures' borrow of `conn` releases. enum ClientStep { /// A frame (or close/error) arrived from the server. Inbound(super::TransportResult>>), @@ -265,8 +236,7 @@ async fn client_loop( { // Whenever the engine returns, fail any buffered/in-flight calls (see guard). let _drain = DrainOnExit(&cmd_rx); - // Consecutive failed attempts since the last successful connection; drives - // exponential backoff and the optional attempt cap. + // Consecutive failed attempts; drives backoff and the attempt cap. let mut attempt: usize = 0; loop { let conn = match dialer.connect().await { @@ -365,13 +335,11 @@ where } loop { - // `biased`, server-read first. The select only *decides* the next step; - // it must not touch `conn` while the `recv` arm still borrows it. + // Biased toward the server read. The select only decides the next step. let step = { let mut recv = conn.recv().fuse(); - // Idle keepalive: re-armed each iteration. With no interval configured - // the arm parks on `pending()` forever, so it never wins the select. - // The async block is `!Unpin`, so pin it for the select arm. + // Idle keepalive, re-armed each iteration; with no interval it parks on + // `pending()` forever. `!Unpin`, so pin it for the arm. let mut keepalive = core::pin::pin!(async { match keepalive_ms { Some(ms) => clock.sleep(clock.millis(ms)).await, @@ -379,8 +347,7 @@ where } } .fuse()); - // async-channel's `recv()` is `!Unpin` (holds a pinned listener), so - // pin it in place for the arm. + // `recv()` is `!Unpin`, so pin it for the arm. let mut cmd = core::pin::pin!(cmd_rx.recv().fuse()); select_biased! { // ---- inbound from server: Reply / Event / Snapshot / Pong -- @@ -404,13 +371,10 @@ where if let Some(tx) = pending.remove(&id) { let _ = tx.send(result); } else if result.is_err() { - // Subscribe-ack contract: a successful subscribe is - // acknowledged implicitly by its events flowing; the - // server replies only on *failure* (unknown record, - // sub cap). Such a Reply carries the subscribe `id`, - // which was never registered as a pending call — so - // drop the matching event sink to end the stream - // (`None`) instead of leaving it hanging forever. + // A subscribe is acked implicitly by its events; the + // server replies only on failure, carrying the subscribe + // `id` (never a pending call). Drop the event sink so the + // stream ends instead of hanging. subs.remove(&id.to_string()); } } @@ -429,9 +393,7 @@ where } } Ok(Outbound::Pong) => {} - // Explicit subscribe ack (WS). Informational — the local - // event sink already exists from the Subscribe command, so - // there is nothing to route; just confirm liveness. + // Explicit subscribe ack — informational; the sink already exists. Ok(Outbound::Subscribed { .. }) => {} Err(_e) => continue, // skip a malformed frame, keep the connection } @@ -475,8 +437,7 @@ where ClientCmd::Subscribe { topic, events } => { let id = next_id; next_id += 1; - // Topic-routed (WS): the wire pushes data keyed by topic, - // so demux by topic; id-routed (AimX): events echo the id. + // Demux key: topic (topic-routed) or the request id. let key = if config.topic_routed_subs { topic.clone() } else { diff --git a/aimdb-core/src/session/connector.rs b/aimdb-core/src/session/connector.rs index 087403e..8301799 100644 --- a/aimdb-core/src/session/connector.rs +++ b/aimdb-core/src/session/connector.rs @@ -2,22 +2,20 @@ //! transport crate (`aimdb-uds-connector`, and later serial/TCP) wraps. //! //! A transport contributes only a [`Dialer`]/[`Listener`]/[`Connection`] triple -//! (doc 037 Layer 1) and an [`EnvelopeCodec`]; the engine wiring (reconnect, -//! pumps, accept loop, fan-out) is inherited here verbatim. So a new transport -//! is a thin crate, and swapping one never ripples into record/link code. +//! and an [`EnvelopeCodec`]; the engine wiring (reconnect, pumps, accept loop, +//! fan-out) is inherited here, so a new transport is a thin crate and swapping +//! one never ripples into record/link code. //! -//! - [`SessionClientConnector`] generalizes the dialing half: on `build` it -//! opens [`run_client`] over the injected dialer/codec and drives -//! [`pump_client`] for every route under its **scheme**. Generalized from the -//! old `AimxClientConnector` (which hardcoded a UDS dialer + the AimX codec). -//! - [`SessionServerConnector`] generalizes the accepting half: it binds a -//! [`Listener`] (behind a factory, so bind errors surface synchronously from -//! `build`) and drives [`serve`] with an injected dispatch + codec. -//! Generalized from the old in-core `build_aimx_server`. +//! - [`SessionClientConnector`] — the dialing half: on `build` it opens +//! [`run_client`] over the injected dialer/codec and drives [`pump_client`] for +//! every route under its **scheme**. +//! - [`SessionServerConnector`] — the accepting half: it binds a [`Listener`] +//! (behind a factory, so bind errors surface synchronously from `build`) and +//! drives [`serve`] with an injected dispatch + codec. //! -//! The **scheme** is a constructor argument (default `"remote"`): it decouples -//! the logical routing key from the transport, so the same scheme can be backed -//! by any transport and two transports can coexist under different schemes. +//! The **scheme** is a constructor argument (default `"remote"`) decoupling the +//! logical routing key from the transport, so two transports can coexist under +//! different schemes. use alloc::boxed::Box; use alloc::string::{String, ToString}; @@ -48,9 +46,8 @@ type BuildFuture<'a> = Pin>> + S // =========================================================================== /// Mirrors records to/from a peer reached via the dialer `D`, speaking codec `C`, -/// under a logical [`scheme`](ConnectorBuilder::scheme). The transport-agnostic -/// generalization of the old `AimxClientConnector`; a transport crate wraps it in -/// a one-line sugar constructor (e.g. `UdsClient`). +/// under a logical [`scheme`](ConnectorBuilder::scheme). A transport crate wraps +/// it in a one-line sugar constructor (e.g. `UdsClient`). pub struct SessionClientConnector { scheme: String, dialer: D, @@ -60,7 +57,7 @@ pub struct SessionClientConnector { impl SessionClientConnector { /// Mirror records over `dialer`, framing messages with `codec`. The scheme - /// defaults to [`DEFAULT_SCHEME`] (`"remote"`). + /// defaults to `"remote"`. pub fn new(dialer: D, codec: C) -> Self { Self { scheme: DEFAULT_SCHEME.to_string(), @@ -116,14 +113,11 @@ where // =========================================================================== /// Accepts connections from a [`Listener`] `L` and serves them with a dispatch, -/// speaking codec `C`, under a logical [`scheme`](ConnectorBuilder::scheme). The -/// transport-agnostic generalization of the old in-core `build_aimx_server`. +/// speaking codec `C`, under a logical [`scheme`](ConnectorBuilder::scheme). /// -/// Two factories keep this transport- and protocol-agnostic: +/// Two factories keep it transport- and protocol-agnostic: /// - `listener_factory` runs at `build` time and returns `DbResult`, so the -/// bind (remove-stale / `bind` / `set_permissions` for UDS) happens there and -/// any error surfaces synchronously from `build`, exactly as the legacy -/// supervisor's synchronous bind did. +/// bind happens there and any error surfaces synchronously from `build`. /// - `dispatch_factory` turns the live `&AimDb` into an `Arc` /// (e.g. an `AimxDispatch`), so the spine never names a concrete protocol. pub struct SessionServerConnector { @@ -137,7 +131,7 @@ pub struct SessionServerConnector { impl SessionServerConnector { /// Build a server connector. `listener_factory` binds the listener at /// `build` time; `dispatch_factory` produces the per-server dispatch from the - /// live db. The scheme defaults to [`DEFAULT_SCHEME`] (`"remote"`). + /// live db. The scheme defaults to `"remote"`. pub fn new( listener_factory: LF, codec: C, @@ -169,8 +163,7 @@ where DF: Fn(&AimDb) -> Arc + Send + Sync + 'static, { fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { - // Bind synchronously so a bind error surfaces from `build` (not from a - // spawned future), mirroring the legacy supervisor. + // Bind synchronously so a bind error surfaces from `build`. let listener = (self.listener_factory)(); let dispatch = (self.dispatch_factory)(db); let codec = Arc::new(self.codec.clone()); diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 9a00747..60266ed 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -1,19 +1,16 @@ -//! Frozen Phase 0 connector-session contracts (trait skeletons only). +//! Connector-session substrate — the shared machinery for transport-based +//! remote access. //! -//! This module locks the cross-cutting trait **signatures** that every later -//! phase of the connector-convergence initiative (issue #39 — embedded remote -//! access) depends on. It ships **contracts, not behavior**: every method body -//! is `unimplemented!()`. The engines (`run_session` / `serve` / `run_client`), -//! the pump helpers, and the transport/dispatch impls all arrive in Phases 1–6. +//! Three layers: transport ([`Connection`]/[`Listener`]/[`Dialer`]), codec +//! ([`EnvelopeCodec`]), and dispatch ([`Dispatch`]/[`Session`]), over a +//! role-neutral [`Inbound`]/[`Outbound`] message set shared by the reactive +//! server engine (`serve`/`run_session`) and the proactive client engine +//! (`run_client`/`pump_client`). Data-plane connectors use `pump_sink`/ +//! `pump_source` over the [`Source`] / [`Connector`](crate::transport::Connector) +//! capabilities. //! -//! See [`docs/design/remote-access-via-connectors.md`] for the design — the -//! decisions, the three-layer substrate (transport + [`EnvelopeCodec`] + -//! [`Dispatch`]), and the capability model. `Sink` is now the canonical -//! [`Connector`](crate::transport::Connector); [`Source`] / [`Dialer`] are here. -//! -//! Everything here is `dyn`-safe and compiles on `std` **and** `no_std + alloc` -//! (boxed-future pattern throughout, no `std`/`tokio`/`serde_json` at the -//! contract level). +//! All contracts are `dyn`-safe and compile on `std` and `no_std + alloc`. See +//! `docs/design/remote-access-via-connectors.md` for the design. extern crate alloc; @@ -23,16 +20,8 @@ use core::pin::Pin; use futures_core::Stream; -// --------------------------------------------------------------------------- -// Phase 2 engines. **Phase 5 made these runtime-neutral** (`futures` channels + -// `select_biased!` + the adapter's `TimeOps` clock — no `tokio`/`embassy-*`), so -// they now gate on `connector-session` (`alloc`) rather than `std` and -// cross-compile to `thumbv7em-none-eabihf`. The frozen contracts above stay -// `no_std + alloc` as before. Only the concrete AimX substrate below (UDS + -// NDJSON) is still std-only until its embedded transport lands in Phase 6. -// See docs/design/remote-access-via-connectors.md (Phases 0/2/5). -// --------------------------------------------------------------------------- - +// The engines are runtime-neutral (`futures` channels + the adapter's `TimeOps` +// clock, no `tokio`/`embassy-*`), so they cross-compile to `no_std + alloc`. #[cfg(feature = "connector-session")] mod client; #[cfg(feature = "connector-session")] @@ -42,12 +31,10 @@ mod pump; #[cfg(feature = "connector-session")] mod server; -// Concrete AimX-v2 protocol substrate (NDJSON codec + server dispatch). The -// codec is `no_std + alloc` (the embedded *client* rides it over a real -// transport); the dispatch stays `std`-gated inside the module until its own -// no_std port. The transport itself is no longer here — it is a swappable -// connector crate (`aimdb-uds-connector`); core keeps the protocol + the -// generic [`SessionClientConnector`] / [`SessionServerConnector`] spine. +// Concrete AimX protocol substrate. The codec is `no_std + alloc`; the server +// dispatch is `std`-gated. The transport lives in a separate connector crate +// (`aimdb-uds-connector`) — core keeps the protocol plus the generic +// [`SessionClientConnector`] / [`SessionServerConnector`] spine. #[cfg(all(feature = "connector-session", feature = "json-serialize"))] pub mod aimx; @@ -64,23 +51,17 @@ pub use server::{run_session, serve, SessionConfig}; // Shared aliases // =========================================================================== -/// Boxed, `Send` future — the object-safe async return shape used by every -/// trait here, matching the existing `Connector` / `ProducerTrait` pattern. +/// Boxed, `Send` future — the object-safe async return shape used throughout. pub type BoxFut<'a, T> = Pin + Send + 'a>>; -/// Boxed, `Send` stream — the reply shape of a subscription -/// ([`Dispatch::subscribe`]). +/// Boxed, `Send` stream — the reply shape of a subscription ([`Session::subscribe`]). pub type BoxStream<'a, T> = Pin + Send + 'a>>; -/// The record-value seam between the outer [`EnvelopeCodec`] and the inner M16 -/// record-value `JsonCodec` (Decision 1: **raw bytes**). +/// A serialized record value, carried opaquely through the codec. /// -/// Opaque serialized bytes; cheap-clone (refcount bump) for WS fan-out, -/// `no_std + alloc`-native, no new dependency. Bytes flow opaque through the hot -/// paths; typed/structured conversion happens only at the ends that need it -/// (`serde_json::Value` materializes only inside RPC handlers that inspect -/// structure). `bytes::Bytes` is reserved for a later need (cheap sub-slicing / -/// zero-copy binary framing). +/// `Arc<[u8]>` so fan-out is a cheap refcount bump; bytes stay opaque on the hot +/// path, with structured (`serde_json::Value`) conversion only where a handler +/// inspects them. pub type Payload = Arc<[u8]>; /// Result of a transport-layer operation. @@ -90,14 +71,12 @@ pub type TransportResult = Result; // Supporting types (stubs — sufficient for the signatures to compile) // =========================================================================== -/// Remote-peer metadata carried by a [`Connection`] (remote addr, pre-resolved -/// auth). +/// Remote-peer metadata carried by a [`Connection`]. /// -/// **Phase 4 (resolved — doc 037 auth-context gate).** One shape serves both -/// connectors: a neutral [`peer_addr`](Self::peer_addr) plus a type-erased -/// [`ext`](Self::ext) slot the connector fills with its own resolved identity -/// (WS stuffs `ClientInfo`/`Permissions` at the HTTP upgrade; AimX stuffs its -/// `SecurityPolicy`). Core stays connector-agnostic; each side downcasts `ext`. +/// A neutral [`peer_addr`](Self::peer_addr) plus a type-erased +/// [`ext`](Self::ext) slot a connector fills with its own resolved identity +/// (e.g. WS attaches `ClientInfo` at the HTTP upgrade), keeping core +/// connector-agnostic. Downcast `ext` with [`ext_as`](Self::ext_as). #[derive(Clone, Default)] #[non_exhaustive] pub struct PeerInfo { @@ -130,12 +109,12 @@ impl core::fmt::Debug for PeerInfo { } } -/// The authenticated session context threaded through [`Dispatch`] calls. +/// The authenticated session context produced by [`Dispatch::authenticate`] and +/// threaded into [`Dispatch::open`]. /// -/// **Phase 4 (resolved — doc 037 auth-context gate).** Carries the resolved -/// principal as a type-erased [`ext`](Self::ext) that [`Dispatch::open`] threads -/// into the per-connection [`Session`] for per-operation authorization -/// (`authorize_subscribe`/`authorize_write`). AimX leaves it `None`. +/// Carries the resolved principal as a type-erased [`ext`](Self::ext) for +/// per-operation authorization in the [`Session`]; downcast with +/// [`ext_as`](Self::ext_as). Connectors that don't authenticate leave it `None`. #[derive(Clone, Default)] #[non_exhaustive] pub struct SessionCtx { @@ -163,11 +142,7 @@ impl core::fmt::Debug for SessionCtx { } } -/// Engine-local bounds for a session (consumed by the Phase 2 engines, not by -/// the contracts here). -/// -/// Whether these become `heapless`/const-generic vs runtime config is -/// **deferred to Phase 5** (bounded-resource policy). +/// Per-session resource bounds consumed by the engines. #[derive(Debug, Clone)] pub struct SessionLimits { /// Maximum concurrently served connections. @@ -224,9 +199,8 @@ pub enum AuthError { } // =========================================================================== -// Logical message set (role-neutral; the server's `Inbound` is the client's -// out-bound and vice versa — doc 034 § The substrate is shared with a client -// engine). Field types align with the existing AimX wire (`remote::protocol`). +// Logical message set — role-neutral: the server's `Inbound` is the client's +// outbound and vice versa. // =========================================================================== /// A logical request arriving over a [`Connection`] (what the server receives). @@ -289,11 +263,9 @@ pub enum Outbound<'a> { data: Payload, }, /// An explicit acknowledgement that a subscription opened. Emitted by - /// [`run_session`](super::server::run_session) only when - /// [`SessionConfig::acks_subscribe`](super::server::SessionConfig) is set - /// (WS needs it; AimX's ack is implicit, so it leaves the flag off and never - /// emits this). The `sub` is the subscription's routing id — the same value - /// that tags its [`Event`](Outbound::Event)s. + /// [`run_session`] only when [`SessionConfig::acks_subscribe`] is set. + /// The `sub` is the subscription's routing id — the same value that tags its + /// [`Event`](Outbound::Event)s. Subscribed { /// Subscription id that was opened. sub: &'a str, @@ -303,9 +275,8 @@ pub enum Outbound<'a> { } // =========================================================================== -// Layer 1 — transport (the std / Embassy seam). Doc 034 § Layer 1; `Dialer` -// from doc 035 § The toolkit (the dual of `Listener`). Framing lives *in* the -// transport: `recv` returns one logical frame. +// Layer 1 — transport. Framing lives in the transport: `recv` returns one +// logical frame. `Dialer` is the client-side dual of `Listener`. // =========================================================================== /// A framed, bidirectional pipe — role-neutral (yielded by either @@ -335,46 +306,35 @@ pub trait Dialer: Send { } // =========================================================================== -// Layer 3 — dispatch (the semantics). Doc 034 § Layer 3 + § EnvelopeCodec. -// RPC and streaming unify in ONE per-connection role (Decision 2): three reply -// cardinalities — `call` (one) / `subscribe` (many) / `write` (none). -// -// The role is split across two traits so the shared, immutable half (one -// `Arc` per server) and the per-connection mutable half (one -// `Box` per accepted connection) each own what they need: -// -// - [`Dispatch`] — `Send + Sync`, shared: `authenticate` + an `open` factory. -// - [`Session`] — `Send`, per-connection: `call` / `subscribe` / `write` on -// `&mut self`, so a connection can hold mutable state (e.g. `record.drain`'s -// lazy per-record cursors — the one seam the AimX wire reshape did not -// dissolve) without a lock. See doc 037 (the additive server-port refinement, -// mirroring the Phase-2 `encode_inbound`/`decode_outbound` precedent). +// Layer 3 — dispatch. RPC and streaming unify in one per-connection role with +// three reply cardinalities: `call` (one) / `subscribe` (many) / `write` (none). +// Split across two traits: the shared, immutable [`Dispatch`] (one +// `Arc` per server) and the per-connection, `&mut`-threaded +// [`Session`] that can hold mutable state without a lock. // =========================================================================== /// The shared application dispatch: authenticate a connection, then open a /// per-connection [`Session`]. One `Arc` is shared across every -/// connection a server accepts, so it stays `Sync` and behind `&self`. +/// accepted connection, so it stays `Send + Sync` and behind `&self`. pub trait Dispatch: Send + Sync { /// Resolve a [`SessionCtx`] from peer metadata and/or the first frame - /// (WS supplies pre-resolved identity via [`PeerInfo`]; UDS reads a Hello). + /// (a pre-resolved identity in [`PeerInfo`], or an in-band Hello in `first`). fn authenticate<'a>( &'a self, peer: &'a PeerInfo, first: Option<&'a [u8]>, ) -> BoxFut<'a, Result>; - /// Open the per-connection [`Session`] once, after [`authenticate`]. The - /// returned session owns the connection's mutable dispatch state (drain - /// cursors today, per-session auth identity in Phase 4) that the shared - /// `Arc` cannot hold behind `&self`; the engine threads `&mut` into it. + /// Open the per-connection [`Session`] once, after [`authenticate`](Self::authenticate). It owns + /// the connection's mutable dispatch state that the shared `Arc` cannot + /// hold behind `&self`; the engine threads `&mut` into it. fn open(&self, ctx: &SessionCtx) -> Box; } /// The per-connection session: serves calls, subscriptions, and writes for one -/// accepted [`Connection`]. The engine ([`run_session`]) owns the -/// `Box` and threads `&mut self` into each method, so a session can -/// hold per-connection mutable state without a lock — while the shared, -/// immutable role stays on [`Dispatch`]. +/// accepted [`Connection`]. The engine owns the `Box` and threads +/// `&mut self` into each method, so it can hold per-connection state without a +/// lock; the shared, immutable role stays on [`Dispatch`]. pub trait Session: Send { /// One-shot RPC: one request → one reply. fn call<'a>( @@ -383,16 +343,10 @@ pub trait Session: Send { params: Payload, ) -> BoxFut<'a, Result>; - /// Streaming: open a subscription that yields many payloads. The stream is - /// `'static` (it captures cloned handles), so it outlives the `&mut` borrow - /// and lives in the engine's `FuturesUnordered` (doc 034 risk list). - /// - /// Defaulted to [`RpcError::NotFound`] so a dispatch with no streaming - /// surface need not implement it (doc 037 § the server-port refinement — - /// the stream is side-neutral, so it is defaulted here for symmetry). - /// - /// Async (Phase 4): opening a subscription may need to *await* per-operation - /// authorization (e.g. WS `authorize_subscribe`); the engine awaits it. + /// Open a subscription yielding many payloads. The stream is `'static` (it + /// captures cloned handles) so it outlives the `&mut` borrow and lives in the + /// engine. Async so a connector can await per-operation authorization. + /// Defaulted to [`RpcError::NotFound`] for dispatches with no streaming. fn subscribe<'a>( &'a mut self, topic: &'a str, @@ -401,18 +355,17 @@ pub trait Session: Send { Box::pin(async { Err(RpcError::NotFound) }) } - /// Late-join snapshot: the current value for `topic`, emitted by - /// [`run_session`](super::server::run_session) as an [`Outbound::Snapshot`] - /// right after a successful [`subscribe`](Session::subscribe) and before the - /// first event. Defaulted to `None` (no snapshot) — AimX inherits this; WS - /// overrides it from its `SnapshotProvider`. + /// Late-join snapshot: the current value for `topic`, emitted as an + /// [`Outbound::Snapshot`] right after a successful + /// [`subscribe`](Session::subscribe) and before the first event. Defaulted to + /// `None` (no snapshot). fn snapshot(&mut self, topic: &str) -> Option { let _ = topic; None } - /// Fire-and-forget write: no reply. Routes through the existing - /// producer/arbiter path (single-writer-per-key stays intact). + /// Fire-and-forget write: no reply. Routes through the producer/arbiter path, + /// so single-writer-per-key stays intact. fn write<'a>( &'a mut self, topic: &'a str, @@ -420,21 +373,15 @@ pub trait Session: Send { ) -> BoxFut<'a, Result<(), RpcError>>; } -/// The protocol-envelope codec: frame bytes ↔ one logical message set. Distinct -/// from, and layered above, the M16 record-value `JsonCodec` it nests; the wire -/// format (NDJSON / WS-JSON / `serde-json-core`) stays pluggable. +/// The protocol-envelope codec: frame bytes ↔ the logical message set. Layered +/// above (and nesting) the record-value `JsonCodec`, so the wire format stays +/// pluggable; `decode`/`encode` keep the record value as an opaque [`Payload`] +/// sliced from / spliced into the frame. /// -/// Per Decision 1, `decode` yields `params`/`data` as an *unparsed* [`Payload`] -/// (a slice of the frame) and `encode` splices a [`Payload`] in verbatim. -/// -/// **Symmetric (both engines, one codec).** The first pair below is the -/// *server* direction (read requests / write replies), frozen in Phase 0. The +/// Symmetric, so one codec object serves both engines: `decode`/`encode` are the +/// server direction (read requests / write replies) and /// [`encode_inbound`](EnvelopeCodec::encode_inbound) / -/// [`decode_outbound`](EnvelopeCodec::decode_outbound) pair is the *client* -/// direction (write requests / read replies), added in Phase 2 so `run_client` -/// reuses the **same** codec object rather than a per-role copy — the -/// role-neutral-substrate invariant (doc 036). The two frozen signatures are -/// unchanged; this is purely additive. +/// [`decode_outbound`](EnvelopeCodec::decode_outbound) the client direction. pub trait EnvelopeCodec: Send + Sync { /// Decode one frame into a logical [`Inbound`] message (server reads a request). fn decode(&self, frame: &[u8]) -> Result; @@ -454,26 +401,21 @@ pub trait EnvelopeCodec: Send + Sync { } // =========================================================================== -// Data-plane capabilities (doc 035 § The toolkit). Connectionless: an external -// library owns any session. +// Data-plane capabilities — connectionless (an external library owns any +// session). The outbound `Sink` is the canonical +// [`Connector`](crate::transport::Connector); the inbound `Source` is below. // =========================================================================== -// AimDB → external data-plane (the `Sink` capability) is the canonical -// [`Connector`](crate::transport::Connector) trait verbatim — Phase 1 collapsed -// the Phase-0 `Sink` skeleton onto it. `pump_sink` takes `Arc`. - -/// External → AimDB data-plane — a stream of inbound frames (the one genuinely -/// new data-plane trait; replaces the hand-rolled read loop). +/// External → AimDB data-plane: a stream of inbound frames, drained by +/// `pump_source`. pub trait Source: Send { /// Yield the next `(topic, payload)`, or `None` when the source is done. fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>>; } // =========================================================================== -// Object-safety: referencing each trait as `&dyn Trait` in non-test code forces -// the dyn-compatibility check on *all* targets (std and `no_std + alloc`), not -// just under `cargo test`. The `#[cfg(test)]` block below additionally builds a -// `Box` from a mock per the acceptance criteria. +// Object-safety: taking each trait as `&dyn Trait` forces the dyn-compatibility +// check on all targets, not just under `cargo test`. // =========================================================================== #[allow(dead_code, clippy::too_many_arguments)] @@ -580,7 +522,7 @@ mod tests { } } - /// Acceptance criterion: every frozen trait is `dyn`-usable. + /// Every trait is `dyn`-usable. #[test] fn traits_are_object_safe() { let _connection: Box = Box::new(MockConnection); diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index 7af69d2..76b79a2 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -1,8 +1,7 @@ -//! Data-plane pump helpers (doc 035 § The toolkit, masterplan 036 Phase 1). +//! Data-plane pump helpers. //! -//! Two free functions that own the boilerplate every data-plane connector used -//! to hand-roll, layered on top of the [`ConnectorBuilder::build -> Vec`] -//! spine. A connector author writes only the pure I/O adapter — a +//! Two free functions that own the boilerplate a data-plane connector used to +//! hand-roll. The author writes only the pure I/O adapter — a //! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`] //! (inbound) — and composes the helpers in `build()`: //! @@ -12,10 +11,7 @@ //! Ok(f) //! ``` //! -//! Both helpers are `no_std + alloc`-native (boxed futures, no `tokio`) and gate -//! on `connector-session` alongside the session engines, so they cross-compile to -//! `thumbv7em-none-eabihf`. The spine stays the universal contract and escape -//! hatch — a connector that fits no helper still implements `build()` directly. +//! Both are `no_std + alloc`-native (boxed futures, no `tokio`). extern crate alloc; @@ -133,14 +129,13 @@ where /// Inbound pump: a single multiplexed reader future for `scheme`. /// -/// Drives one [`Source`] — never one task per topic (doc 035 Decision 2) — fanning -/// each `(topic, payload)` out to the matching producers via a [`Router`] built -/// from [`collect_inbound_routes`](AimDb::collect_inbound_routes). Replaces the -/// hand-rolled read+route loop. +/// Drives one [`Source`] (never one task per topic), fanning each +/// `(topic, payload)` out to the matching producers via a [`Router`] built from +/// [`collect_inbound_routes`](AimDb::collect_inbound_routes). /// -/// Backpressure (doc 035 Decision 3): [`Router::route`] drops + logs on a full -/// producer buffer rather than blocking, so one slow record never stalls the -/// shared source. Route errors are non-fatal and never propagate. +/// Backpressure: [`Router::route`] drops + logs on a full producer buffer rather +/// than blocking, so one slow record never stalls the shared source. Route errors +/// are non-fatal. /// /// [`Router`]: crate::router::Router /// [`Router::route`]: crate::router::Router::route diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 168b31b..3428c70 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -1,26 +1,19 @@ -//! Phase 2 **server** engine — the reactive half of the shared session -//! substrate (doc 034 § Layer 2). Written once here; it generalizes the two -//! hand-rolled loops it will replace in Phases 3–4: +//! The reactive **server** engine of the session substrate. //! -//! - [`run_session`] = `remote/handler.rs`'s biased `select!` per-connection loop -//! (RPC + streaming + writes over one [`Connection`]), transport-erased. -//! - [`serve`] = `remote/supervisor.rs`'s accept loop, generalized over -//! [`Listener`] and honoring [`SessionLimits::max_connections`]. +//! - [`run_session`] drives one accepted [`Connection`]: a biased `select_biased!` +//! loop interleaving inbound requests (RPC + subscribe + write) with outbound +//! subscription events. +//! - [`serve`] is the accept loop over a [`Listener`], honoring +//! [`SessionLimits::max_connections`]. //! -//! Spawn-free: every per-connection and per-subscription task lives in a -//! [`FuturesUnordered`] owned by the engine future the runner drives — no -//! `tokio::spawn`. -//! -//! **Runtime-neutral (Phase 5).** This engine is purely reactive — it touches no -//! timer — so it carries *zero* runtime knowledge: `futures` channels + a -//! `select_biased!` over the wire, the event funnel, and the subscription task -//! set. No `tokio`/`embassy-*` here; it runs unchanged on both via the adapters. +//! Spawn-free (every per-connection/-subscription task lives in a +//! [`FuturesUnordered`] the runner drives) and runtime-neutral (purely reactive, +//! so no timer and no `tokio`/`embassy-*`). //! //! The loops use an **extract-then-act** shape: `select_biased!` only computes a -//! small [`Step`]/action value (it must not touch `conn`/`subs` while a sibling -//! arm's future still borrows them — unlike `tokio::select!`, `futures`' macro -//! keeps the non-selected futures alive across the handler), then the loop acts -//! on that value once the borrows release. +//! small [`Step`], then the loop acts on it once the arm futures (and their +//! borrows of `conn`/`subs`) drop — `futures`' macro, unlike `tokio::select!`, +//! keeps non-selected futures alive across the handler. use alloc::boxed::Box; use alloc::string::{String, ToString}; @@ -41,27 +34,21 @@ use super::{ /// Per-session engine knobs. #[derive(Debug, Clone, Default)] pub struct SessionConfig { - /// Bounds for one session (connection cap is consulted by [`serve`]; - /// per-connection subscription cap by [`run_session`]). + /// Connection cap (consulted by [`serve`]) and per-connection subscription + /// cap (by [`run_session`]). pub limits: SessionLimits, - /// How identity is resolved: - /// - `true` (UDS-style) — read one frame before authenticating and pass it - /// to [`Dispatch::authenticate`] as the in-band Hello. - /// - `false` (WS-style, the default) — authenticate from - /// [`PeerInfo`](super::PeerInfo) alone (identity pre-resolved at the HTTP - /// upgrade), no frame consumed. + /// If `true`, read one frame before authenticating and pass it to + /// [`Dispatch::authenticate`] as an in-band Hello. If `false` (default), + /// authenticate from [`PeerInfo`](super::PeerInfo) alone. pub reads_hello: bool, - /// Emit an explicit [`Outbound::Subscribed`] ack when a subscription opens. - /// - `false` (default, AimX-style) — the ack is implicit; events flow and - /// carry the subscription id back, no ack frame. - /// - `true` (WS-style) — `run_session` emits `Subscribed { sub }` before the - /// first event, restoring the explicit ack WS clients wait on. + /// If `true`, emit an explicit [`Outbound::Subscribed`] ack before the first + /// event. If `false` (default) the ack is implicit (events carry the + /// subscription id back). pub acks_subscribe: bool, } -/// Bound for the per-connection event funnel — caps how many pending outbound -/// updates a single connection may buffer before pumps start dropping (matches -/// the hand-rolled WS server's default per-client channel capacity). +/// Bound for the per-connection event funnel: pending outbound updates a +/// connection may buffer before pumps start dropping. const EVENT_BUFFER: usize = 256; /// One subscription update on its way back to the connection's send half. @@ -71,9 +58,8 @@ struct SubEvent { data: Payload, } -/// What [`run_session`]'s `select_biased!` decided this iteration. Extracted so -/// the connection/subscription work runs *after* the select's arm futures (and -/// their borrows of `conn`/`subs`) are dropped — see the module note. +/// What [`run_session`]'s `select_biased!` decided this iteration — extracted so +/// the work runs after the arm futures' borrows of `conn`/`subs` release. enum Step { /// A logical frame arrived from the peer (decode + dispatch). Frame(Vec), @@ -88,11 +74,10 @@ enum Step { /// Drive one accepted [`Connection`] until it closes. /// -/// Authenticates once, then interleaves — `biased`, request-read first so a -/// chatty subscription cannot starve the RPC path — incoming requests, outgoing -/// subscription events funneled from the per-subscription pumps, and draining of -/// finished subscription futures. Dropping the engine (runner cancelled) drops -/// `subs`, cancelling every live subscription. +/// Authenticates once, then interleaves (biased toward inbound reads, so a chatty +/// subscription cannot starve the RPC path) incoming requests, outgoing +/// subscription events, and reaping of finished subscription pumps. Dropping the +/// engine cancels every live subscription. pub async fn run_session( mut conn: Box, codec: &C, @@ -121,40 +106,29 @@ pub async fn run_session( } }; - // Open the per-connection session once. It owns the connection's mutable - // dispatch state (e.g. `record.drain` cursors); the loop below threads - // `&mut` into its `call` / `subscribe` / `write`. + // Open the per-connection session once; the loop threads `&mut` into it. let mut session = dispatch.open(&ctx); - // Event funnel: every per-subscription pump sends its updates here; the main - // loop is the sole writer to the connection. **Bounded** so a slow client - // (one whose socket is backpressured, stalling the main loop) cannot grow the - // funnel without limit — the pumps drop on overflow rather than accumulate - // (events carry a monotonic `seq`, so a client can detect the gap). This - // restores the bounded-buffer slow-client protection the hand-rolled loops had. + // Event funnel: every per-subscription pump sends updates here; the main loop + // is the sole writer to the connection. Bounded, so a slow client cannot grow + // it without limit — pumps drop on overflow (events carry a monotonic `seq`). let (event_tx, event_rx) = async_channel::bounded::(EVENT_BUFFER); // Per-connection subscription pumps; the engine future is their sole owner. - // Each pump resolves to its own sub id on completion, so the loop can prune - // the matching `cancels` entry (a pump ended via Unsubscribe was already - // pruned, making the redundant remove a harmless no-op). + // Each resolves to its own sub id, so the loop can prune the matching + // `cancels` entry (a no-op if Unsubscribe already removed it). let mut subs: FuturesUnordered> = FuturesUnordered::new(); - // sub-id → cancel handle (dropping/sending the oneshot cancels the pump, - // race-free unlike a bare `Notify`). + // sub-id → cancel handle; dropping/firing the oneshot cancels the pump. let mut cancels: HashMap> = HashMap::new(); // Reused encode scratch buffer. let mut out = Vec::new(); loop { - // `biased`, request-read first so a chatty subscription cannot starve the - // RPC path. The `select_biased!` block only *decides* the next step — it - // must not touch `conn`/`subs` while a sibling arm's future still borrows - // them; the work happens after, in the `match`. + // Biased toward inbound reads. The select only decides the next step; the + // work happens after, in the `match`, once the arm borrows release. let step = { - // Per-iteration futures, fused for the `select_biased!` arms. The - // channel `recv()` is `!Unpin` (async-channel holds a pinned - // listener), so it is pinned in place; `subs` is a `FuturesUnordered` - // (`Unpin` + `FusedStream`), so `select_next_some` parks on the empty - // set and the always-active `recv` arm keeps the select alive. + // Per-iteration futures, fused for the select. `event_rx.recv()` is + // `!Unpin`, so pin it; `subs` (a `FusedStream`) parks on the empty set + // while the always-active `recv` arm keeps the select alive. let mut recv = conn.recv().fuse(); let mut event = core::pin::pin!(event_rx.recv().fuse()); select_biased! { @@ -222,8 +196,8 @@ pub async fn run_session( } } Inbound::Subscribe { id, topic } => { - // The request id that opened the subscription is its - // routing key; events carry it back as `Outbound::Event.sub`. + // The opening request id is the subscription's routing key; + // events carry it back as `Outbound::Event.sub`. let sub_id = id.to_string(); if cancels.len() >= config.limits.max_subs_per_connection { send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await; @@ -231,8 +205,7 @@ pub async fn run_session( } match session.subscribe(&topic).await { Ok(stream) => { - // Optional explicit ack (WS-style); AimX leaves - // `acks_subscribe` off so its ack stays implicit. + // Optional explicit ack (see `acks_subscribe`). if config.acks_subscribe { out.clear(); if codec @@ -279,7 +252,7 @@ pub async fn run_session( cancels.remove(&sub); } Inbound::Write { topic, payload } => { - // Fire-and-forget; routes through the session (single-writer-per-key intact). + // Fire-and-forget; single-writer-per-key stays intact. let _ = session.write(&topic, payload).await; } Inbound::Ping => { @@ -295,8 +268,7 @@ pub async fn run_session( } } - // Sole owner of `subs` and `cancels` drops here → every live subscription - // pump is cancelled. + // Dropping `subs` here cancels every live subscription pump. drop(subs); } @@ -338,16 +310,14 @@ async fn pump_subscription( tx: Sender, cancel: oneshot::Receiver<()>, ) -> String { - // `oneshot::Receiver` reports `is_terminated() == true` once its sender drops - // (the cancel signal!), and `select_biased!` *skips* terminated arms — so a - // bare `cancel` arm would never fire on Unsubscribe. Fuse it once: `Fuse`'s - // `is_terminated` stays false until the fused future itself yields `Ready`, so - // the arm is polled and the cancellation is observed. + // Fuse the cancel receiver: a bare `oneshot::Receiver` reports + // `is_terminated()` once its sender drops, and `select_biased!` skips + // terminated arms — so the cancel would never fire. `Fuse` keeps the arm + // polled until it actually resolves. let mut cancel = cancel.fuse(); let mut seq: u64 = 0; loop { - // `cancel` and `stream` are independent, and neither handler touches the - // other's borrow, so this stays a direct `select_biased!`. + // Independent arms, so a direct `select_biased!` is fine here. let data = select_biased! { // Resolves on explicit Unsubscribe (send) or on sender drop. _ = cancel => break, @@ -358,9 +328,8 @@ async fn pump_subscription( }, }; seq += 1; - // `try_send` keeps the pump non-blocking: a backpressured funnel drops - // this update (slow-client protection) rather than stalling the bus; only - // a disconnected funnel ends the pump. + // Non-blocking: drop on a full funnel (slow-client protection); only a + // disconnected funnel ends the pump. match tx.try_send(SubEvent { sub: sub_id.clone(), seq, @@ -376,36 +345,31 @@ async fn pump_subscription( /// Accept connections from `listener` and serve each with [`run_session`], /// bounded by [`SessionLimits::max_connections`]. The accept loop and all -/// per-connection futures share one [`FuturesUnordered`] — spawn-free, mirroring -/// `remote/supervisor.rs`. +/// per-connection futures share one [`FuturesUnordered`] — spawn-free. pub async fn serve(mut listener: L, codec: Arc, dispatch: Arc, config: SessionConfig) where L: Listener, C: EnvelopeCodec + 'static, - // `?Sized` so a caller can serve an `Arc` (the generic - // `SessionServerConnector` does, to stay protocol-agnostic). `run_session` - // already accepts `?Sized`; `serve` only uses `dispatch` via `clone`/`as_ref`. + // `?Sized` so a caller can serve an `Arc`. D: Dispatch + 'static + ?Sized, { let mut conns: FuturesUnordered> = FuturesUnordered::new(); loop { - // Extract the accept result first (the accept future borrows `listener`, - // and pushing/reaping borrows `conns` — keep them apart), then act. + // Extract the accept result first, then act (keeps the `listener` and + // `conns` borrows apart). let accept = { let mut accept = listener.accept().fuse(); select_biased! { a = accept => a, - // `select_next_some` parks on the empty-`FuturesUnordered` case, - // so the accept arm keeps the select alive without a guard. + // Parks on the empty set, so the accept arm keeps the select alive. () = conns.select_next_some() => continue, } }; match accept { Ok(conn) => { - // Soft cap; `len()` is conservative (a completed-but-undrained - // future still counts), which only ever refuses one extra. + // Soft cap; `len()` is conservative (counts not-yet-reaped futures). if conns.len() >= config.limits.max_connections { #[cfg(feature = "tracing")] tracing::warn!( diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 8c16cdf..8f6cd5c 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -131,7 +131,7 @@ type JoinInputFactory = Box< /// loop) is created by the runtime adapter at database startup — capacity is an /// internal constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). /// -/// Obtain via [`RecordRegistrar::transform_join`]. +/// Obtain via [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). pub struct JoinBuilder { inputs: Vec<(String, JoinInputFactory)>, _phantom: PhantomData<(O, R)>, @@ -241,7 +241,7 @@ where /// Completed multi-input join pipeline, ready to be registered on a record. /// /// Produced by [`JoinBuilder::on_triggers`] and consumed by -/// [`RecordRegistrar::transform_join`]. Not normally constructed directly. +/// [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). Not normally constructed directly. pub struct JoinPipeline { pub(crate) spawn_factory: Box TransformDescriptor + Send>, } diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 181b2ab..c0dbc61 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -540,7 +540,7 @@ pub struct TypedRecord< > { /// Optional producer service - a task that generates data /// This will be auto-spawned during build() if present - /// Stored as FnOnce that takes (Producer, RuntimeContext) and returns a Future + /// Stored as `FnOnce` that takes (`Producer`, `RuntimeContext`) and returns a `Future` /// Wrapped in Mutex for interior mutability (needed to take() during spawning) producer: Mutex>>, diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 8747962..992bb5d 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -1,17 +1,16 @@ //! Unix-domain-socket transport connector for AimDB remote access. //! -//! A transport is a thin, swappable connector crate (doc 041): it contributes -//! only the [`Dialer`]/[`Listener`]/[`Connection`] triple ([`UdsConnection`] / +//! A thin, swappable transport crate: it contributes only the +//! `Dialer`/`Listener`/`Connection` triple ([`UdsConnection`] / //! [`UdsDialer`] / [`UdsListener`]); the AimX codec + dispatch and the engine -//! wiring are reused verbatim from `aimdb-core`. Two ergonomic constructors wrap -//! the generic core connectors: +//! wiring are reused from `aimdb-core`. Two ergonomic constructors wrap the +//! generic core connectors: //! //! - [`UdsClient`] — dials a peer over UDS and mirrors records under a scheme -//! (`"remote"` by default). It uses `link_to`/`link_from` like any data-plane +//! (`"remote"` by default), using `link_to`/`link_from` like any data-plane //! connector. Sugar over [`SessionClientConnector`]``. -//! - [`UdsServer`] — *accepts* connections and serves the AimX toolset over UDS. -//! Register it with `with_connector` to stand up remote access (this replaces -//! the old `AimDbBuilder::with_remote_access(config)`). Sugar over +//! - [`UdsServer`] — accepts connections and serves the AimX toolset over UDS; +//! register it with `with_connector` to stand up remote access. Sugar over //! [`SessionServerConnector`]. //! //! ```rust,ignore @@ -99,8 +98,8 @@ impl UdsServer { } } - /// Build from a full [`AimxConfig`] — the one-line migration for code that - /// used the old `AimDbBuilder::with_remote_access(config)`. + /// Build from a full [`AimxConfig`] (the one-line migration from the former + /// `AimDbBuilder::with_remote_access`). pub fn from_config(config: AimxConfig) -> Self { Self { config, @@ -183,8 +182,7 @@ where // =========================================================================== /// Bind the Unix-domain socket synchronously (remove a stale socket file, -/// `bind`, `set_permissions`) so bind errors surface from `build`. Relocated out -/// of core's `build_aimx_server`. +/// `bind`, `set_permissions`) so bind errors surface from `build`. fn bind_uds_listener(config: &AimxConfig) -> DbResult { #[cfg(feature = "tracing")] tracing::info!( diff --git a/aimdb-uds-connector/src/transport.rs b/aimdb-uds-connector/src/transport.rs index 8f7b266..cdb832c 100644 --- a/aimdb-uds-connector/src/transport.rs +++ b/aimdb-uds-connector/src/transport.rs @@ -1,15 +1,9 @@ //! AimX UDS transport — a [`Connection`] over a Unix-domain socket with NDJSON //! framing in the transport: one line == one logical frame. //! -//! Relocated out of `aimdb-core` in Phase 6: a transport is a swappable -//! connector crate that contributes only the [`Dialer`]/[`Listener`]/ -//! [`Connection`] triple (doc 037 Layer 1). The engine, codec, and dispatch are -//! reused verbatim from core. -//! -//! Both transport roles ride the same role-neutral [`UdsConnection`]: the -//! dialing half ([`UdsDialer`]) that the proactive `run_client` engine drives, -//! and the accepting half ([`UdsListener`]) that the reactive `serve` engine -//! drives. +//! Both roles ride the same role-neutral [`UdsConnection`]: the dialing half +//! ([`UdsDialer`], driven by `run_client`) and the accepting half +//! ([`UdsListener`], driven by `serve`). use std::path::PathBuf; diff --git a/aimdb-websocket-connector/src/auth.rs b/aimdb-websocket-connector/src/auth.rs index df93a96..9a56911 100644 --- a/aimdb-websocket-connector/src/auth.rs +++ b/aimdb-websocket-connector/src/auth.rs @@ -140,8 +140,8 @@ impl AuthError { pub trait AuthHandler: Send + Sync + 'static { /// Called during WebSocket upgrade to authenticate the client. /// - /// Return [`Ok(Permissions)`] to accept the connection with the assigned - /// permissions, or [`Err(AuthError)`] to reject it (HTTP 401). + /// Return `Ok(Permissions)` to accept the connection with the assigned + /// permissions, or `Err(AuthError)` to reject it (HTTP 401). fn authenticate<'a>( &'a self, request: &'a AuthRequest, diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs index 6db9ac0..0d9cb35 100644 --- a/aimdb-websocket-connector/src/codec.rs +++ b/aimdb-websocket-connector/src/codec.rs @@ -1,26 +1,19 @@ -//! Per-connection WS-JSON [`EnvelopeCodec`] (Phase 4 — doc 039 § 2). +//! Per-connection WS-JSON `EnvelopeCodec`. //! //! Maps the WS wire ([`ClientMessage`]/[`ServerMessage`]) onto the engine's -//! [`Inbound`]/[`Outbound`] so `run_session` ([`aimdb_core::session::run_session`]) -//! can drive a WebSocket exactly as it drives AimX. +//! `Inbound`/`Outbound` so `run_session` drives a WebSocket exactly as it +//! drives AimX. //! -//! **Why per-connection (not the shared `Arc` that `serve` uses).** `decode` -//! is **1→1**, so it cannot fan a multi-topic `Subscribe` into N -//! `Inbound::Subscribe` — the transport splits the frame instead (see -//! [`crate::transport`]) and the codec synthesizes a `u64` id per topic, which the -//! `Subscribed` ack and `Unsubscribe` map back to a topic. That `id↔topic` -//! bookkeeping is per-connection state a shared `Arc` cannot hold. Option A -//! calls `run_session(conn, &codec, …)` directly (only `serve` shares `Arc`), -//! so each upgrade builds its own `WsCodec` holding the maps behind a `Mutex` (it -//! stays `Send + Sync`; encode/decode take `&self`). +//! Per-connection (not the shared `Arc` that `serve` uses) because the codec +//! holds `id↔topic` bookkeeping: `decode` is 1→1, so the transport splits a +//! multi-topic `Subscribe` (see [`crate::transport`]) and the codec synthesizes a +//! `u64` id per topic that the `Subscribed` ack and `Unsubscribe` map back. The +//! maps sit behind a `Mutex` so the codec stays `Send + Sync` with `&self` methods. //! -//! **Data frames are pre-serialized by the bus.** The hot fan-out path does *not* -//! pass through the id maps: [`ClientManager::broadcast`](crate::client_manager) -//! serializes the complete `Data` frame **once** (it owns the real topic) and the -//! codec writes it verbatim — O(1) in subscribers. The explicit `Subscribed` ack -//! and late-join `Snapshot` are engine emissions (`Outbound::Subscribed` + -//! `Session::snapshot`, gated by `SessionConfig::acks_subscribe`); the codec maps -//! those to wire frames. +//! The hot fan-out path skips the maps: [`ClientManager::broadcast`](crate::client_manager) +//! serializes the complete `Data` frame once (it owns the topic) and the codec +//! writes it verbatim — O(1) in subscribers. The `Subscribed` ack and late-join +//! `Snapshot` are engine emissions the codec maps to wire frames. use std::collections::HashMap; use std::sync::Mutex; @@ -30,8 +23,8 @@ use serde_json::Value; use crate::protocol::{ClientMessage, ErrorCode, ServerMessage}; -/// Per-connection id bookkeeping (doc 039 § 2). Lives behind a `Mutex` so the -/// `&self` codec methods can mutate it. +/// Per-connection id bookkeeping, behind a `Mutex` so the `&self` codec methods +/// can mutate it. #[derive(Default)] struct WsCodecState { /// Monotonic id allocator for engine `Subscribe`/`Request` correlation. The @@ -169,10 +162,8 @@ impl aimdb_core::EnvelopeCodec for WsCodec { fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError> { match msg { - // The bus pre-serializes the complete `Data` frame **once** per - // broadcast (it knows the real topic + raw-payload mode), so fan-out - // is O(1) in subscribers, not O(N) re-serializations — see - // `ClientManager::broadcast`. The codec just writes it verbatim. + // The bus pre-serializes the complete `Data` frame once per broadcast, + // so fan-out is O(1) — the codec writes it verbatim. Outbound::Event { data, .. } => { out.extend_from_slice(&data); Ok(()) @@ -220,8 +211,8 @@ impl aimdb_core::EnvelopeCodec for WsCodec { } // ---- client direction: write a ClientMessage, read a ServerMessage ------ - // Used by the WS client port (`run_client`, Workstream D). The client engine - // is configured topic-routed, so `Event.sub` carries the topic. + // Used by the WS client port; the client engine is topic-routed, so + // `Event.sub` carries the topic. fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { let client_msg = match msg { @@ -264,11 +255,9 @@ impl aimdb_core::EnvelopeCodec for WsCodec { } fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { - // `Outbound::Event`/`Snapshot` borrow `topic` as `&'a str` from the frame. - // serde's internally-tagged enums can't borrow (they buffer), so we peek - // the `type` tag, then deserialize the matching struct that borrows the - // topic slice **zero-copy** (no interning, no leak). Topics are record - // keys without JSON escapes, so the borrow always succeeds. + // `Event`/`Snapshot` borrow `topic` zero-copy from the frame. serde's + // internally-tagged enums can't borrow, so peek the `type` tag, then + // deserialize a struct that borrows the topic slice. let tag: TagOnly = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; match tag.ty { "data" => { @@ -288,13 +277,11 @@ impl aimdb_core::EnvelopeCodec for WsCodec { data: value_to_payload(d.payload), }) } - // Informational on the client; the engine ignores `Subscribed`, so the - // `sub` value is irrelevant — hand back an empty borrow (no leak). + // Informational; the engine ignores `Subscribed`, so `sub` is irrelevant. "subscribed" => Ok(Outbound::Subscribed { sub: "" }), "pong" => Ok(Outbound::Pong), - // Query/list responses + errors are RPC replies; the caller-RPC path - // is not wired on the WS client (records mirror via Data/Snapshot), - // so map them to a benign Pong. + // Query/list/error replies aren't wired on the WS client (records + // mirror via Data/Snapshot), so map them to a benign Pong. _ => Ok(Outbound::Pong), } } @@ -424,9 +411,8 @@ mod tests { } } - // Layer 2.3 (#1): decoding many *distinct*-topic Data frames must not - // accumulate any process-lifetime state (the old `leak_topic` interner would - // have grown one `&'static str` per topic here). The borrow is zero-copy. + // Decoding many distinct-topic Data frames must not accumulate any + // process-lifetime state; the borrow is zero-copy. #[test] fn decode_outbound_high_cardinality_no_static_growth() { let codec = WsCodec::new(); diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/connector.rs index 77d84e8..f2be32a 100644 --- a/aimdb-websocket-connector/src/connector.rs +++ b/aimdb-websocket-connector/src/connector.rs @@ -13,7 +13,7 @@ //! //! # Inbound routing //! -//! Inbound writes from WebSocket clients go through the shared [`Router`] +//! Inbound writes from WebSocket clients go through the shared `Router` //! (same infrastructure as MQTT). The `Connector::publish()` impl is a //! no-op because WebSocket inbound happens via the session receive loop instead //! of the standard publish path. diff --git a/aimdb-websocket-connector/src/dispatch.rs b/aimdb-websocket-connector/src/dispatch.rs index 4857a7b..62b78e1 100644 --- a/aimdb-websocket-connector/src/dispatch.rs +++ b/aimdb-websocket-connector/src/dispatch.rs @@ -1,17 +1,14 @@ -//! WS server [`Dispatch`] + [`Session`] (Phase 4 — doc 039 § 3). +//! WS server [`Dispatch`] + [`Session`]. //! //! [`WsDispatch`] is the shared half (one `Arc` per server): -//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (carried in -//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`]. The session -//! threads `&mut self` from `run_session` and homes the application surface — the -//! [`ClientManager`] bus handle, the auth principal, the query handler — exactly -//! as `AimxSession` homes `drain_readers`. +//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (in +//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`] homing the +//! application surface (the [`ClientManager`] bus handle, auth principal, query +//! handler). //! -//! The id↔topic bookkeeping does **not** live here — it lives in the -//! per-connection [`WsCodec`](crate::codec) (doc 039 § 2). The explicit -//! `Subscribed` ack and late-join `Snapshot` are engine emissions -//! (`acks_subscribe` + `Session::snapshot`); this session only supplies the -//! snapshot bytes and the filtered subscription stream. +//! The id↔topic bookkeeping lives in the per-connection [`WsCodec`](crate::codec), +//! not here. The `Subscribed` ack and late-join `Snapshot` are engine emissions; +//! this session only supplies the snapshot bytes and the subscription stream. use std::any::Any; use std::sync::Arc; @@ -45,8 +42,7 @@ impl Dispatch for WsDispatch { peer: &'a PeerInfo, _first: Option<&'a [u8]>, ) -> BoxFut<'a, Result> { - // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo` - // as a `ClientInfo` (WS-style `reads_hello:false`, doc 039 § 4). + // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo`. let info = peer.ext_as::(); Box::pin(async move { match info { @@ -132,7 +128,7 @@ impl Session for WsSession { }, _ => return Err(RpcError::NotFound), }; - // The codec writes this complete `ServerMessage` verbatim (doc 039 § 2). + // The codec writes this complete `ServerMessage` verbatim. let bytes = serde_json::to_vec(&response).map_err(|_| RpcError::Internal)?; Ok(Payload::from(bytes.as_slice())) }) @@ -143,14 +139,11 @@ impl Session for WsSession { topic: &'a str, ) -> BoxFut<'a, Result, RpcError>> { Box::pin(async move { - // Per-operation authorization — the full async `AuthHandler` hook, so - // a custom `authorize_subscribe` (per-topic ACL, token introspection) - // is honored, not just the static permission set. + // Per-operation authorization via the async `AuthHandler` hook. if !self.auth.authorize_subscribe(&self.info, topic).await { return Err(RpcError::Denied); } - // Register on the shared bus; the engine owns the returned stream and - // drops it on Unsubscribe/teardown (the bus prunes the dead entry). + // Register on the shared bus; the engine owns and drops the stream. let (_sub_id, stream) = self.client_mgr.subscribe(topic); Ok(stream) }) diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 3cda91b..232705b 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -67,7 +67,7 @@ //! //! ## Authentication (server only) //! -//! See [`auth`] for the [`AuthHandler`][auth::AuthHandler] trait. +//! See [`auth`] for the [`AuthHandler`] trait. // ════════════════════════════════════════════════════════════════════ // Server modules (feature = "server") @@ -101,13 +101,12 @@ pub mod client; // Shared session-engine glue (Phase 4 — server and/or client) // ════════════════════════════════════════════════════════════════════ -/// Per-connection WS-JSON [`EnvelopeCodec`](aimdb_core::EnvelopeCodec) shared by -/// the server (`run_session`) and client (`run_client`) ports. +/// Per-connection WS-JSON `EnvelopeCodec` shared by the server (`run_session`) +/// and client (`run_client`) ports. #[cfg(any(feature = "server", feature = "client"))] pub mod codec; -/// WS transport adapters ([`Connection`](aimdb_core::Connection)/`Dialer`) over a -/// real WebSocket. +/// WS transport adapters (`Connection`/`Dialer`) over a real WebSocket. #[cfg(any(feature = "server", feature = "client"))] pub mod transport; diff --git a/aimdb-websocket-connector/src/transport.rs b/aimdb-websocket-connector/src/transport.rs index b313ade..82a9f73 100644 --- a/aimdb-websocket-connector/src/transport.rs +++ b/aimdb-websocket-connector/src/transport.rs @@ -1,13 +1,9 @@ -//! WS transport adapters — [`Connection`](aimdb_core::Connection) (and, for the -//! client, `Dialer`) over a real WebSocket so the shared session engines drive it -//! (Phase 4 — doc 039 § 6). +//! WS transport adapters — `Connection` (and, for the client, `Dialer`) over a +//! real WebSocket, so the shared session engines drive it. //! -//! The **server** side ([`WsServerConnection`]) wraps axum's upgraded -//! [`WebSocket`]; the upgrade handler hands it to `run_session` -//! ([`aimdb_core::session::run_session`]). It also performs the **multi-topic -//! split** (doc 039 § 2 / issue.md § 1a): a `Subscribe`/`Unsubscribe` frame -//! carrying N topics is yielded as N single-topic logical frames so the codec's -//! `decode` stays 1→1. +//! The **server** side (`WsServerConnection`) wraps axum's upgraded `WebSocket` +//! and performs the **multi-topic split**: a `Subscribe`/`Unsubscribe` carrying N +//! topics is yielded as N single-topic frames, so the codec's `decode` stays 1→1. #[cfg(feature = "server")] use std::collections::VecDeque; @@ -35,8 +31,8 @@ impl WsServerConnection { /// Wrap an upgraded socket with its pre-resolved peer identity. /// /// `auto_subscribe` seeds synthetic single-topic `Subscribe` frames so the - /// engine subscribes the client to those patterns on connect (replacing the - /// legacy `ClientManager::subscribe`-on-connect path) without a client message. + /// engine subscribes the client to those patterns on connect, without a + /// client message. pub fn new(ws: WebSocket, peer: PeerInfo, auto_subscribe: &[String]) -> Self { let pending = auto_subscribe .iter() From 8c928b6fe923f2554fae664a32485ab1f291aa4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 1 Jun 2026 19:33:01 +0000 Subject: [PATCH 21/34] feat: Update documentation and code comments for clarity and consistency across modules --- aimdb-client/Cargo.toml | 4 -- aimdb-client/src/engine.rs | 14 +++--- aimdb-client/tests/aimx_session.rs | 16 +++---- aimdb-client/tests/pump_client.rs | 7 ++- aimdb-core/src/builder.rs | 12 ++--- aimdb-core/src/lib.rs | 5 +- aimdb-core/src/session/client.rs | 9 +++- aimdb-core/src/session/server.rs | 4 +- aimdb-core/tests/session_engine.rs | 15 +++--- aimdb-embassy-adapter/tests/session_smoke.rs | 4 +- aimdb-uds-connector/src/lib.rs | 2 +- aimdb-websocket-connector/src/builder.rs | 10 ++-- .../src/client_manager.rs | 48 +++++++++++-------- aimdb-websocket-connector/src/codec.rs | 23 +++++---- aimdb-websocket-connector/src/dispatch.rs | 2 +- aimdb-websocket-connector/src/e2e.rs | 2 +- aimdb-websocket-connector/src/lib.rs | 2 +- aimdb-websocket-connector/src/session.rs | 9 ++-- tools/aimdb-cli/src/error.rs | 2 +- 19 files changed, 102 insertions(+), 88 deletions(-) diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index e97d6e1..4fdd937 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -45,7 +45,3 @@ thiserror = "1" tokio = { version = "1", features = ["rt-multi-thread", "test-util"] } tokio-test = "0.4" tempfile = "3" -# The aimx_session exit test stands up a real AimDb + production AimX server -# (`build_aimx_server`) and drives it with the engine-based `AimxConnection`. -aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } -serde = { version = "1", features = ["derive"] } diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 4fb6bf8..2092e47 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -1,12 +1,10 @@ -//! Engine-based AimX client (Phase 3, client-first). +//! Engine-based AimX client. //! -//! Rebuilds the client on the shared session engine: a [`UdsDialer`] + the -//! symmetric [`AimxCodec`] drive [`run_client`], which owns the wire, the -//! request-id demux, and (optionally) reconnect. The public surface is the -//! cheap-clone [`ClientHandle`] plus typed convenience wrappers and -//! per-subscription [`futures::Stream`]s — a deliberate **break** from the -//! retired synchronous `AimxClient` (`&mut self`, single global -//! `receive_event()` queue). +//! The client rides the shared session engine: a [`UdsDialer`] + the symmetric +//! [`AimxCodec`] drive [`run_client`], which owns the wire, the request-id +//! demux, and (optionally) reconnect. The public surface is the cheap-clone +//! [`ClientHandle`] plus typed convenience wrappers and per-subscription +//! [`futures::Stream`]s. //! //! `run_client` is itself spawn-free (it returns a future for a runner to //! drive); this convenience layer is a *client application*, so it drives the diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index 89613b6..82abff4 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -1,16 +1,12 @@ -//! Phase 3 **server-port** exit criterion: the engine-based [`AimxConnection`] -//! round-trips the reshaped AimX-v2 wire — `hello` handshake, RPC -//! (`record.get`/`record.set`), a streaming subscription, and a +//! The engine-based [`AimxConnection`] round-trips the AimX-v2 wire — `hello` +//! handshake, RPC (`record.get`/`record.set`), a streaming subscription, and a //! fire-and-forget write — against the **production** server //! ([`build_aimx_server`] → `serve`/`run_session` + `AimxDispatch`) over a real -//! Unix-domain socket. +//! Unix-domain socket, standing up an actual `AimDb` and proving the wire +//! end-to-end through the shared session engine. //! -//! This swaps the client-half milestone's test-local `UdsListener` + -//! `TestDispatch` for real server code standing up an actual `AimDb`, proving -//! the reshaped wire end-to-end through the shared session engine. -//! -//! Exercises the back-compat `build_aimx_server` alias (relocated to -//! `aimdb-uds-connector` in Phase 6); hence the crate-level `allow(deprecated)`. +//! Exercises the back-compat `build_aimx_server` alias (in +//! `aimdb-uds-connector`); hence the crate-level `allow(deprecated)`. #![allow(deprecated)] diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 0515e75..03dc506 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -1,6 +1,5 @@ -//! Phase 3 server-port exit criterion (§4): `pump_client` mirrors a record -//! **both directions** between a local AimDb and a remote AimDb over the shared -//! session engine. +//! `pump_client` mirrors a record **both directions** between a local AimDb and +//! a remote AimDb over the shared session engine. //! //! Topology: a server `AimDb` (served by `build_aimx_server`) and a client //! `AimDb` whose records carry `aimx://` connector links. `run_client` opens the @@ -12,7 +11,7 @@ //! through a subscription → the client's inbound producer (arbiter path). //! //! Exercises the back-compat `build_aimx_server`/`AimxClientConnector` aliases -//! (relocated to `aimdb-uds-connector` in Phase 6); hence `allow(deprecated)`. +//! (in `aimdb-uds-connector`); hence `allow(deprecated)`. #![allow(deprecated)] diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 238a8a5..1271cda 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -492,16 +492,14 @@ where self } - // NOTE: the former `with_remote_access(config: AimxConfig)` builder method was - // removed in the Phase-6 connector convergence. A remote-access **server** is - // now registered like any other connector: + // NOTE: a remote-access **server** is registered like any other connector — + // there is no dedicated builder method: // // .with_connector(aimdb_uds_connector::UdsServer::from_config(config)) // - // This unifies the server onto the `with_connector` spine (see its docs) and - // lets the transport be swapped (UDS / serial / TCP) without touching the - // builder. The per-record `TypedRecord::with_remote_access()` is unrelated and - // unchanged. + // This rides the `with_connector` spine (see its docs) and lets the transport + // be swapped (UDS / serial / TCP) without touching the builder. The per-record + // `TypedRecord::with_remote_access()` is unrelated. /// Configures a record type manually with a unique key /// diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index d503526..89d3132 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -88,9 +88,8 @@ pub use typed_record::{AnyRecord, AnyRecordExt, TypedRecord}; #[cfg(feature = "json-serialize")] pub use codec::{JsonCodec, RemoteSerialize, SerdeJsonCodec}; -// Phase 0 connector-session contracts (feature `connector-session`, no_std + -// alloc compatible). Frozen trait skeletons only — see -// docs/design/remote-access-via-connectors.md. +// connector-session contracts (feature `connector-session`, no_std + alloc +// compatible). See docs/design/remote-access-via-connectors.md. #[cfg(feature = "connector-session")] pub use session::{ pump_sink, pump_source, AuthError, BoxFut, BoxStream, CodecError, Connection, Dialer, Dispatch, diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 67d1af7..8f4b155 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -35,6 +35,9 @@ use crate::{AimDb, RuntimeAdapter}; #[derive(Debug, Clone)] pub struct ClientConfig { /// Redial after a dropped/failed connection instead of ending the engine. + /// Replays outbound traffic only: pending calls fail and open subscriptions + /// are not re-issued (so `pump_client` inbound mirroring stops after the first + /// disconnect; outbound survives). pub reconnect: bool, /// Base delay (ms) before the first redial; subsequent redials grow /// exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). @@ -140,7 +143,8 @@ impl ClientHandle { /// Open a subscription; returns the stream of updates immediately (the engine /// sends the `Subscribe` request asynchronously). Dropping the stream stops - /// local delivery. + /// local delivery. The stream ends on disconnect and is not re-subscribed on + /// reconnect (see [`ClientConfig::reconnect`]) — re-call to resume. pub fn subscribe( &self, topic: impl Into, @@ -483,6 +487,9 @@ where /// Returns one spawn-free pump future per route for the runner to drive /// (mirroring the `ConnectorBuilder::build -> Vec` spine); it drives /// the **same** engine as [`run_client`], never a second one. +/// +/// Reconnect caveat: inbound pumps subscribe once and are not replayed across a +/// reconnect (see [`ClientConfig::reconnect`]); outbound mirroring is unaffected. pub fn pump_client( db: &AimDb, scheme: &str, diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs index 3428c70..c16d599 100644 --- a/aimdb-core/src/session/server.rs +++ b/aimdb-core/src/session/server.rs @@ -369,7 +369,9 @@ where match accept { Ok(conn) => { - // Soft cap; `len()` is conservative (counts not-yet-reaped futures). + // Soft cap; `len()` counts finished-but-not-yet-reaped futures, so + // under an accept flood (the biased `accept` arm starves the reap + // arm) it may read high transiently — acceptable for a soft cap. if conns.len() >= config.limits.max_connections { #[cfg(feature = "tracing")] tracing::warn!( diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index 78d6fe0..5082904 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -1,13 +1,14 @@ -//! Phase 2 exit criterion (doc 036 / issue.md): a `serve` server and a -//! `run_client` client engine, talking over a throwaway in-memory pipe, -//! round-trip **RPC + a streaming subscription + a fire-and-forget write** in -//! both directions — proving the shared substrate (`Connection` / -//! `EnvelopeCodec` / `Inbound`/`Outbound`) is genuinely role-neutral. +//! A `serve` server and a `run_client` client engine, talking over a throwaway +//! in-memory pipe, round-trip **RPC + a streaming subscription + a +//! fire-and-forget write** in both directions — proving the shared substrate +//! (`Connection` / `EnvelopeCodec` / `Inbound`/`Outbound`) is genuinely +//! role-neutral. //! //! The substrate here is deliberately throwaway: a channel-backed `Connection` //! (framing-in-transport: one `Vec` per logical frame), a `Listener`/ //! `Dialer` pair over a connect channel, a tiny line-oriented `EnvelopeCodec`, -//! and an echo `Dispatch`. The real UDS/NDJSON/AimX impls land in Phase 3. +//! and an echo `Dispatch`. The real UDS/NDJSON/AimX impls live in +//! `aimdb-uds-connector` and `aimdb-core::session::aimx`. #![cfg(feature = "connector-session")] @@ -224,7 +225,7 @@ impl EnvelopeCodec for LineCodec { Ok(()) } - // --- client direction (Phase 2 dual) ----------------------------------- + // --- client direction (dual) ------------------------------------------- fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { let s = match msg { Inbound::Request { id, method, params } => { diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs index 8c17f5d..def5638 100644 --- a/aimdb-embassy-adapter/tests/session_smoke.rs +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -1,5 +1,5 @@ -//! Phase 5 Embassy smoke — the runtime-neutral session **client engine** runs on -//! the Embassy adapter's [`TimeOps`](aimdb_executor::TimeOps) clock. +//! Embassy smoke — the runtime-neutral session **client engine** runs on the +//! Embassy adapter's [`TimeOps`](aimdb_executor::TimeOps) clock. //! //! `run_client` is parametrized over the runtime clock (its only runtime //! dependency, for reconnect backoff / keepalive). This test instantiates it with diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 992bb5d..6c70a9a 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -259,7 +259,7 @@ where // =========================================================================== /// Deprecated alias for [`UdsClient`] that defaults the scheme to `"aimx"` -/// (preserving the pre-Phase-6 behavior of `AimxClientConnector`). +/// (preserving the legacy `AimxClientConnector` behavior). #[deprecated( since = "0.1.0", note = "use `UdsClient::new(path)` (scheme defaults to \"remote\"); pass `.scheme(\"aimx\")` for the old scheme" diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/builder.rs index f081da5..b13d923 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/builder.rs @@ -163,9 +163,11 @@ impl WebSocketConnectorBuilder { self } - /// Set the maximum number of concurrent WebSocket clients (default: 1 024). + /// Set the per-connection subscription ceiling (default: 1 024). /// - /// Currently informational — used for pre-allocating the client map. + /// Despite the name, this bounds live subscriptions per connection + /// (`max_subs_per_connection`), not the client count — connection count is the + /// axum accept loop's concern, not enforced here. pub fn with_max_clients(mut self, max: usize) -> Self { self.max_clients = max; self @@ -314,7 +316,7 @@ where Arc::new(Mutex::new(HashMap::new())); // ── Client manager ──────────────────────────────────── - let client_mgr = ClientManager::new(self.raw_payload); + let client_mgr = ClientManager::new(self.raw_payload, self.channel_capacity.max(1)); // ── Build snapshot provider ────────────────────────── let snapshot_provider: Arc = if self.late_join { @@ -368,6 +370,8 @@ where auth: self.auth.clone(), client_mgr, auto_subscribe: Arc::new(self.auto_subscribe_topics.clone()), + // `max_clients` now supplies the per-connection subscription cap; + // connection count stays axum's concern (see `with_max_clients`). max_subs_per_connection: self.max_clients.max(1), started_at: Instant::now(), }; diff --git a/aimdb-websocket-connector/src/client_manager.rs b/aimdb-websocket-connector/src/client_manager.rs index ec0e598..b9ea499 100644 --- a/aimdb-websocket-connector/src/client_manager.rs +++ b/aimdb-websocket-connector/src/client_manager.rs @@ -1,4 +1,4 @@ -//! Shared per-topic broadcast bus (Phase 4 — doc 039 § 3). +//! Shared per-topic broadcast bus. //! //! [`ClientManager`] is the **fan-out bridge** behind `Dispatch::subscribe`: one //! record update reaches every matching subscription. Each `WsSession::subscribe` @@ -7,10 +7,8 @@ //! each into a `ServerMessage::Data` on encode. The outbound record→broadcast //! tasks ([`crate::connector`]) feed [`broadcast`](ClientManager::broadcast). //! -//! This replaces the pre-Phase-4 model where the manager owned per-client -//! `mpsc::Sender` channels and formatted `ServerMessage`s itself — that -//! formatting now lives in the codec, and the per-connection send half is owned -//! by `run_session`. +//! Frame formatting lives in the codec; the per-connection send half is owned by +//! `run_session`. use std::sync::{ atomic::{AtomicU64, Ordering}, @@ -30,7 +28,8 @@ use crate::{ /// One live subscription: a wildcard pattern + the channel feeding its stream. struct SubEntry { pattern: String, - tx: mpsc::UnboundedSender, + /// Bounded; `broadcast` drops on a full channel (slow-client protection). + tx: mpsc::Sender, } /// Shared per-topic broadcast bus. Cloning is cheap (all clones share state). @@ -44,19 +43,23 @@ pub struct ClientManager { next_client: Arc, /// Live connection count (for the health endpoint). connections: Arc, + /// Per-subscription channel bound (the builder's `with_channel_capacity`). + sub_capacity: usize, /// Mirrors the builder's `with_raw_payload`: when set, `broadcast` ships the /// serializer bytes verbatim instead of wrapping them in a `Data` envelope. raw_payload: bool, } impl ClientManager { - /// Create a new, empty bus. `raw_payload` mirrors the builder flag. - pub fn new(raw_payload: bool) -> Self { + /// Create a new, empty bus. `raw_payload` mirrors the builder flag; + /// `sub_capacity` bounds each subscription's queue. + pub fn new(raw_payload: bool, sub_capacity: usize) -> Self { Self { subs: Arc::new(DashMap::new()), next_sub: Arc::new(AtomicU64::new(1)), next_client: Arc::new(AtomicU64::new(1)), connections: Arc::new(AtomicU64::new(0)), + sub_capacity: sub_capacity.max(1), raw_payload, } } @@ -80,11 +83,11 @@ impl ClientManager { } /// Register a subscription for `pattern`; returns its id and the stream of - /// matching record-value payloads. Dropping the stream ends the subscription - /// (the next [`broadcast`](Self::broadcast) prunes the dead entry). + /// matching record-value payloads. Dropping the stream ends the subscription; + /// the next matching [`broadcast`](Self::broadcast) lazily prunes the entry. pub fn subscribe(&self, pattern: &str) -> (u64, BoxStream<'static, Payload>) { let id = self.next_sub.fetch_add(1, Ordering::Relaxed); - let (tx, rx) = mpsc::unbounded_channel::(); + let (tx, rx) = mpsc::channel::(self.sub_capacity); self.subs.insert( id, SubEntry { @@ -98,7 +101,9 @@ impl ClientManager { (id, Box::pin(stream)) } - /// Explicitly drop a subscription (on `Unsubscribe`). + /// Explicitly drop a subscription by id. Unused by the WS + /// [`Dispatch`](crate::dispatch) path (it tears down via dropped streams, + /// pruned lazily); kept for direct bus users. pub fn unsubscribe(&self, sub_id: u64) { self.subs.remove(&sub_id); } @@ -126,7 +131,12 @@ impl ClientManager { let payload = Payload::from(frame.as_slice()); let mut dead: Vec = Vec::new(); for entry in self.subs.iter() { - if topic_matches(&entry.pattern, topic) && entry.tx.send(payload.clone()).is_err() { + if !topic_matches(&entry.pattern, topic) { + continue; + } + // Bounded: drop on a full queue (slow-client protection), prune only + // when the receiver is gone (stream dropped). + if let Err(mpsc::error::TrySendError::Closed(_)) = entry.tx.try_send(payload.clone()) { dead.push(*entry.key()); } } @@ -143,7 +153,7 @@ impl ClientManager { impl Default for ClientManager { fn default() -> Self { - Self::new(false) + Self::new(false, 256) } } @@ -165,7 +175,7 @@ mod tests { #[tokio::test] async fn broadcast_reaches_matching_subscriptions() { - let mgr = ClientManager::new(false); + let mgr = ClientManager::new(false, 256); let (_id, mut stream) = mgr.subscribe("sensors/#"); mgr.broadcast("sensors/temp/vienna", b"22.5").await; @@ -185,7 +195,7 @@ mod tests { #[tokio::test] async fn non_matching_topic_is_not_delivered() { use futures_util::FutureExt; - let mgr = ClientManager::new(false); + let mgr = ClientManager::new(false, 256); let (_id, mut stream) = mgr.subscribe("commands/#"); mgr.broadcast("sensors/temp", b"22.5").await; // Nothing queued: the next() future is not ready. @@ -194,7 +204,7 @@ mod tests { #[tokio::test] async fn fan_out_to_n_subscribers() { - let mgr = ClientManager::new(false); + let mgr = ClientManager::new(false, 256); let mut streams: Vec<_> = (0..5).map(|_| mgr.subscribe("#").1).collect(); mgr.broadcast("any/topic", b"\"v\"").await; for s in &mut streams { @@ -208,7 +218,7 @@ mod tests { #[tokio::test] async fn dropped_stream_is_pruned() { - let mgr = ClientManager::new(false); + let mgr = ClientManager::new(false, 256); let (_id, stream) = mgr.subscribe("#"); assert_eq!(mgr.subscription_count(), 1); drop(stream); @@ -221,7 +231,7 @@ mod tests { // regardless of subscriber count (O(1) fan-out, not O(N)). #[tokio::test] async fn broadcast_serializes_once_and_shares_to_all() { - let mgr = ClientManager::new(false); + let mgr = ClientManager::new(false, 256); let mut streams: Vec<_> = (0..8).map(|_| mgr.subscribe("#").1).collect(); mgr.broadcast("t", b"123").await; let mut frames = Vec::new(); diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs index 0d9cb35..b95642c 100644 --- a/aimdb-websocket-connector/src/codec.rs +++ b/aimdb-websocket-connector/src/codec.rs @@ -193,19 +193,24 @@ impl aimdb_core::EnvelopeCodec for WsCodec { // `Reply::Ok` payloads are already a complete `ServerMessage` JSON // (`QueryResult`/`TopicList`/`Error`) built by the dispatch with the // client's `String` id spliced in — write them verbatim. - Outbound::Reply { result, .. } => match result { + Outbound::Reply { id, result } => match result { Ok(payload) => { out.extend_from_slice(&payload); Ok(()) } - Err(e) => write_server( - out, - &ServerMessage::Error { - code: rpc_to_code(&e), - topic: None, - message: format!("{e:?}"), - }, - ), + Err(e) => { + // Restore the topic for subscribe/cap denials from the id↔topic + // map; Query/ListTopics use a bare id, so it stays `None`. + let topic = self.state.lock().unwrap().id_to_topic.get(&id).cloned(); + write_server( + out, + &ServerMessage::Error { + code: rpc_to_code(&e), + topic, + message: format!("{e:?}"), + }, + ) + } }, } } diff --git a/aimdb-websocket-connector/src/dispatch.rs b/aimdb-websocket-connector/src/dispatch.rs index 62b78e1..9186871 100644 --- a/aimdb-websocket-connector/src/dispatch.rs +++ b/aimdb-websocket-connector/src/dispatch.rs @@ -224,7 +224,7 @@ mod tests { fn dispatch_with(snapshot: Arc) -> Arc { Arc::new(WsDispatch { - client_mgr: ClientManager::new(false), + client_mgr: ClientManager::new(false, 256), snapshot_provider: snapshot, query_handler: Arc::new(NoQuery), router: Arc::new(RouterBuilder::from_routes(Vec::new()).build()), diff --git a/aimdb-websocket-connector/src/e2e.rs b/aimdb-websocket-connector/src/e2e.rs index feef03f..4aa43e3 100644 --- a/aimdb-websocket-connector/src/e2e.rs +++ b/aimdb-websocket-connector/src/e2e.rs @@ -116,7 +116,7 @@ impl Default for Opts { /// Bring up the real axum server on an ephemeral port; return its address and the /// shared bus (so the test can `broadcast`, simulating an outbound record update). async fn spawn(opts: Opts) -> (SocketAddr, ClientManager) { - let client_mgr = ClientManager::new(false); + let client_mgr = ClientManager::new(false, 256); let dispatch: Arc = Arc::new(WsDispatch { client_mgr: client_mgr.clone(), snapshot_provider: opts.snapshot, diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 232705b..de8e241 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -98,7 +98,7 @@ pub(crate) mod session; pub mod client; // ════════════════════════════════════════════════════════════════════ -// Shared session-engine glue (Phase 4 — server and/or client) +// Shared session-engine glue (server and/or client) // ════════════════════════════════════════════════════════════════════ /// Per-connection WS-JSON `EnvelopeCodec` shared by the server (`run_session`) diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/session.rs index af1e49b..8bbdf61 100644 --- a/aimdb-websocket-connector/src/session.rs +++ b/aimdb-websocket-connector/src/session.rs @@ -1,9 +1,8 @@ -//! Reusable handler traits for the WebSocket dispatch (Phase 4). +//! Reusable handler traits for the WebSocket dispatch. //! -//! The hand-rolled per-connection recv/send loops that used to live here were -//! retired in Phase 4 — the WS server now rides `run_session` -//! ([`aimdb_core::session::run_session`]) via [`crate::dispatch`]. What survives -//! is the pluggable application surface the dispatch consumes: +//! The WS server rides `run_session` ([`aimdb_core::session::run_session`]) via +//! [`crate::dispatch`]; what lives here is the pluggable application surface the +//! dispatch consumes: //! //! - [`QueryHandler`] — answers client `query` messages from a persistence backend; //! - [`SnapshotProvider`] — supplies the late-join current value for a topic. diff --git a/tools/aimdb-cli/src/error.rs b/tools/aimdb-cli/src/error.rs index 4c93748..7ef1321 100644 --- a/tools/aimdb-cli/src/error.rs +++ b/tools/aimdb-cli/src/error.rs @@ -17,7 +17,7 @@ pub enum CliError { ConnectionFailed { socket: String, reason: String }, /// No running AimDB instances found - #[error("No AimDB instances found\n Searched: /tmp, /var/run/aimdb\n Hint: Start an AimDB application with .with_remote_access()")] + #[error("No AimDB instances found\n Searched: /tmp, /var/run/aimdb\n Hint: Start an AimDB application that registers a remote-access server (e.g. UdsServer via .with_connector(...))")] NoInstancesFound, /// Requested record does not exist From 2dd106dbe8d00106128bd60f2add72834ec893bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 1 Jun 2026 20:17:07 +0000 Subject: [PATCH 22/34] feat: Update UDS connector to use "uds" scheme for remote access and improve documentation --- aimdb-core/src/builder.rs | 4 ++-- aimdb-uds-connector/CHANGELOG.md | 2 +- aimdb-uds-connector/Cargo.toml | 2 +- aimdb-uds-connector/src/lib.rs | 17 +++++++++++------ 4 files changed, 15 insertions(+), 10 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 1271cda..cd967cb 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -459,7 +459,7 @@ where /// AimDB itself over a transport so peers can introspect/subscribe/write. /// - The **client** half (e.g. `UdsClient`) dials a peer and *does* use /// `link_to`/`link_from` under its scheme — just like (1), the scheme is - /// `"remote"` by default instead of `"mqtt"`. + /// `"uds"` by default instead of `"mqtt"`. /// - The **server** half (e.g. `UdsServer`) *accepts* connections and takes /// **no links** — registering it is how a server stands up remote access /// (this replaces the old `with_remote_access(config)`). @@ -481,7 +481,7 @@ where /// // (2b) remote-access CLIENT — mirror a record to a peer over UDS /// AimDbBuilder::new().runtime(rt) /// .with_connector(UdsClient::new("/run/aimdb.sock")) - /// .configure::(|r| { r.with_remote_access().link_to("remote://temp"); }) + /// .configure::(|r| { r.with_remote_access().link_to("uds://temp"); }) /// .build().await?; /// ``` pub fn with_connector( diff --git a/aimdb-uds-connector/CHANGELOG.md b/aimdb-uds-connector/CHANGELOG.md index a3abf18..c4170b6 100644 --- a/aimdb-uds-connector/CHANGELOG.md +++ b/aimdb-uds-connector/CHANGELOG.md @@ -11,5 +11,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New crate — the Unix-domain-socket transport for AimDB remote access (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A thin, swappable transport that rides the shared session engine in `aimdb-core::session`: it contributes only the `Dialer`/`Listener`/`Connection` triple (`UdsDialer` / `UdsListener` / `UdsConnection`, NDJSON framing in the transport — one line per logical frame); the AimX codec + dispatch and the engine wiring are reused verbatim from core. Two ergonomic constructors: - **`UdsServer`** — accepts connections and serves the full AimX toolset over a socket. Register it via `with_connector` to stand up remote access (this replaces `aimdb-core`'s removed `AimDbBuilder::with_remote_access(config)`). Sugar over `SessionServerConnector`; `UdsServer::from_config(config)` is the one-line migration, plus `new`/`max_connections`/`max_subs_per_connection`/`socket_permissions`/`scheme` builders. Binds synchronously (remove-stale → `bind` → `set_permissions`) so bind errors surface from `build()`, and applies the security policy's writable-record marking. - - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"remote"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. + - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"uds"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. - **Deprecated back-compat shims** for the types that relocated here from `aimdb-core`: `AimxClientConnector::new(path)` (defaults the scheme to `"aimx"`, preserving pre-Phase-6 behavior) and the free-standing `build_aimx_server(db, config)` (returns the `serve` future directly). Prefer `UdsClient` / `UdsServer`. diff --git a/aimdb-uds-connector/Cargo.toml b/aimdb-uds-connector/Cargo.toml index 3c85c7e..d6d9fe0 100644 --- a/aimdb-uds-connector/Cargo.toml +++ b/aimdb-uds-connector/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" license.workspace = true repository.workspace = true homepage.workspace = true -description = "Unix-domain-socket transport connector for AimDB remote access (AimX over UDS)" +description = "Unix-domain-socket transport connector for AimDB: record mirroring and remote access (AimX over UDS)" keywords = ["aimdb", "connector", "uds", "unix-socket", "remote"] categories = ["network-programming", "database"] diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 6c70a9a..7a76eb9 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -1,4 +1,5 @@ -//! Unix-domain-socket transport connector for AimDB remote access. +//! Unix-domain-socket transport connector for AimDB — record mirroring and +//! remote access over a local socket. //! //! A thin, swappable transport crate: it contributes only the //! `Dialer`/`Listener`/`Connection` triple ([`UdsConnection`] / @@ -7,7 +8,7 @@ //! generic core connectors: //! //! - [`UdsClient`] — dials a peer over UDS and mirrors records under a scheme -//! (`"remote"` by default), using `link_to`/`link_from` like any data-plane +//! (`"uds"` by default), using `link_to`/`link_from` like any data-plane //! connector. Sugar over [`SessionClientConnector`]``. //! - [`UdsServer`] — accepts connections and serves the AimX toolset over UDS; //! register it with `with_connector` to stand up remote access. Sugar over @@ -24,7 +25,7 @@ //! // client: mirror a record to a peer over the socket //! AimDbBuilder::new().runtime(rt) //! .with_connector(UdsClient::new("/run/aimdb.sock")) -//! .configure::("temp", |r| { r.with_remote_access().link_to("remote://temp")...; }) +//! .configure::("temp", |r| { r.with_remote_access().link_to("uds://temp")...; }) //! .build().await?; //! ``` @@ -50,7 +51,11 @@ type BoxFuture = Pin + Send + 'static>>; type BuildFuture<'a> = Pin>> + Send + 'a>>; /// The default scheme `UdsClient`/`UdsServer` register when none is given. -pub const DEFAULT_SCHEME: &str = "remote"; +/// +/// Transport-matched (like MQTT's `"mqtt"`), so `link_to("uds://")` reads +/// at the call site. Override with `.scheme(...)` when running more than one +/// remote connector. +pub const DEFAULT_SCHEME: &str = "uds"; // =========================================================================== // Client sugar @@ -67,7 +72,7 @@ impl UdsClient { // Sugar constructor: intentionally returns the generic connector, not `Self`. #[allow(clippy::new_ret_no_self)] pub fn new(socket_path: impl Into) -> SessionClientConnector { - SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec) + SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec).scheme(DEFAULT_SCHEME) } } @@ -262,7 +267,7 @@ where /// (preserving the legacy `AimxClientConnector` behavior). #[deprecated( since = "0.1.0", - note = "use `UdsClient::new(path)` (scheme defaults to \"remote\"); pass `.scheme(\"aimx\")` for the old scheme" + note = "use `UdsClient::new(path)` (scheme defaults to \"uds\"); pass `.scheme(\"aimx\")` for the old scheme" )] pub struct AimxClientConnector; From 3f5e389291cab23740b39d98ef20b33aa8624d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 1 Jun 2026 20:36:24 +0000 Subject: [PATCH 23/34] feat: Enable UDS connector for remote access and update related documentation --- aimdb-client/tests/aimx_session.rs | 45 +++++++--------- aimdb-client/tests/pump_client.rs | 44 +++++++-------- aimdb-uds-connector/src/lib.rs | 53 +------------------ .../src/client/builder.rs | 6 +-- 4 files changed, 43 insertions(+), 105 deletions(-) diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index 82abff4..ddf559c 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -1,14 +1,9 @@ //! The engine-based [`AimxConnection`] round-trips the AimX-v2 wire — `hello` //! handshake, RPC (`record.get`/`record.set`), a streaming subscription, and a -//! fire-and-forget write — against the **production** server -//! ([`build_aimx_server`] → `serve`/`run_session` + `AimxDispatch`) over a real -//! Unix-domain socket, standing up an actual `AimDb` and proving the wire -//! end-to-end through the shared session engine. -//! -//! Exercises the back-compat `build_aimx_server` alias (in -//! `aimdb-uds-connector`); hence the crate-level `allow(deprecated)`. - -#![allow(deprecated)] +//! fire-and-forget write — against the **production** server (`UdsServer` → +//! `serve`/`run_session` + `AimxDispatch`) over a real Unix-domain socket, +//! standing up an actual `AimDb` and proving the wire end-to-end through the +//! shared session engine. use std::sync::Arc; use std::time::Duration; @@ -18,7 +13,7 @@ use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; -use aimdb_uds_connector::build_aimx_server; +use aimdb_uds_connector::UdsServer; use futures::StreamExt; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -40,8 +35,20 @@ async fn aimx_roundtrip_over_uds_production_server() { let dir = tempfile::tempdir().unwrap(); let sock = dir.path().join("aimdb.sock"); - // Build a real AimDb with two remote-accessible records. - let mut builder = AimDbBuilder::new().runtime(Arc::new(TokioAdapter)); + // ReadWrite policy with `setting` writable; `events` stays read-only. + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("setting"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy) + .max_connections(8) + .max_subs_per_connection(8); + + // Build a real AimDb with two remote-accessible records, served over UDS via + // the production `UdsServer` connector (binds during `build()`). + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); builder.configure::("setting", |reg| { reg.buffer(BufferCfg::SingleLatest).with_remote_access(); }); @@ -51,24 +58,13 @@ async fn aimx_roundtrip_over_uds_production_server() { }); let (db, runner) = builder.build().await.expect("build db"); let db = Arc::new(db); + // The runner drives both the records and the UDS serve loop (spawn-free). tokio::spawn(runner.run()); // Seed the writable record before connecting so `record.get` has a value. db.set_record_from_json("setting", json!({ "level": 1 })) .expect("seed setting"); - // ReadWrite policy with `setting` writable; `events` stays read-only. - let mut policy = SecurityPolicy::read_write(); - policy.allow_write_key("setting"); - let config = AimxConfig::uds_default() - .socket_path(&sock) - .security_policy(policy) - .max_connections(8) - .max_subs_per_connection(8); - - // Production server future, driven on a task (the engine itself is spawn-free). - let server = tokio::spawn(build_aimx_server(db.clone(), config).expect("bind server")); - // Connect: performs the `hello` handshake and captures the Welcome. let conn = AimxConnection::connect(&sock).await.expect("connect"); assert_eq!(conn.server_info().server, "aimdb"); @@ -139,5 +135,4 @@ async fn aimx_roundtrip_over_uds_production_server() { assert_eq!(after, json!({ "level": 9 })); drop(conn); // stops the client engine - server.abort(); } diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 03dc506..8df2319 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -1,19 +1,13 @@ //! `pump_client` mirrors a record **both directions** between a local AimDb and //! a remote AimDb over the shared session engine. //! -//! Topology: a server `AimDb` (served by `build_aimx_server`) and a client -//! `AimDb` whose records carry `aimx://` connector links. `run_client` opens the -//! connection; `pump_client` wires the client's outbound/inbound routes to the -//! `ClientHandle`: +//! Topology: a server `AimDb` (served by `UdsServer`) and a client `AimDb` whose +//! records carry `uds://` connector links. `run_client` opens the connection; +//! `pump_client` wires the client's outbound/inbound routes to the `ClientHandle`: //! - **client → server**: producing the client's `cfg` record streams it to the //! server via `ClientHandle::write` → the server's `record.set` path. //! - **server → client**: updating the server's `tele` record streams it back //! through a subscription → the client's inbound producer (arbiter path). -//! -//! Exercises the back-compat `build_aimx_server`/`AimxClientConnector` aliases -//! (in `aimdb-uds-connector`); hence `allow(deprecated)`. - -#![allow(deprecated)] use std::sync::Arc; use std::time::Duration; @@ -23,7 +17,7 @@ use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::session::ClientConfig; use aimdb_core::{AimDb, AimDbBuilder}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; -use aimdb_uds_connector::{build_aimx_server, AimxClientConnector}; +use aimdb_uds_connector::{UdsClient, UdsServer}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -57,7 +51,15 @@ async fn pump_client_mirrors_record_both_directions() { let sock = dir.path().join("aimdb.sock"); // --- server: cfg (writable target) + tele (streamed source) ------------ - let mut sb = AimDbBuilder::new().runtime(Arc::new(TokioAdapter)); + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("cfg"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy); + + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); sb.configure::("cfg", |reg| { reg.buffer(BufferCfg::SingleLatest).with_remote_access(); }); @@ -66,21 +68,15 @@ async fn pump_client_mirrors_record_both_directions() { }); let (server_db, server_runner) = sb.build().await.expect("build server db"); let server_db = Arc::new(server_db); + // The runner drives both the records and the UDS serve loop. tokio::spawn(server_runner.run()); - let mut policy = SecurityPolicy::read_write(); - policy.allow_write_key("cfg"); - let config = AimxConfig::uds_default() - .socket_path(&sock) - .security_policy(policy); - let server = tokio::spawn(build_aimx_server(server_db.clone(), config).expect("bind server")); - // --- client: cfg links *to* the server, tele links *from* it ----------- - // The AimxClientConnector registers the `aimx://` scheme (so the links - // validate) and, on build, dials the server + drives the mirroring pumps. + // UdsClient registers the `uds://` scheme (so the links validate) and, on + // build, dials the server + drives the mirroring pumps. let mut cb = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) - .with_connector(AimxClientConnector::new(&sock).with_config(ClientConfig { + .with_connector(UdsClient::new(&sock).with_config(ClientConfig { reconnect: true, reconnect_delay: 50, max_reconnect_delay: 50, @@ -93,14 +89,14 @@ async fn pump_client_mirrors_record_both_directions() { cb.configure::("cfg", |reg| { reg.buffer(BufferCfg::SingleLatest) .with_remote_access() - .link_to("aimx://cfg") + .link_to("uds://cfg") .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) .finish(); }); cb.configure::("tele", |reg| { reg.buffer(BufferCfg::SingleLatest) .with_remote_access() - .link_from("aimx://tele") + .link_from("uds://tele") .with_deserializer_raw(|d: &[u8]| { serde_json::from_slice::(d).map_err(|e| e.to_string()) }) @@ -134,6 +130,4 @@ async fn pump_client_mirrors_record_both_directions() { }) .await; assert!(mirrored_in, "server→client mirror did not reach the client"); - - server.abort(); } diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 7a76eb9..cfdc613 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -43,7 +43,7 @@ use aimdb_core::connector::ConnectorBuilder; use aimdb_core::remote::AimxConfig; use aimdb_core::session::aimx::{AimxCodec, AimxDispatch}; use aimdb_core::session::{ - serve, Dispatch, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, + Dispatch, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, }; use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; @@ -258,54 +258,3 @@ where } } } - -// =========================================================================== -// Deprecated back-compat aliases (the types relocated here from core). -// =========================================================================== - -/// Deprecated alias for [`UdsClient`] that defaults the scheme to `"aimx"` -/// (preserving the legacy `AimxClientConnector` behavior). -#[deprecated( - since = "0.1.0", - note = "use `UdsClient::new(path)` (scheme defaults to \"uds\"); pass `.scheme(\"aimx\")` for the old scheme" -)] -pub struct AimxClientConnector; - -#[allow(deprecated)] -impl AimxClientConnector { - /// Mirror records over the AimX peer at `socket_path`, under scheme `"aimx"`. - #[allow(clippy::new_ret_no_self)] - pub fn new(socket_path: impl Into) -> SessionClientConnector { - SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec).scheme("aimx") - } -} - -/// Deprecated free-standing AimX server builder. Prefer registering -/// [`UdsServer::from_config`] via `with_connector`; this returns the single -/// `serve` future directly for callers that spawn it by hand. -#[deprecated( - since = "0.1.0", - note = "register `UdsServer::from_config(config)` via `with_connector` instead" -)] -pub fn build_aimx_server(db: Arc>, config: AimxConfig) -> DbResult -where - R: RuntimeAdapter + 'static, -{ - let listener = bind_uds_listener(&config)?; - apply_writable(&db, &config); - let session_config = SessionConfig { - limits: SessionLimits { - max_connections: config.max_connections, - max_subs_per_connection: config.max_subs_per_connection, - }, - reads_hello: false, - acks_subscribe: false, - }; - let dispatch = Arc::new(AimxDispatch::new(db, config)); - Ok(Box::pin(serve( - listener, - Arc::new(AimxCodec), - dispatch, - session_config, - ))) -} diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 054cc7b..99d725d 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -168,9 +168,9 @@ where }; // ── Drive the shared client engine + record-mirroring pumps ── - // Mirrors `AimxClientConnector`: `run_client` owns demux/reconnect/ - // keepalive over the WS `Dialer` + per-connection `WsCodec`; - // `pump_client` wires `link_to`/`link_from` routes to the handle. + // Like `UdsClient`: `run_client` owns demux/reconnect/keepalive over + // the WS `Dialer` + per-connection `WsCodec`; `pump_client` wires + // `link_to`/`link_from` routes to the handle. // The runtime's `TimeOps` clock drives reconnect backoff/keepalive. let (handle, engine_fut) = run_client( WsDialer::new(self.url.clone()), From c56ca20cbd5407bd7b90e38c8a15f2f74b348ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 17:54:22 +0000 Subject: [PATCH 24/34] feat: Enhance WebSocket connector with improved routing and snapshot caching for outbound messages --- aimdb-websocket-connector/src/builder.rs | 58 +++---- aimdb-websocket-connector/src/connector.rs | 171 +++++-------------- aimdb-websocket-connector/src/registry.rs | 187 +++++---------------- 3 files changed, 106 insertions(+), 310 deletions(-) diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/builder.rs index b13d923..1b276eb 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/builder.rs @@ -8,10 +8,10 @@ //! ```text //! AimDbBuilder::build() //! └─ WebSocketConnectorBuilder::build(&db) -//! ├─ db.collect_inbound_routes("ws") → Router -//! ├─ db.collect_outbound_routes("ws") → outbound tasks -//! ├─ start Axum / WebSocket server -//! └─ return Arc +//! ├─ inbound Router (client writes → producers, via the session Dispatch) +//! ├─ outbound `pump_sink` over the `WsBusSink` (records → broadcast bus) +//! ├─ start Axum / WebSocket server (per-connection `run_session`) +//! └─ return the server + pump futures //! ``` use std::{ @@ -24,13 +24,13 @@ use std::{ use aimdb_data_contracts::Streamable; -use aimdb_core::{router::RouterBuilder, ConnectorBuilder, Dispatch}; +use aimdb_core::{pump_sink, router::RouterBuilder, ConnectorBuilder, Dispatch}; use axum::Router as AxumRouter; use crate::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, - connector::WebSocketConnectorImpl, + connector::{SnapshotCache, WsBusSink}, dispatch::WsDispatch, registry::StreamableRegistry, server::{build_server_future, ServerState}, @@ -302,39 +302,29 @@ where let router = Arc::new(RouterBuilder::from_routes(inbound_routes).build()); - // ── Outbound routes ────────────────────────────────────── - let outbound_routes = db.collect_outbound_routes("ws"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS connector: {} outbound routes collected", - outbound_routes.len() - ); - - // ── Shared snapshot cache (for late-join) ───────────── - let snapshot_map: Arc>>> = - Arc::new(Mutex::new(HashMap::new())); + // ── Late-join snapshot cache (only when enabled) ────── + let snapshot_map: Option = + self.late_join.then(|| Arc::new(Mutex::new(HashMap::new()))); // ── Client manager ──────────────────────────────────── let client_mgr = ClientManager::new(self.raw_payload, self.channel_capacity.max(1)); // ── Build snapshot provider ────────────────────────── - let snapshot_provider: Arc = if self.late_join { - let snap = snapshot_map.clone(); - Arc::new(DynMapSnapshot(snap)) - } else { - Arc::new(NoSnapshot) + let snapshot_provider: Arc = match &snapshot_map { + Some(map) => Arc::new(DynMapSnapshot(map.clone())), + None => Arc::new(NoSnapshot), }; // ── Known topics (for list_topics responses) ────────── // Use the registered streamable types to resolve TypeId → schema name. - let type_id_map = &self.streamable_registry.type_id_to_name; - let topic_type_ids = db.collect_outbound_topic_type_ids("ws"); let known_topics: Vec = topic_type_ids .into_iter() .map(|(topic, type_id)| { - let schema_type = type_id_map.get(&type_id).map(|s| s.to_string()); + let schema_type = self + .streamable_registry + .resolve_name(&type_id) + .map(|s| s.to_string()); // Extract entity from topic name: "temp.vienna" → "vienna". // The server owns the naming convention — clients receive // the entity as a first-class field and never parse topics. @@ -359,10 +349,16 @@ where runtime_ctx: Some(db.runtime_any()), }); - // ── Build connector & collect outbound publishers ─────────────── - let connector = WebSocketConnectorImpl::new(client_mgr.clone()); - let outbound_futures = - connector.collect_outbound_futures(db, outbound_routes, snapshot_map); + // ── Outbound: the shared `pump_sink` drives records → bus ─────── + // (same helper MQTT uses; the `WsBusSink` just broadcasts + caches). + let outbound_futures = pump_sink( + db, + "ws", + Arc::new(WsBusSink { + client_mgr: client_mgr.clone(), + snapshot: snapshot_map, + }), + ); // ── Build Axum server future ────────────────────────── let state = ServerState { @@ -391,7 +387,7 @@ where // Dynamic snapshot provider backed by the shared Mutex // ════════════════════════════════════════════════════════════════════ -struct DynMapSnapshot(Arc>>>); +struct DynMapSnapshot(SnapshotCache); impl SnapshotProvider for DynMapSnapshot { fn snapshot(&self, topic: &str) -> Option> { diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/connector.rs index f2be32a..7d042d3 100644 --- a/aimdb-websocket-connector/src/connector.rs +++ b/aimdb-websocket-connector/src/connector.rs @@ -1,145 +1,54 @@ -//! WebSocket connector implementation (`Connector` trait). +//! WebSocket outbound sink — the [`Connector`] adapter that `pump_sink` drives. //! -//! [`WebSocketConnectorImpl`] is the live connector instance built by -//! [`crate::builder::WebSocketConnectorBuilder`]. +//! Outbound record updates (`link_to("ws://…")`) fan out to subscribed clients +//! through the [`ClientManager`] bus. The shared +//! [`pump_sink`](aimdb_core::pump_sink) helper owns the consume → serialize → +//! publish loop (the same one MQTT uses); this adapter just routes each +//! serialized value to [`broadcast`](ClientManager::broadcast) and, when +//! late-join is enabled, caches it for snapshots. //! -//! # Outbound publishing -//! -//! Each outbound route (`link_to("ws://…")`) gets a dedicated Tokio task: -//! -//! ```text -//! consumer.subscribe_any() → recv_any() → serializer() → ClientManager::broadcast() -//! ``` -//! -//! # Inbound routing -//! -//! Inbound writes from WebSocket clients go through the shared `Router` -//! (same infrastructure as MQTT). The `Connector::publish()` impl is a -//! no-op because WebSocket inbound happens via the session receive loop instead -//! of the standard publish path. +//! Inbound writes from WebSocket clients do **not** go through here — they ride +//! the session `Dispatch` (`WsSession::write` → the shared `Router`). -use std::{collections::HashMap, pin::Pin, sync::Arc}; +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; -use aimdb_core::OutboundRoute; +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use crate::client_manager::ClientManager; -type BoxFuture = Pin + Send + 'static>>; +/// Shared late-join cache: topic → last serialized bytes. +pub(crate) type SnapshotCache = Arc>>>; -/// Live WebSocket connector returned by `build()` — owns the outbound publisher -/// tasks that feed the [`ClientManager`] bus. (Envelope vs raw-payload framing is -/// now the per-connection [`WsCodec`](crate::codec)'s job, not the broadcaster's.) -pub struct WebSocketConnectorImpl { +/// Outbound sink: feeds each serialized record value into the broadcast bus. +pub(crate) struct WsBusSink { pub(crate) client_mgr: ClientManager, + /// Late-join cache — `Some` only when late-join is on, so a disabled + /// late-join does zero per-message snapshot work. + pub(crate) snapshot: Option, } -impl WebSocketConnectorImpl { - pub(crate) fn new(client_mgr: ClientManager) -> Self { - Self { client_mgr } - } - - /// Collects one outbound publisher future per route. - /// - /// Each future: - /// 1. Calls `consumer.subscribe_any()` to get a type-erased reader. - /// 2. Loops calling `reader.recv_any()`. - /// 3. Runs the serializer. - /// 4. Broadcasts the bytes via `ClientManager::broadcast()`. - pub(crate) fn collect_outbound_futures( +impl Connector for WsBusSink { + fn publish( &self, - db: &aimdb_core::builder::AimDb, - outbound_routes: Vec, - snapshot_map: Arc>>>, - ) -> Vec - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(outbound_routes.len()); - - for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { - let client_mgr = self.client_mgr.clone(); - let snap = snapshot_map.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: failed to subscribe for '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Resolve topic (dynamic or static) - let topic = topic_provider - .as_ref() - .and_then(|p| p.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Update snapshot cache for late-join - { - let mut map = snap.lock().unwrap(); - map.insert(topic.clone(), bytes.clone()); - } - - // Fan-out to subscribed clients via the bus. The per-connection - // `WsCodec` applies the `Data` envelope (or, in raw mode, sends - // the bytes verbatim) — so the bus always carries raw bytes. - client_mgr.broadcast(&topic, &bytes).await; - } - - #[cfg(feature = "tracing")] - tracing::info!( - "WS outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } - - futures + destination: &str, + _config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { + // Own the args so the returned future borrows only `&self` (the trait + // binds the future's lifetime to the receiver, not the arguments). + let dest = destination.to_string(); + let bytes = payload.to_vec(); + Box::pin(async move { + if let Some(map) = &self.snapshot { + map.lock().unwrap().insert(dest.clone(), bytes.clone()); + } + // The per-connection `WsCodec` applies the `Data` envelope (or, in raw + // mode, sends the bytes verbatim) — so the bus carries raw bytes. + self.client_mgr.broadcast(&dest, &bytes).await; + Ok(()) + }) } } diff --git a/aimdb-websocket-connector/src/registry.rs b/aimdb-websocket-connector/src/registry.rs index 607b8ad..165918c 100644 --- a/aimdb-websocket-connector/src/registry.rs +++ b/aimdb-websocket-connector/src/registry.rs @@ -1,131 +1,56 @@ -//! Type-erased dispatch registry for [`Streamable`] types. +//! Schema-name registry for [`Streamable`] types. //! -//! Built incrementally via [`StreamableRegistry::register::()`] at -//! connector construction time. Each entry stores monomorphized closures -//! that capture the concrete type `T` — runtime downcasts are limited to -//! a `TypeId`-guarded path inside the serializer closure. +//! Built incrementally via [`StreamableRegistry::register::()`] at connector +//! construction time. It exists only to answer `list_topics` (resolve an +//! outbound topic's `TypeId` → schema name) and to reject schema-name +//! collisions. The actual record (de)serialization lives in the link routes' +//! serializer/deserializer, not here. use std::any::TypeId; use std::collections::HashMap; use aimdb_data_contracts::Streamable; -// ─── Type-erased operations ─────────────────────────────────────── - -/// Type-erased serialization closure: takes `&dyn Any`, downcasts to `T`, -/// and serializes to JSON bytes. -type SerializeFn = Box Result, String> + Send + Sync>; - -/// Type-erased deserialization closure: takes JSON bytes and produces a -/// boxed `Any` value of the concrete type `T`. -type DeserializeFn = - Box Result, String> + Send + Sync>; - -/// Type-erased operations for a single [`Streamable`] type. -/// -/// Each field is a monomorphized closure that captures `T` at compile time -/// through generic instantiation. The serializer performs a `downcast_ref` -/// on `&dyn Any` to recover the concrete type. -#[allow(dead_code)] -pub(crate) struct StreamableOps { - /// The `TypeId` of the concrete type. - pub type_id: TypeId, - /// The schema name (`T::NAME`). - pub name: &'static str, - /// Serialize a `&dyn Any` (known to be `&T`) to JSON bytes. - pub serialize: SerializeFn, - /// Deserialize JSON bytes into a `Box` (actually `Box`). - pub deserialize: DeserializeFn, -} - -// ─── Registry ───────────────────────────────────────────────────── - -/// Maps schema names and type IDs to type-erased operations. -/// -/// Built incrementally via [`register::()`](StreamableRegistry::register) -/// before the connector is started. +/// Maps registered [`Streamable`] types to their schema names. pub(crate) struct StreamableRegistry { - /// Schema name → operations. - pub name_to_ops: HashMap<&'static str, StreamableOps>, - /// TypeId → schema name (for outbound topic resolution). - pub type_id_to_name: HashMap, + /// Schema name → `TypeId` (collision detection on `register`). + name_to_type_id: HashMap<&'static str, TypeId>, + /// `TypeId` → schema name (outbound topic → schema for `list_topics`). + type_id_to_name: HashMap, } impl StreamableRegistry { /// Create an empty registry. pub fn new() -> Self { Self { - name_to_ops: HashMap::new(), + name_to_type_id: HashMap::new(), type_id_to_name: HashMap::new(), } } - /// Register a [`Streamable`] type. - /// - /// Each call monomorphizes closures for `T`'s serialization and - /// deserialization. Re-registering the same type is idempotent. - /// - /// # Errors - /// - /// Returns an error if a *different* type has already been registered - /// under the same schema name (`T::NAME`). + /// Register a [`Streamable`] type. Idempotent for the same type; errors if a + /// *different* type already claims the same schema name (`T::NAME`). pub fn register(&mut self) -> Result<(), String> { let type_id = TypeId::of::(); let name = T::NAME; - - // Same type re-registered — idempotent, nothing to do. - if let Some(existing) = self.name_to_ops.get(name) { - if existing.type_id == type_id { - return Ok(()); + match self.name_to_type_id.get(name) { + Some(existing) if *existing == type_id => return Ok(()), + Some(_) => { + return Err(format!( + "schema name collision: \"{name}\" is already registered by a different type" + )) } - return Err(format!( - "schema name collision: \"{name}\" is already registered by a different type" - )); + None => {} } - - let ops = StreamableOps { - type_id, - name, - serialize: Box::new(|any_ref| { - let value = any_ref - .downcast_ref::() - .expect("type mismatch: registry is internally consistent"); - serde_json::to_vec(value).map_err(|e| e.to_string()) - }), - deserialize: Box::new(|bytes| { - let value: T = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; - Ok(Box::new(value)) - }), - }; - - self.name_to_ops.insert(name, ops); + self.name_to_type_id.insert(name, type_id); self.type_id_to_name.insert(type_id, name); Ok(()) } - /// Look up operations by schema name. - #[allow(dead_code)] - pub fn get_by_name(&self, name: &str) -> Option<&StreamableOps> { - self.name_to_ops.get(name) - } - - /// Resolve a `TypeId` to its schema name. - #[allow(dead_code)] + /// Resolve a `TypeId` to its registered schema name. pub fn resolve_name(&self, type_id: &TypeId) -> Option<&'static str> { self.type_id_to_name.get(type_id).copied() } - - /// Returns all registered schema names. - #[allow(dead_code)] - pub fn known_names(&self) -> Vec<&'static str> { - self.name_to_ops.keys().copied().collect() - } - - /// Returns `true` if no types have been registered. - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.name_to_ops.is_empty() - } } #[cfg(test)] @@ -158,44 +83,19 @@ mod tests { impl Streamable for TestActuator {} #[test] - fn register_and_lookup_by_name() { + fn register_and_resolve_name() { let mut reg = StreamableRegistry::new(); reg.register::().unwrap(); - - let ops = reg.get_by_name("test_sensor").unwrap(); - assert_eq!(ops.name, "test_sensor"); - assert_eq!(ops.type_id, TypeId::of::()); - } - - #[test] - fn register_and_resolve_type_id() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - assert_eq!( reg.resolve_name(&TypeId::of::()), Some("test_sensor") ); - assert_eq!(reg.resolve_name(&TypeId::of::()), None); } #[test] - fn serialize_roundtrip() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - let sensor = TestSensor { - value: 42.5, - timestamp: 1000, - }; - - let ops = reg.get_by_name("test_sensor").unwrap(); - let bytes = (ops.serialize)(&sensor).unwrap(); - let restored = (ops.deserialize)(&bytes).unwrap(); - let restored_sensor = restored.downcast_ref::().unwrap(); - - assert_eq!(restored_sensor.value, 42.5); - assert_eq!(restored_sensor.timestamp, 1000); + fn unknown_type_resolves_to_none() { + let reg = StreamableRegistry::new(); + assert_eq!(reg.resolve_name(&TypeId::of::()), None); } #[test] @@ -203,8 +103,10 @@ mod tests { let mut reg = StreamableRegistry::new(); reg.register::().unwrap(); reg.register::().unwrap(); - - assert_eq!(reg.known_names().len(), 1); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); } #[test] @@ -233,24 +135,13 @@ mod tests { let mut reg = StreamableRegistry::new(); reg.register::().unwrap(); reg.register::().unwrap(); - - assert_eq!(reg.known_names().len(), 2); - assert!(reg.get_by_name("test_sensor").is_some()); - assert!(reg.get_by_name("test_actuator").is_some()); - } - - #[test] - fn empty_registry() { - let reg = StreamableRegistry::new(); - assert!(reg.is_empty()); - assert!(reg.get_by_name("anything").is_none()); - } - - #[test] - fn unknown_schema_returns_none() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - assert!(reg.get_by_name("unknown_schema").is_none()); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_actuator") + ); } } From f840451fc93af99c7f6353a25fe3937459d35d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 18:43:10 +0000 Subject: [PATCH 25/34] feat: Implement robust error handling in client and pump for improved message processing --- aimdb-core/src/session/client.rs | 9 +- aimdb-core/src/session/pump.rs | 24 +- aimdb-websocket-connector/src/lib.rs | 6 +- .../{src => tests}/e2e.rs | 457 +++++++++--------- 4 files changed, 252 insertions(+), 244 deletions(-) rename aimdb-websocket-connector/{src => tests}/e2e.rs (55%) diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 8f4b155..11389aa 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -513,7 +513,14 @@ where Ok(r) => r, Err(_e) => return, }; - while let Ok(value) = reader.recv_any().await { + loop { + let value = match reader.recv_any().await { + Ok(v) => v, + // Lagged (ring overflow) — skip the gap, keep mirroring. + Err(crate::DbError::BufferLagged { .. }) => continue, + // Buffer closed — the record is gone; end this mirror. + Err(_) => break, + }; // Dynamic destination (topic provider) or the static link target. let dest = topic_provider .as_ref() diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index 76b79a2..aa2dc28 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -71,7 +71,29 @@ where default_topic ); - while let Ok(value_any) = reader.recv_any().await { + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // SPMC-ring overflow: messages were missed, but the reader + // recovers (cursor resets to the oldest live value). Skip the + // gap and keep pumping — a transient lag must not permanently + // kill the publisher. + Err(crate::DbError::BufferLagged { .. }) => { + #[cfg(feature = "tracing")] + tracing::warn!("pump_sink: consumer lagged for '{}'", default_topic); + continue; + } + // Buffer closed / fatal — the record is gone; end the publisher. + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher stopping for '{}': {:?}", + default_topic, + _e + ); + break; + } + }; // Resolve destination: dynamic (from provider) or default (from URL). let dest = topic_provider .as_ref() diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index de8e241..cc5a6cf 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -110,9 +110,7 @@ pub mod codec; #[cfg(any(feature = "server", feature = "client"))] pub mod transport; -/// Layer 1 real-socket integration tests (doc 039-validation) — needs both halves. -#[cfg(all(test, feature = "server", feature = "client"))] -mod e2e; +// Real-socket integration tests live in `tests/e2e.rs` (black-box, public API). // ════════════════════════════════════════════════════════════════════ // Protocol (always available) @@ -144,4 +142,4 @@ pub type WsClientConnector = client::WsClientConnectorBuilder; pub use protocol::{ClientMessage, ErrorCode, QueryRecord, ServerMessage}; #[cfg(feature = "server")] -pub use session::{NoQuery, QueryHandler}; +pub use session::{NoQuery, QueryFuture, QueryHandler}; diff --git a/aimdb-websocket-connector/src/e2e.rs b/aimdb-websocket-connector/tests/e2e.rs similarity index 55% rename from aimdb-websocket-connector/src/e2e.rs rename to aimdb-websocket-connector/tests/e2e.rs index 4aa43e3..e4edd40 100644 --- a/aimdb-websocket-connector/src/e2e.rs +++ b/aimdb-websocket-connector/tests/e2e.rs @@ -1,63 +1,70 @@ -//! Layer 1 real-socket integration tests (doc 039-validation). +//! Real-socket integration tests for the WebSocket connector — black-box. //! -//! These run the **real** stack over a real TCP socket: a tungstenite client (or -//! the WS client engine) ↔ an axum server driving `run_session` + `WsCodec` + -//! `WsDispatch` + the `ClientManager` bus. Gated on `test` + both features so the -//! per-connection path that unit tests can only mock is actually exercised. +//! These drive the connector through its **public API only**: a server `AimDb` +//! stood up with [`WebSocketConnector`] over a real TCP socket, talked to by a +//! raw `tokio-tungstenite` client (or the public `run_client` + [`WsDialer`] +//! engine). Server→client data is pushed by *producing a record* — an "injector" +//! record whose dynamic topic + raw serializer let a test broadcast an arbitrary +//! `(topic, payload)` through the real `pump_sink` → bus → session path. +//! +//! Needs both halves (`server` + `client`); compiles away otherwise. + +#![cfg(all(feature = "server", feature = "client"))] -use core::future::Future; -use core::pin::Pin; +use std::future::Future; use std::net::SocketAddr; +use std::pin::Pin; use std::sync::Arc; -use std::time::{Duration, Instant}; +use std::time::Duration; -use aimdb_core::router::RouterBuilder; +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::TopicProvider; use aimdb_core::session::{run_client, ClientConfig}; -use aimdb_core::Dispatch; -use aimdb_ws_protocol::{QueryRecord, TopicInfo}; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_data_contracts::{SchemaType, Streamable}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::codec::WsCodec; +use aimdb_websocket_connector::transport::WsDialer; +use aimdb_websocket_connector::{ + AuthError, AuthHandler, AuthRequest, ClientInfo, ClientMessage, ErrorCode, Permissions, + QueryFuture, QueryHandler, QueryRecord, ServerMessage, WebSocketConnector, +}; use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::net::TcpStream; +use tokio::time::timeout; use tokio_tungstenite::tungstenite::Message; -use crate::auth::{AuthError, AuthHandler, AuthRequest, NoAuth, Permissions}; -use crate::client_manager::ClientManager; -use crate::codec::WsCodec; -use crate::dispatch::WsDispatch; -use crate::protocol::{ClientMessage, ServerMessage}; -use crate::server::{build_app, ServerState}; -use crate::session::{NoQuery, NoSnapshot, QueryFuture, QueryHandler, SnapshotProvider}; -use crate::transport::WsDialer; - -// ── Test fixtures ──────────────────────────────────────────────────── - -struct OneSnap(&'static str, &'static [u8]); -impl SnapshotProvider for OneSnap { - fn snapshot(&self, topic: &str) -> Option> { - (topic == self.0).then(|| self.1.to_vec()) - } +// ── Injector record ────────────────────────────────────────────────── +// Producing one pushes `payload` out on `topic` via the real outbound path. + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Inject { + topic: String, + payload: Value, } -struct OneRecordQuery; -impl QueryHandler for OneRecordQuery { - fn handle_query<'a>( - &'a self, - _pattern: &'a str, - _from: Option, - _to: Option, - _limit: Option, - ) -> QueryFuture<'a> { - Box::pin(async { - Ok(( - vec![QueryRecord { - topic: "temp".into(), - payload: serde_json::json!(21.0), - ts: 7, - }], - 1, - )) - }) +struct InjectTopic; +impl TopicProvider for InjectTopic { + fn topic(&self, v: &Inject) -> Option { + Some(v.topic.clone()) } } +// ── A registered Streamable type (for the `list_topics` schema name) ── + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Temp { + c: f32, +} +impl SchemaType for Temp { + const NAME: &'static str = "temperature"; +} +impl Streamable for Temp {} + +// ── Auth + query fixtures ──────────────────────────────────────────── + struct DenyAuth; impl AuthHandler for DenyAuth { fn authenticate<'a>( @@ -68,10 +75,9 @@ impl AuthHandler for DenyAuth { } } -/// Allows the connection with **allow-all** permissions, but asynchronously -/// **denies** subscribing to `secret/*` via `authorize_subscribe`. If the engine -/// only consulted the static permission set (the pre-fix sync path), `secret` -/// would be allowed — so this fixture proves the async hook actually gates (#3). +/// Allows the connection (allow-all permissions) but asynchronously *denies* +/// `secret/*` via `authorize_subscribe`. If the engine only consulted the static +/// permission set, `secret` would be allowed — so this proves the async hook gates. struct AsyncTopicAuth; impl AuthHandler for AsyncTopicAuth { fn authenticate<'a>( @@ -82,71 +88,93 @@ impl AuthHandler for AsyncTopicAuth { } fn authorize_subscribe<'a>( &'a self, - _client: &'a crate::auth::ClientInfo, + _client: &'a ClientInfo, topic: &'a str, ) -> Pin + Send + 'a>> { let denied = topic.starts_with("secret"); - // Simulate an async ACL lookup. Box::pin(async move { - tokio::task::yield_now().await; + tokio::task::yield_now().await; // simulate an async ACL lookup !denied }) } } -/// Knobs for the spawned server; defaults give an allow-all, no-snapshot server. -struct Opts { - snapshot: Arc, - query: Arc, - known_topics: Vec, - auth: Arc, +struct OneRecordQuery; +impl QueryHandler for OneRecordQuery { + fn handle_query<'a>( + &'a self, + _pattern: &'a str, + _from: Option, + _to: Option, + _limit: Option, + ) -> QueryFuture<'a> { + Box::pin(async { + Ok(( + vec![QueryRecord { + topic: "temp".into(), + payload: json!(21.0), + ts: 7, + }], + 1, + )) + }) + } +} + +// ── Harness ────────────────────────────────────────────────────────── + +/// Reserve an ephemeral port, then free it so the server can bind it. +fn free_addr() -> SocketAddr { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() } -impl Default for Opts { - fn default() -> Self { - Self { - snapshot: Arc::new(NoSnapshot), - query: Arc::new(NoQuery), - known_topics: Vec::new(), - auth: Arc::new(NoAuth), +/// Wait until the server is accepting connections at `addr`. +async fn wait_for_listen(addr: SocketAddr) { + for _ in 0..200 { + if TcpStream::connect(addr).await.is_ok() { + return; } + tokio::time::sleep(Duration::from_millis(10)).await; } + panic!("server never bound at {addr}"); } -/// Bring up the real axum server on an ephemeral port; return its address and the -/// shared bus (so the test can `broadcast`, simulating an outbound record update). -async fn spawn(opts: Opts) -> (SocketAddr, ClientManager) { - let client_mgr = ClientManager::new(false, 256); - let dispatch: Arc = Arc::new(WsDispatch { - client_mgr: client_mgr.clone(), - snapshot_provider: opts.snapshot, - query_handler: opts.query, - router: Arc::new(RouterBuilder::from_routes(Vec::new()).build()), - known_topics: Arc::new(opts.known_topics), - auth: opts.auth.clone(), - late_join: true, - runtime_ctx: None, +/// Stand up a WS server (with the injector record) on an ephemeral port. The +/// caller pre-configures `ws` (auth / late-join / …); we add `bind`/`path`. +async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc>) { + let addr = free_addr(); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(ws.bind(addr).path("/ws")); + sb.configure::("inject", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 1024 }) + .with_remote_access() + .link_to("ws://_") // overridden per-value by the topic provider + .with_topic_provider(InjectTopic) + .with_serializer_raw(|m: &Inject| { + Ok(serde_json::to_vec(&m.payload).expect("serialize payload")) + }) + .finish(); }); - let state = ServerState { - dispatch, - auth: opts.auth, - client_mgr: client_mgr.clone(), - auto_subscribe: Arc::new(Vec::new()), - max_subs_per_connection: 64, - started_at: Instant::now(), - }; - let app = build_app("/ws", state, None); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let addr = listener.local_addr().unwrap(); - tokio::spawn(async move { - axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ) - .await - .unwrap(); - }); - (addr, client_mgr) + let (db, runner) = sb.build().await.expect("build server db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + wait_for_listen(addr).await; + (addr, db) +} + +/// Default allow-all, late-join-on server. +async fn spawn_default() -> (SocketAddr, Arc>) { + spawn(WebSocketConnector::new().with_late_join(true)).await +} + +/// Push `payload` to subscribers of `topic` (one outbound record update). +fn inject(db: &AimDb, topic: &str, payload: Value) { + db.set_record_from_json("inject", json!({ "topic": topic, "payload": payload })) + .expect("inject"); } type WsClient = @@ -168,7 +196,7 @@ async fn ws_send(c: &mut WsClient, m: ClientMessage) { /// Read the next `ServerMessage`, with a timeout so a hang fails loudly. async fn ws_recv(c: &mut WsClient) -> ServerMessage { loop { - match tokio::time::timeout(Duration::from_secs(3), c.next()) + match timeout(Duration::from_secs(3), c.next()) .await .expect("recv timed out") { @@ -180,11 +208,31 @@ async fn ws_recv(c: &mut WsClient) -> ServerMessage { } } -// ── 1.1 Server e2e ─────────────────────────────────────────────────── +/// Like [`ws_recv`] but returns the raw JSON with `ts` normalized to `0`. +async fn recv_value(c: &mut WsClient) -> Value { + loop { + match timeout(Duration::from_secs(3), c.next()) + .await + .expect("recv timed out") + { + Some(Ok(Message::Text(t))) => { + let mut v: Value = serde_json::from_str(&t).unwrap(); + if let Some(ts) = v.get_mut("ts") { + *ts = json!(0); + } + return v; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + other => panic!("unexpected ws frame: {other:?}"), + } + } +} + +// ── Server e2e ─────────────────────────────────────────────────────── #[tokio::test] async fn server_subscribe_ack_and_wildcard_fanout() { - let (addr, bus) = spawn(Opts::default()).await; + let (addr, db) = spawn_default().await; let mut c = ws_connect(addr).await; ws_send( @@ -198,12 +246,12 @@ async fn server_subscribe_ack_and_wildcard_fanout() { matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { topics } if topics == vec!["sensors/#".to_string()]) ); - // An outbound record update fans out as a Data frame with the *real* topic. - bus.broadcast("sensors/temp/vienna", b"22.5").await; + // The ack means the bus subscription is registered, so a fan-out reaches us. + inject(&db, "sensors/temp/vienna", json!(22.5)); match ws_recv(&mut c).await { ServerMessage::Data { topic, payload, .. } => { assert_eq!(topic, "sensors/temp/vienna"); - assert_eq!(payload, Some(serde_json::json!(22.5))); + assert_eq!(payload, Some(json!(22.5))); } other => panic!("expected Data, got {other:?}"), } @@ -211,7 +259,7 @@ async fn server_subscribe_ack_and_wildcard_fanout() { #[tokio::test] async fn server_multi_topic_subscribe_and_unsubscribe() { - let (addr, bus) = spawn(Opts::default()).await; + let (addr, db) = spawn_default().await; let mut c = ws_connect(addr).await; ws_send( @@ -221,7 +269,6 @@ async fn server_multi_topic_subscribe_and_unsubscribe() { }, ) .await; - // Per-topic acks (the documented wire nuance). let mut acked = Vec::new(); for _ in 0..2 { if let ServerMessage::Subscribed { topics } = ws_recv(&mut c).await { @@ -231,10 +278,10 @@ async fn server_multi_topic_subscribe_and_unsubscribe() { acked.sort(); assert_eq!(acked, vec!["a".to_string(), "b".to_string()]); - bus.broadcast("a", b"1").await; + inject(&db, "a", json!(1)); assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "a")); - // Unsubscribe from "a"; a later broadcast to "a" must not arrive, but "b" still does. + // Unsubscribe "a"; a later "a" must not arrive, but "b" still does. ws_send( &mut c, ClientMessage::Unsubscribe { @@ -242,10 +289,9 @@ async fn server_multi_topic_subscribe_and_unsubscribe() { }, ) .await; - // Give the engine a moment to process the unsubscribe. - tokio::time::sleep(Duration::from_millis(100)).await; - bus.broadcast("a", b"2").await; - bus.broadcast("b", b"3").await; + tokio::time::sleep(Duration::from_millis(100)).await; // let the unsub settle + inject(&db, "a", json!(2)); + inject(&db, "b", json!(3)); match ws_recv(&mut c).await { ServerMessage::Data { topic, .. } => assert_eq!(topic, "b", "only 'b' should arrive"), other => panic!("expected Data on b, got {other:?}"), @@ -254,13 +300,12 @@ async fn server_multi_topic_subscribe_and_unsubscribe() { #[tokio::test] async fn server_late_join_snapshot() { - let (addr, _bus) = spawn(Opts { - snapshot: Arc::new(OneSnap("sensors/temp", b"99")), - ..Opts::default() - }) - .await; - let mut c = ws_connect(addr).await; + let (addr, db) = spawn_default().await; + // Produce the value first so the late-join cache holds it, then subscribe. + inject(&db, "sensors/temp", json!(99)); + tokio::time::sleep(Duration::from_millis(100)).await; + let mut c = ws_connect(addr).await; ws_send( &mut c, ClientMessage::Subscribe { @@ -275,7 +320,7 @@ async fn server_late_join_snapshot() { match ws_recv(&mut c).await { ServerMessage::Snapshot { topic, payload } => { assert_eq!(topic, "sensors/temp"); - assert_eq!(payload, Some(serde_json::json!(99))); + assert_eq!(payload, Some(json!(99))); } other => panic!("expected Snapshot, got {other:?}"), } @@ -283,16 +328,23 @@ async fn server_late_join_snapshot() { #[tokio::test] async fn server_query_and_list_topics() { - let (addr, _bus) = spawn(Opts { - query: Arc::new(OneRecordQuery), - known_topics: vec![TopicInfo { - name: "temp".into(), - schema_type: Some("temperature".into()), - entity: None, - }], - ..Opts::default() - }) - .await; + let addr = free_addr(); + let mut ws = WebSocketConnector::new().with_query_handler(OneRecordQuery); + ws.register::(); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(ws.bind(addr).path("/ws")); + sb.configure::("temp", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://temp") + .with_serializer_raw(|t: &Temp| Ok(serde_json::to_vec(t).unwrap())) + .finish(); + }); + let (_db, runner) = sb.build().await.expect("build db"); + tokio::spawn(runner.run()); + wait_for_listen(addr).await; + let mut c = ws_connect(addr).await; ws_send( @@ -321,6 +373,7 @@ async fn server_query_and_list_topics() { assert_eq!(id, "l1"); assert_eq!(topics.len(), 1); assert_eq!(topics[0].name, "temp"); + assert_eq!(topics[0].schema_type.as_deref(), Some("temperature")); } other => panic!("expected TopicList, got {other:?}"), } @@ -328,7 +381,7 @@ async fn server_query_and_list_topics() { #[tokio::test] async fn server_ping_pong() { - let (addr, _bus) = spawn(Opts::default()).await; + let (addr, _db) = spawn_default().await; let mut c = ws_connect(addr).await; ws_send(&mut c, ClientMessage::Ping).await; assert!(matches!(ws_recv(&mut c).await, ServerMessage::Pong)); @@ -336,11 +389,7 @@ async fn server_ping_pong() { #[tokio::test] async fn server_rejects_unauthenticated_upgrade() { - let (addr, _bus) = spawn(Opts { - auth: Arc::new(DenyAuth), - ..Opts::default() - }) - .await; + let (addr, _db) = spawn(WebSocketConnector::new().with_auth(DenyAuth)).await; // The upgrade must be refused with HTTP 401 → the WS handshake fails. let result = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")).await; assert!(result.is_err(), "auth-rejected upgrade should not connect"); @@ -348,7 +397,7 @@ async fn server_rejects_unauthenticated_upgrade() { #[tokio::test] async fn server_survives_malformed_frame() { - let (addr, bus) = spawn(Opts::default()).await; + let (addr, db) = spawn_default().await; let mut c = ws_connect(addr).await; // Garbage that is not a ClientMessage — the session must skip it, not die. @@ -367,15 +416,15 @@ async fn server_survives_malformed_frame() { ws_recv(&mut c).await, ServerMessage::Subscribed { .. } )); - bus.broadcast("x", b"1").await; + inject(&db, "x", json!(1)); assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "x")); } -// ── 1.2 Client engine e2e (run_client + WsDialer over a real socket) ── +// ── Client engine e2e (run_client + WsDialer over a real socket) ───── #[tokio::test] async fn client_engine_receives_broadcast_over_real_socket() { - let (addr, bus) = spawn(Opts::default()).await; + let (addr, db) = spawn_default().await; let config = ClientConfig { topic_routed_subs: true, @@ -386,56 +435,34 @@ async fn client_engine_receives_broadcast_over_real_socket() { WsDialer::new(format!("ws://{addr}/ws")), WsCodec::new(), config, - Arc::new(aimdb_tokio_adapter::TokioAdapter), + Arc::new(TokioAdapter), ); let driver = tokio::spawn(engine); - // Subscribe through the client engine; the dialer opens a real WebSocket. let mut stream = handle.subscribe("sensors/temp").unwrap(); - // Wait for the subscription to register on the server bus, then broadcast. - for _ in 0..50 { - if bus.subscription_count() == 1 { + // Subscription registration is async; re-inject until the value arrives. + let mut got = None; + for _ in 0..100 { + inject(&db, "sensors/temp", json!(42)); + if let Ok(Some(item)) = timeout(Duration::from_millis(20), stream.next()).await { + got = Some(item); break; } - tokio::time::sleep(Duration::from_millis(10)).await; } - assert_eq!( - bus.subscription_count(), - 1, - "client subscribe should reach the server" - ); - - bus.broadcast("sensors/temp", b"42").await; - - let item = tokio::time::timeout(Duration::from_secs(3), stream.next()) - .await - .expect("stream timed out") - .expect("a value"); // The record value round-trips: Data{payload:42} → engine stream yields b"42". - assert_eq!(&item[..], b"42"); + assert_eq!(&got.expect("a value")[..], b"42"); drop(handle); drop(stream); let _ = driver.await; } -// ── Layer 4 — concurrency / resource cleanup / backpressure ────────── - -/// Poll `pred` until true or fail loudly. -async fn wait_until(mut pred: impl FnMut() -> bool, label: &str) { - for _ in 0..300 { - if pred() { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("timed out waiting for: {label}"); -} +// ── Concurrency / backpressure ─────────────────────────────────────── #[tokio::test] -async fn many_clients_fanout_and_resource_cleanup() { - let (addr, bus) = spawn(Opts::default()).await; +async fn many_clients_fanout() { + let (addr, db) = spawn_default().await; let mut clients = Vec::new(); for _ in 0..20 { @@ -453,31 +480,19 @@ async fn many_clients_fanout_and_resource_cleanup() { )); clients.push(c); } - wait_until(|| bus.subscription_count() == 20, "20 subscriptions").await; - assert_eq!(bus.client_count(), 20); // One broadcast reaches all 20. - bus.broadcast("evt/x", b"1").await; + inject(&db, "evt/x", json!(1)); for c in &mut clients { assert!(matches!(ws_recv(c).await, ServerMessage::Data { topic, .. } if topic == "evt/x")); } - - // Disconnect everyone; the live-connection count returns to zero… - for mut c in clients.drain(..) { - let _ = c.close(None).await; - } - wait_until(|| bus.client_count() == 0, "0 connections").await; - // …and a subsequent broadcast prunes the now-dead subscriptions. - bus.broadcast("evt/x", b"2").await; - wait_until(|| bus.subscription_count() == 0, "0 subscriptions").await; } #[tokio::test] async fn stalled_client_does_not_block_a_healthy_one() { - let (addr, bus) = spawn(Opts::default()).await; + let (addr, db) = spawn_default().await; - // Stalled: subscribes but never reads — its socket backpressures and its - // bounded funnel fills, but it must not stall the server for others. + // Stalled: subscribes but never reads — its socket backpressures. let mut stalled = ws_connect(addr).await; ws_send( &mut stalled, @@ -499,15 +514,16 @@ async fn stalled_client_does_not_block_a_healthy_one() { ws_recv(&mut healthy).await, ServerMessage::Subscribed { .. } )); - wait_until(|| bus.subscription_count() == 2, "2 subscriptions").await; + tokio::time::sleep(Duration::from_millis(100)).await; // let the stalled sub register - // Flood well past the bounded funnel (256). The stalled client's pump drops - // on overflow rather than growing without bound; the healthy client keeps up. + // Flood well past the bounded funnel (256). This also overruns the injector + // ring, so the outbound `pump_sink` consumer lags — it must skip the gap and + // keep publishing (not die), while the stalled client's pump drops on overflow + // and the healthy client keeps up. for i in 0..2000u32 { - bus.broadcast("x", i.to_string().as_bytes()).await; + inject(&db, "x", json!(i)); } - // The healthy client still receives data despite its stalled peer. assert!( matches!(ws_recv(&mut healthy).await, ServerMessage::Data { topic, .. } if topic == "x"), "healthy client must keep receiving past a stalled peer", @@ -515,42 +531,15 @@ async fn stalled_client_does_not_block_a_healthy_one() { let _ = stalled.close(None).await; } -// ── Layer 3.1 — golden wire frames (the masterplan wire-capture gate) ─ +// ── Golden wire frames (locks the exact on-the-wire JSON shape) ────── -/// Receive the next text frame and parse it to a `Value`, normalizing the -/// time-dependent `ts` field to `0` so the shape can be asserted exactly. -async fn recv_value(c: &mut WsClient) -> serde_json::Value { - loop { - match tokio::time::timeout(Duration::from_secs(3), c.next()) - .await - .expect("recv timed out") - { - Some(Ok(Message::Text(t))) => { - let mut v: serde_json::Value = serde_json::from_str(&t).unwrap(); - if let Some(ts) = v.get_mut("ts") { - *ts = serde_json::json!(0); - } - return v; - } - Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, - other => panic!("unexpected ws frame: {other:?}"), - } - } -} - -/// Locks the exact on-the-wire JSON shape (tag + field names + value types) the -/// server emits, so any accidental wire change is caught. Frame *shape* is what a -/// browser/wasm client depends on (key order is irrelevant to JSON). #[tokio::test] async fn golden_wire_frames() { - use serde_json::json; - let (addr, bus) = spawn(Opts { - snapshot: Arc::new(OneSnap("t", b"5")), - ..Opts::default() - }) - .await; - let mut c = ws_connect(addr).await; + let (addr, db) = spawn_default().await; + inject(&db, "t", json!(5)); // seed the late-join cache + tokio::time::sleep(Duration::from_millis(100)).await; + let mut c = ws_connect(addr).await; ws_send( &mut c, ClientMessage::Subscribe { @@ -567,7 +556,7 @@ async fn golden_wire_frames() { json!({"type": "snapshot", "topic": "t", "payload": 5}) ); - bus.broadcast("t", b"42").await; + inject(&db, "t", json!(42)); assert_eq!( recv_value(&mut c).await, json!({"type": "data", "topic": "t", "payload": 42, "ts": 0}) @@ -577,20 +566,14 @@ async fn golden_wire_frames() { assert_eq!(recv_value(&mut c).await, json!({"type": "pong"})); } -// ── Layer 2.1 — the async-authz fix (#3) over a real socket ────────── +// ── Async authorization over a real socket ─────────────────────────── #[tokio::test] async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { - let (addr, bus) = spawn(Opts { - auth: Arc::new(AsyncTopicAuth), - ..Opts::default() - }) - .await; + let (addr, db) = spawn(WebSocketConnector::new().with_auth(AsyncTopicAuth)).await; let mut c = ws_connect(addr).await; // Denied topic: permissions are allow-all, but the *async* hook says no. - // Pre-fix (sync permission check) this would have been allowed → the test - // would see a `Subscribed` ack instead of an error. ws_send( &mut c, ClientMessage::Subscribe { @@ -599,9 +582,7 @@ async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { ) .await; match ws_recv(&mut c).await { - ServerMessage::Error { code, .. } => { - assert!(matches!(code, crate::protocol::ErrorCode::Forbidden)); - } + ServerMessage::Error { code, .. } => assert!(matches!(code, ErrorCode::Forbidden)), other => panic!("expected Forbidden Error for denied subscribe, got {other:?}"), } @@ -617,7 +598,7 @@ async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { ws_recv(&mut c).await, ServerMessage::Subscribed { .. } )); - bus.broadcast("public/x", b"1").await; + inject(&db, "public/x", json!(1)); assert!( matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "public/x") ); From 7eff20247e1b70e71fdd9c21e9bd84b3795ec9f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 19:05:55 +0000 Subject: [PATCH 26/34] feat: implement WebSocket server with client management and broadcasting - Add ClientManager for handling subscriptions and broadcasting messages to clients. - Introduce WsBusSink for routing serialized record updates to the ClientManager. - Create WsDispatch for managing WebSocket sessions and handling authentication. - Implement HTTP server with WebSocket upgrade and health check endpoints. - Add StreamableRegistry for managing schema names of streamable types. - Define reusable session handler traits for query handling and snapshot provision. - Implement tests for ClientManager and StreamableRegistry functionalities. --- aimdb-websocket-connector/src/codec.rs | 2 +- aimdb-websocket-connector/src/dispatch.rs | 385 ------------------ aimdb-websocket-connector/src/lib.rs | 28 +- .../src/{ => server}/auth.rs | 0 .../src/{ => server}/builder.rs | 4 +- .../src/{ => server}/client_manager.rs | 12 +- .../src/{ => server}/connector.rs | 2 +- .../src/server/dispatch.rs | 177 ++++++++ .../src/{server.rs => server/http.rs} | 31 +- aimdb-websocket-connector/src/server/mod.rs | 18 + .../src/{ => server}/registry.rs | 0 .../src/{ => server}/session.rs | 2 +- 12 files changed, 219 insertions(+), 442 deletions(-) delete mode 100644 aimdb-websocket-connector/src/dispatch.rs rename aimdb-websocket-connector/src/{ => server}/auth.rs (100%) rename aimdb-websocket-connector/src/{ => server}/builder.rs (99%) rename aimdb-websocket-connector/src/{ => server}/client_manager.rs (96%) rename aimdb-websocket-connector/src/{ => server}/connector.rs (98%) create mode 100644 aimdb-websocket-connector/src/server/dispatch.rs rename aimdb-websocket-connector/src/{server.rs => server/http.rs} (88%) create mode 100644 aimdb-websocket-connector/src/server/mod.rs rename aimdb-websocket-connector/src/{ => server}/registry.rs (100%) rename aimdb-websocket-connector/src/{ => server}/session.rs (98%) diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs index b95642c..c6a4ebd 100644 --- a/aimdb-websocket-connector/src/codec.rs +++ b/aimdb-websocket-connector/src/codec.rs @@ -10,7 +10,7 @@ //! `u64` id per topic that the `Subscribed` ack and `Unsubscribe` map back. The //! maps sit behind a `Mutex` so the codec stays `Send + Sync` with `&self` methods. //! -//! The hot fan-out path skips the maps: [`ClientManager::broadcast`](crate::client_manager) +//! The hot fan-out path skips the maps: [`ClientManager::broadcast`](crate::server::client_manager) //! serializes the complete `Data` frame once (it owns the topic) and the codec //! writes it verbatim — O(1) in subscribers. The `Subscribed` ack and late-join //! `Snapshot` are engine emissions the codec maps to wire frames. diff --git a/aimdb-websocket-connector/src/dispatch.rs b/aimdb-websocket-connector/src/dispatch.rs deleted file mode 100644 index 9186871..0000000 --- a/aimdb-websocket-connector/src/dispatch.rs +++ /dev/null @@ -1,385 +0,0 @@ -//! WS server [`Dispatch`] + [`Session`]. -//! -//! [`WsDispatch`] is the shared half (one `Arc` per server): -//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (in -//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`] homing the -//! application surface (the [`ClientManager`] bus handle, auth principal, query -//! handler). -//! -//! The id↔topic bookkeeping lives in the per-connection [`WsCodec`](crate::codec), -//! not here. The `Subscribed` ack and late-join `Snapshot` are engine emissions; -//! this session only supplies the snapshot bytes and the subscription stream. - -use std::any::Any; -use std::sync::Arc; - -use aimdb_core::session::Session; -use aimdb_core::{AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, SessionCtx}; -use aimdb_ws_protocol::TopicInfo; - -use crate::{ - auth::{AuthHandler, ClientId, ClientInfo, Permissions}, - client_manager::{ClientManager, ConnectionGuard}, - protocol::{ClientMessage, ErrorCode, ServerMessage}, - session::{QueryHandler, Router, SnapshotProvider}, -}; - -/// The shared WS dispatch — one `Arc` per server. -pub struct WsDispatch { - pub(crate) client_mgr: ClientManager, - pub(crate) snapshot_provider: Arc, - pub(crate) query_handler: Arc, - pub(crate) router: Arc, - pub(crate) known_topics: Arc>, - pub(crate) auth: Arc, - pub(crate) late_join: bool, - pub(crate) runtime_ctx: Option>, -} - -impl Dispatch for WsDispatch { - fn authenticate<'a>( - &'a self, - peer: &'a PeerInfo, - _first: Option<&'a [u8]>, - ) -> BoxFut<'a, Result> { - // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo`. - let info = peer.ext_as::(); - Box::pin(async move { - match info { - Some(info) => Ok(SessionCtx::with_ext(info)), - None => Err(AuthError::Unauthorized), - } - }) - } - - fn open(&self, ctx: &SessionCtx) -> Box { - let info = ctx.ext_as::().unwrap_or_else(|| { - // Should not happen (authenticate populates it); deny-all fallback. - Arc::new(ClientInfo { - id: ClientId(0), - remote_addr: ([0, 0, 0, 0], 0).into(), - permissions: Permissions::default(), - }) - }); - Box::new(WsSession { - client_mgr: self.client_mgr.clone(), - snapshot_provider: self.snapshot_provider.clone(), - query_handler: self.query_handler.clone(), - router: self.router.clone(), - known_topics: self.known_topics.clone(), - auth: self.auth.clone(), - late_join: self.late_join, - runtime_ctx: self.runtime_ctx.clone(), - info, - _conn_guard: self.client_mgr.connection_guard(), - }) - } -} - -/// One connection's per-session state (owned by the engine, `&mut`-threaded). -struct WsSession { - client_mgr: ClientManager, - snapshot_provider: Arc, - query_handler: Arc, - router: Arc, - known_topics: Arc>, - auth: Arc, - late_join: bool, - runtime_ctx: Option>, - info: Arc, - /// Decrements the live-connection count on drop. - _conn_guard: ConnectionGuard, -} - -impl Session for WsSession { - fn call<'a>( - &'a mut self, - method: &'a str, - params: Payload, - ) -> BoxFut<'a, Result> { - Box::pin(async move { - let msg: ClientMessage = - serde_json::from_slice(¶ms).map_err(|_| RpcError::Internal)?; - let response = match (method, msg) { - ( - "query", - ClientMessage::Query { - id, - pattern, - from, - to, - limit, - }, - ) => match self - .query_handler - .handle_query(&pattern, from, to, limit) - .await - { - Ok((records, total)) => ServerMessage::QueryResult { id, records, total }, - Err(message) => ServerMessage::Error { - code: ErrorCode::ServerError, - topic: None, - message, - }, - }, - ("list_topics", ClientMessage::ListTopics { id }) => ServerMessage::TopicList { - id, - topics: (*self.known_topics).clone(), - }, - _ => return Err(RpcError::NotFound), - }; - // The codec writes this complete `ServerMessage` verbatim. - let bytes = serde_json::to_vec(&response).map_err(|_| RpcError::Internal)?; - Ok(Payload::from(bytes.as_slice())) - }) - } - - fn subscribe<'a>( - &'a mut self, - topic: &'a str, - ) -> BoxFut<'a, Result, RpcError>> { - Box::pin(async move { - // Per-operation authorization via the async `AuthHandler` hook. - if !self.auth.authorize_subscribe(&self.info, topic).await { - return Err(RpcError::Denied); - } - // Register on the shared bus; the engine owns and drops the stream. - let (_sub_id, stream) = self.client_mgr.subscribe(topic); - Ok(stream) - }) - } - - fn snapshot(&mut self, topic: &str) -> Option { - if !self.late_join { - return None; - } - self.snapshot_provider - .snapshot(topic) - .map(|bytes| Payload::from(bytes.as_slice())) - } - - fn write<'a>( - &'a mut self, - topic: &'a str, - payload: Payload, - ) -> BoxFut<'a, Result<(), RpcError>> { - Box::pin(async move { - if !self.auth.authorize_write(&self.info, topic).await { - return Err(RpcError::Denied); - } - self.router - .route(topic, &payload, self.runtime_ctx.as_ref()) - .await - .map_err(|_| RpcError::Internal) - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::Mutex; - use std::time::Duration; - - use aimdb_core::router::RouterBuilder; - use aimdb_core::session::{run_session, SessionConfig}; - use aimdb_core::{Connection, SessionLimits, TransportResult}; - use tokio::sync::mpsc; - - use crate::auth::{NoAuth, Permissions}; - use crate::codec::WsCodec; - use crate::session::NoQuery; - - /// A mock [`Connection`]: inbound frames arrive on a channel, outbound frames - /// are captured. Closing the channel ends the session. - struct MockConn { - rx: mpsc::UnboundedReceiver>, - out: Arc>>>, - peer: PeerInfo, - } - - impl Connection for MockConn { - fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { - Box::pin(async move { Ok(self.rx.recv().await) }) - } - fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { - let out = self.out.clone(); - let frame = frame.to_vec(); - Box::pin(async move { - out.lock().unwrap().push(frame); - Ok(()) - }) - } - fn peer(&self) -> &PeerInfo { - &self.peer - } - } - - struct OneSnap(String, Vec); - impl SnapshotProvider for OneSnap { - fn snapshot(&self, topic: &str) -> Option> { - (topic == self.0).then(|| self.1.clone()) - } - } - - fn dispatch_with(snapshot: Arc) -> Arc { - Arc::new(WsDispatch { - client_mgr: ClientManager::new(false, 256), - snapshot_provider: snapshot, - query_handler: Arc::new(NoQuery), - router: Arc::new(RouterBuilder::from_routes(Vec::new()).build()), - known_topics: Arc::new(Vec::new()), - auth: Arc::new(NoAuth), - late_join: true, - runtime_ctx: None, - }) - } - - fn allow_all_peer() -> PeerInfo { - PeerInfo::default().with_ext(Arc::new(ClientInfo { - id: ClientId(1), - remote_addr: ([127, 0, 0, 1], 0).into(), - permissions: Permissions::allow_all(), - })) - } - - fn parse(out: &Arc>>>) -> Vec { - out.lock() - .unwrap() - .iter() - .map(|f| serde_json::from_slice(f).unwrap()) - .collect() - } - - // run_session drives the real codec + dispatch: subscribe → ack + late-join - // snapshot, then a bus broadcast fans out as a Data frame. - #[tokio::test] - async fn subscribe_ack_snapshot_and_fanout() { - let dispatch = dispatch_with(Arc::new(OneSnap( - "sensors/temp".into(), - b"\"last\"".to_vec(), - ))); - let mgr = dispatch.client_mgr.clone(); - - let (tx, rx) = mpsc::unbounded_channel::>(); - let out = Arc::new(Mutex::new(Vec::new())); - let conn = MockConn { - rx, - out: out.clone(), - peer: allow_all_peer(), - }; - - let task = { - let dispatch = dispatch.clone(); - tokio::spawn(async move { - let codec = WsCodec::new(); - let config = SessionConfig { - limits: SessionLimits::default(), - reads_hello: false, - acks_subscribe: true, - }; - run_session(Box::new(conn), &codec, dispatch.as_ref(), &config).await; - }) - }; - - // Subscribe to an exact topic so the snapshot provider matches. - tx.send( - serde_json::to_vec(&ClientMessage::Subscribe { - topics: vec!["sensors/temp".into()], - }) - .unwrap(), - ) - .unwrap(); - tokio::time::sleep(Duration::from_millis(50)).await; - - // Ack + snapshot should have been emitted, in order. - let msgs = parse(&out); - assert!( - matches!(&msgs[0], ServerMessage::Subscribed { topics } if topics == &vec!["sensors/temp".to_string()]) - ); - assert!( - matches!(&msgs[1], ServerMessage::Snapshot { topic, .. } if topic == "sensors/temp") - ); - - // A bus broadcast now fans out to this subscription as a Data frame. - mgr.broadcast("sensors/temp", b"\"22.5\"").await; - tokio::time::sleep(Duration::from_millis(50)).await; - let msgs = parse(&out); - let data = msgs - .iter() - .find_map(|m| match m { - ServerMessage::Data { topic, payload, .. } => { - Some((topic.clone(), payload.clone())) - } - _ => None, - }) - .expect("a Data frame"); - assert_eq!(data.0, "sensors/temp"); - assert_eq!(data.1, Some(serde_json::json!("22.5"))); - - drop(tx); // close the connection → end the session - let _ = task.await; - } - - // One broadcast reaches N subscribed connections (the fan-out bridge). - #[tokio::test] - async fn fanout_to_multiple_connections() { - let dispatch = dispatch_with(Arc::new(crate::session::NoSnapshot)); - let mgr = dispatch.client_mgr.clone(); - - let mut conns = Vec::new(); - for _ in 0..3 { - let (tx, rx) = mpsc::unbounded_channel::>(); - let out = Arc::new(Mutex::new(Vec::new())); - let conn = MockConn { - rx, - out: out.clone(), - peer: allow_all_peer(), - }; - let dispatch = dispatch.clone(); - let task = tokio::spawn(async move { - let codec = WsCodec::new(); - let config = SessionConfig { - limits: SessionLimits::default(), - reads_hello: false, - acks_subscribe: true, - }; - run_session(Box::new(conn), &codec, dispatch.as_ref(), &config).await; - }); - tx.send( - serde_json::to_vec(&ClientMessage::Subscribe { - topics: vec!["weather/#".into()], - }) - .unwrap(), - ) - .unwrap(); - conns.push((tx, out, task)); - } - // Wait until all three subscriptions have registered on the bus. - for _ in 0..50 { - if mgr.subscription_count() == 3 { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - assert_eq!(mgr.subscription_count(), 3); - - mgr.broadcast("weather/vienna", b"\"sunny\"").await; - - for (tx, out, task) in conns { - let mut got_data = false; - for _ in 0..50 { - got_data = parse(&out).iter().any( - |m| matches!(m, ServerMessage::Data { topic, .. } if topic == "weather/vienna"), - ); - if got_data { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - assert!(got_data, "each connection should receive the broadcast"); - drop(tx); - let _ = task.await; - } - } -} diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index cc5a6cf..ab2a9ad 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -74,21 +74,7 @@ // ════════════════════════════════════════════════════════════════════ #[cfg(feature = "server")] -pub mod auth; -#[cfg(feature = "server")] -pub mod builder; -#[cfg(feature = "server")] -pub mod client_manager; -#[cfg(feature = "server")] -pub mod connector; -#[cfg(feature = "server")] -pub(crate) mod dispatch; -#[cfg(feature = "server")] -pub(crate) mod registry; -#[cfg(feature = "server")] -pub(crate) mod server; -#[cfg(feature = "server")] -pub(crate) mod session; +pub mod server; // ════════════════════════════════════════════════════════════════════ // Client module (feature = "client") @@ -124,14 +110,16 @@ pub mod protocol; /// The primary entry point for a WebSocket **server** connector. /// -/// This is a type alias for [`builder::WebSocketConnectorBuilder`]. +/// This is a type alias for [`server::builder::WebSocketConnectorBuilder`]. #[cfg(feature = "server")] -pub type WebSocketConnector = builder::WebSocketConnectorBuilder; +pub type WebSocketConnector = server::builder::WebSocketConnectorBuilder; #[cfg(feature = "server")] -pub use auth::{AuthError, AuthHandler, AuthRequest, ClientId, ClientInfo, NoAuth, Permissions}; +pub use server::auth::{ + AuthError, AuthHandler, AuthRequest, ClientId, ClientInfo, NoAuth, Permissions, +}; #[cfg(feature = "server")] -pub use client_manager::ClientManager; +pub use server::client_manager::ClientManager; /// The primary entry point for a WebSocket **client** connector. /// @@ -142,4 +130,4 @@ pub type WsClientConnector = client::WsClientConnectorBuilder; pub use protocol::{ClientMessage, ErrorCode, QueryRecord, ServerMessage}; #[cfg(feature = "server")] -pub use session::{NoQuery, QueryFuture, QueryHandler}; +pub use server::session::{NoQuery, QueryFuture, QueryHandler}; diff --git a/aimdb-websocket-connector/src/auth.rs b/aimdb-websocket-connector/src/server/auth.rs similarity index 100% rename from aimdb-websocket-connector/src/auth.rs rename to aimdb-websocket-connector/src/server/auth.rs diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/server/builder.rs similarity index 99% rename from aimdb-websocket-connector/src/builder.rs rename to aimdb-websocket-connector/src/server/builder.rs index 1b276eb..d821eda 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -27,13 +27,13 @@ use aimdb_data_contracts::Streamable; use aimdb_core::{pump_sink, router::RouterBuilder, ConnectorBuilder, Dispatch}; use axum::Router as AxumRouter; -use crate::{ +use super::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, connector::{SnapshotCache, WsBusSink}, dispatch::WsDispatch, + http::{build_server_future, ServerState}, registry::StreamableRegistry, - server::{build_server_future, ServerState}, session::{NoQuery, NoSnapshot, QueryHandler, SnapshotProvider}, }; use aimdb_ws_protocol::TopicInfo; diff --git a/aimdb-websocket-connector/src/client_manager.rs b/aimdb-websocket-connector/src/server/client_manager.rs similarity index 96% rename from aimdb-websocket-connector/src/client_manager.rs rename to aimdb-websocket-connector/src/server/client_manager.rs index b9ea499..45c0ef0 100644 --- a/aimdb-websocket-connector/src/client_manager.rs +++ b/aimdb-websocket-connector/src/server/client_manager.rs @@ -5,7 +5,7 @@ //! registers a per-subscription channel and gets back a [`BoxStream`] of raw //! record-value [`Payload`]s; the per-connection [`WsCodec`](crate::codec) wraps //! each into a `ServerMessage::Data` on encode. The outbound record→broadcast -//! tasks ([`crate::connector`]) feed [`broadcast`](ClientManager::broadcast). +//! tasks ([`super::connector`]) feed [`broadcast`](ClientManager::broadcast). //! //! Frame formatting lives in the codec; the per-connection send half is owned by //! `run_session`. @@ -20,11 +20,12 @@ use dashmap::DashMap; use tokio::sync::mpsc; use crate::{ - auth::ClientId, codec::parse_payload, protocol::{now_ms, topic_matches, ServerMessage}, }; +use super::auth::ClientId; + /// One live subscription: a wildcard pattern + the channel feeding its stream. struct SubEntry { pattern: String, @@ -101,13 +102,6 @@ impl ClientManager { (id, Box::pin(stream)) } - /// Explicitly drop a subscription by id. Unused by the WS - /// [`Dispatch`](crate::dispatch) path (it tears down via dropped streams, - /// pruned lazily); kept for direct bus users. - pub fn unsubscribe(&self, sub_id: u64) { - self.subs.remove(&sub_id); - } - /// Fan a serialized record-value out to every subscription whose pattern /// matches `topic`. Dead subscriptions (dropped streams) are pruned. pub async fn broadcast(&self, topic: &str, payload_bytes: &[u8]) { diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/server/connector.rs similarity index 98% rename from aimdb-websocket-connector/src/connector.rs rename to aimdb-websocket-connector/src/server/connector.rs index 7d042d3..0a4d777 100644 --- a/aimdb-websocket-connector/src/connector.rs +++ b/aimdb-websocket-connector/src/server/connector.rs @@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex}; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; -use crate::client_manager::ClientManager; +use super::client_manager::ClientManager; /// Shared late-join cache: topic → last serialized bytes. pub(crate) type SnapshotCache = Arc>>>; diff --git a/aimdb-websocket-connector/src/server/dispatch.rs b/aimdb-websocket-connector/src/server/dispatch.rs new file mode 100644 index 0000000..7d9f523 --- /dev/null +++ b/aimdb-websocket-connector/src/server/dispatch.rs @@ -0,0 +1,177 @@ +//! WS server [`Dispatch`] + [`Session`]. +//! +//! [`WsDispatch`] is the shared half (one `Arc` per server): +//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (in +//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`] homing the +//! application surface (the [`ClientManager`] bus handle, auth principal, query +//! handler). +//! +//! The id↔topic bookkeeping lives in the per-connection [`WsCodec`](crate::codec), +//! not here. The `Subscribed` ack and late-join `Snapshot` are engine emissions; +//! this session only supplies the snapshot bytes and the subscription stream. + +use std::any::Any; +use std::sync::Arc; + +use aimdb_core::session::Session; +use aimdb_core::{AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, SessionCtx}; +use aimdb_ws_protocol::TopicInfo; + +use crate::protocol::{ClientMessage, ErrorCode, ServerMessage}; + +use super::{ + auth::{AuthHandler, ClientId, ClientInfo, Permissions}, + client_manager::{ClientManager, ConnectionGuard}, + session::{QueryHandler, Router, SnapshotProvider}, +}; + +/// The shared WS dispatch — one `Arc` per server. +pub struct WsDispatch { + pub(crate) client_mgr: ClientManager, + pub(crate) snapshot_provider: Arc, + pub(crate) query_handler: Arc, + pub(crate) router: Arc, + pub(crate) known_topics: Arc>, + pub(crate) auth: Arc, + pub(crate) late_join: bool, + pub(crate) runtime_ctx: Option>, +} + +impl Dispatch for WsDispatch { + fn authenticate<'a>( + &'a self, + peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo`. + let info = peer.ext_as::(); + Box::pin(async move { + match info { + Some(info) => Ok(SessionCtx::with_ext(info)), + None => Err(AuthError::Unauthorized), + } + }) + } + + fn open(&self, ctx: &SessionCtx) -> Box { + let info = ctx.ext_as::().unwrap_or_else(|| { + // Should not happen (authenticate populates it); deny-all fallback. + Arc::new(ClientInfo { + id: ClientId(0), + remote_addr: ([0, 0, 0, 0], 0).into(), + permissions: Permissions::default(), + }) + }); + Box::new(WsSession { + client_mgr: self.client_mgr.clone(), + snapshot_provider: self.snapshot_provider.clone(), + query_handler: self.query_handler.clone(), + router: self.router.clone(), + known_topics: self.known_topics.clone(), + auth: self.auth.clone(), + late_join: self.late_join, + runtime_ctx: self.runtime_ctx.clone(), + info, + _conn_guard: self.client_mgr.connection_guard(), + }) + } +} + +/// One connection's per-session state (owned by the engine, `&mut`-threaded). +struct WsSession { + client_mgr: ClientManager, + snapshot_provider: Arc, + query_handler: Arc, + router: Arc, + known_topics: Arc>, + auth: Arc, + late_join: bool, + runtime_ctx: Option>, + info: Arc, + /// Decrements the live-connection count on drop. + _conn_guard: ConnectionGuard, +} + +impl Session for WsSession { + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + Box::pin(async move { + let msg: ClientMessage = + serde_json::from_slice(¶ms).map_err(|_| RpcError::Internal)?; + let response = match (method, msg) { + ( + "query", + ClientMessage::Query { + id, + pattern, + from, + to, + limit, + }, + ) => match self + .query_handler + .handle_query(&pattern, from, to, limit) + .await + { + Ok((records, total)) => ServerMessage::QueryResult { id, records, total }, + Err(message) => ServerMessage::Error { + code: ErrorCode::ServerError, + topic: None, + message, + }, + }, + ("list_topics", ClientMessage::ListTopics { id }) => ServerMessage::TopicList { + id, + topics: (*self.known_topics).clone(), + }, + _ => return Err(RpcError::NotFound), + }; + // The codec writes this complete `ServerMessage` verbatim. + let bytes = serde_json::to_vec(&response).map_err(|_| RpcError::Internal)?; + Ok(Payload::from(bytes.as_slice())) + }) + } + + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + Box::pin(async move { + // Per-operation authorization via the async `AuthHandler` hook. + if !self.auth.authorize_subscribe(&self.info, topic).await { + return Err(RpcError::Denied); + } + // Register on the shared bus; the engine owns and drops the stream. + let (_sub_id, stream) = self.client_mgr.subscribe(topic); + Ok(stream) + }) + } + + fn snapshot(&mut self, topic: &str) -> Option { + if !self.late_join { + return None; + } + self.snapshot_provider + .snapshot(topic) + .map(|bytes| Payload::from(bytes.as_slice())) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + Box::pin(async move { + if !self.auth.authorize_write(&self.info, topic).await { + return Err(RpcError::Denied); + } + self.router + .route(topic, &payload, self.runtime_ctx.as_ref()) + .await + .map_err(|_| RpcError::Internal) + }) + } +} diff --git a/aimdb-websocket-connector/src/server.rs b/aimdb-websocket-connector/src/server/http.rs similarity index 88% rename from aimdb-websocket-connector/src/server.rs rename to aimdb-websocket-connector/src/server/http.rs index 3f71e19..a73b074 100644 --- a/aimdb-websocket-connector/src/server.rs +++ b/aimdb-websocket-connector/src/server/http.rs @@ -29,11 +29,11 @@ use axum::{ }; use tower_http::cors::CorsLayer; -use crate::{ +use crate::{codec::WsCodec, transport::WsServerConnection}; + +use super::{ auth::{AuthError, AuthRequest, ClientInfo, DynAuthHandler}, client_manager::ClientManager, - codec::WsCodec, - transport::WsServerConnection, }; // ════════════════════════════════════════════════════════════════════ @@ -64,26 +64,9 @@ pub(crate) struct ServerState { type BoxFuture = std::pin::Pin + Send + 'static>>; -/// Builds the WebSocket Axum server future. -/// -/// Returns a `BoxFuture` containing the `axum::serve()` accept loop. The -/// future is appended to the `AimDbRunner` accumulator (design 028 §"Connector -/// futures"). Per-connection handlers spawned by Axum internally continue to -/// use `tokio::spawn` — outside the scope of issue #88. -/// -/// # Arguments -/// -/// * `bind_addr` — TCP address to listen on. -/// * `ws_path` — URL path for the WebSocket endpoint (e.g., `"/ws"`). -/// * `session_ctx` — Shared session context (auth, router, client manager, …). -/// -/// Extracted so tests can serve the **real** app on a known ephemeral port -/// (`build_server_future` binds internally and does not surface the port). -pub(crate) fn build_app( - ws_path: &str, - state: ServerState, - additional_routes: Option, -) -> Router { +/// Assemble the axum `Router`: the WS endpoint + `/health`, with the shared +/// [`ServerState`] and any user-supplied extra routes merged in. +fn build_app(ws_path: &str, state: ServerState, additional_routes: Option) -> Router { // Apply state first so the router becomes `Router<()>`, which can then be // merged with user-supplied `additional_routes: Router<()>` without a // type-parameter mismatch. @@ -99,6 +82,8 @@ pub(crate) fn build_app( } } +/// Bind `bind_addr` and serve [`build_app`] as the connector's runner future +/// (the axum accept loop). Each upgraded socket is driven by `run_session`. pub(crate) fn build_server_future( bind_addr: SocketAddr, ws_path: String, diff --git a/aimdb-websocket-connector/src/server/mod.rs b/aimdb-websocket-connector/src/server/mod.rs new file mode 100644 index 0000000..d26b122 --- /dev/null +++ b/aimdb-websocket-connector/src/server/mod.rs @@ -0,0 +1,18 @@ +//! Server-side WebSocket connector: accepts browser/client connections and +//! bridges records to them. +//! +//! The HTTP/WS accept loop is axum's ([`http`]); each upgraded socket is driven +//! by the shared `run_session` engine over the root [`codec`](crate::codec) / +//! [`transport`](crate::transport) substrate, with [`dispatch`] supplying the +//! subscribe/write/query semantics and [`client_manager`] the cross-connection +//! fan-out bus. The outbound data plane rides [`connector`]'s `WsBusSink` through +//! the core `pump_sink`. + +pub mod auth; +pub mod builder; +pub mod client_manager; +pub(crate) mod connector; +pub(crate) mod dispatch; +pub(crate) mod http; +pub(crate) mod registry; +pub(crate) mod session; diff --git a/aimdb-websocket-connector/src/registry.rs b/aimdb-websocket-connector/src/server/registry.rs similarity index 100% rename from aimdb-websocket-connector/src/registry.rs rename to aimdb-websocket-connector/src/server/registry.rs diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/server/session.rs similarity index 98% rename from aimdb-websocket-connector/src/session.rs rename to aimdb-websocket-connector/src/server/session.rs index 8bbdf61..9af7948 100644 --- a/aimdb-websocket-connector/src/session.rs +++ b/aimdb-websocket-connector/src/server/session.rs @@ -1,7 +1,7 @@ //! Reusable handler traits for the WebSocket dispatch. //! //! The WS server rides `run_session` ([`aimdb_core::session::run_session`]) via -//! [`crate::dispatch`]; what lives here is the pluggable application surface the +//! [`super::dispatch`]; what lives here is the pluggable application surface the //! dispatch consumes: //! //! - [`QueryHandler`] — answers client `query` messages from a persistence backend; From b1ae505edb6c7b94b343041877aec8a861264e68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 19:15:07 +0000 Subject: [PATCH 27/34] feat: add runnable WebSocket client and server examples for demonstration --- aimdb-websocket-connector/Cargo.toml | 9 ++ .../examples/ws_client.rs | 86 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 aimdb-websocket-connector/examples/ws_client.rs diff --git a/aimdb-websocket-connector/Cargo.toml b/aimdb-websocket-connector/Cargo.toml index 22f40fc..ac34be4 100644 --- a/aimdb-websocket-connector/Cargo.toml +++ b/aimdb-websocket-connector/Cargo.toml @@ -72,3 +72,12 @@ tracing = { version = "0.1", optional = true } tokio = { version = "1", features = ["full", "test-util"] } tokio-tungstenite = "0.26" aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } + +# Runnable demos — see their headers for the two-terminal workflow. +[[example]] +name = "ws_server" +required-features = ["server"] + +[[example]] +name = "ws_client" +required-features = ["client"] diff --git a/aimdb-websocket-connector/examples/ws_client.rs b/aimdb-websocket-connector/examples/ws_client.rs new file mode 100644 index 0000000..6092ad6 --- /dev/null +++ b/aimdb-websocket-connector/examples/ws_client.rs @@ -0,0 +1,86 @@ +//! Minimal runnable WebSocket **client** demo — pairs with the `ws_server` example +//! to show AimDB ↔ AimDB mirroring over a real socket. +//! +//! Two terminals: +//! ```text +//! cargo run -p aimdb-websocket-connector --example ws_server +//! cargo run -p aimdb-websocket-connector --example ws_client --features client +//! ``` +//! +//! The client dials the server at `ws://127.0.0.1:8080/ws` and: +//! - mirrors the server's ticking `counter` into a local record (printing each +//! update) — `link_from("ws-client://counter")` ← the server's `link_to("ws://counter")`; +//! - writes an `echo` record back to the server — `link_to("ws-client://echo")` +//! → the server's `link_from("ws://echo")`, which the server prints. + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::{buffer::BufferCfg, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::WsClientConnector; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Counter { + n: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Echo { + msg: String, +} + +#[tokio::main] +async fn main() { + let url = "ws://127.0.0.1:8080/ws"; + println!("ws_client: dialing {url} (start `--example ws_server` first)"); + + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WsClientConnector::new(url)); + + // Subscribe to the server's `counter` and mirror it into a local record. + cb.configure::("counter", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws-client://counter") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + + // Produce an `echo` locally; the client connector writes it to the server. + cb.configure::("echo", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws-client://echo") + .with_serializer_raw(|e: &Echo| Ok(serde_json::to_vec(e).unwrap())) + .finish(); + }); + + let (db, runner) = cb.build().await.expect("build client db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + // Each tick: write an echo (the server prints it) and print the mirrored + // counter whenever it advances. + let mut last: Option = None; + let mut tick = 0u64; + loop { + tick += 1; + db.set_record_from_json("echo", json!({ "msg": format!("hello #{tick} from ws_client") })) + .ok(); + + if let Some(v) = db.try_latest_as_json("counter") { + if last.as_ref() != Some(&v) { + println!("← counter from server: {v}"); + last = Some(v); + } + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} From 03e5624831c9b7a0ae972c564b5dfb784ea3cf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 19:21:29 +0000 Subject: [PATCH 28/34] feat: improve documentation for latest value retrieval in records and buffers --- aimdb-core/src/builder.rs | 7 ++++++- aimdb-core/src/typed_record.rs | 7 +++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index cd967cb..74eeebd 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -1273,7 +1273,12 @@ impl AimDb { /// * `record_name` - The full Rust type name (e.g., "server::Temperature") /// /// # Returns - /// `Some(JsonValue)` with current value, or `None` if unavailable + /// `Some(JsonValue)` with the current value, or `None` if the record has no + /// value yet, no buffer, or a buffer with **no canonical latest** — i.e. + /// [`SpmcRing`](crate::buffer::BufferCfg::SpmcRing). A ring is a stream/backlog + /// with no single "current value"; read it via a subscriber or `record.drain`, + /// not a peek (`record.get`). Use [`SingleLatest`](crate::buffer::BufferCfg::SingleLatest) + /// for state you want to read latest-value style. #[cfg(feature = "std")] pub fn try_latest_as_json(&self, record_name: &str) -> Option { self.inner.try_latest_as_json(record_name) diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index c0dbc61..3196fe5 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -1138,8 +1138,11 @@ impl`, updated atomically on each `produce()`. - /// Non-blocking and buffer-agnostic. + /// Returns the most recent value wrapped in `RecordValue`, or `None` if no + /// value has been produced yet, the record has no buffer, or the buffer has + /// **no canonical latest** — i.e. [`SpmcRing`](crate::buffer::BufferCfg::SpmcRing) + /// (a ring is a stream/backlog; read it via a subscriber/drain instead). + /// Non-blocking. /// /// **Both std and no_std**: Direct access via `Deref`, `.get()`, `.into_inner()` /// From 63faceeb229d24bf3e5d2f5006ce8573661c16c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 19:30:53 +0000 Subject: [PATCH 29/34] feat: enhance record retrieval logic for ring buffers and improve WebSocket client example formatting --- aimdb-client/src/engine.rs | 7 +++ aimdb-client/tests/aimx_session.rs | 42 +++++++++++++++ aimdb-core/src/session/aimx/dispatch.rs | 51 ++++++++++++++----- .../examples/ws_client.rs | 7 ++- 4 files changed, 93 insertions(+), 14 deletions(-) diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs index 2092e47..870a84a 100644 --- a/aimdb-client/src/engine.rs +++ b/aimdb-client/src/engine.rs @@ -144,6 +144,13 @@ impl AimxConnection { } /// Get a record's current value. + /// + /// For a `SingleLatest`/state record this is a non-destructive read. A ring + /// (`SpmcRing`) has no canonical latest, so the server returns the most recent + /// value from this connection's drain cursor — which **advances that cursor** + /// (interleaving with [`drain_record`](Self::drain_record)) and is empty until + /// the ring produces a value after the connection first reads it. Prefer + /// [`drain_record`](Self::drain_record) for ring/history records. pub async fn get_record(&self, name: &str) -> ClientResult { let reply = self .call("record.get", to_payload(&json!({ "name": name }))?) diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs index ddf559c..e6aa8b2 100644 --- a/aimdb-client/tests/aimx_session.rs +++ b/aimdb-client/tests/aimx_session.rs @@ -136,3 +136,45 @@ async fn aimx_roundtrip_over_uds_production_server() { drop(conn); // stops the client engine } + +/// `record.get` on a ring (`SpmcRing`) record has no canonical latest, so it +/// falls back to draining the connection's cursor for the most-recent value. +#[tokio::test] +async fn record_get_on_ring_falls_back_to_drain() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + let config = AimxConfig::uds_default().socket_path(&sock); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); + builder.configure::("stream", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .with_remote_access(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + let conn = AimxConnection::connect(&sock).await.expect("connect"); + let producer = db.producer::("stream").expect("producer"); + + // First get opens the cursor; a fresh broadcast reader starts at the tail, so + // it sees nothing until a value is produced afterwards. + assert!(conn.get_record("stream").await.is_err()); + + // Produce after the cursor is open; get now returns the most-recent value. + let mut got = None; + for n in 1..=50u64 { + producer.produce(Reading { n }); + tokio::time::sleep(Duration::from_millis(10)).await; + if let Ok(v) = conn.get_record("stream").await { + got = Some(v); + break; + } + } + let got = got.expect("ring get returns a value after producing"); + assert!(got.get("n").is_some(), "ring get yields a Reading: {got}"); + + drop(conn); +} diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index 735da8f..76220bd 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -140,7 +140,7 @@ where "record.list" => Ok(json!(self.db.list_records())), "record.get" => { let name = str_field(¶ms, "name").ok_or(RpcError::NotFound)?; - self.db.try_latest_as_json(&name).ok_or(RpcError::NotFound) + self.record_get(&name) } "record.set" => self.record_set(params), "record.drain" => self.record_drain(params), @@ -182,9 +182,29 @@ where }) } - /// `record.drain`: lazily create a per-record cursor on first call, then - /// return everything accumulated since the previous drain (capped by an - /// optional `limit`). + /// `record.get`: the record's current value. + /// + /// A `SingleLatest`/state record exposes a non-destructive canonical latest + /// ([`try_latest_as_json`](crate::AimDb::try_latest_as_json)). A ring + /// ([`SpmcRing`](crate::buffer::BufferCfg::SpmcRing)) has none, so we fall back + /// to the connection's drain cursor and return the **most recent** available + /// value. Two consequences of that fallback: it *advances the shared drain + /// cursor* (so `record.get` and `record.drain` interleave on one connection), + /// and it yields `NotFound` until the ring produces a value *after* the cursor + /// is first opened (a fresh broadcast reader starts at the tail). Use + /// `record.drain` for a ring's full backlog. + fn record_get(&mut self, name: &str) -> Result { + if let Some(v) = self.db.try_latest_as_json(name) { + return Ok(v); + } + // Ring fallback: drain to the newest currently-available value (or NotFound). + self.drain_values(name, usize::MAX)? + .pop() + .ok_or(RpcError::NotFound) + } + + /// `record.drain`: return everything accumulated since the previous drain + /// (capped by an optional `limit`), via the per-connection cursor. fn record_drain(&mut self, params: Value) -> Result { let name = str_field(¶ms, "name").ok_or(RpcError::Internal)?; let limit = params @@ -192,34 +212,41 @@ where .and_then(|v| v.as_u64()) .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) .unwrap_or(usize::MAX); + let values = self.drain_values(&name, limit)?; + let count = values.len(); + Ok(json!({ "record_name": name, "values": values, "count": count })) + } - if !self.drain_readers.contains_key(&name) { + /// Lazily open (on first call) the per-record drain cursor and read up to + /// `limit` values accumulated since the previous read (oldest-first). Shared + /// by [`record.drain`](Self::record_drain) and [`record.get`](Self::record_get)'s + /// ring fallback, so both read from the same per-connection cursor. + fn drain_values(&mut self, name: &str, limit: usize) -> Result, RpcError> { + if !self.drain_readers.contains_key(name) { let id = self .db .inner() - .resolve_str(&name) + .resolve_str(name) .ok_or(RpcError::NotFound)?; let record = self.db.inner().storage(id).ok_or(RpcError::NotFound)?; // `subscribe_json` fails if the record was not configured with // `.with_remote_access()`. let reader = record.subscribe_json().map_err(map_db_err)?; - self.drain_readers.insert(name.clone(), reader); + self.drain_readers.insert(name.to_string(), reader); } - let reader = self.drain_readers.get_mut(&name).expect("inserted above"); + let reader = self.drain_readers.get_mut(name).expect("inserted above"); let mut values = Vec::new(); while values.len() < limit { match reader.try_recv_json() { Ok(val) => values.push(val), Err(DbError::BufferEmpty) => break, - // Ring overflowed since the last drain — cursor resets; keep going. + // Ring overflowed since the last read — cursor resets; keep going. Err(DbError::BufferLagged { .. }) => continue, Err(_) => break, } } - - let count = values.len(); - Ok(json!({ "record_name": name, "values": values, "count": count })) + Ok(values) } /// `record.query`: resolve the persistence query handler registered in the diff --git a/aimdb-websocket-connector/examples/ws_client.rs b/aimdb-websocket-connector/examples/ws_client.rs index 6092ad6..408b381 100644 --- a/aimdb-websocket-connector/examples/ws_client.rs +++ b/aimdb-websocket-connector/examples/ws_client.rs @@ -71,8 +71,11 @@ async fn main() { let mut tick = 0u64; loop { tick += 1; - db.set_record_from_json("echo", json!({ "msg": format!("hello #{tick} from ws_client") })) - .ok(); + db.set_record_from_json( + "echo", + json!({ "msg": format!("hello #{tick} from ws_client") }), + ) + .ok(); if let Some(v) = db.try_latest_as_json("counter") { if last.as_ref() != Some(&v) { From 9e394b7cb7f248af3f1c6f619303f5e78f565f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 2 Jun 2026 19:48:57 +0000 Subject: [PATCH 30/34] feat: update record retrieval logic for SpmcRing and enhance documentation for point-in-time reads --- examples/remote-access-demo/src/client.rs | 43 +++++++++++++++-------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs index 174e078..3a64ab4 100644 --- a/examples/remote-access-demo/src/client.rs +++ b/examples/remote-access-demo/src/client.rs @@ -42,18 +42,8 @@ async fn main() -> Result<(), Box> { println!(); // ── Point-in-time reads: record.get ────────────────────────────────── - // A SpmcRing keeps a *history* for independent consumers, so there is no one - // "latest" — record.get answers not_found by design. Read rings with - // record.drain (history) or record.subscribe (live), both shown below. - println!("📤 record.get on Temperature (SpmcRing — expecting an error)..."); - match conn.get_record("server::Temperature").await { - Ok(v) => println!("⚠️ Unexpected success: {v}"), - Err(_) => println!( - "✅ Expected error — rings have no point-in-time latest; use drain / subscribe." - ), - } - println!(); - + // `record.get` is a point-in-time read. For a SingleLatest/state record it + // returns the canonical latest, non-destructively. println!("📤 record.get on Config (SingleLatest — point-in-time read)..."); match conn.get_record("server::Config").await { Ok(v) => println!("⚙️ Current Config:\n{}", serde_json::to_string_pretty(&v)?), @@ -61,6 +51,29 @@ async fn main() -> Result<(), Box> { } println!(); + // A ring (SpmcRing) has no canonical latest, so the server falls back to + // *draining* this connection's cursor and returning the most recent value. A + // fresh cursor starts at the ring tail, so the first read is empty until a new + // value arrives — we retry over a couple of ticks. (This opens the *same* + // cursor `record.drain` uses below.) + println!("📤 record.get on Temperature (SpmcRing — drains the cursor for the latest)..."); + let mut latest = None; + for _ in 0..10 { + if let Ok(v) = conn.get_record("server::Temperature").await { + latest = Some(v); + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + match latest { + Some(v) => println!( + "🌡️ Most recent Temperature (via drain fallback):\n{}", + serde_json::to_string_pretty(&v)? + ), + None => println!("ℹ️ No reading yet — the ring cursor was still empty."), + } + println!(); + // ── record.set (write operations) ──────────────────────────────────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("✍️ Testing record.set (Write Operations)"); @@ -119,9 +132,11 @@ async fn main() -> Result<(), Box> { println!("🧪 Record History (record.drain)"); println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); - println!("📤 Drain #1: cold start (creates the cursor, returns empty)..."); + // `record.get` above already opened this connection's Temperature cursor, so + // this drain returns only what's accrued since (they share one cursor). + println!("📤 Drain #1: history since the (already-open) cursor..."); let d1 = conn.drain_record("server::Temperature").await?; - println!(" Values: {} (expected 0 on cold start)\n", d1.count); + println!(" Values: {} (cursor shared with the record.get above)\n", d1.count); println!("⏳ Waiting 7s for temperature readings to accumulate..."); tokio::time::sleep(Duration::from_secs(7)).await; From a105a904d9d65d89ec437681b55bcb3ae2bcf8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 3 Jun 2026 04:18:38 +0000 Subject: [PATCH 31/34] feat: enable remote access for embedded no-std environments with SendFutureWrapper integration --- Cargo.lock | 2 +- _external/embassy | 2 +- aimdb-embassy-adapter/src/lib.rs | 7 + aimdb-embassy-adapter/src/send_wrapper.rs | 34 ++ aimdb-knx-connector/src/embassy_client.rs | 36 +- aimdb-knx-connector/src/tokio_client.rs | 373 +++++++-------------- aimdb-mqtt-connector/src/embassy_client.rs | 39 +-- 7 files changed, 193 insertions(+), 300 deletions(-) create mode 100644 aimdb-embassy-adapter/src/send_wrapper.rs diff --git a/Cargo.lock b/Cargo.lock index c4c37a5..3ed6c6c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3097,7 +3097,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-6d108b6d3695cafd30923b033bc82291da5859a7#afce0040f03f376ad34e62e78e201fbfe275a0a1" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-7aaa9af0001abcfb01c01e1a9b048697a82b7d57#f4d6b404521840db5ffd97712d29a557a22cbfa4" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/_external/embassy b/_external/embassy index 664d4ea..91e4bb0 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 664d4ead36bb24a63955ca649bcec66c6e70bf6d +Subproject commit 91e4bb095aa95fdaf72a4c7c618f3aef1af976ed diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 0bdd77e..2906afe 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -78,10 +78,17 @@ mod runtime; #[cfg(all(not(feature = "std"), feature = "embassy-time"))] pub mod time; +// Force-`Send` helper for Embassy data-plane connectors (see module docs). +#[cfg(not(feature = "std"))] +pub mod send_wrapper; + // Error handling exports #[cfg(not(feature = "std"))] pub use error::EmbassyErrorSupport; +#[cfg(not(feature = "std"))] +pub use send_wrapper::SendFutureWrapper; + // Runtime exports #[cfg(not(feature = "std"))] pub use runtime::EmbassyAdapter; diff --git a/aimdb-embassy-adapter/src/send_wrapper.rs b/aimdb-embassy-adapter/src/send_wrapper.rs new file mode 100644 index 0000000..21f0075 --- /dev/null +++ b/aimdb-embassy-adapter/src/send_wrapper.rs @@ -0,0 +1,34 @@ +//! `SendFutureWrapper` — force-`Send` an Embassy future for AimDB's connector spine. +//! +//! AimDB's connector framework requires `Send` futures: `ConnectorBuilder::build` +//! returns `Vec>>` and `AimDbRunner` drives them on a +//! `Send` `BoxFuture`. Embassy's primitives (channels over `NoopRawMutex`, …) are +//! `!Send` *by design* — single-core, cooperative, no preemption or thread +//! migration — so an Embassy connector's data-plane futures must be force-`Send`ed +//! to satisfy that bound. +//! +//! This is also *why* Embassy data-plane connectors hand-roll their outbound / +//! inbound loops instead of riding core's `pump_sink` / `pump_source`: those need a +//! `Send + Sync` `Connector` / `Send` `Source`, which `!Send` Embassy channels +//! cannot be without force-`Send`ing every primitive. + +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; + +/// Asserts `Send` for a future driven exclusively by an Embassy executor. +pub struct SendFutureWrapper(pub F); + +// SAFETY: Embassy executors run cooperatively on a single core with no preemption or +/// thread migration, so the wrapped value is never actually moved across threads. +/// Only wrap futures that are polled solely by an Embassy executor. +unsafe impl Send for SendFutureWrapper {} + +impl Future for SendFutureWrapper { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // SAFETY: structural pin projection onto the single field; never moved out. + unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } + } +} diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index ddcb492..38effa0 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -34,6 +34,7 @@ use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; use aimdb_core::ConnectorBuilder; +use aimdb_embassy_adapter::SendFutureWrapper; use alloc::boxed::Box; use alloc::string::ToString; use alloc::sync::Arc; @@ -1072,7 +1073,21 @@ impl KnxConnectorImpl { default_group_addr_clone.as_str() ); - while let Ok(value_any) = reader.recv_any().await { + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // SPMC-ring overflow — skip the gap, keep publishing. + Err(aimdb_core::DbError::BufferLagged { .. }) => { + #[cfg(feature = "defmt")] + defmt::warn!( + "KNX outbound: consumer lagged for '{}'", + default_group_addr_clone.as_str() + ); + continue; + } + // Buffer closed — stop the publisher. + Err(_) => break, + }; // Determine group address: dynamic (from provider) or default (from URL) let group_addr_str = topic_provider .as_ref() @@ -1204,22 +1219,3 @@ impl aimdb_core::transport::Connector for KnxConnectorImpl { }) } } - -// SAFETY: Embassy is single-threaded, so we can safely implement Send -// even though some Embassy types don't implement it. Embassy executors run -// cooperatively on a single core with no preemption or thread migration. -struct SendFutureWrapper(F); - -unsafe impl Send for SendFutureWrapper {} - -impl core::future::Future for SendFutureWrapper { - type Output = F::Output; - - fn poll( - self: core::pin::Pin<&mut Self>, - cx: &mut core::task::Context<'_>, - ) -> core::task::Poll { - // SAFETY: We're just forwarding the poll call - unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } - } -} diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index dda000a..d4b4e1c 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -1,15 +1,15 @@ //! KNX/IP client management and lifecycle for Tokio runtime //! //! This module provides a KNX connector that: -//! - Manages a single KNX/IP gateway connection -//! - Automatic event loop spawning with reconnection -//! - Thread-safe access from multiple consumers -//! - Router-based dispatch for inbound telegrams +//! - Manages a single KNX/IP gateway connection (with reconnection) +//! - Rides core's `pump_sink` / `pump_source`: a `KnxSink` (outbound +//! `GroupValueWrite`) and a `KnxSource` (inbound telegrams) over the +//! connection task's command / telegram channels use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; -use aimdb_core::router::{Router, RouterBuilder}; -use aimdb_core::ConnectorBuilder; +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; +use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; use knx_pico::protocol::{ CEMIFrame, ConnectRequest, ConnectResponse, ConnectionHeader, ConnectionStateRequest, Hpai, KnxnetIpFrame, ServiceType, TunnelingAck, TunnelingRequest, @@ -34,10 +34,11 @@ enum KnxCommand { }, } -/// KNX connector for a single gateway connection with router-based dispatch +/// KNX connector for a single gateway connection. /// -/// Each connector manages ONE KNX/IP gateway connection. The router determines -/// how incoming telegrams are dispatched to AimDB producers. +/// Each connector manages ONE KNX/IP gateway connection; inbound telegrams are +/// dispatched to AimDB producers by `pump_source`, outbound records published by +/// `pump_sink`. /// /// # Usage Pattern /// @@ -103,67 +104,32 @@ impl ConnectorBuilder for KnxCon db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // Collect inbound routes from database - let inbound_routes = db.collect_inbound_routes("knx"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} inbound routes for KNX connector", - inbound_routes.len() - ); - - // Convert routes to Router - let router = RouterBuilder::from_routes(inbound_routes).build(); - - #[cfg(feature = "tracing")] - tracing::info!( - "KNX router has {} group addresses", - router.resource_ids().len() - ); - - // Build the command channel + connection-task future. - // - // Channel ownership ordering (design 028 §"KNX channel ownership"): - // 1. mpsc::channel created here. - // 2. Receiver captured by `connection_future`. - // 3. Sender cloned into each outbound publisher future below. - let runtime_ctx = db.runtime_any(); - let (command_tx, connection_future) = KnxConnectorImpl::build_internal( - &self.gateway_url, - router, - Some(runtime_ctx), - self.command_queue_size, - ) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build KNX connector: {}", e), - } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; - - let outbound_routes = db.collect_outbound_routes("knx"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} outbound routes for KNX connector", - outbound_routes.len() - ); + // Build the command channel, the inbound-telegram channel, and the + // connection task. Inbound flows connection-task → `KnxSource` → + // `pump_source`; outbound flows `pump_sink` → `KnxSink` → the command + // channel → connection task. The routing `Router` is (re)built inside + // `pump_source` from `collect_inbound_routes`. + let (command_tx, telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&self.gateway_url, self.command_queue_size) + .await + .map_err(|_e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build KNX connector: {}", _e), + } + } + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(1 + outbound_routes.len()); - futures.push(connection_future); - futures.extend(KnxConnectorImpl::collect_outbound_futures( - command_tx, - runtime_ctx, - outbound_routes, - )); + let mut futures: Vec = vec![connection_future]; + // Inbound: the KNX bus source, fanned out to producers by `pump_source`. + futures.extend(pump_source(db, "knx", KnxSource { telegram_rx })); + // Outbound: `pump_sink` serializes each record and hands it to `KnxSink`. + futures.extend(pump_sink(db, "knx", Arc::new(KnxSink { command_tx }))); Ok(futures) }) @@ -184,29 +150,30 @@ impl ConnectorBuilder for KnxCon pub struct KnxConnectorImpl; impl KnxConnectorImpl { - /// Builds the KNX connection-task future and returns it along with the - /// command sender for use by outbound publishers. + /// Builds the KNX connection-task future, returning the outbound command + /// sender and the inbound-telegram receiver for `KnxSink` / `KnxSource`. /// /// # Arguments /// * `gateway_url` - Gateway URL (knx://host:port) - /// * `router` - Pre-configured router with all routes + /// * `command_queue_size` - Capacity of both the command and telegram channels async fn build_internal( gateway_url: &str, - router: Router, - runtime_ctx: Option>, command_queue_size: usize, - ) -> Result<(mpsc::Sender, BoxFuture), String> { + ) -> Result< + ( + mpsc::Sender, + mpsc::Receiver<(String, Payload)>, + BoxFuture, + ), + String, + > { // Parse the gateway URL let mut url = gateway_url.to_string(); - - // If no group address is provided, add a dummy one for parsing if !url.contains('/') || url.matches('/').count() < 3 { url = format!("{}/0/0/0", url.trim_end_matches('/')); } - let connector_url = ConnectorUrl::parse(&url).map_err(|e| format!("Invalid KNX URL: {}", e))?; - let gateway_ip = connector_url.host.clone(); let gateway_port = connector_url.port.unwrap_or(3671); @@ -217,163 +184,65 @@ impl KnxConnectorImpl { gateway_port ); - let router_arc = Arc::new(router); - - // 1. Create command channel; receiver goes to the connection future, - // sender is returned for the publisher futures to clone. + // Outbound commands (publishers → connection task) and inbound telegrams + // (connection task → `KnxSource`/`pump_source`). let (command_tx, command_rx) = mpsc::channel::(command_queue_size); + let (telegram_tx, telegram_rx) = mpsc::channel::<(String, Payload)>(command_queue_size); - // 2. Build the connection-task future (captures the receiver). - let connection_future = build_connection_future( - gateway_ip, - gateway_port, - router_arc, - runtime_ctx, - command_rx, - ); + let connection_future = + build_connection_future(gateway_ip, gateway_port, telegram_tx, command_rx); - Ok((command_tx, connection_future)) + Ok((command_tx, telegram_rx, connection_future)) } +} - /// Collects outbound publisher futures for all configured routes (internal). - /// - /// Each route's future subscribes to its typed record, serializes values, and - /// sends them as `KnxCommand::GroupWrite` to the connection task via - /// `command_tx`. Returned futures are appended to the runner's accumulator. - fn collect_outbound_futures( - command_tx: mpsc::Sender, - runtime_ctx: Arc, - routes: Vec, - ) -> Vec { - let mut futures: Vec = Vec::with_capacity(routes.len()); - - for (default_group_addr_str, consumer, serializer, _config, topic_provider) in routes { - let command_tx = command_tx.clone(); - let default_group_addr_clone = default_group_addr_str.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - // Parse default group address using knx-pico's type-safe parser - let default_group_addr = match default_group_addr_clone.parse::() { - Ok(addr) => Some(addr), - Err(_e) => { - // If no topic provider, this is an error - if topic_provider.is_none() { - #[cfg(feature = "tracing")] - tracing::error!( - "Invalid group address for outbound: '{}'", - default_group_addr_clone - ); - return; - } - // With topic provider, the default can be invalid (will be overridden) - None - } - }; - - // Subscribe to typed values (type-erased) - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to subscribe for outbound: '{}'", - default_group_addr_clone - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "KNX outbound publisher started for: {}", - default_group_addr_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Determine group address: dynamic (from provider) or default (from URL) - let group_addr_str = topic_provider - .as_ref() - .and_then(|provider| provider.topic_any(&*value_any)) - .unwrap_or_else(|| default_group_addr_clone.clone()); - - // Parse group address (may be dynamic) - let group_addr = match group_addr_str.parse::() { - Ok(addr) => addr, - Err(_e) => { - // Try to use cached default if available - if let Some(addr) = default_group_addr { - addr - } else { - #[cfg(feature = "tracing")] - tracing::error!( - "Invalid dynamic group address: '{}'", - group_addr_str - ); - continue; - } - } - }; - - // Serialize the type-erased value - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for group address '{}': {:?}", - group_addr_str, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for group address '{}': {:?}", - group_addr_str, - _e - ); - continue; - } - } - } - }; - - // Send command to connection task - let cmd = KnxCommand::GroupWrite { - group_addr, - data: bytes, - response: None, // Fire-and-forget - }; - - if let Err(_e) = command_tx.send(cmd).await { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to send command for group address '{}': channel closed", - group_addr_str - ); - break; // Connection task died, stop publishing - } +/// Outbound publish adapter driven by `pump_sink`. +/// +/// `pump_sink` resolves each record's destination group address (dynamic via a +/// topic provider, or the link's default) and serializes the value; `publish` +/// parses that address and forwards a fire-and-forget `GroupValueWrite` to the +/// connection task over the command channel. +struct KnxSink { + command_tx: mpsc::Sender, +} - #[cfg(feature = "tracing")] - tracing::debug!("Published to KNX: {}", group_addr_str); - } +impl Connector for KnxSink { + fn publish( + &self, + destination: &str, + _config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { + let group_addr_str = destination.to_string(); + let data = payload.to_vec(); + let command_tx = self.command_tx.clone(); + Box::pin(async move { + let group_addr = group_addr_str + .parse::() + .map_err(|_| PublishError::InvalidDestination)?; + command_tx + .send(KnxCommand::GroupWrite { + group_addr, + data, + response: None, // fire-and-forget + }) + .await + .map_err(|_| PublishError::ConnectionFailed) // connection task gone + }) + } +} - #[cfg(feature = "tracing")] - tracing::info!( - "KNX outbound publisher stopped for: {}", - default_group_addr_clone - ); - })); - } +/// Inbound telegram source driven by `pump_source`. +/// +/// Yields each `(group_address, payload)` the connection task parsed off the KNX +/// bus; `pump_source` deserializes and fans it out to the matching producers. +struct KnxSource { + telegram_rx: mpsc::Receiver<(String, Payload)>, +} - futures +impl Source for KnxSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { self.telegram_rx.recv().await }) } } @@ -382,21 +251,19 @@ impl KnxConnectorImpl { /// The connection task handles: /// - KNXnet/IP connection establishment /// - Telegram reception and parsing -/// - Router-based dispatch to producers +/// - Forwarding parsed inbound telegrams to the `telegram_tx` channel (`pump_source`) /// - Outbound command processing /// - Automatic reconnection on failure /// /// # Arguments /// * `gateway_ip` - Gateway IP address /// * `gateway_port` - Gateway port (typically 3671) -/// * `router` - Router for dispatching telegrams to producers -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers +/// * `telegram_tx` - Sender for inbound telegrams → `KnxSource`/`pump_source` /// * `command_rx` - Receiver half of the outbound command channel fn build_connection_future( gateway_ip: String, gateway_port: u16, - router: Arc, - runtime_ctx: Option>, + telegram_tx: mpsc::Sender<(String, Payload)>, mut command_rx: mpsc::Receiver, ) -> BoxFuture { Box::pin(async move { @@ -408,14 +275,7 @@ fn build_connection_future( ); loop { - match connect_and_listen( - &gateway_ip, - gateway_port, - router.clone(), - &mut command_rx, - runtime_ctx.as_ref(), - ) - .await + match connect_and_listen(&gateway_ip, gateway_port, &telegram_tx, &mut command_rx).await { Ok(_) => { #[cfg(feature = "tracing")] @@ -543,15 +403,13 @@ impl ChannelState { /// # Arguments /// * `gateway_ip` - Gateway IP address /// * `gateway_port` - Gateway port -/// * `router` - Router for dispatching messages +/// * `telegram_tx` - Sender for parsed inbound telegrams → `pump_source` /// * `command_rx` - Command receiver for outbound publishing -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers async fn connect_and_listen( gateway_ip: &str, gateway_port: u16, - router: Arc, + telegram_tx: &mpsc::Sender<(String, Payload)>, command_rx: &mut mpsc::Receiver, - runtime_ctx: Option<&Arc>, ) -> Result<(), String> { // 1. Create UDP socket let socket = UdpSocket::bind("0.0.0.0:0") @@ -667,10 +525,18 @@ async fn connect_and_listen( #[cfg(feature = "tracing")] tracing::debug!("KNX telegram: {} ({} bytes)", resource_id, data.len()); - // Dispatch via router - if let Err(_e) = router.route(&resource_id, &data, runtime_ctx).await { + // Forward to `pump_source` (which routes to producers). + // `try_send` so a slow/full sink never stalls the + // protocol task (ACKs, keepalive, outbound). + if telegram_tx + .try_send((resource_id, Payload::from(data.as_slice()))) + .is_err() + { #[cfg(feature = "tracing")] - tracing::warn!("Router dispatch failed for {}: {:?}", resource_id, _e); + tracing::warn!( + "KNX inbound: dropping telegram for {} (channel full/closed)", + group_addr + ); } } else { #[cfg(feature = "tracing")] @@ -1078,21 +944,16 @@ async fn send_group_write_internal( #[cfg(test)] mod tests { use super::*; - use aimdb_core::router::RouterBuilder; #[tokio::test] - async fn test_connector_creation_with_router() { - let router = RouterBuilder::new().build(); - let connector = - KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", router, None, 32).await; + async fn test_connector_creation() { + let connector = KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", 32).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_connector_with_port() { - let router = RouterBuilder::new().build(); - let connector = - KnxConnectorImpl::build_internal("knx://gateway.local:3672", router, None, 32).await; + let connector = KnxConnectorImpl::build_internal("knx://gateway.local:3672", 32).await; assert!(connector.is_ok()); } diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 886190c..7d405d0 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -53,6 +53,7 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; +use aimdb_embassy_adapter::SendFutureWrapper; use embassy_net::Ipv4Address; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::channel::{Channel, Sender}; @@ -594,7 +595,22 @@ impl MqttConnectorImpl { default_topic_clone.as_str() ); - while let Ok(value_any) = reader.recv_any().await { + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // Lagged (ring overflow) — skip the gap, keep publishing + // rather than letting a transient overrun kill the publisher. + Err(aimdb_core::DbError::BufferLagged { .. }) => { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT outbound: consumer lagged for '{}'", + default_topic_clone.as_str() + ); + continue; + } + // Buffer closed — stop the publisher. + Err(_e) => break, + }; // Determine topic: dynamic (from provider) or default (from URL) let topic = topic_provider .as_ref() @@ -664,27 +680,6 @@ impl MqttConnectorImpl { } } -// Helper wrapper to make futures Send for Embassy's single-threaded environment -// -// SAFETY: Embassy is single-threaded, so we can safely implement Send -// even though some Embassy types don't implement it. Embassy executors run -// cooperatively on a single core with no preemption or thread migration. -struct SendFutureWrapper(F); - -unsafe impl Send for SendFutureWrapper {} - -impl core::future::Future for SendFutureWrapper { - type Output = F::Output; - - fn poll( - self: core::pin::Pin<&mut Self>, - cx: &mut core::task::Context<'_>, - ) -> core::task::Poll { - // SAFETY: We're just forwarding the poll call - unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } - } -} - #[cfg(test)] mod tests { use super::*; From 769bd719aa698e69d705ce0ce58c7441c3173054 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 3 Jun 2026 18:43:30 +0000 Subject: [PATCH 32/34] feat: update connector-session documentation and improve SendFutureWrapper safety comments --- _external/embassy | 2 +- aimdb-core/Cargo.toml | 12 ++++++++---- aimdb-embassy-adapter/src/send_wrapper.rs | 4 ++-- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/_external/embassy b/_external/embassy index 91e4bb0..44729ce 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 91e4bb095aa95fdaf72a4c7c618f3aef1af976ed +Subproject commit 44729ce14de1694600d398b836c883e3fd2aff02 diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 35abe26..3793322 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -39,10 +39,14 @@ alloc = ["serde"] # Enable heap in no_std # get `record.latest()?.as_json()` without std/AimX. `std` enables it for AimX. json-serialize = ["alloc", "serde_json"] -# Phase 0 connector-session contracts (`crate::session`): the frozen, dyn-safe -# trait skeletons for the connector capability model — Connection/Listener/Dialer, -# Dispatch/EnvelopeCodec, Sink/Source + shared types. Contracts only, no engine -# logic; compiles on `no_std + alloc`. See docs/design/detailed/037-phase0-contracts.md. +# The connector-session substrate (`crate::session`): the dyn-safe trait set +# (Connection/Listener/Dialer, Dispatch/EnvelopeCodec, Source + shared types) and +# the runtime-neutral engines built on it — the reactive server (`serve`/ +# `run_session`), the proactive client (`run_client`/`pump_client`), the +# `pump_sink`/`pump_source` data plane, and the generic session connectors. Engine +# logic included; all compiles on `no_std + alloc`. The AimX protocol port +# (`session::aimx`) additionally needs `json-serialize`. See the design doc: +# docs/design/remote-access-via-connectors.md. connector-session = ["alloc"] # Observability features (available on both std/no_std) diff --git a/aimdb-embassy-adapter/src/send_wrapper.rs b/aimdb-embassy-adapter/src/send_wrapper.rs index 21f0075..6a634a0 100644 --- a/aimdb-embassy-adapter/src/send_wrapper.rs +++ b/aimdb-embassy-adapter/src/send_wrapper.rs @@ -20,8 +20,8 @@ use core::task::{Context, Poll}; pub struct SendFutureWrapper(pub F); // SAFETY: Embassy executors run cooperatively on a single core with no preemption or -/// thread migration, so the wrapped value is never actually moved across threads. -/// Only wrap futures that are polled solely by an Embassy executor. +// thread migration, so the wrapped value is never actually moved across threads. +// Only wrap futures that are polled solely by an Embassy executor. unsafe impl Send for SendFutureWrapper {} impl Future for SendFutureWrapper { From 7573c11e351370601e3553a8d2be48c3f21f6065 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 3 Jun 2026 18:45:13 +0000 Subject: [PATCH 33/34] feat: update changelogs for remote access enhancements and shared SendFutureWrapper integration --- CHANGELOG.md | 2 +- aimdb-embassy-adapter/CHANGELOG.md | 1 + aimdb-knx-connector/CHANGELOG.md | 6 ++++++ aimdb-mqtt-connector/CHANGELOG.md | 1 + aimdb-uds-connector/CHANGELOG.md | 1 - 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3873c8..d34f7f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Remote access via connectors — Phases 0–6: converge four hand-rolled networking stacks onto two shared engines (Issue #39, [design doc](docs/design/remote-access-via-connectors.md)).** AimX remote access (and any future transport) now rides the connector layer instead of a bespoke I/O abstraction. New, runtime-neutral `aimdb-core::session` module (feature `connector-session`, `no_std + alloc`): the three-layer substrate (`Connection`/`Listener`/`Dialer` + `EnvelopeCodec` + `Dispatch`/`Session`), the reactive **server** engine (`serve`/`run_session`) and proactive **client** engine (`run_client`/`pump_client`), the `pump_sink`/`pump_source` data-plane toolkit, and the transport-agnostic `SessionClientConnector`/`SessionServerConnector`. The AimX-v2 NDJSON protocol (`session::aimx`: `AimxCodec` + `AimxDispatch`) and the WebSocket connector are ports onto this substrate, so the AimX server/client and WS server/client stacks collapse onto the two engines. New **`aimdb-uds-connector`** crate carries the UDS transport (`UdsClient`/`UdsServer`). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) +- **Remote access via connectors — Phases 0–6: converge four hand-rolled networking stacks onto two shared engines (Issue #39, [design doc](docs/design/remote-access-via-connectors.md)).** AimX remote access (and any future transport) now rides the connector layer instead of a bespoke I/O abstraction. New, runtime-neutral `aimdb-core::session` module (feature `connector-session`, `no_std + alloc`): the three-layer substrate (`Connection`/`Listener`/`Dialer` + `EnvelopeCodec` + `Dispatch`/`Session`), the reactive **server** engine (`serve`/`run_session`) and proactive **client** engine (`run_client`/`pump_client`), the `pump_sink`/`pump_source` data-plane toolkit, and the transport-agnostic `SessionClientConnector`/`SessionServerConnector`. The AimX-v2 NDJSON protocol (`session::aimx`: `AimxCodec` + `AimxDispatch`) and the WebSocket connector are ports onto this substrate, so the AimX server/client and WS server/client stacks collapse onto the two engines. New **`aimdb-uds-connector`** crate carries the UDS transport (`UdsClient`/`UdsServer`). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) - **M16 — JSON codec extracted behind the `json-serialize` feature; `RecordValue::as_json()` now works on `no_std + alloc`, not just `std` ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** New `aimdb-core::codec` module: `RemoteSerialize` (blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec`, and the zero-sized `SerdeJsonCodec`. `serde_json` runs on `alloc`, so embedded targets can opt in; `std` enables the feature transitively, so std builds are unaffected. ([aimdb-core](aimdb-core/CHANGELOG.md)) - **Embassy buffer + join-queue tests now run in CI (Issue #85).** The join-queue tests previously sat behind `embassy-runtime`, which pulls `embassy-executor`'s cortex-m assembly and can't compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions were never caught. The `join_queue` module is now gated on `embassy-sync`, and `make test` runs the embassy adapter's unit tests + doctests on the host (no executor). Also adds `EmbassyBuffer::peek()` and fixes a stale `EmbassyBuffer` doc example. ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index e8d3d74..9d93630 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`SendFutureWrapper` — shared force-`Send` wrapper for Embassy data-plane connectors (Issue #39).** New `pub` type (`no_std` only): asserts `Send` for a future driven exclusively by a single-core, cooperative Embassy executor, so an Embassy connector's `!Send` data-plane futures (over `NoopRawMutex` channels) satisfy the connector spine's `Send` `BoxFuture` bound. Consolidates the identical wrappers that the KNX and MQTT Embassy clients previously each hand-rolled. - **Session-engine smoke test on the Embassy clock (Issue #39, Phase 5, [design doc](../docs/design/remote-access-via-connectors.md)).** New `tests/session_smoke.rs` drives `aimdb-core`'s runtime-neutral `run_client` engine using the `EmbassyAdapter`'s `TimeOps` clock for reconnect backoff / keepalive — proving the shared session engines run on Embassy, not just Tokio. Dev-only: pulls in `aimdb-core` with the `connector-session` feature, so the normal `no_std` lib build and the `thumbv7em` cross-checks stay `alloc`-only. - **`EmbassyBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read matching the Tokio adapter's semantics: `SingleLatest` (`Watch`) via `Watch::try_get()`, `Mailbox` (`Channel<_, T, 1>`) via `Channel::try_peek()`, `SpmcRing` (`PubSubChannel`) returns `None`. Neither path consumes a receiver slot or advances a cursor. - **Embassy buffer + join-queue unit tests now run in CI on the host (Issue #85).** Previously the join-queue tests sat behind `feature = "embassy-runtime"`, which transitively pulls `embassy-executor`'s `platform-cortex-m` ARM assembly and fails to compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions went uncaught. The `join_queue` module is now gated on `embassy-sync` instead (the `JoinFanInRuntime for EmbassyAdapter` impl keeps its own `embassy-runtime` gate), and `make test` runs `cargo test -p aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time"` (15 unit tests + doctests). A test-only no-op `#[defmt::global_logger]` / `#[defmt::panic_handler]` and a trivial `embassy-time-driver` satisfy the host link targets that `defmt` + `defmt-timestamp-uptime` would otherwise leave undefined. diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 65ece96..5e90967 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and telegram read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers: the connector now writes only a `KnxSink` (`Connector`, parses the destination group address and forwards a fire-and-forget `GroupValueWrite`) and a `KnxSource` (`Source`, yields each inbound `(group_address, payload)`) and composes the pumps in `build()`. The routing `Router` is (re)built inside `pump_source`. `std` enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. +- **Outbound publishers survive a consumer lag (Tokio + Embassy).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. +- **`SendFutureWrapper` moved to `aimdb-embassy-adapter`.** The Embassy client's local force-`Send` wrapper is gone in favour of the shared `aimdb_embassy_adapter::SendFutureWrapper` (single definition, no behavior change). + ### Changed (breaking) - **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 40fd432..06441db 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers (the connector now writes only its `Connector`/`Source` I/O adapters and composes the pumps in `build()`). Per-route configuration (`qos` / `retain` / `timeout_ms` / …) is threaded from each link URL's query via `ConnectorConfig::from_query`. `std` now enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. +- **Outbound publisher survives a consumer lag; `SendFutureWrapper` relocated (Embassy client, Issue #39).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. The Embassy client's local force-`Send` wrapper is gone in favour of the shared `aimdb_embassy_adapter::SendFutureWrapper` (single definition, no behavior change). ### Changed (breaking) diff --git a/aimdb-uds-connector/CHANGELOG.md b/aimdb-uds-connector/CHANGELOG.md index c4170b6..b48f7f7 100644 --- a/aimdb-uds-connector/CHANGELOG.md +++ b/aimdb-uds-connector/CHANGELOG.md @@ -12,4 +12,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **New crate — the Unix-domain-socket transport for AimDB remote access (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A thin, swappable transport that rides the shared session engine in `aimdb-core::session`: it contributes only the `Dialer`/`Listener`/`Connection` triple (`UdsDialer` / `UdsListener` / `UdsConnection`, NDJSON framing in the transport — one line per logical frame); the AimX codec + dispatch and the engine wiring are reused verbatim from core. Two ergonomic constructors: - **`UdsServer`** — accepts connections and serves the full AimX toolset over a socket. Register it via `with_connector` to stand up remote access (this replaces `aimdb-core`'s removed `AimDbBuilder::with_remote_access(config)`). Sugar over `SessionServerConnector`; `UdsServer::from_config(config)` is the one-line migration, plus `new`/`max_connections`/`max_subs_per_connection`/`socket_permissions`/`scheme` builders. Binds synchronously (remove-stale → `bind` → `set_permissions`) so bind errors surface from `build()`, and applies the security policy's writable-record marking. - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"uds"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. -- **Deprecated back-compat shims** for the types that relocated here from `aimdb-core`: `AimxClientConnector::new(path)` (defaults the scheme to `"aimx"`, preserving pre-Phase-6 behavior) and the free-standing `build_aimx_server(db, config)` (returns the `serve` future directly). Prefer `UdsClient` / `UdsServer`. From 89a745c46d06cd0784d68dfe3e0ff071b19bac53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Wed, 3 Jun 2026 20:12:15 +0000 Subject: [PATCH 34/34] feat: update connection handling in record tools for improved performance and reliability --- aimdb-core/src/builder.rs | 4 +- .../embassy-knx-connector-demo/src/main.rs | 2 +- .../embassy-mqtt-connector-demo/src/main.rs | 2 +- examples/tokio-knx-connector-demo/src/main.rs | 2 +- tools/aimdb-mcp/src/tools/record.rs | 67 ++++++++++++++----- 5 files changed, 56 insertions(+), 21 deletions(-) diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 74eeebd..20654dc 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -883,10 +883,12 @@ where tracing::debug!("Building connector for scheme: {}", scheme); let connector_futures = builder.build(&db).await?; + #[cfg(feature = "tracing")] + let n_futures = connector_futures.len(); futures_acc.extend(connector_futures); #[cfg(feature = "tracing")] - tracing::info!("Connector '{}' contributed {} future(s)", scheme, "n"); + tracing::info!("Connector '{}' contributed {} future(s)", scheme, n_futures); } // Collect on_start futures (registered by external crates like aimdb-persistence). diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 7b8b980..61b22f3 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -127,7 +127,7 @@ async fn button_handler( // ============================================================================ /// KNX/IP gateway IP address (modify for your network) -const KNX_GATEWAY_IP: &str = "192.168.1.19"; +const KNX_GATEWAY_IP: &str = "192.168.1.4"; /// KNX/IP gateway port (default: 3671) const KNX_GATEWAY_PORT: u16 = 3671; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 789a6a5..8f112b8 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -175,7 +175,7 @@ async fn server_room_temp_producer( // ============================================================================ /// MQTT broker IP address (modify for your network) -const MQTT_BROKER_IP: &str = "192.168.1.3"; +const MQTT_BROKER_IP: &str = "192.168.1.10"; /// MQTT broker port const MQTT_BROKER_PORT: u16 = 1883; diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index 8f53528..e6f1ce4 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -83,7 +83,7 @@ async fn main() -> DbResult<()> { println!("⚠️ Update gateway URL and group addresses to match your setup!\n"); let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new("knx://192.168.1.19:3671"), + aimdb_knx_connector::KnxConnector::new("knx://192.168.1.4:3671"), ); // Temperature sensors (inbound) - using link_address() from key metadata diff --git a/tools/aimdb-mcp/src/tools/record.rs b/tools/aimdb-mcp/src/tools/record.rs index 9c1266d..9934929 100644 --- a/tools/aimdb-mcp/src/tools/record.rs +++ b/tools/aimdb-mcp/src/tools/record.rs @@ -1,9 +1,10 @@ //! Record-related tools (list_records, get_record, set_record) use crate::error::{McpError, McpResult}; -use aimdb_client::AimxConnection; +use aimdb_client::{AimxConnection, ClientError}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::{Duration, Instant}; use tracing::debug; /// Parameters for list_records tool @@ -158,27 +159,59 @@ pub async fn get_record(args: Option) -> McpResult { socket_path, params.record_name ); - // Get or create connection from pool (if available) - let client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) - .await - .map_err(McpError::Client)? - } else { - // Fallback to direct connection if pool not initialized - AimxConnection::connect(&socket_path) - .await - .map_err(McpError::Client)? - }; + // Reuse the *persistent* connection (the same pool `drain_record` uses) rather + // than a throwaway one. For ring buffers (`SpmcRing`, which has no canonical + // latest), the server's `record.get` falls back to *this connection's* drain + // cursor (see `aimdb_core::session::aimx::dispatch`'s `record_get`). A fresh + // connection per call opens a new cursor at the ring tail every time and always + // reads empty → `not_found`; the persistent connection lets that cursor + // accumulate. The get→drain fallback already lives server-side, so nothing is + // duplicated here — we just stop discarding the connection. + let pool = super::connection_pool() + .ok_or_else(|| McpError::Internal("Connection pool not initialized".to_string()))?; - // Get record value - let value = client - .get_record(¶ms.record_name) + let client_arc = pool + .get_drain_client(&socket_path) .await .map_err(McpError::Client)?; - debug!("✅ Retrieved record '{}'", params.record_name); + let client = client_arc.lock().await; - Ok(value) + // A ring's drain cursor opens at the tail, so the first read is empty until a + // value is produced after it opened. Briefly retry on `not_found` so a one-shot + // `get_record` on a ring returns the latest value instead of failing. This adds + // no latency for `single_latest` records (they return immediately via the + // server's canonical-latest path) nor for an already-warm ring cursor. Note: a + // record that simply doesn't exist also reports `not_found`, so a bad name + // costs the full window before erroring. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let err = match client.get_record(¶ms.record_name).await { + Ok(value) => { + debug!("✅ Retrieved record '{}'", params.record_name); + return Ok(value); + } + Err(e) => e, + }; + + let cursor_warming = + matches!(&err, ClientError::ServerError { code, .. } if code == "not_found"); + + if cursor_warming && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + + // A genuine connection/protocol error (not a warming ring cursor) means the + // persistent client is unhealthy — drop it so the next call reconnects. + if !cursor_warming { + let socket = socket_path.clone(); + let pool = pool.clone(); + tokio::spawn(async move { pool.invalidate_drain_client(&socket).await }); + } + + return Err(McpError::Client(err)); + } } /// Set the value of a writable record