From 5df3435af0b6136ab2fbadbec5adf72759d335ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 17:47:10 +0000 Subject: [PATCH 01/19] feat(io): add runtime-neutral byte-stream layer and associated traits --- aimdb-core/src/session/io.rs | 315 ++++++++++++++++++++++++++++++++++ aimdb-core/src/session/mod.rs | 82 ++++----- 2 files changed, 350 insertions(+), 47 deletions(-) create mode 100644 aimdb-core/src/session/io.rs diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs new file mode 100644 index 00000000..cb0f1e93 --- /dev/null +++ b/aimdb-core/src/session/io.rs @@ -0,0 +1,315 @@ +//! Runtime-neutral byte-stream layer: the seam where an adapter owns sockets, +//! clocks and name resolution, and a connector owns framing and protocol. +//! +//! These traits sit one layer **below** [`Connection`](super::Connection): a +//! [`ByteStream`] is unframed, a [`Connection`](super::Connection) is framed. A +//! connector generic over them needs no per-runtime module and no `cfg` on its +//! code path. +//! +//! Every async method returns `impl Future<…> + Send`. The bound is on the +//! return type because generic code must produce `Send` futures at the boxing +//! boundary and cannot otherwise prove it — return-type notation, which would +//! say exactly that, is still experimental on the pinned toolchain. An +//! implementor whose runtime futures are `!Send` wraps them in a force-`Send` +//! newtype; that `unsafe` belongs to the adapter, and core has none. +//! +//! The traits are deliberately not `dyn`-compatible. Connectors are generic +//! over them; the `dyn` boundary stays at `Box` per frame. + +use alloc::boxed::Box; +use alloc::vec::Vec; +use core::future::Future; + +use super::{BoxFut, PeerInfo, TransportError, TransportResult}; + +/// Failure of a byte-level I/O operation. +/// +/// An alias, not a new type: these traits sit directly beneath +/// [`Connection`](super::Connection), so nothing converts at that boundary. +pub type IoError = TransportError; + +// =========================================================================== +// Byte streams — the one real fork between runtimes. +// =========================================================================== + +/// An unframed, bidirectional byte stream — one TCP connection, one UART, one +/// TLS session. The adapter owns it; the connector never names its type. +/// +/// `read` returning `Ok(0)` is end of stream, matching both +/// `embedded_io_async::Read` and `tokio::io::AsyncRead`. +/// +/// The stream is **unsplit** — one value, `&mut self` on both directions — so +/// it can wrap a socket that lends out only borrowed halves while a +/// [`Connection`](super::Connection) must own it. Nothing is lost by it: +/// `Connection`'s own `recv`/`send` take `&mut self`, so reads and writes were +/// already serialized. +pub trait ByteStream { + /// Read into `buf`, returning the byte count; `Ok(0)` is EOF. + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a; + + /// Write every byte of `buf`, or fail. + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a; + + /// Flush any buffered bytes toward the peer. + fn flush(&mut self) -> impl Future> + Send + '_; +} + +/// Produces streams: the client side. +/// +/// `host` is unresolved — name resolution belongs to the adapter, so a +/// connector carries no resolver. +pub trait StreamDialer { + /// The stream this dialer produces. + type Stream: ByteStream + Send; + + /// Open a stream to `host:port`. + fn connect<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future> + Send + 'a; +} + +/// Produces streams: the server side, and the dual of [`StreamDialer`]. +/// +/// One `accept` at a time, matching +/// [`Listener::accept`](super::Listener::accept) and the `serve` loop that +/// drives it. The single-shot signature costs no concurrency: an adapter +/// backing it with a pool of sockets keeps every one of them listening across +/// calls and consumes only the one that completes. +pub trait StreamListener { + /// The stream this listener produces. + type Stream: ByteStream + Send; + + /// Accept the next inbound stream, with whatever peer metadata the + /// transport exposes. + fn accept( + &mut self, + ) -> impl Future> + Send + '_; +} + +// =========================================================================== +// Datagrams. +// =========================================================================== + +/// Connectionless I/O. +pub trait Datagram { + /// Send `buf` to `to`. + fn send_to<'a>( + &'a mut self, + buf: &'a [u8], + to: core::net::SocketAddr, + ) -> impl Future> + Send + 'a; + + /// Receive one datagram into `buf`, with its source address. + fn recv_from<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a; + + /// The address this socket is bound to, or `None` on stacks that expose + /// none. A protocol that advertises its own endpoint in-band needs the real + /// address rather than the NAT-style `0.0.0.0:0`. + fn local_addr(&self) -> Option; +} + +/// Binds [`Datagram`] sockets on demand. +/// +/// A protocol task takes this rather than a socket, because a moved-in socket +/// cannot be rebound after a reset. A std adapter binds a fresh socket; an +/// Embassy one closes and re-binds the same socket, whose buffers live as long +/// as it does. +pub trait DatagramBinder { + /// The socket this binder produces. + type Socket: Datagram + Send; + + /// Bind a socket to `port` (`0` for any). + fn bind(&self, port: u16) -> impl Future> + Send + '_; +} + +// =========================================================================== +// Time. +// =========================================================================== + +/// A non-allocating sleep. +/// +/// [`RuntimeOps::sleep`](crate::executor::RuntimeOps::sleep) is `dyn` and must +/// box; this one is generic and returns the adapter's own timer type with +/// nothing on the heap. Only sleeping lives here — `RuntimeOps::now_nanos` is a +/// plain call and stays the clock. +/// +/// The returned future borrows nothing, so a task holding a `D: Delay` still +/// produces `'static` futures from it. +pub trait Delay { + /// Complete after at least `d` has elapsed. + fn sleep(&self, d: core::time::Duration) -> impl Future + Send; +} + +// =========================================================================== +// Framing — a transport crate contributes one of these and inherits the rest. +// =========================================================================== + +/// Frames a byte stream: COBS, length-prefix, NDJSON. +pub trait Framer { + /// Encode one logical frame, appending its wire bytes to `out`. + fn encode(&self, frame: &[u8], out: &mut Vec); + /// Feed received bytes into the accumulator. + fn push_bytes(&mut self, bytes: &[u8]); + /// Pull the next complete frame: `Some(Ok(frame))`, `Some(Err(()))` for a + /// malformed/unsynced run (skipped, the stream resyncs), or `None`. + fn next_frame(&mut self) -> Option, ()>>; +} + +/// Builds a fresh [`Framer`] per connection. +/// +/// A blanket impl covers closures, so a caller writes `|| CobsFramer::new()`. +pub trait FramerFactory { + /// The framer produced. + type Framer: Framer + Send; + /// Build one, for one connection. + fn framer(&self) -> Self::Framer; +} + +impl FramerFactory for F +where + F: Fn() -> T, + T: Framer + Send, +{ + type Framer = T; + fn framer(&self) -> T { + self() + } +} + +// =========================================================================== +// Moved-in resources. +// =========================================================================== + +/// A cell holding a resource until something takes it, once. +/// +/// `ConnectorBuilder` is `Send + Sync` and its `build` takes `&self`, so a +/// connector holding a moved-in listener, peripheral or credential set must +/// take it through interior mutability. `spin::Mutex>` is +/// `Send + Sync` whenever `T: Send`, with no `unsafe`. +/// +/// The `T: Send` bound is the contract: a type that is not `Send` cannot be +/// held here, and that refusal is the signal to fix the type rather than to +/// reach for `unsafe impl`. +pub struct OneShot { + inner: spin::Mutex>, +} + +impl OneShot { + /// Hold `value` for a single [`take`](Self::take). + pub fn new(value: T) -> Self { + Self { + inner: spin::Mutex::new(Some(value)), + } + } + + /// Take the value, or `None` if it was already taken. + pub fn take(&self) -> Option { + self.inner.lock().take() + } +} + +impl Default for OneShot { + /// An already-empty cell, for a resource that may never be supplied. + fn default() -> Self { + Self { + inner: spin::Mutex::new(None), + } + } +} + +impl From for OneShot { + fn from(value: T) -> Self { + Self::new(value) + } +} + +// =========================================================================== +// Compile-time assertions. +// =========================================================================== + +/// Generic code over these traits produces `Send` futures, so a connector task +/// built from them boxes as the runner requires. Drop a `+ Send` from any +/// return type above and this stops compiling. +#[allow(dead_code)] +fn _generic_code_over_the_traits_is_send( + dialer: D, + mut listener: L, + binder: B, + delay: T, +) -> BoxFut<'static, ()> +where + D: StreamDialer + Send + 'static, + L: StreamListener + Send + 'static, + B: DatagramBinder + Send + 'static, + T: Delay + Send + 'static, +{ + Box::pin(async move { + let mut buf = [0u8; 16]; + + if let Ok(mut stream) = dialer.connect("example.test", 1883).await { + let _ = stream.read(&mut buf).await; + let _ = stream.write_all(&buf).await; + let _ = stream.flush().await; + } + if let Ok((mut stream, _peer)) = listener.accept().await { + let _ = stream.read(&mut buf).await; + } + if let Ok(mut socket) = binder.bind(0).await { + let _ = socket.recv_from(&mut buf).await; + let _ = socket.local_addr(); + } + delay.sleep(core::time::Duration::from_millis(1)).await; + }) +} + +/// `OneShot` is `Send + Sync` for any `T: Send`, with no `unsafe`. +#[allow(dead_code)] +fn _one_shot_is_send_sync_without_unsafe() { + fn assert_send_sync() {} + assert_send_sync::>>(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_shot_yields_its_value_once() { + let cell = OneShot::new(7u32); + assert_eq!(cell.take(), Some(7)); + assert_eq!(cell.take(), None, "a second take must not get the resource"); + } + + #[test] + fn default_one_shot_is_empty() { + assert_eq!(OneShot::::default().take(), None); + } + + #[test] + fn framer_factory_is_implemented_for_closures() { + struct Noop; + impl Framer for Noop { + fn encode(&self, _frame: &[u8], _out: &mut Vec) {} + fn push_bytes(&mut self, _bytes: &[u8]) {} + fn next_frame(&mut self) -> Option, ()>> { + None + } + } + + fn takes_factory(ff: FF) -> FF::Framer { + ff.framer() + } + let _framer = takes_factory(|| Noop); + } +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index ae26c43e..e54b122d 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -9,8 +9,7 @@ //! `pump_source` over the [`Source`] / [`Connector`](crate::transport::Connector) //! capabilities. //! -//! All contracts are `dyn`-safe and compile on `std` and `no_std + alloc`. See -//! `docs/design/remote-access-via-connectors.md` for the design. +//! All contracts are `dyn`-safe and compile on `std` and `no_std + alloc`. use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; use core::future::Future; @@ -25,6 +24,8 @@ mod client; #[cfg(feature = "connector-session")] mod connector; #[cfg(feature = "connector-session")] +mod io; +#[cfg(feature = "connector-session")] mod pump; #[cfg(feature = "connector-session")] mod server; @@ -43,6 +44,11 @@ pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; #[cfg(feature = "connector-session")] pub use connector::{SessionClientConnector, SessionServerConnector}; #[cfg(feature = "connector-session")] +pub use io::{ + ByteStream, Datagram, DatagramBinder, Delay, Framer, FramerFactory, IoError, OneShot, + StreamDialer, StreamListener, +}; +#[cfg(feature = "connector-session")] pub use pump::{pump_sink, pump_source}; #[cfg(feature = "connector-session")] pub use server::{run_session, serve, SessionConfig}; @@ -59,19 +65,16 @@ pub type BoxStream<'a, T> = Pin + Send + 'a>>; /// A serialized record value, carried opaquely through the codec. /// -/// `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 +/// `Arc<[u8]>` so fan-out is a refcount bump. Bytes stay opaque until a handler /// inspects them. pub type Payload = Arc<[u8]>; /// One update delivered on a subscription stream (server [`Session::subscribe`] /// and client [`ClientHandle::subscribe`] alike). /// -/// `topic` names the concrete record that fired — `Some` on wildcard -/// subscriptions, which fan in many records under one subscription id (and on -/// any transport that tags every event, like the WS bus); `None` where the -/// subscription is exact-topic and the wire stays minimal. `Arc` so -/// per-event tagging is a refcount bump, not a string allocation. +/// `topic` names the concrete record that fired: `Some` on wildcard +/// subscriptions, which fan in many records under one subscription id, and on +/// any transport that tags every event; `None` on an exact-topic subscription. #[derive(Clone, Debug)] pub struct SubUpdate { /// Concrete record topic that fired, when the producer side tags it. @@ -110,8 +113,7 @@ impl SubUpdate { } /// Mark that `n` updates were lost on this subscription immediately before - /// this one (`0` leaves the update lossless). Builder form so the server's - /// subscribe-stream fold can attach a buffer's `BufferLagged` count. + /// this one (`0` leaves the update lossless). pub fn with_skipped(mut self, n: u64) -> Self { self.skipped = n; self @@ -251,9 +253,7 @@ pub enum RpcError { Internal, /// The peer's declared protocol version is incompatible with this server's /// [`PROTOCOL_VERSION`](crate::remote::PROTOCOL_VERSION). Raised at the - /// handshake so an old-version client is refused fast, rather than - /// completing `hello` and tripping over the new reply/event shapes on its - /// first call. + /// handshake, before `hello` completes. VersionMismatch, } @@ -333,15 +333,12 @@ pub enum Outbound<'a> { sub: &'a str, /// Monotonic sequence number, in the **same space** as this /// subscription's [`Event`](Outbound::Event)s: the burst is numbered - /// `1..=N` and the first event continues at `N + 1`. So a snapshot lost - /// anywhere between here and the subscriber surfaces as a gap in the - /// next delivered update's [`SubUpdate::skipped`]. + /// `1..=N` and the first event continues at `N + 1`, so a lost snapshot + /// surfaces in the next update's [`SubUpdate::skipped`]. seq: u64, /// Set on the last snapshot of the burst, so the loss total lands on an - /// update the subscriber is *guaranteed* to see. Without it a burst - /// truncated at its tail would stay silent until some later event - /// happened to close the sequence — which on a static subscription may - /// be never. The client engine reserves a sink slot for this frame. + /// update the subscriber is guaranteed to see. The client engine + /// reserves a sink slot for this frame. last: bool, /// Topic the snapshot is for. topic: &'a str, @@ -392,9 +389,9 @@ pub trait Dialer: Send { } /// A boxed dialer is itself a [`Dialer`], so a runtime-selected -/// `Box` (e.g. from a `scheme://` URL resolver) can be handed -/// straight to [`run_client`]`` without a generic transport at the -/// call site. `dyn Dialer: Send` (supertrait) makes the box `Send + 'static`. +/// `Box` — from a `scheme://` URL resolver, say — can be passed +/// where [`run_client`]`` is expected. The `Send` supertrait makes +/// the box `Send + 'static`. impl Dialer for Box { fn connect(&self) -> BoxFut<'_, TransportResult>> { (**self).connect() @@ -457,27 +454,19 @@ pub trait Session: Send { /// [`subscribe`](Session::subscribe) and before the first event. Defaulted /// to empty (no snapshots). /// - /// The engine numbers the returned burst `1..=N` and starts the event - /// stream at `N + 1`, so "one snapshot per matched record" is *auditable* - /// downstream rather than merely intended: any snapshot dropped in transit - /// shows up as [`SubUpdate::skipped`] on the next delivered update. + /// The engine numbers the burst `1..=N` and starts the event stream at + /// `N + 1`, so a snapshot dropped in transit shows up as + /// [`SubUpdate::skipped`] on the next delivered update. /// - /// The burst's last snapshot is flagged, reaching the subscriber as the one - /// update with [`SubUpdate::snapshot_end`] set — the client engine reserves - /// a sink slot so it lands even when the rest of the burst overran a slow - /// consumer. Its `skipped` then carries the burst's whole loss, letting a - /// subscriber distinguish a complete initial state from a truncated one - /// *without* waiting for a live event, which a static subscription may - /// never produce. + /// The burst's last snapshot is flagged and reaches the subscriber as the + /// one update with [`SubUpdate::snapshot_end`] set, carrying the burst's + /// whole loss; the client engine reserves a sink slot so it survives a slow + /// consumer. /// - /// That covers the slow consumer, which is the case worth engineering for, - /// but it is not an unconditional promise and a subscriber must not *block* - /// on it: no such update arrives when `topic` matches no records (there is - /// no burst), nor when the final snapshot fails to encode or its frame is - /// rejected as malformed — the flag rides that frame and is lost with it. - /// Loss accounting itself survives all of these (the shortfall still folds - /// into the next delivered update's `skipped`); only the end-of-burst - /// signal is missing. Treat end-of-stream as terminal too. + /// **Do not block on that flag.** It is absent when `topic` matches no + /// records, and when the final snapshot fails to encode or its frame is + /// rejected — the flag rides that frame. Loss accounting survives all of + /// these; only the end-of-burst signal is lost. fn snapshots(&mut self, topic: &str) -> Vec<(String, Payload)> { let _ = topic; Vec::new() @@ -533,8 +522,8 @@ pub trait Source: Send { } // =========================================================================== -// Object-safety: taking each trait as `&dyn Trait` forces the dyn-compatibility -// check on all targets, not just under `cargo test`. +// 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)] @@ -654,8 +643,7 @@ mod tests { } /// `Box` satisfies the `Dialer` bound, so a runtime-selected - /// dialer (the URL resolver's return type) can be passed where `D: Dialer` - /// is expected. Compile-time proof via a generic that requires the bound. + /// dialer can be passed where `D: Dialer` is expected. #[test] fn boxed_dialer_is_a_dialer() { fn takes_dialer(_d: D) {} From a652fe45f0a38a5b8339943cb64be4e2eaf4e0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 17:53:06 +0000 Subject: [PATCH 02/19] fix doc warnings --- aimdb-core/src/connector.rs | 4 ++-- aimdb-core/src/remote/metadata.rs | 4 ++-- aimdb-core/src/remote/mod.rs | 5 ++--- aimdb-core/src/session/client.rs | 3 ++- aimdb-core/src/session/mod.rs | 3 +-- aimdb-core/src/transform/join.rs | 4 ++-- aimdb-core/src/typed_record.rs | 5 +++-- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 6da82cc7..f54e37ab 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -43,8 +43,8 @@ use crate::{builder::AimDb, DbResult}; /// Error shared by outbound record serialization operations. /// -/// This is deliberately separate from [`crate::CodecError`], which describes -/// failures in a session envelope codec (AimX, WebSocket, and similar framed +/// Deliberately separate from the session `CodecError`, which describes +/// failures in an envelope codec (AimX, WebSocket, and similar framed /// protocols). A link codec sits one layer higher: it turns a typed record into /// the opaque payload passed to a connector. /// diff --git a/aimdb-core/src/remote/metadata.rs b/aimdb-core/src/remote/metadata.rs index fc1c50a0..71fe8cbd 100644 --- a/aimdb-core/src/remote/metadata.rs +++ b/aimdb-core/src/remote/metadata.rs @@ -28,8 +28,8 @@ use crate::record_id::{RecordId, RecordKey}; /// /// A `record.list` reply enumerates records the server **registered**, not /// records that have carried a value — a server registering a fixed pool up -/// front lists the whole pool. Only [`produced_count`](Self::produced_count) -/// distinguishes them, and it is `observability`-gated by design (liveness +/// front lists the whole pool. Only `produced_count` distinguishes them, and +/// it is `observability`-gated by design (liveness /// derives from buffer counters, which constrained targets compile away). So a /// server whose clients need that distinction must be built with /// `observability` on; without it the honest client answer is "unknown". diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index 1fa27ad4..93aa03b7 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -8,9 +8,8 @@ //! //! AimX uses NDJSON (newline-delimited JSON) tagged frames over a session //! transport (Unix domain sockets via `aimdb-uds-connector`, serial via -//! `aimdb-serial-connector`). The envelope codec lives in -//! [`crate::session::aimx`]; see `docs/design/remote-access-via-connectors.md` -//! for the architecture. Compatibility is by major version — see +//! `aimdb-serial-connector`). The envelope codec lives in `session::aimx` +//! (feature `connector-session`). Compatibility is by major version — see //! [`PROTOCOL_VERSION`] and [`version_compatible`]. //! //! # Security diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 4f1d2c0a..54c59aca 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -847,7 +847,8 @@ where /// 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). Mirroring is -/// latest-state and best-effort: see [`inbound_pump`] for the loss contract. +/// latest-state and best-effort — a gap the server reports +/// ([`SubUpdate::skipped`]) is logged and stepped over, never backfilled. /// /// Returns one spawn-free pump future per route for the runner to drive /// (mirroring the `ConnectorBuilder::build -> Vec` spine); it drives diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index e54b122d..dec44bd4 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -252,8 +252,7 @@ pub enum RpcError { /// The handler failed. Internal, /// The peer's declared protocol version is incompatible with this server's - /// [`PROTOCOL_VERSION`](crate::remote::PROTOCOL_VERSION). Raised at the - /// handshake, before `hello` completes. + /// `PROTOCOL_VERSION`. Raised at the handshake, before `hello` completes. VersionMismatch, } diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 86b41fb6..c5fe9779 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -129,8 +129,8 @@ type JoinInputFactory = Box< /// Configures a multi-input join transform. /// /// Available on every runtime. The fan-in queue (bounded channel between input -/// forwarders and the trigger loop) lives in core; its capacity is -/// [`JOIN_QUEUE_CAPACITY`]. +/// forwarders and the trigger loop) lives in core, with capacity 64 on std and +/// wasm32 and 16 on embedded `no_std`. /// /// Obtain via [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). pub struct JoinBuilder { diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index b2e20714..ef1e29a0 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -207,8 +207,9 @@ type ProducerServiceFn = /// /// This trait carries the storage/lifecycle /// surface, plus graph/metadata introspection and observability counter -/// resets. JSON remote access is the one genuinely optional capability and -/// lives in [`JsonRecordAccess`], reachable via [`AnyRecord::json_access`]. +/// resets. JSON remote access is the one genuinely optional capability: it +/// lives in `JsonRecordAccess`, reachable via `json_access`, both behind the +/// `remote` feature. /// /// Consumers: `AimDbBuilder::build()` (config-error draining, the dependency /// graph fed to [`crate::graph`], typed downcasts via [`AnyRecordExt`]), From 9840be45946d9a2f69bd806c9b9f035046fa6ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 18:23:11 +0000 Subject: [PATCH 03/19] feat(io): add FramedConnection, FramingDialer, and FramingListener for enhanced connection handling --- aimdb-core/src/session/io.rs | 378 +++++++++++++++++++++++++++++++++- aimdb-core/src/session/mod.rs | 4 +- 2 files changed, 379 insertions(+), 3 deletions(-) diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index cb0f1e93..6b3d99bb 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -17,10 +17,11 @@ //! over them; the `dyn` boundary stays at `Box` per frame. use alloc::boxed::Box; +use alloc::string::String; use alloc::vec::Vec; use core::future::Future; -use super::{BoxFut, PeerInfo, TransportError, TransportResult}; +use super::{BoxFut, Connection, Dialer, Listener, PeerInfo, TransportError, TransportResult}; /// Failure of a byte-level I/O operation. /// @@ -187,6 +188,153 @@ where } } +// =========================================================================== +// Framed connections — a [`ByteStream`] plus a [`Framer`] is a [`Connection`]. +// =========================================================================== + +/// A framed [`Connection`] over any [`ByteStream`] and [`Framer`]. +/// +/// `RC` caps the per-`read` chunk and `WC` caps a single `write_all`; both are +/// stack buffers, and some HAL `BufferedUart::write` rejects a write larger +/// than its TX ring, which is what `WC` exists for. +pub struct FramedConnection { + stream: S, + framer: F, + peer: PeerInfo, +} + +impl FramedConnection { + /// Frame `stream` with `framer`. + pub fn new(stream: S, framer: F) -> Self { + Self { + stream, + framer, + peer: PeerInfo::default(), + } + } + + /// Frame `stream` with `framer`, carrying `peer` metadata from the accept. + pub fn with_peer(stream: S, framer: F, peer: PeerInfo) -> Self { + Self { + stream, + framer, + peer, + } + } +} + +impl Connection for FramedConnection +where + S: ByteStream + Send, + F: Framer + Send, +{ + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { + loop { + // A run that fails to decode is line noise or a mid-stream + // join, not fatal: skip it and resync on the next frame. + match self.framer.next_frame() { + Some(Ok(frame)) => return Ok(Some(frame)), + Some(Err(())) => continue, + None => {} + } + let mut chunk = [0u8; RC]; + match self.stream.read(&mut chunk).await { + Ok(0) => return Ok(None), // EOF — peer closed + Ok(n) => self.framer.push_bytes(&chunk[..n]), + Err(e) => return Err(e), + } + } + }) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + Box::pin(async move { + let mut out = Vec::new(); + self.framer.encode(frame, &mut out); + for chunk in out.chunks(WC) { + self.stream.write_all(chunk).await?; + } + self.stream.flush().await + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// Lifts a [`StreamDialer`] and a [`FramerFactory`] into a [`Dialer`], so +/// `run_client` drives an adapter transport unchanged. +pub struct FramingDialer { + dialer: D, + framers: FF, + host: String, + port: u16, +} + +impl FramingDialer { + /// Dial `host:port` through `dialer`, framing each stream with a framer + /// from `framers`. + pub fn new(dialer: D, framers: FF, host: impl Into, port: u16) -> Self { + Self { + dialer, + framers, + host: host.into(), + port, + } + } +} + +impl Dialer for FramingDialer +where + D: StreamDialer + Send + Sync, + FF: FramerFactory + Send + Sync, + D::Stream: 'static, + FF::Framer: 'static, +{ + fn connect(&self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let stream = self.dialer.connect(&self.host, self.port).await?; + let conn: FramedConnection = + FramedConnection::new(stream, self.framers.framer()); + Ok(Box::new(conn) as Box) + }) + } +} + +/// Lifts a [`StreamListener`] and a [`FramerFactory`] into a [`Listener`], so +/// `serve` drives an adapter transport unchanged. +pub struct FramingListener { + listener: L, + framers: FF, +} + +impl FramingListener { + /// Accept through `listener`, framing each stream with a framer from + /// `framers`. + pub fn new(listener: L, framers: FF) -> Self { + Self { listener, framers } + } +} + +impl Listener for FramingListener +where + L: StreamListener + Send, + FF: FramerFactory + Send, + L::Stream: 'static, + FF::Framer: 'static, +{ + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let (stream, peer) = self.listener.accept().await?; + let conn: FramedConnection = + FramedConnection::with_peer(stream, self.framers.framer(), peer); + Ok(Box::new(conn) as Box) + }) + } +} + // =========================================================================== // Moved-in resources. // =========================================================================== @@ -283,6 +431,234 @@ fn _one_shot_is_send_sync_without_unsafe() { #[cfg(test)] mod tests { use super::*; + use alloc::sync::Arc; + use alloc::vec; + + // --- Test doubles ----------------------------------------------------- + + /// Length-prefixed framer: one length byte, then that many payload bytes. + /// A `0xFF` length marks a corrupt run, so resync has something to skip. + #[derive(Default)] + struct LenFramer { + buf: Vec, + } + + impl Framer for LenFramer { + fn encode(&self, frame: &[u8], out: &mut Vec) { + out.push(frame.len() as u8); + out.extend_from_slice(frame); + } + fn push_bytes(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + fn next_frame(&mut self) -> Option, ()>> { + let len = *self.buf.first()? as usize; + if len == 0xFF { + self.buf.remove(0); + return Some(Err(())); + } + if self.buf.len() < len + 1 { + return None; + } + let frame = self.buf[1..len + 1].to_vec(); + self.buf.drain(..len + 1); + Some(Ok(frame)) + } + } + + /// Shared so a test can inspect what the connection wrote after moving the + /// stream into it. + #[derive(Default)] + struct StreamState { + /// Chunks `read` hands out, in order; empty means EOF. + reads: Vec>, + /// Bytes written, concatenated. + written: Vec, + /// Size of each individual `write_all` call. + write_sizes: Vec, + flushes: usize, + } + + #[derive(Clone, Default)] + struct MockStream(Arc>); + + impl MockStream { + fn with_reads(reads: Vec>) -> Self { + Self(Arc::new(spin::Mutex::new(StreamState { + reads, + ..Default::default() + }))) + } + } + + // Written as plain `async fn`s: an impl on a runtime whose futures are + // already `Send` needs nothing more, and the compiler checks the bound the + // trait declares. + impl ByteStream for MockStream { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + let mut st = self.0.lock(); + if st.reads.is_empty() { + return Ok(0); + } + let chunk = st.reads.remove(0); + let n = chunk.len().min(buf.len()); + buf[..n].copy_from_slice(&chunk[..n]); + Ok(n) + } + + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + let mut st = self.0.lock(); + st.written.extend_from_slice(buf); + st.write_sizes.push(buf.len()); + Ok(()) + } + + async fn flush(&mut self) -> TransportResult<()> { + self.0.lock().flushes += 1; + Ok(()) + } + } + + /// A stream whose first read fails, to check the error is propagated as-is + /// rather than flattened. + struct FailingStream; + + impl ByteStream for FailingStream { + async fn read<'a>(&'a mut self, _buf: &'a mut [u8]) -> TransportResult { + Err(TransportError::Closed) + } + async fn write_all<'a>(&'a mut self, _buf: &'a [u8]) -> TransportResult<()> { + Err(TransportError::Closed) + } + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + } + + struct MockDialer(MockStream); + + impl StreamDialer for MockDialer { + type Stream = MockStream; + async fn connect<'a>(&'a self, _host: &'a str, _port: u16) -> TransportResult { + Ok(self.0.clone()) + } + } + + struct MockListener(Option); + + impl StreamListener for MockListener { + type Stream = MockStream; + async fn accept(&mut self) -> TransportResult<(MockStream, PeerInfo)> { + let s = self.0.take().ok_or(TransportError::Closed)?; + let peer = PeerInfo { + peer_addr: Some("10.0.0.1:5555".into()), + ..Default::default() + }; + Ok((s, peer)) + } + } + + fn framed(stream: MockStream) -> FramedConnection { + FramedConnection::new(stream, LenFramer::default()) + } + + // --- FramedConnection ------------------------------------------------- + + #[tokio::test] + async fn recv_yields_each_frame_then_eof() { + let mut conn = framed(MockStream::with_reads(vec![vec![ + 2, b'h', b'i', 3, b'y', b'e', b's', + ]])); + assert_eq!(conn.recv().await.unwrap(), Some(b"hi".to_vec())); + assert_eq!(conn.recv().await.unwrap(), Some(b"yes".to_vec())); + assert_eq!(conn.recv().await.unwrap(), None, "closed peer is Ok(None)"); + } + + #[tokio::test] + async fn recv_reassembles_a_frame_split_across_reads() { + let mut conn = framed(MockStream::with_reads(vec![ + vec![3, b'a'], + vec![b'b'], + vec![b'c'], + ])); + assert_eq!(conn.recv().await.unwrap(), Some(b"abc".to_vec())); + } + + #[tokio::test] + async fn recv_skips_a_corrupt_run_and_resyncs() { + let mut conn = framed(MockStream::with_reads(vec![vec![ + 0xFF, 0xFF, 2, b'o', b'k', + ]])); + assert_eq!( + conn.recv().await.unwrap(), + Some(b"ok".to_vec()), + "a run that fails to decode is skipped, not fatal" + ); + } + + #[tokio::test] + async fn recv_propagates_the_streams_own_error() { + let mut conn = FramedConnection::<_, _, 256, 256>::new(FailingStream, LenFramer::default()); + assert_eq!( + conn.recv().await, + Err(TransportError::Closed), + "the stream classifies the failure; framing must not flatten it" + ); + } + + #[tokio::test] + async fn send_encodes_then_flushes() { + let stream = MockStream::default(); + let mut conn = framed(stream.clone()); + conn.send(b"hi").await.unwrap(); + + let st = stream.0.lock(); + assert_eq!(st.written, vec![2, b'h', b'i']); + assert_eq!(st.flushes, 1); + } + + #[tokio::test] + async fn send_splits_a_frame_larger_than_the_write_chunk() { + let stream = MockStream::default(); + let mut conn: FramedConnection = + FramedConnection::new(stream.clone(), LenFramer::default()); + conn.send(b"0123456789").await.unwrap(); + + let st = stream.0.lock(); + assert_eq!(st.write_sizes, vec![4, 4, 3], "11 encoded bytes at WC = 4"); + assert_eq!(st.written.len(), 11); + } + + // --- FramingDialer / FramingListener ---------------------------------- + + #[tokio::test] + async fn framing_dialer_produces_a_working_connection() { + let stream = MockStream::with_reads(vec![vec![2, b'h', b'i']]); + let dialer: FramingDialer<_, _, 256, 256> = + FramingDialer::new(MockDialer(stream), LenFramer::default, "host.test", 1883); + + let mut conn = dialer.connect().await.unwrap(); + assert_eq!(conn.recv().await.unwrap(), Some(b"hi".to_vec())); + } + + #[tokio::test] + async fn framing_listener_carries_peer_metadata_through() { + let stream = MockStream::with_reads(vec![vec![2, b'h', b'i']]); + let mut listener: FramingListener<_, _, 256, 256> = + FramingListener::new(MockListener(Some(stream)), LenFramer::default); + + let mut conn = listener.accept().await.unwrap(); + assert_eq!(conn.peer().peer_addr.as_deref(), Some("10.0.0.1:5555")); + assert_eq!(conn.recv().await.unwrap(), Some(b"hi".to_vec())); + } + + #[test] + fn a_framed_connection_is_boxable_as_dyn_connection() { + let conn = framed(MockStream::default()); + let _boxed: Box = Box::new(conn); + } + + // --- OneShot / FramerFactory ------------------------------------------ #[test] fn one_shot_yields_its_value_once() { diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index dec44bd4..ab4915d2 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -45,8 +45,8 @@ pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; pub use connector::{SessionClientConnector, SessionServerConnector}; #[cfg(feature = "connector-session")] pub use io::{ - ByteStream, Datagram, DatagramBinder, Delay, Framer, FramerFactory, IoError, OneShot, - StreamDialer, StreamListener, + ByteStream, Datagram, DatagramBinder, Delay, FramedConnection, Framer, FramerFactory, + FramingDialer, FramingListener, IoError, OneShot, StreamDialer, StreamListener, }; #[cfg(feature = "connector-session")] pub use pump::{pump_sink, pump_source}; From f75355bcc730480ccaf2bf7c5637114a629a1a3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 18:40:05 +0000 Subject: [PATCH 04/19] feat(tokioadapter): add runtime-neutral I/O traits and implementations for Tokio --- Makefile | 11 +- aimdb-tokio-adapter/Cargo.toml | 4 + aimdb-tokio-adapter/src/lib.rs | 6 + aimdb-tokio-adapter/src/net.rs | 317 +++++++++++++++++++++++++++++++++ 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 aimdb-tokio-adapter/src/net.rs diff --git a/Makefile b/Makefile index 8929cc98..737a2d66 100644 --- a/Makefile +++ b/Makefile @@ -92,6 +92,8 @@ build: 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,observability" + @printf "$(YELLOW) → Building tokio adapter (runtime-neutral transports)$(NC)\n" + cargo build --package aimdb-tokio-adapter --features "net" @printf "$(YELLOW) → Building sync wrapper$(NC)\n" cargo build --package aimdb-sync @printf "$(YELLOW) → Building sync wrapper (no_std)$(NC)\n" @@ -171,6 +173,8 @@ test: cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing tokio adapter (with observability)$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" + @printf "$(YELLOW) → Testing tokio adapter (runtime-neutral transports)$(NC)\n" + cargo test --package aimdb-tokio-adapter --features "net" @printf "$(YELLOW) → Testing embassy adapter (host, no executor: buffers, join-queue, connector spine, doctests)$(NC)\n" cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time,connectors" @printf "$(YELLOW) → Testing WASM adapter (host lib: buffer semantics + shared contract suite; browser layer runs via wasm-test)$(NC)\n" @@ -262,11 +266,16 @@ clippy: cargo clippy --package aimdb-core --no-default-features --features "alloc,remote" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (std)$(NC)\n" cargo clippy --package aimdb-core --features "std,tracing,observability" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on aimdb-core (connector-session contracts, no_std + alloc and std)$(NC)\n" + cargo clippy --package aimdb-core --no-default-features --features "alloc,connector-session" --all-targets -- -D warnings + cargo clippy --package aimdb-core --features "std,connector-session" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on aimdb-core (log destination, alone and beside tracing)$(NC)\n" cargo clippy --package aimdb-core --features "std,log" --all-targets -- -D warnings cargo clippy --package aimdb-core --features "std,log,tracing" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on tokio adapter$(NC)\n" cargo clippy --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on tokio adapter (runtime-neutral transports)$(NC)\n" + cargo clippy --package aimdb-tokio-adapter --features "net" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter$(NC)\n" cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter with network support$(NC)\n" @@ -348,7 +357,7 @@ doc: @printf "$(YELLOW) → Building cloud/edge documentation$(NC)\n" cargo doc --package aimdb-data-contracts --features "std,simulatable,migratable,observable,linkable-json,linkable-postcard" --no-deps cargo doc --package aimdb-core --features "std,tracing,observability" --no-deps - cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability" --no-deps + cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net" --no-deps cargo doc --package aimdb-sync --no-deps cargo doc --package aimdb-mqtt-connector --features "std,tokio-runtime" --no-deps cargo doc --package aimdb-knx-connector --features "std,tokio-runtime" --no-deps diff --git a/aimdb-tokio-adapter/Cargo.toml b/aimdb-tokio-adapter/Cargo.toml index b67d7feb..84c5b9a2 100644 --- a/aimdb-tokio-adapter/Cargo.toml +++ b/aimdb-tokio-adapter/Cargo.toml @@ -21,6 +21,10 @@ std = ["aimdb-core/std"] # Runtime features tokio-runtime = ["tokio", "tokio-util", "std"] +# Tokio sockets, dialers, listeners, datagrams and clock behind core's +# runtime-neutral I/O traits, so connector crates need no tokio dependency. +net = ["tokio-runtime", "aimdb-core/connector-session", "tokio/net", "tokio/io-util"] + # Observability features tracing = ["aimdb-core/tracing", "dep:tracing"] observability = ["aimdb-core/observability", "tokio-runtime"] diff --git a/aimdb-tokio-adapter/src/lib.rs b/aimdb-tokio-adapter/src/lib.rs index a754705a..6028fc8f 100644 --- a/aimdb-tokio-adapter/src/lib.rs +++ b/aimdb-tokio-adapter/src/lib.rs @@ -17,6 +17,12 @@ compile_error!("tokio-adapter requires the std feature"); pub mod buffer; + +// Tokio implementations of core's runtime-neutral I/O traits, so connector +// crates stay runtime-neutral. +#[cfg(feature = "net")] +pub mod net; + pub mod runtime; pub use buffer::TokioBuffer; diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs new file mode 100644 index 00000000..574d6f09 --- /dev/null +++ b/aimdb-tokio-adapter/src/net.rs @@ -0,0 +1,317 @@ +//! Tokio implementations of core's runtime-neutral I/O traits — the std dual of +//! the Embassy adapter's `net` module. +//! +//! Every future here is a plain `async fn`: the compiler proves it `Send`, so +//! the `+ Send` the traits declare on their return types costs nothing and this +//! module contains no `unsafe`. The Embassy side, whose socket futures are +//! `!Send`, wraps them instead — that asymmetry is why the bound sits on the +//! trait rather than at each use site. + +use std::net::{IpAddr, SocketAddr}; + +use aimdb_core::session::{ + ByteStream, Datagram, DatagramBinder, Delay, PeerInfo, StreamDialer, StreamListener, + TransportError, TransportResult, +}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream, UdpSocket}; + +/// Entry point for the Tokio transports. +pub struct TokioNet; + +impl TokioNet { + /// A TCP dialer. Name resolution happens here, so a connector hands over a + /// host string and never touches `std::net`. + pub fn tcp() -> TokioTcpDialer { + TokioTcpDialer + } + + /// Bind a TCP listener on `addr` (`"host:port"`). + pub async fn listen(addr: &str) -> TransportResult { + TcpListener::bind(addr) + .await + .map(TokioTcpListener) + .map_err(|_| TransportError::Io) + } + + /// A UDP binder on `local_ip`, which sockets are bound to as + /// `local_ip:port`. Pass [`Ipv4Addr::UNSPECIFIED`](std::net::Ipv4Addr) for + /// any interface. + pub fn udp(local_ip: impl Into) -> TokioUdpBinder { + TokioUdpBinder { + local_ip: local_ip.into(), + } + } + + /// The clock, as a non-boxing [`Delay`]. + pub fn delay() -> TokioDelay { + TokioDelay + } +} + +// =========================================================================== +// Streams. +// =========================================================================== + +/// Any Tokio async byte source as a [`ByteStream`] — a `TcpStream`, a +/// `tokio::io::duplex` pipe, a serial port. +pub struct TokioByteStream(pub S); + +impl ByteStream for TokioByteStream +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + async fn read(&mut self, buf: &mut [u8]) -> TransportResult { + self.0.read(buf).await.map_err(|_| TransportError::Io) + } + + async fn write_all(&mut self, buf: &[u8]) -> TransportResult<()> { + self.0 + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + } + + async fn flush(&mut self) -> TransportResult<()> { + self.0.flush().await.map_err(|_| TransportError::Closed) + } +} + +/// Dials TCP connections. +pub struct TokioTcpDialer; + +impl StreamDialer for TokioTcpDialer { + type Stream = TokioByteStream; + + async fn connect(&self, host: &str, port: u16) -> TransportResult { + TcpStream::connect((host, port)) + .await + .map(TokioByteStream) + .map_err(|_| TransportError::Io) + } +} + +/// Accepts TCP connections. +pub struct TokioTcpListener(TcpListener); + +impl TokioTcpListener { + /// The address actually bound — the way to learn the port after binding + /// one of the ephemeral `:0` forms. + pub fn local_addr(&self) -> Option { + self.0.local_addr().ok() + } +} + +impl StreamListener for TokioTcpListener { + type Stream = TokioByteStream; + + async fn accept(&mut self) -> TransportResult<(Self::Stream, PeerInfo)> { + let (stream, addr) = self.0.accept().await.map_err(|_| TransportError::Io)?; + // `PeerInfo` is `#[non_exhaustive]`, so it is built by mutation. + let mut peer = PeerInfo::default(); + peer.peer_addr = Some(addr.to_string()); + Ok((TokioByteStream(stream), peer)) + } +} + +// =========================================================================== +// Datagrams. +// =========================================================================== + +/// One bound Tokio UDP socket as a [`Datagram`]. +pub struct TokioDatagram { + socket: UdpSocket, + local: Option, +} + +impl Datagram for TokioDatagram { + async fn send_to(&mut self, buf: &[u8], to: SocketAddr) -> TransportResult<()> { + self.socket + .send_to(buf, to) + .await + .map(|_| ()) + .map_err(|_| TransportError::Io) + } + + async fn recv_from(&mut self, buf: &mut [u8]) -> TransportResult<(usize, SocketAddr)> { + self.socket + .recv_from(buf) + .await + .map_err(|_| TransportError::Io) + } + + fn local_addr(&self) -> Option { + self.local + } +} + +/// Binds [`TokioDatagram`]s, one per reconnect cycle. +pub struct TokioUdpBinder { + local_ip: IpAddr, +} + +impl DatagramBinder for TokioUdpBinder { + type Socket = TokioDatagram; + + async fn bind(&self, port: u16) -> TransportResult { + let socket = UdpSocket::bind(SocketAddr::new(self.local_ip, port)) + .await + .map_err(|_| TransportError::Io)?; + let local = socket.local_addr().ok(); + Ok(TokioDatagram { socket, local }) + } +} + +// =========================================================================== +// Clock. +// =========================================================================== + +/// [`Delay`] over `tokio::time::sleep`, returning the timer itself rather than +/// a boxed future. +#[derive(Clone, Copy, Default)] +pub struct TokioDelay; + +impl Delay for TokioDelay { + fn sleep(&self, d: std::time::Duration) -> impl std::future::Future + Send { + tokio::time::sleep(d) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_core::session::{Dialer, Framer, FramingDialer, FramingListener, Listener}; + use std::net::Ipv4Addr; + + /// Length-prefixed framer, enough to drive a `FramedConnection`. + #[derive(Default)] + struct LenFramer { + buf: Vec, + } + + impl Framer for LenFramer { + fn encode(&self, frame: &[u8], out: &mut Vec) { + out.push(frame.len() as u8); + out.extend_from_slice(frame); + } + fn push_bytes(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + fn next_frame(&mut self) -> Option, ()>> { + let len = *self.buf.first()? as usize; + if self.buf.len() < len + 1 { + return None; + } + let frame = self.buf[1..len + 1].to_vec(); + self.buf.drain(..len + 1); + Some(Ok(frame)) + } + } + + #[tokio::test] + async fn tcp_round_trips_between_dialer_and_listener() { + let mut listener = TokioNet::listen("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (mut stream, peer) = listener.accept().await.unwrap(); + assert!( + peer.peer_addr.is_some(), + "accept must carry the peer address" + ); + let mut buf = [0u8; 16]; + let n = stream.read(&mut buf).await.unwrap(); + stream.write_all(&buf[..n]).await.unwrap(); + stream.flush().await.unwrap(); + }); + + let mut client = TokioNet::tcp().connect("127.0.0.1", port).await.unwrap(); + client.write_all(b"ping").await.unwrap(); + let mut buf = [0u8; 16]; + let n = client.read(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"ping"); + server.await.unwrap(); + } + + #[tokio::test] + async fn a_closed_peer_reads_as_eof() { + let mut listener = TokioNet::listen("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + drop(stream); + }); + + let mut client = TokioNet::tcp().connect("127.0.0.1", port).await.unwrap(); + server.await.unwrap(); + let mut buf = [0u8; 16]; + assert_eq!(client.read(&mut buf).await.unwrap(), 0, "EOF is Ok(0)"); + } + + /// The transports drive core's `Dialer`/`Listener` unchanged, which is the + /// point of the adapter owning sockets. + #[tokio::test] + async fn the_transports_drive_a_framed_connection() { + let listener = TokioNet::listen("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let mut listener: FramingListener<_, _, 256, 256> = + FramingListener::new(listener, LenFramer::default); + + let server = tokio::spawn(async move { + let mut conn = listener.accept().await.unwrap(); + let frame = conn.recv().await.unwrap().unwrap(); + conn.send(&frame).await.unwrap(); + }); + + let dialer: FramingDialer<_, _, 256, 256> = + FramingDialer::new(TokioNet::tcp(), LenFramer::default, "127.0.0.1", port); + let mut conn = dialer.connect().await.unwrap(); + conn.send(b"hello").await.unwrap(); + assert_eq!(conn.recv().await.unwrap(), Some(b"hello".to_vec())); + server.await.unwrap(); + } + + #[tokio::test] + async fn udp_round_trips_and_reports_its_bound_address() { + let binder = TokioNet::udp(Ipv4Addr::LOCALHOST); + let mut a = binder.bind(0).await.unwrap(); + let mut b = binder.bind(0).await.unwrap(); + + let a_addr = a.local_addr().expect("a bound address must be reported"); + let b_addr = b.local_addr().expect("a bound address must be reported"); + assert_ne!( + a_addr.port(), + 0, + "an ephemeral bind resolves to a real port" + ); + + a.send_to(b"knx", b_addr).await.unwrap(); + let mut buf = [0u8; 16]; + let (n, from) = b.recv_from(&mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"knx"); + assert_eq!(from, a_addr, "the source address must be the sender's"); + } + + /// Rebinding is what the KNX socket reset needs: a fresh socket each cycle, + /// on the same binder. + #[tokio::test] + async fn a_binder_can_rebind_after_its_socket_is_dropped() { + let binder = TokioNet::udp(Ipv4Addr::LOCALHOST); + let first = binder.bind(0).await.unwrap(); + let port = first.local_addr().unwrap().port(); + drop(first); + + let second = binder.bind(port).await.unwrap(); + assert_eq!(second.local_addr().unwrap().port(), port); + } + + #[tokio::test] + async fn delay_sleeps_without_boxing() { + let start = std::time::Instant::now(); + TokioNet::delay() + .sleep(std::time::Duration::from_millis(20)) + .await; + assert!(start.elapsed() >= std::time::Duration::from_millis(15)); + } +} From f2eb21b30f83e2a4ab8720badd5858d6076e8204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 18:49:38 +0000 Subject: [PATCH 05/19] fix(docs): correct formatting and wording in documentation comments across multiple files --- Makefile | 3 +++ aimdb-codegen/src/rust.rs | 2 +- aimdb-embassy-adapter/src/runtime.rs | 2 +- aimdb-knx-connector/src/embassy_client.rs | 6 +++--- aimdb-knx-connector/src/tokio_client.rs | 6 +++--- aimdb-knx-connector/src/tunnel.rs | 2 +- aimdb-mqtt-connector/src/embassy_client.rs | 11 +++++------ aimdb-mqtt-connector/src/tokio_client.rs | 4 ++-- aimdb-persistence-sqlite/src/lib.rs | 2 +- tools/aimdb-mcp/src/connection.rs | 8 ++++---- tools/aimdb-mcp/src/protocol/jsonrpc.rs | 2 +- 11 files changed, 25 insertions(+), 23 deletions(-) diff --git a/Makefile b/Makefile index 737a2d66..fa2c9d41 100644 --- a/Makefile +++ b/Makefile @@ -349,6 +349,9 @@ clippy: @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" cargo clippy --package aimdb-bench --all-targets -- -D warnings +# Doc links are public API: one pointing at a private or feature-gated item +# breaks the published page, and nothing else in `check` looks at rustdoc. +doc: export RUSTDOCFLAGS := -D warnings doc: @printf "$(GREEN)Generating dual-platform documentation...$(NC)\n" @# Create directory structure diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index 6cd394e6..39e04bc7 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -4,7 +4,7 @@ //! current AimDB API: `#[derive(RecordKey)]`, `BufferCfg`, and //! `AimDbBuilder::configure()`. //! -//! Uses [`quote`] for quasi-quoting token streams and [`prettyplease`] for +//! Uses `quote` for quasi-quoting token streams and `prettyplease` for //! formatting the output into idiomatic Rust. use proc_macro2::TokenStream; diff --git a/aimdb-embassy-adapter/src/runtime.rs b/aimdb-embassy-adapter/src/runtime.rs index f8d6c4b4..866d8139 100644 --- a/aimdb-embassy-adapter/src/runtime.rs +++ b/aimdb-embassy-adapter/src/runtime.rs @@ -10,7 +10,7 @@ use tracing::debug; /// /// A unit type: the runtime travels as `Arc` and network /// connectors take the `embassy_net::Stack` at construction (wrapped in -/// [`NetStack`](crate::connectors::NetStack)), so the adapter carries no state +/// `NetStack`, feature `connectors`), so the adapter carries no state /// and no `unsafe`. All futures are driven by the `AimDbRunner` returned from /// `AimDbBuilder::build()`, which is awaited inside the Embassy main task. /// diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 247d8574..7a636cdc 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -3,7 +3,7 @@ //! This module contributes only socket glue for embedded systems: an //! `embassy-net` UDP socket, the static channels between the pumps and the //! connection task, and a select loop driving the shared sans-io -//! [`TunnelEngine`](crate::tunnel::TunnelEngine). The entire tunneling +//! [`TunnelEngine`]. The entire tunneling //! lifecycle (handshake, ACK bookkeeping, keepalive, reconnect backoff) lives //! in [`crate::tunnel`]. //! @@ -14,9 +14,9 @@ //! `CriticalSectionRawMutex` channel the connection task drains). //! - **Inbound** (telegrams → records) rides core's `pump_source`: the //! connection task pushes `(group-address, payload)` onto an inbound channel -//! that [`KnxSource`] drains. +//! that `KnxSource` drains. //! - The connection task is force-`Send`ed once via -//! [`into_box_future`](aimdb_embassy_adapter::connectors::into_box_future); the +//! [`into_box_future`]; the //! only `unsafe` in this crate is the audited //! [`NetStack::new`](aimdb_embassy_adapter::connectors::NetStack) call in the //! builder (single-core cooperative executor invariant). diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index 35475802..f68a5dbb 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -2,14 +2,14 @@ //! //! This module contributes only socket glue: a UDP socket, the channels //! between the pumps and the connection task, and a select loop driving the -//! shared sans-io [`TunnelEngine`](crate::tunnel::TunnelEngine). The entire +//! shared sans-io [`TunnelEngine`]. The entire //! tunneling lifecycle (handshake, ACK bookkeeping, keepalive, reconnect //! backoff) lives in [`crate::tunnel`]. //! -//! - Outbound rides core's `pump_sink`: [`KnxSink`] forwards each serialized +//! - Outbound rides core's `pump_sink`: `KnxSink` forwards each serialized //! record as a [`GroupWrite`] command to the connection task. //! - Inbound rides core's `pump_source`: the connection task pushes parsed -//! `(group-address, payload)` telegrams that [`KnxSource`] yields. +//! `(group-address, payload)` telegrams that `KnxSource` yields. use crate::tunnel::{ drain_actions, GroupWrite, LocalEndpoint, TunnelConfig, TunnelEngine, TunnelIo, diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs index f3ad8cac..2317ab11 100644 --- a/aimdb-knx-connector/src/tunnel.rs +++ b/aimdb-knx-connector/src/tunnel.rs @@ -77,7 +77,7 @@ impl GroupWrite { pub enum Action { /// Send this datagram to the gateway. `await_ack` carries the sequence /// number of a tracked TUNNELING_REQUEST so a failed send can stop its - /// ACK tracking (see [`TunnelIo::send`]); `None` for everything else. + /// ACK tracking (see `TunnelIo::send`); `None` for everything else. Send { frame: Frame, await_ack: Option }, /// Deliver a parsed inbound telegram toward `pump_source` /// (`try_send`, drop-on-full — never stall the protocol loop). diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 74d35ab1..cc32f097 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -6,12 +6,11 @@ //! # Architecture //! //! The data-flow (outbound publish, inbound routing) rides core's -//! [`pump_sink`](aimdb_core::session::pump_sink) / -//! [`pump_source`](aimdb_core::session::pump_source) via the force-`Send` +//! [`pump_sink`] / [`pump_source`] via the force-`Send` //! [`EmbassySink`]/[`EmbassySource`] bridges in `aimdb-embassy-adapter`, exactly //! like the Tokio half rides them. This crate contributes only the //! transport-specific bits: the broker **manager task** (mountain-mqtt's `run`), -//! the [`MqttSink`]/[`MqttSource`] over its action/event channels, and the +//! the `MqttSink`/`MqttSource` over its action/event channels, and the //! `MqttOperations`/`FromApplicationMessage` glue. The single `unsafe` block //! is the [`NetStack`](aimdb_embassy_adapter::connectors::NetStack) //! construction in [`MqttConnectorBuilder::new`], acknowledging the adapter's @@ -298,8 +297,8 @@ unsafe impl Sync for TlsSlot {} /// Collects routes from the database during `build()` and wires the broker /// manager + the outbound/inbound pumps. The broker URL scheme selects the /// transport: `mqtt://` is plain TCP (default port 1883), `mqtts://` is TLS -/// (default port 8883) and requires both the `embassy-tls` feature and -/// [`with_tls`](Self::with_tls). +/// (default port 8883) and requires both the `embassy-tls` feature and the +/// `with_tls` method it gates. pub struct MqttConnectorBuilder { broker_url: String, client_id: String, @@ -314,7 +313,7 @@ impl MqttConnectorBuilder { /// /// # Arguments /// * `broker_url` - Broker URL in format `mqtt://host:port` (plain TCP) - /// or `mqtts://host:port` (TLS, see [`with_tls`](Self::with_tls)) + /// or `mqtts://host:port` (TLS, see `with_tls`, feature `embassy-tls`) /// * `stack` - The device's network stack (the runtime travels as /// `Arc` and cannot surface it) pub fn new(broker_url: impl Into, stack: &'static embassy_net::Stack<'static>) -> Self { diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 3ed6a403..51a076c0 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -118,8 +118,8 @@ impl ConnectorBuilder for MqttConnectorBuilder { /// /// 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. +/// reusable `pump_sink` / `pump_source` helpers + the `MqttSink` / +/// `MqttEventLoopSource` adapters below. pub struct MqttConnectorImpl; impl MqttConnectorImpl { diff --git a/aimdb-persistence-sqlite/src/lib.rs b/aimdb-persistence-sqlite/src/lib.rs index 514ed82f..767e4873 100644 --- a/aimdb-persistence-sqlite/src/lib.rs +++ b/aimdb-persistence-sqlite/src/lib.rs @@ -3,7 +3,7 @@ //! SQLite persistence backend for AimDB. //! //! Owns a dedicated OS thread that holds the `rusqlite::Connection`. All async -//! callers send [`DbCommand`] messages via `std::sync::mpsc::sync_channel` and +//! callers send `DbCommand` messages via `std::sync::mpsc::sync_channel` and //! await a `tokio::sync::oneshot` reply. The async executor is never blocked; //! the writer thread is never awaited. //! diff --git a/tools/aimdb-mcp/src/connection.rs b/tools/aimdb-mcp/src/connection.rs index ce35ea03..06bb6ca7 100644 --- a/tools/aimdb-mcp/src/connection.rs +++ b/tools/aimdb-mcp/src/connection.rs @@ -47,10 +47,10 @@ impl ConnectionPool { /// Get or create a connection to an AimDB instance /// - /// 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 AimxConnection becomes Sync). + /// Note: since `AimxConnection` does not implement `Clone`, a fresh + /// connection is created each time. The pool tracks connection metadata for + /// monitoring and future optimization (e.g. persistent connections via + /// `Arc>` if `AimxConnection` becomes `Sync`). pub async fn get_connection(&self, endpoint: &str) -> Result { let mut pool = self.connections.lock().await; diff --git a/tools/aimdb-mcp/src/protocol/jsonrpc.rs b/tools/aimdb-mcp/src/protocol/jsonrpc.rs index cfc0a81b..5b634fec 100644 --- a/tools/aimdb-mcp/src/protocol/jsonrpc.rs +++ b/tools/aimdb-mcp/src/protocol/jsonrpc.rs @@ -1,6 +1,6 @@ //! JSON-RPC 2.0 types //! -//! See: https://www.jsonrpc.org/specification +//! See . use serde::{Deserialize, Serialize}; use serde_json::Value; From e9fac817ddb21ba30a44e405730cfe78306983cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 19:11:27 +0000 Subject: [PATCH 06/19] feat(embassy-adapter): enhance runtime-neutral I/O traits and testing for embassy adapter --- Makefile | 8 + aimdb-embassy-adapter/Cargo.toml | 6 + aimdb-embassy-adapter/src/lib.rs | 5 + aimdb-embassy-adapter/src/net.rs | 454 +++++++++++++++++++ aimdb-embassy-adapter/tests/session_smoke.rs | 2 +- 5 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 aimdb-embassy-adapter/src/net.rs diff --git a/Makefile b/Makefile index fa2c9d41..957d0c4e 100644 --- a/Makefile +++ b/Makefile @@ -177,6 +177,8 @@ test: cargo test --package aimdb-tokio-adapter --features "net" @printf "$(YELLOW) → Testing embassy adapter (host, no executor: buffers, join-queue, connector spine, doctests)$(NC)\n" cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time,connectors" + @printf "$(YELLOW) → Testing embassy adapter (host: runtime-neutral UART stream and clock)$(NC)\n" + cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" @printf "$(YELLOW) → Testing WASM adapter (host lib: buffer semantics + shared contract suite; browser layer runs via wasm-test)$(NC)\n" cargo test --package aimdb-wasm-adapter --no-default-features --lib @printf "$(YELLOW) → Testing WASM adapter (host lib with observability)$(NC)\n" @@ -280,6 +282,9 @@ clippy: cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter with network support$(NC)\n" cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --features "embassy-runtime,embassy-net-support" -- -D warnings + @printf "$(YELLOW) → Clippy on embassy adapter (runtime-neutral transports, target and host tests)$(NC)\n" + cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "alloc,net,embassy-runtime" -- -D warnings + cargo clippy --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on sync wrapper$(NC)\n" cargo clippy --package aimdb-sync --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on sync wrapper (no_std)$(NC)\n" @@ -432,6 +437,9 @@ test-embedded: cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,observability" @printf "$(YELLOW) → Checking aimdb-embassy-adapter connector spine (connector-io) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,connector-io" + @printf "$(YELLOW) → Checking aimdb-embassy-adapter runtime-neutral transports, with and without the clock, on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,net,embassy-runtime" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,net" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" 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" diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index fa994ba2..19737567 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -24,6 +24,12 @@ embassy-net-support = ["embassy-net"] # Network connectors = ["aimdb-core/connector-session"] connector-io = ["connectors", "dep:embedded-io-async"] +# Embassy sockets, dialers and UART halves behind core's runtime-neutral I/O +# traits, so connector crates need no `embassy-net` dependency. Deliberately +# does not imply `embassy-time`: that turns on `defmt-timestamp-uptime`, whose +# `_defmt_timestamp` collides with every host test binary's `defmt::timestamp!`. +net = ["connectors", "embassy-net-support", "dep:embedded-io-async"] + # Observability features (no_std compatible) tracing = ["aimdb-core/tracing", "dep:tracing"] diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 6cfc5349..2e489ef4 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -37,6 +37,11 @@ pub mod send_wrapper; #[cfg(all(not(feature = "std"), feature = "connectors"))] pub mod connectors; +// Embassy implementations of core's runtime-neutral I/O traits, so connector +// crates stay runtime-neutral. +#[cfg(all(not(feature = "std"), feature = "net"))] +pub mod net; + /// Link stubs for **host** test binaries that touch the Embassy adapter: /// a no-op `#[defmt::global_logger]` + `#[defmt::panic_handler]` /// and a pinned-at-0 embassy-time driver. diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs new file mode 100644 index 00000000..4d610618 --- /dev/null +++ b/aimdb-embassy-adapter/src/net.rs @@ -0,0 +1,454 @@ +//! Embassy implementations of core's runtime-neutral I/O traits: the adapter +//! owns sockets and clocks, the connector owns framing and protocol. +//! +//! The traits declare `+ Send` futures and Embassy's are not, so every impl +//! here returns a [`SendFutureWrapper`]. That force-`Send` lives here, once, +//! and connector crates carry none. +//! +//! # Safety invariant +//! +//! As in [`crate::connectors`]: an Embassy executor runs cooperatively on a +//! single core with no preemption or thread migration, so the wrapped `!Send` +//! values are never touched from another thread. + +use core::cell::RefCell; +use core::future::{poll_fn, Future}; +use core::task::{Context, Poll, Waker}; + +use alloc::sync::Arc; + +use aimdb_core::session::{ByteStream, StreamDialer, TransportError, TransportResult}; + +use embassy_net::tcp::TcpSocket; +use embassy_net::{IpEndpoint, Stack}; +use embedded_io_async::Write as _; + +use crate::SendFutureWrapper; + +// =========================================================================== +// Socket slot. +// =========================================================================== + +/// Holds one reusable `embassy-net` TCP socket between uses, so the caller's +/// buffers are allocated once and a dropped [`EmbassyTcpStream`] can hand its +/// socket back. +pub struct TcpSocketSlot { + socket: RefCell>>, + // One `Waker` is enough: at most one caller waits on a given slot. + waker: RefCell>, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for TcpSocketSlot {} +// SAFETY: same invariant; shared only so a dropped stream can return its socket. +unsafe impl Sync for TcpSocketSlot {} + +impl TcpSocketSlot { + /// Hold `socket` for the next taker. + pub fn new(socket: TcpSocket<'static>) -> Self { + Self { + socket: RefCell::new(Some(socket)), + waker: RefCell::new(None), + } + } + + /// Take the socket if it is free right now. + pub fn take(&self) -> Option> { + self.socket.borrow_mut().take() + } + + /// Wait until the socket is free, then take it. + pub async fn acquire(&self) -> TcpSocket<'static> { + poll_fn(|cx| self.poll_take(cx)).await + } + + fn poll_take(&self, cx: &mut Context<'_>) -> Poll> { + let mut slot = self.socket.borrow_mut(); + if let Some(socket) = slot.take() { + Poll::Ready(socket) + } else { + drop(slot); + *self.waker.borrow_mut() = Some(cx.waker().clone()); + Poll::Pending + } + } + + /// Return the socket to the slot, waking whoever is waiting for it. + pub fn put(&self, socket: TcpSocket<'static>) { + let mut slot = self.socket.borrow_mut(); + debug_assert!(slot.is_none(), "Embassy TCP socket returned twice"); + if slot.is_none() { + *slot = Some(socket); + } + if let Some(waker) = self.waker.borrow_mut().take() { + waker.wake(); + } + } +} + +// =========================================================================== +// TCP. +// =========================================================================== + +/// One `embassy-net` TCP connection as a [`ByteStream`]. Owns its socket and +/// returns it to its slot on drop. +pub struct EmbassyTcpStream { + socket: Option>, + recycler: Option>, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for EmbassyTcpStream {} + +impl EmbassyTcpStream { + pub(crate) fn recyclable(socket: TcpSocket<'static>, recycler: Arc) -> Self { + Self { + socket: Some(socket), + recycler: Some(recycler), + } + } + + fn socket_mut(&mut self) -> TransportResult<&mut TcpSocket<'static>> { + self.socket.as_mut().ok_or(TransportError::Closed) + } +} + +impl Drop for EmbassyTcpStream { + fn drop(&mut self) { + if let Some(mut socket) = self.socket.take() { + // Reset now rather than leave the link half-open; the next taker + // aborts again before reuse. + socket.abort(); + if let Some(recycler) = &self.recycler { + recycler.put(socket); + } + } + } +} + +impl ByteStream for EmbassyTcpStream { + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let socket = self.socket_mut()?; + socket.read(buf).await.map_err(|_| TransportError::Io) + }) + } + + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let socket = self.socket_mut()?; + socket + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { + let socket = self.socket_mut()?; + socket.flush().await.map_err(|_| TransportError::Closed) + }) + } +} + +/// Dials TCP connections over one caller-owned socket. +pub struct EmbassyTcpDialer { + slot: Arc, +} + +impl StreamDialer for EmbassyTcpDialer { + type Stream = EmbassyTcpStream; + + fn connect<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + // Resolution belongs to the adapter: IP literals here, hostnames + // once embassy-net's `dns` feature is on. + let addr: core::net::IpAddr = host.parse().map_err(|_| TransportError::Io)?; + let endpoint = IpEndpoint::new(addr.into(), port); + + let Some(mut socket) = self.slot.take() else { + return Err(TransportError::Io); + }; + socket.abort(); + match socket.connect(endpoint).await { + Ok(()) => Ok(EmbassyTcpStream::recyclable(socket, self.slot.clone())), + Err(_) => { + socket.abort(); + self.slot.put(socket); + Err(TransportError::Io) + } + } + }) + } +} + +// =========================================================================== +// UART. +// =========================================================================== + +/// A UART, or any `embedded-io-async` read/write pair, as one [`ByteStream`] — +/// so the connector names no `embedded-io-async` types of its own. +pub struct EmbassyUart { + rx: Rd, + tx: Wr, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for EmbassyUart {} + +impl EmbassyUart { + /// Present an already-split UART's halves as one stream. + pub fn new(rx: Rd, tx: Wr) -> Self { + Self { rx, tx } + } +} + +impl ByteStream for EmbassyUart +where + Rd: embedded_io_async::Read, + Wr: embedded_io_async::Write, +{ + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { self.rx.read(buf).await.map_err(|_| TransportError::Io) }) + } + + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + self.tx + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { self.tx.flush().await.map_err(|_| TransportError::Closed) }) + } +} + +// =========================================================================== +// Constructors and clock. +// =========================================================================== + +/// Entry point for the Embassy transports, keeping `embassy_net::Stack` inside +/// the adapter. +pub struct EmbassyNet; + +impl EmbassyNet { + /// A reusable TCP dialer over caller-owned socket buffers. + pub fn tcp( + stack: Stack<'static>, + rx_buffer: &'static mut [u8], + tx_buffer: &'static mut [u8], + ) -> EmbassyTcpDialer { + EmbassyTcpDialer { + slot: Arc::new(TcpSocketSlot::new(TcpSocket::new( + stack, rx_buffer, tx_buffer, + ))), + } + } +} + +/// [`Delay`](aimdb_core::session::Delay) over `embassy_time::Timer`, which is +/// `Send` and allocates nothing. +/// +/// Gated on `embassy-time` separately from `net`, so a sockets-only consumer +/// does not pull in `defmt-timestamp-uptime`'s `_defmt_timestamp` symbol. +#[cfg(feature = "embassy-time")] +#[derive(Clone, Copy, Default)] +pub struct EmbassyDelay; + +#[cfg(feature = "embassy-time")] +impl aimdb_core::session::Delay for EmbassyDelay { + fn sleep(&self, d: core::time::Duration) -> impl Future + Send { + embassy_time::Timer::after(embassy_time::Duration::from_micros(d.as_micros() as u64)) + } +} + +// =========================================================================== +// Compile-time assertions — a regression in the force-`Send` above must +// surface here, not in a connector. +// =========================================================================== + +#[allow(dead_code)] +fn _transports_are_send() { + fn assert_send() {} + assert_send::(); + assert_send::(); + #[cfg(feature = "embassy-time")] + assert_send::(); +} + +/// A task built from these transports boxes as `ConnectorBuilder::build` +/// requires — the boundary the force-`Send` exists to cross. +#[allow(dead_code)] +fn _dialed_stream_drives_a_boxed_send_task(dialer: D) -> aimdb_core::session::BoxFut<'static, ()> +where + D: StreamDialer + Send + 'static, +{ + alloc::boxed::Box::pin(async move { + let mut buf = [0u8; 8]; + if let Ok(mut stream) = dialer.connect("127.0.0.1", 7001).await { + let _ = stream.read(&mut buf).await; + let _ = stream.write_all(&buf).await; + let _ = stream.flush().await; + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_core::session::{Connection, FramedConnection, Framer}; + use alloc::vec; + use alloc::vec::Vec; + + // `host_test_stubs!()` must expand once per binary; `buffer` already does + // it for this one. + + /// An in-memory read half: hands out queued chunks, then EOF. + struct MockRx(Vec>); + + impl embedded_io_async::ErrorType for MockRx { + type Error = embedded_io_async::ErrorKind; + } + + impl embedded_io_async::Read for MockRx { + async fn read(&mut self, buf: &mut [u8]) -> Result { + if self.0.is_empty() { + return Ok(0); + } + let chunk = self.0.remove(0); + let n = chunk.len().min(buf.len()); + buf[..n].copy_from_slice(&chunk[..n]); + Ok(n) + } + } + + /// An in-memory write half recording what it was given. + #[derive(Default)] + struct MockTx { + written: Vec, + flushes: usize, + } + + impl embedded_io_async::ErrorType for MockTx { + type Error = embedded_io_async::ErrorKind; + } + + impl embedded_io_async::Write for MockTx { + async fn write(&mut self, buf: &[u8]) -> Result { + self.written.extend_from_slice(buf); + Ok(buf.len()) + } + async fn flush(&mut self) -> Result<(), Self::Error> { + self.flushes += 1; + Ok(()) + } + } + + /// Length-prefixed framer, enough to drive a `FramedConnection`. + #[derive(Default)] + struct LenFramer { + buf: Vec, + } + + impl Framer for LenFramer { + fn encode(&self, frame: &[u8], out: &mut Vec) { + out.push(frame.len() as u8); + out.extend_from_slice(frame); + } + fn push_bytes(&mut self, bytes: &[u8]) { + self.buf.extend_from_slice(bytes); + } + fn next_frame(&mut self) -> Option, ()>> { + let len = *self.buf.first()? as usize; + if self.buf.len() < len + 1 { + return None; + } + let frame = self.buf[1..len + 1].to_vec(); + self.buf.drain(..len + 1); + Some(Ok(frame)) + } + } + + fn block_on(f: F) -> F::Output { + futures::executor::block_on(f) + } + + #[test] + fn uart_reads_queued_chunks_then_reports_eof() { + let mut uart = EmbassyUart::new(MockRx(vec![b"hi".to_vec()]), MockTx::default()); + block_on(async { + let mut buf = [0u8; 8]; + assert_eq!(uart.read(&mut buf).await.unwrap(), 2); + assert_eq!(&buf[..2], b"hi"); + assert_eq!(uart.read(&mut buf).await.unwrap(), 0, "EOF is Ok(0)"); + }); + } + + #[test] + fn uart_writes_every_byte_and_flushes() { + let mut uart = EmbassyUart::new(MockRx(vec![]), MockTx::default()); + block_on(async { + uart.write_all(b"payload").await.unwrap(); + uart.flush().await.unwrap(); + }); + assert_eq!(uart.tx.written, b"payload"); + assert_eq!(uart.tx.flushes, 1); + } + + /// The UART drives core's framed connection, which is what the serial + /// connector rides. + #[test] + fn uart_drives_a_framed_connection() { + let rx = MockRx(vec![vec![2, b'h', b'i'], vec![3, b'y', b'e', b's']]); + let mut conn: FramedConnection<_, LenFramer, 64, 64> = FramedConnection::new( + EmbassyUart::new(rx, MockTx::default()), + LenFramer::default(), + ); + + block_on(async { + assert_eq!(conn.recv().await.unwrap(), Some(b"hi".to_vec())); + assert_eq!(conn.recv().await.unwrap(), Some(b"yes".to_vec())); + assert_eq!(conn.recv().await.unwrap(), None); + conn.send(b"ack").await.unwrap(); + }); + } + + /// The force-`Send` has to survive as far as the runner's boxed + /// `dyn Connection`. + #[test] + fn a_uart_connection_is_boxable_as_a_send_dyn_connection() { + let conn: FramedConnection<_, LenFramer, 64, 64> = FramedConnection::new( + EmbassyUart::new(MockRx(vec![]), MockTx::default()), + LenFramer::default(), + ); + let _boxed: alloc::boxed::Box = alloc::boxed::Box::new(conn); + } + + /// The host time driver pins the clock at 0, so only an already-expired + /// sleep can be driven here. + #[test] + fn delay_completes_an_already_expired_sleep() { + use aimdb_core::session::Delay; + block_on(EmbassyDelay.sleep(core::time::Duration::ZERO)); + } +} diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs index ae709cbb..b427bafb 100644 --- a/aimdb-embassy-adapter/tests/session_smoke.rs +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -112,7 +112,7 @@ fn embassy_clock_drives_client_engine_rpc() { use futures::future::{select, Either}; // The exact `run_client<_, _, EmbassyAdapter>` monomorphization an MCU uses. - let clock = Arc::new(EmbassyAdapter::default()); + let clock = Arc::new(EmbassyAdapter); let config = ClientConfig { reconnect: false, sends_hello: false, From 136c6aa035482b2290174bbb72279dc1efae0fd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 19:26:13 +0000 Subject: [PATCH 07/19] feat(tcp-connector): implement pooled TCP listener for concurrent accept handling --- Makefile | 4 + aimdb-embassy-adapter/src/net.rs | 112 +++++++- aimdb-tcp-connector/Cargo.toml | 3 + aimdb-tcp-connector/tests/neutral_pool.rs | 319 ++++++++++++++++++++++ 4 files changed, 436 insertions(+), 2 deletions(-) create mode 100644 aimdb-tcp-connector/tests/neutral_pool.rs diff --git a/Makefile b/Makefile index 957d0c4e..756e8aac 100644 --- a/Makefile +++ b/Makefile @@ -223,6 +223,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-tokio" @printf "$(YELLOW) → Testing TCP connector (embassy: socket recycle + concurrent slots + redial over an embassy-net loopback)$(NC)\n" cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback + @printf "$(YELLOW) → Testing TCP connector (neutral accept pool over two embassy-net stacks)$(NC)\n" + cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test neutral_pool fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -349,6 +351,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on TCP connector (embassy-net loopback smoke, host)$(NC)\n" cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -D warnings + @printf "$(YELLOW) → Clippy on TCP connector (neutral accept pool, host)$(NC)\n" + cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test neutral_pool -- -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 @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 4d610618..684b668d 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -13,14 +13,19 @@ use core::cell::RefCell; use core::future::{poll_fn, Future}; +use core::pin::Pin; use core::task::{Context, Poll, Waker}; +use alloc::boxed::Box; +use alloc::string::ToString; use alloc::sync::Arc; -use aimdb_core::session::{ByteStream, StreamDialer, TransportError, TransportResult}; +use aimdb_core::session::{ + ByteStream, PeerInfo, StreamDialer, StreamListener, TransportError, TransportResult, +}; use embassy_net::tcp::TcpSocket; -use embassy_net::{IpEndpoint, Stack}; +use embassy_net::{IpEndpoint, IpListenEndpoint, Stack}; use embedded_io_async::Write as _; use crate::SendFutureWrapper; @@ -193,6 +198,93 @@ impl StreamDialer for EmbassyTcpDialer { } } +// =========================================================================== +// Pooled listener. +// =========================================================================== + +/// One slot's accept, owning its socket so the listener can store it between +/// calls. +type PendingAccept = Pin>>>>; + +/// An Embassy TCP listener over `N` caller-owned sockets, behind the +/// single-accept [`StreamListener`] contract. +/// +/// Each slot's accept future is created once and **kept**, so returning slot +/// *i*'s connection leaves the other `N-1` pending and still in `LISTEN` — a +/// SYN arriving between accepts lands. Rebuilding them instead would need an +/// `abort()` to make `accept()` re-enterable, and that abort is what drops the +/// `LISTEN`. `aimdb-tcp-connector`'s `tests/neutral_pool.rs` holds both halves +/// of this to real sockets. +pub struct EmbassyTcpListener { + local_endpoint: IpListenEndpoint, + slots: [Arc; N], + pending: [Option; N], +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for EmbassyTcpListener {} + +impl EmbassyTcpListener { + /// Arm slot `i` unless its accept is already in flight. + fn arm(&mut self, i: usize) { + if self.pending[i].is_some() { + return; + } + let slot = self.slots[i].clone(); + let endpoint = self.local_endpoint; + self.pending[i] = Some(Box::pin(async move { + // A live connection may still hold the socket. + let mut socket = slot.acquire().await; + socket.abort(); + match socket.accept(endpoint).await { + Ok(()) => Ok(socket), + Err(_) => { + socket.abort(); + slot.put(socket); + Err(TransportError::Io) + } + } + })); + } +} + +impl StreamListener for EmbassyTcpListener { + type Stream = EmbassyTcpStream; + + fn accept( + &mut self, + ) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { + for i in 0..N { + self.arm(i); + } + poll_fn(|cx| { + for i in 0..N { + let Some(fut) = self.pending[i].as_mut() else { + continue; + }; + let Poll::Ready(result) = fut.as_mut().poll(cx) else { + continue; + }; + // Only this slot is consumed; the rest stay in LISTEN. + self.pending[i] = None; + let socket = match result { + Ok(socket) => socket, + Err(e) => return Poll::Ready(Err(e)), + }; + // `PeerInfo` is `#[non_exhaustive]`: build it by mutation. + let mut peer = PeerInfo::default(); + peer.peer_addr = socket.remote_endpoint().map(|e| e.to_string()); + let stream = EmbassyTcpStream::recyclable(socket, self.slots[i].clone()); + return Poll::Ready(Ok((stream, peer))); + } + Poll::Pending + }) + .await + }) + } +} + // =========================================================================== // UART. // =========================================================================== @@ -264,6 +356,21 @@ impl EmbassyNet { ))), } } + + /// An `N`-socket listener on `local_endpoint`, one caller-owned + /// `(rx, tx)` pair per socket. + pub fn listen( + stack: Stack<'static>, + local_endpoint: impl Into, + buffers: [(&'static mut [u8], &'static mut [u8]); N], + ) -> EmbassyTcpListener { + EmbassyTcpListener { + local_endpoint: local_endpoint.into(), + slots: buffers + .map(|(rx, tx)| Arc::new(TcpSocketSlot::new(TcpSocket::new(stack, rx, tx)))), + pending: core::array::from_fn(|_| None), + } + } } /// [`Delay`](aimdb_core::session::Delay) over `embassy_time::Timer`, which is @@ -292,6 +399,7 @@ fn _transports_are_send() { fn assert_send() {} assert_send::(); assert_send::(); + assert_send::>(); #[cfg(feature = "embassy-time")] assert_send::(); } diff --git a/aimdb-tcp-connector/Cargo.toml b/aimdb-tcp-connector/Cargo.toml index 120b0c12..cde67686 100644 --- a/aimdb-tcp-connector/Cargo.toml +++ b/aimdb-tcp-connector/Cargo.toml @@ -52,6 +52,9 @@ _test-tokio = ["tokio-runtime", "dep:aimdb-tokio-adapter"] # the `_test-tokio` build too). Run with `--features _test-embassy-loopback`. _test-embassy-loopback = [ "embassy-runtime", + # The adapter's neutral transports, exercised by `tests/neutral_pool.rs` + # over the same two real stacks as `embassy_loopback.rs`. + "aimdb-embassy-adapter/net", "embassy-net/medium-ip", "embassy-net/proto-ipv4", "dep:embassy-net-driver-channel", diff --git a/aimdb-tcp-connector/tests/neutral_pool.rs b/aimdb-tcp-connector/tests/neutral_pool.rs new file mode 100644 index 00000000..f0005fde --- /dev/null +++ b/aimdb-tcp-connector/tests/neutral_pool.rs @@ -0,0 +1,319 @@ +//! Can a single-accept [`StreamListener`] back the Embassy N-socket pool +//! without losing accept concurrency? +//! +//! The two pools here differ only in whether pending accepts are kept: +//! `EmbassyNet::listen::` stores one per slot, `NaivePooledListener` +//! rebuilds all `N` each call. Both run over the same two crossover-wired +//! stacks as `embassy_loopback.rs`, driven one `accept().await` at a time. +#![cfg(feature = "_test-embassy-loopback")] + +extern crate alloc; + +use core::future::Future; + +use aimdb_core::session::{ + ByteStream, StreamDialer, StreamListener, TransportError, TransportResult, +}; +use aimdb_embassy_adapter::net::{EmbassyNet, EmbassyTcpStream, TcpSocketSlot}; +use alloc::sync::Arc; +use embassy_net::tcp::TcpSocket; +use embassy_net::{Config, IpListenEndpoint, Ipv4Address, Ipv4Cidr, Stack, StaticConfigV4}; +use embassy_net_driver_channel as ch; +use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; + +// --------------------------------------------------------------------------- +// Host stubs and two crossover stacks. Duplicated from `embassy_loopback.rs`: +// each test binary must define the defmt logger and time driver exactly once. +// --------------------------------------------------------------------------- + +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +defmt::timestamp!("{=u64}", 0u64); + +/// Real wall-clock time; a frozen `now()` stalls the delayed-ACK timer and +/// `flush()` never returns. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +const MTU: usize = 1514; +const SERVER_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const CLIENT_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); + +/// As `StreamDialer::connect` takes it: a host string the adapter resolves. +const SERVER_HOST: &str = "192.168.0.1"; + +type ChState = ch::State; + +fn leak(v: T) -> &'static mut T { + alloc::boxed::Box::leak(alloc::boxed::Box::new(v)) +} + +fn buf() -> &'static mut [u8] { + alloc::boxed::Box::leak(alloc::vec![0u8; 1024].into_boxed_slice()) +} + +/// One `(rx, tx)` pair, as the pooled listener takes them. +fn bufs() -> (&'static mut [u8], &'static mut [u8]) { + (buf(), buf()) +} + +fn make_stack( + ip: Ipv4Address, + seed: u64, +) -> ( + Stack<'static>, + embassy_net::Runner<'static, ch::Device<'static, MTU>>, + ch::Runner<'static, MTU>, +) { + let state: &'static mut ChState = leak(ch::State::new()); + let (ch_runner, device) = ch::new(state, HardwareAddress::Ip); + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers: heapless::Vec::new(), + }); + let resources = leak(embassy_net::StackResources::<8>::new()); + let (stack, net_runner) = embassy_net::new(device, config, resources, seed); + (stack, net_runner, ch_runner) +} + +async fn cable(mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, MTU>) -> ! { + loop { + let tx_slot = tx.tx_buf().await; + let len = tx_slot.len(); + let mut rx_slot = rx.rx_buf().await; + rx_slot[..len].copy_from_slice(&tx_slot[..len]); + tx_slot.tx_done(); + rx_slot.rx_done(len); + } +} + +/// Run `foreground` while both stacks poll in the background, watchdogged so a +/// hang fails the test rather than the CI job. +fn drive(foreground: F) -> Result<(), &'static str> +where + Fut: Future, + F: FnOnce(Stack<'static>, Stack<'static>) -> Fut, +{ + use core::future::poll_fn; + use core::task::Poll; + use std::time::{Duration, Instant}; + + use futures::future::{join4, select, Either}; + use futures::pin_mut; + + const WATCHDOG: Duration = Duration::from_secs(20); + + let (server_stack, mut server_net, server_ch) = make_stack(SERVER_IP, 0x1111_2222); + let (client_stack, mut client_net, client_ch) = make_stack(CLIENT_IP, 0x3333_4444); + + let (server_state, server_rx, server_tx) = server_ch.split(); + let (client_state, client_rx, client_tx) = client_ch.split(); + server_state.set_link_state(LinkState::Up); + client_state.set_link_state(LinkState::Up); + + let background = join4( + server_net.run(), + client_net.run(), + cable(server_tx, client_rx), + cable(client_tx, server_rx), + ); + let foreground = foreground(server_stack, client_stack); + + futures::executor::block_on(async { + pin_mut!(foreground); + pin_mut!(background); + let session = select(foreground, background); + pin_mut!(session); + + let deadline = Instant::now() + WATCHDOG; + let watchdog = poll_fn(move |cx| { + if Instant::now() >= deadline { + Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + Poll::Pending + } + }); + pin_mut!(watchdog); + + match select(session, watchdog).await { + Either::Left((Either::Left(_), _)) => Ok(()), + Either::Left((Either::Right(_), _)) => Err("background ended before the test"), + Either::Right(_) => Err("watchdog: foreground stuck"), + } + }) +} + +/// Round-trip both ways: a live connection, not just a handshake. +async fn roundtrip(server: &mut EmbassyTcpStream, client: &mut EmbassyTcpStream, tag: &[u8]) { + client.write_all(tag).await.expect("client write"); + client.flush().await.expect("client flush"); + + let mut got = [0u8; 16]; + let n = server.read(&mut got).await.expect("server read"); + assert_eq!(&got[..n], tag, "client -> server bytes"); + + server.write_all(b"pong").await.expect("server write"); + server.flush().await.expect("server flush"); + + let n = client.read(&mut got).await.expect("client read"); + assert_eq!(&got[..n], b"pong", "server -> client bytes"); +} + +// --------------------------------------------------------------------------- +// The contrast: a pool that rebuilds, and so cancels, its accepts. +// --------------------------------------------------------------------------- + +/// Builds `N` accepts per call, races them, drops the losers. Each call must +/// `abort()` first to make `accept()` re-enterable, which takes the other slots +/// out of `LISTEN`. +struct NaivePooledListener { + local_endpoint: IpListenEndpoint, + slots: [Arc; N], +} + +impl NaivePooledListener { + fn new( + stack: Stack<'static>, + local_endpoint: impl Into, + buffers: [(&'static mut [u8], &'static mut [u8]); N], + ) -> Self { + Self { + local_endpoint: local_endpoint.into(), + slots: buffers + .map(|(rx, tx)| Arc::new(TcpSocketSlot::new(TcpSocket::new(stack, rx, tx)))), + } + } + + async fn accept(&mut self) -> TransportResult> { + use futures::future::{select_all, FutureExt}; + + let endpoint = self.local_endpoint; + let futs: alloc::vec::Vec<_> = self + .slots + .iter() + .map(|slot| { + let slot = slot.clone(); + async move { + let mut socket = slot.acquire().await; + // Re-enterable again — and out of any previous `LISTEN`. + socket.abort(); + match socket.accept(endpoint).await { + Ok(()) => Ok(socket), + Err(_) => { + socket.abort(); + slot.put(socket); + Err(TransportError::Io) + } + } + } + .boxed_local() + }) + .collect(); + + let (result, _idx, _rest) = select_all(futs).await; + result + } +} + +// --------------------------------------------------------------------------- +// The tests. +// --------------------------------------------------------------------------- + +/// Two clients on one port, accepted one at a time as `serve` does — the second +/// dialing *after* the first accept returned. A pool holding only one socket in +/// `LISTEN` would have that SYN meet a closed port. +#[test] +fn pool_keeps_every_slot_listening_between_accepts() { + let outcome = drive(|server_stack, client_stack| async move { + let mut listener = EmbassyNet::listen::<2>(server_stack, 7101u16, [bufs(), bufs()]); + let dialer_a = EmbassyNet::tcp(client_stack, buf(), buf()); + let dialer_b = EmbassyNet::tcp(client_stack, buf(), buf()); + + // Accept #1 arms both slots, returns when A lands. + let (accepted_a, mut client_a) = futures::join!( + async { listener.accept().await.expect("accept A") }, + async { dialer_a.connect(SERVER_HOST, 7101).await.expect("dial A") }, + ); + let (mut server_a, peer_a) = accepted_a; + assert!( + peer_a.peer_addr.is_some(), + "accept must carry peer metadata" + ); + + // A must be live before B dials, so B lands between accepts. + roundtrip(&mut server_a, &mut client_a, b"aaa").await; + + // Slot 1 must still be in LISTEN. + let mut client_b = dialer_b.connect(SERVER_HOST, 7101).await.expect( + "second SYN was refused: the pool did not keep slot 1 listening between accepts", + ); + + // Only now does the server come back. + let (mut server_b, _) = listener.accept().await.expect("accept B"); + roundtrip(&mut server_b, &mut client_b, b"bbb").await; + + // Both stay independently usable. + roundtrip(&mut server_a, &mut client_a, b"a2").await; + }); + assert_eq!( + outcome, + Ok(()), + "a single-accept StreamListener over a stored-accept pool should serve both clients" + ); +} + +/// What makes the above a finding rather than a coincidence: same scenario, but +/// the rebuild-and-cancel pool loses B's SYN. +/// +/// If this starts failing, embassy-net's cancellation semantics changed and +/// `EmbassyNet::listen` can be simplified. +#[test] +fn naive_pool_loses_the_syn_that_arrives_between_accepts() { + let outcome = drive(|server_stack, client_stack| async move { + let mut listener = NaivePooledListener::<2>::new(server_stack, 7102u16, [bufs(), bufs()]); + let dialer_a = EmbassyNet::tcp(client_stack, buf(), buf()); + let dialer_b = EmbassyNet::tcp(client_stack, buf(), buf()); + + let (socket_a, _client_a) = futures::join!( + async { listener.accept().await.expect("accept A") }, + async { dialer_a.connect(SERVER_HOST, 7102).await.expect("dial A") }, + ); + let _keep_a = socket_a; + + let refused = dialer_b.connect(SERVER_HOST, 7102).await; + assert_eq!( + refused.err(), + Some(TransportError::Io), + "the naive pool is expected to lose this SYN" + ); + }); + assert_eq!( + outcome, + Ok(()), + "the naive-pool scenario should run to completion" + ); +} From b7fc5b578a951cd7f42e4a337691eb67a656c7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 19:39:11 +0000 Subject: [PATCH 08/19] feat(embassy-adapter): add UDP support with EmbassyUdpSocket and tests for datagram handling --- Cargo.lock | 1 + Makefile | 9 +- aimdb-embassy-adapter/Cargo.toml | 11 +- aimdb-embassy-adapter/src/net.rs | 145 +++++++++++++- aimdb-embassy-adapter/tests/neutral_udp.rs | 219 +++++++++++++++++++++ 5 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 aimdb-embassy-adapter/tests/neutral_udp.rs diff --git a/Cargo.lock b/Cargo.lock index b6201889..fee544ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,7 @@ dependencies = [ "defmt 1.1.1", "embassy-executor", "embassy-net", + "embassy-net-driver-channel", "embassy-sync", "embassy-time", "embassy-time-driver", diff --git a/Makefile b/Makefile index 756e8aac..d0513754 100644 --- a/Makefile +++ b/Makefile @@ -177,8 +177,10 @@ test: cargo test --package aimdb-tokio-adapter --features "net" @printf "$(YELLOW) → Testing embassy adapter (host, no executor: buffers, join-queue, connector spine, doctests)$(NC)\n" cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time,connectors" - @printf "$(YELLOW) → Testing embassy adapter (host: runtime-neutral UART stream and clock)$(NC)\n" - cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" + @printf "$(YELLOW) → Testing embassy adapter (host: runtime-neutral transports, UART + UDP over two embassy-net stacks)$(NC)\n" + cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,net" + @printf "$(YELLOW) → Testing embassy adapter (host: the neutral clock; --lib only, embassy-time's uptime timestamp collides with the test binaries')$(NC)\n" + cargo test --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" --lib @printf "$(YELLOW) → Testing WASM adapter (host lib: buffer semantics + shared contract suite; browser layer runs via wasm-test)$(NC)\n" cargo test --package aimdb-wasm-adapter --no-default-features --lib @printf "$(YELLOW) → Testing WASM adapter (host lib with observability)$(NC)\n" @@ -286,7 +288,8 @@ clippy: cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --features "embassy-runtime,embassy-net-support" -- -D warnings @printf "$(YELLOW) → Clippy on embassy adapter (runtime-neutral transports, target and host tests)$(NC)\n" cargo clippy --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "alloc,net,embassy-runtime" -- -D warnings - cargo clippy --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" --all-targets -- -D warnings + cargo clippy --package aimdb-embassy-adapter --no-default-features --features "alloc,net" --all-targets -- -D warnings + cargo clippy --package aimdb-embassy-adapter --no-default-features --features "alloc,net,embassy-sync,embassy-time" --lib -- -D warnings @printf "$(YELLOW) → Clippy on sync wrapper$(NC)\n" cargo clippy --package aimdb-sync --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on sync wrapper (no_std)$(NC)\n" diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index 19737567..da66b486 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -28,7 +28,7 @@ connector-io = ["connectors", "dep:embedded-io-async"] # traits, so connector crates need no `embassy-net` dependency. Deliberately # does not imply `embassy-time`: that turns on `defmt-timestamp-uptime`, whose # `_defmt_timestamp` collides with every host test binary's `defmt::timestamp!`. -net = ["connectors", "embassy-net-support", "dep:embedded-io-async"] +net = ["connectors", "embassy-net-support", "embassy-net/udp", "dep:embedded-io-async"] # Observability features (no_std compatible) tracing = ["aimdb-core/tracing", "dep:tracing"] @@ -78,6 +78,15 @@ aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = fal "connector-session", ] } +# Two crossover-wired embassy-net stacks for the host UDP test +# (`tests/neutral_udp.rs`), the same rig the TCP connector's loopback uses. +embassy-net = { workspace = true, features = [ + "medium-ip", + "proto-ipv4", + "udp", +] } +embassy-net-driver-channel = "0.4.0" + # For testing on embedded targets heapless = "0.9.1" diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 684b668d..dd75c367 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -21,10 +21,12 @@ use alloc::string::ToString; use alloc::sync::Arc; use aimdb_core::session::{ - ByteStream, PeerInfo, StreamDialer, StreamListener, TransportError, TransportResult, + ByteStream, Datagram, DatagramBinder, PeerInfo, StreamDialer, StreamListener, TransportError, + TransportResult, }; use embassy_net::tcp::TcpSocket; +use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, IpListenEndpoint, Stack}; use embedded_io_async::Write as _; @@ -335,6 +337,126 @@ where } } +// =========================================================================== +// Datagrams. +// =========================================================================== + +/// Holds the reusable `embassy-net` UDP socket between binds. +/// +/// `UdpSocket` owns its buffers for its whole lifetime, so a rebind cannot +/// recreate it without stranding them. It need not: `close()` then `bind()` +/// returns the same socket to a fresh unbound state. +struct UdpSlot { + socket: RefCell>>, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for UdpSlot {} +// SAFETY: same invariant. +unsafe impl Sync for UdpSlot {} + +/// One bound `embassy-net` UDP socket as a [`Datagram`]. +pub struct EmbassyUdpSocket { + socket: Option>, + slot: Arc, + local: Option, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for EmbassyUdpSocket {} + +impl Drop for EmbassyUdpSocket { + fn drop(&mut self) { + if let Some(mut socket) = self.socket.take() { + socket.close(); + *self.slot.socket.borrow_mut() = Some(socket); + } + } +} + +fn to_endpoint(addr: core::net::SocketAddr) -> IpEndpoint { + IpEndpoint::new(addr.ip().into(), addr.port()) +} + +impl Datagram for EmbassyUdpSocket { + fn send_to<'a>( + &'a mut self, + buf: &'a [u8], + to: core::net::SocketAddr, + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let socket = self.socket.as_mut().ok_or(TransportError::Closed)?; + socket + .send_to(buf, to_endpoint(to)) + .await + .map_err(|_| TransportError::Io) + }) + } + + fn recv_from<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let socket = self.socket.as_mut().ok_or(TransportError::Closed)?; + let (n, meta) = socket + .recv_from(buf) + .await + .map_err(|_| TransportError::Io)?; + let addr = core::net::SocketAddr::new(meta.endpoint.addr.into(), meta.endpoint.port); + Ok((n, addr)) + }) + } + + /// Assembled from the socket's bound port and the stack's IPv4 config, so + /// a protocol that advertises its own endpoint gets a real address. + fn local_addr(&self) -> Option { + self.local + } +} + +/// Binds [`EmbassyUdpSocket`]s over one caller-owned socket. +pub struct EmbassyUdpBinder { + stack: Stack<'static>, + slot: Arc, +} + +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +unsafe impl Send for EmbassyUdpBinder {} +// SAFETY: same invariant. +unsafe impl Sync for EmbassyUdpBinder {} + +impl DatagramBinder for EmbassyUdpBinder { + type Socket = EmbassyUdpSocket; + + fn bind(&self, port: u16) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { + let mut socket = self + .slot + .socket + .borrow_mut() + .take() + .ok_or(TransportError::Io)?; + // Idempotent: a socket returned by a dropped `EmbassyUdpSocket` is + // already closed, and closing an unbound socket is a no-op. + socket.close(); + if socket.bind(port).is_err() { + *self.slot.socket.borrow_mut() = Some(socket); + return Err(TransportError::Io); + } + let bound_port = socket.endpoint().port; + let local = self.stack.config_v4().map(|cfg| { + core::net::SocketAddr::new(core::net::IpAddr::V4(cfg.address.address()), bound_port) + }); + Ok(EmbassyUdpSocket { + socket: Some(socket), + slot: self.slot.clone(), + local, + }) + }) + } +} + // =========================================================================== // Constructors and clock. // =========================================================================== @@ -371,6 +493,24 @@ impl EmbassyNet { pending: core::array::from_fn(|_| None), } } + + /// A UDP binder over one caller-owned socket, for KNX/IP and SNTP. + pub fn udp( + stack: Stack<'static>, + rx_meta: &'static mut [PacketMetadata], + rx_buffer: &'static mut [u8], + tx_meta: &'static mut [PacketMetadata], + tx_buffer: &'static mut [u8], + ) -> EmbassyUdpBinder { + EmbassyUdpBinder { + stack, + slot: Arc::new(UdpSlot { + socket: RefCell::new(Some(UdpSocket::new( + stack, rx_meta, rx_buffer, tx_meta, tx_buffer, + ))), + }), + } + } } /// [`Delay`](aimdb_core::session::Delay) over `embassy_time::Timer`, which is @@ -400,6 +540,8 @@ fn _transports_are_send() { assert_send::(); assert_send::(); assert_send::>(); + assert_send::(); + assert_send::(); #[cfg(feature = "embassy-time")] assert_send::(); } @@ -554,6 +696,7 @@ mod tests { /// The host time driver pins the clock at 0, so only an already-expired /// sleep can be driven here. + #[cfg(feature = "embassy-time")] #[test] fn delay_completes_an_already_expired_sleep() { use aimdb_core::session::Delay; diff --git a/aimdb-embassy-adapter/tests/neutral_udp.rs b/aimdb-embassy-adapter/tests/neutral_udp.rs new file mode 100644 index 00000000..1701c9d5 --- /dev/null +++ b/aimdb-embassy-adapter/tests/neutral_udp.rs @@ -0,0 +1,219 @@ +//! Host smoke for the Embassy [`Datagram`] path over two crossover-wired +//! `embassy-net` stacks. +//! +//! Covers what a KNX/IP tunnel needs from it: a real bound address to advertise +//! (`local_addr`), a round trip carrying the sender's address, and rebinding the +//! same socket across a reconnect cycle. +#![cfg(feature = "net")] + +extern crate alloc; + +use core::future::Future; + +use aimdb_core::session::{Datagram, DatagramBinder}; +use aimdb_embassy_adapter::net::EmbassyNet; +use embassy_net::udp::PacketMetadata; +use embassy_net::{Config, Ipv4Address, Ipv4Cidr, Stack, StaticConfigV4}; +use embassy_net_driver_channel as ch; +use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; + +// Each test binary must define these exactly once. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +defmt::timestamp!("{=u64}", 0u64); + +/// Real wall-clock time; a frozen `now()` stalls the stack's timers. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +const MTU: usize = 1514; +const A_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const B_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); + +type ChState = ch::State; + +fn leak(v: T) -> &'static mut T { + alloc::boxed::Box::leak(alloc::boxed::Box::new(v)) +} + +fn buf() -> &'static mut [u8] { + alloc::boxed::Box::leak(alloc::vec![0u8; 1024].into_boxed_slice()) +} + +fn meta() -> &'static mut [PacketMetadata] { + alloc::boxed::Box::leak(alloc::vec![PacketMetadata::EMPTY; 8].into_boxed_slice()) +} + +fn make_stack( + ip: Ipv4Address, + seed: u64, +) -> ( + Stack<'static>, + embassy_net::Runner<'static, ch::Device<'static, MTU>>, + ch::Runner<'static, MTU>, +) { + let state: &'static mut ChState = leak(ch::State::new()); + let (ch_runner, device) = ch::new(state, HardwareAddress::Ip); + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers: heapless::Vec::new(), + }); + let resources = leak(embassy_net::StackResources::<4>::new()); + let (stack, net_runner) = embassy_net::new(device, config, resources, seed); + (stack, net_runner, ch_runner) +} + +async fn cable(mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, MTU>) -> ! { + loop { + let tx_slot = tx.tx_buf().await; + let len = tx_slot.len(); + let mut rx_slot = rx.rx_buf().await; + rx_slot[..len].copy_from_slice(&tx_slot[..len]); + tx_slot.tx_done(); + rx_slot.rx_done(len); + } +} + +/// Run `foreground` while both stacks poll in the background, watchdogged so a +/// hang fails the test rather than the CI job. +fn drive(foreground: F) -> Result<(), &'static str> +where + Fut: Future, + F: FnOnce(Stack<'static>, Stack<'static>) -> Fut, +{ + use core::future::poll_fn; + use core::task::Poll; + use std::time::{Duration, Instant}; + + use futures::future::{join4, select, Either}; + use futures::pin_mut; + + const WATCHDOG: Duration = Duration::from_secs(20); + + let (a_stack, mut a_net, a_ch) = make_stack(A_IP, 0x1111_2222); + let (b_stack, mut b_net, b_ch) = make_stack(B_IP, 0x3333_4444); + + let (a_state, a_rx, a_tx) = a_ch.split(); + let (b_state, b_rx, b_tx) = b_ch.split(); + a_state.set_link_state(LinkState::Up); + b_state.set_link_state(LinkState::Up); + + let background = join4( + a_net.run(), + b_net.run(), + cable(a_tx, b_rx), + cable(b_tx, a_rx), + ); + let foreground = foreground(a_stack, b_stack); + + futures::executor::block_on(async { + pin_mut!(foreground); + pin_mut!(background); + let session = select(foreground, background); + pin_mut!(session); + + let deadline = Instant::now() + WATCHDOG; + let watchdog = poll_fn(move |cx| { + if Instant::now() >= deadline { + Poll::Ready(()) + } else { + cx.waker().wake_by_ref(); + Poll::Pending + } + }); + pin_mut!(watchdog); + + match select(session, watchdog).await { + Either::Left((Either::Left(_), _)) => Ok(()), + Either::Left((Either::Right(_), _)) => Err("background ended before the test"), + Either::Right(_) => Err("watchdog: foreground stuck"), + } + }) +} + +/// A datagram round trip, with both ends reporting a routable bound address — +/// what a tunnel handshake advertises instead of `0.0.0.0:0`. +#[test] +fn udp_round_trips_and_reports_a_real_bound_address() { + let outcome = drive(|a_stack, b_stack| async move { + let a_binder = EmbassyNet::udp(a_stack, meta(), buf(), meta(), buf()); + let b_binder = EmbassyNet::udp(b_stack, meta(), buf(), meta(), buf()); + + let mut a = a_binder.bind(3671).await.expect("bind A"); + let mut b = b_binder.bind(3672).await.expect("bind B"); + + let a_addr = a.local_addr().expect("A must report a bound address"); + let b_addr = b.local_addr().expect("B must report a bound address"); + assert_eq!(a_addr, core::net::SocketAddr::new(A_IP.into(), 3671)); + assert_eq!(b_addr, core::net::SocketAddr::new(B_IP.into(), 3672)); + + a.send_to(b"tunnel", b_addr).await.expect("A send"); + + let mut got = [0u8; 32]; + let (n, from) = b.recv_from(&mut got).await.expect("B recv"); + assert_eq!(&got[..n], b"tunnel"); + assert_eq!(from, a_addr, "source address must be the sender's"); + }); + assert_eq!(outcome, Ok(())); +} + +/// A socket reset drops the socket and binds a fresh one on the same binder — +/// the cycle `Action::ResetSocket` drives. The buffers are reused, so the +/// rebound socket must still work. +#[test] +fn a_binder_rebinds_after_its_socket_is_dropped() { + let outcome = drive(|a_stack, b_stack| async move { + let a_binder = EmbassyNet::udp(a_stack, meta(), buf(), meta(), buf()); + let b_binder = EmbassyNet::udp(b_stack, meta(), buf(), meta(), buf()); + let mut b = b_binder.bind(3672).await.expect("bind B"); + let b_addr = b.local_addr().expect("B bound address"); + + let first = a_binder.bind(3671).await.expect("first bind"); + drop(first); + + let mut second = a_binder.bind(3673).await.expect("rebind on a new port"); + assert_eq!(second.local_addr().unwrap().port(), 3673); + + second.send_to(b"after-reset", b_addr).await.expect("send"); + let mut got = [0u8; 32]; + let (n, from) = b.recv_from(&mut got).await.expect("recv"); + assert_eq!(&got[..n], b"after-reset"); + assert_eq!(from.port(), 3673, "the rebound port must be on the wire"); + }); + assert_eq!(outcome, Ok(())); +} + +/// The binder owns exactly one socket, so a second bind while the first is +/// live is refused rather than silently sharing. +#[test] +fn a_second_bind_fails_while_the_socket_is_held() { + let outcome = drive(|a_stack, _b_stack| async move { + let binder = EmbassyNet::udp(a_stack, meta(), buf(), meta(), buf()); + let _held = binder.bind(3671).await.expect("first bind"); + assert!(binder.bind(3672).await.is_err(), "socket is already taken"); + }); + assert_eq!(outcome, Ok(())); +} From 2247918bd4679218bc540cb4abe7b887ec94647a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 19:45:13 +0000 Subject: [PATCH 09/19] feat(tunnel): update TunnelIo trait to use non-async send method with Send future wrapper --- aimdb-knx-connector/src/embassy_client.rs | 25 ++++++++++++++--------- aimdb-knx-connector/src/tunnel.rs | 18 +++++++++++++++- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 7a636cdc..7e2b3ddc 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -51,6 +51,7 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::session::{pump_sink, pump_source, Payload}; use aimdb_core::ConnectorBuilder; use aimdb_embassy_adapter::connectors::into_box_future; +use aimdb_embassy_adapter::SendFutureWrapper; use alloc::boxed::Box; use alloc::string::{String, ToString}; use alloc::sync::Arc; @@ -361,16 +362,20 @@ struct EmbassyIo<'a, 'b> { } impl TunnelIo for EmbassyIo<'_, '_> { - async fn send(&mut self, frame: &[u8]) -> bool { - // Log-and-continue: a transient send error must not tear down the - // tunnel; a persistently dead send path surfaces through the engine's - // heartbeat-response timeout. - if self.socket.send_to(frame, self.gateway).await.is_err() { - #[cfg(feature = "defmt")] - defmt::error!("KNX send failed"); - return false; - } - true + fn send(&mut self, frame: &[u8]) -> impl Future + Send { + // `embassy_net`'s send future is `!Send`; the wrapper is the adapter's + // audited force-`Send`, same single-core invariant as everywhere else. + SendFutureWrapper(async move { + // Log-and-continue: a transient send error must not tear down the + // tunnel; a persistently dead send path surfaces through the + // engine's heartbeat-response timeout. + if self.socket.send_to(frame, self.gateway).await.is_err() { + #[cfg(feature = "defmt")] + defmt::error!("KNX send failed"); + return false; + } + true + }) } fn forward(&mut self, addr: GroupAddress, payload: Vec) { diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs index 2317ab11..bf7fae4d 100644 --- a/aimdb-knx-connector/src/tunnel.rs +++ b/aimdb-knx-connector/src/tunnel.rs @@ -542,7 +542,11 @@ pub(crate) trait TunnelIo { /// path surfaces through the heartbeat-response timeout /// ([`TunnelConfig::heartbeat_response_timeout_ms`]), which drops the /// connection even when the recv path never errors. - async fn send(&mut self, frame: &[u8]) -> bool; + /// + /// `+ Send` on the return type, not an `async fn`: `drain_actions` is + /// generic over this trait and its future has to be boxable as the + /// runner's `Send` future. An impl whose socket future is `!Send` wraps it. + fn send(&mut self, frame: &[u8]) -> impl core::future::Future + Send; /// Forward a parsed telegram toward `pump_source`. Non-blocking: /// drop + log on a full channel rather than stalling the protocol loop. fn forward(&mut self, addr: GroupAddress, payload: Vec); @@ -746,6 +750,18 @@ fn parse_telegram(cemi_data: &[u8]) -> Option<(GroupAddress, Vec)> { mod tests { use super::*; + /// `drain_actions` is generic over [`TunnelIo`] and the runner boxes its + /// future as `Send`. Type-checked from the bounds alone, so dropping + /// `+ Send` from `TunnelIo::send`'s return type stops this compiling. + #[allow(dead_code)] + fn drain_actions_future_is_send_in_generic_code( + engine: &mut TunnelEngine, + io: &mut Io, + ) { + fn assert_send(_: T) {} + assert_send(drain_actions(engine, io)); + } + /// Legacy-mode config: no retransmits, expire-and-warn only. Most tests /// pin this pre-retransmit contract; the retransmit tests use /// [`RETRANSMIT_CFG`]. From 691430a51a872ab55406cc14178ad3fe0c74b778 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 19:54:45 +0000 Subject: [PATCH 10/19] feat(knx-connector): add critical-section dependency and tests for shared channel on std --- Cargo.lock | 1 + aimdb-knx-connector/Cargo.toml | 19 +++++-- .../tests/shared_channel_on_std.rs | 52 +++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) create mode 100644 aimdb-knx-connector/tests/shared_channel_on_std.rs diff --git a/Cargo.lock b/Cargo.lock index fee544ac..47f85111 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -209,6 +209,7 @@ dependencies = [ "aimdb-knx-pico", "aimdb-tokio-adapter", "async-stream", + "critical-section", "defmt 1.1.1", "embassy-executor", "embassy-futures 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index ebd7fdf0..8125a06b 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -14,7 +14,15 @@ categories = ["network-programming", "embedded", "asynchronous"] [features] default = ["aimdb-core/alloc"] std = ["aimdb-core/std", "knx-pico/std", "thiserror"] -tokio-runtime = ["std", "tokio", "uuid", "async-stream", "futures-util"] +tokio-runtime = [ + "std", + "tokio", + "uuid", + "async-stream", + "futures-util", + "embassy-sync", + "critical-section/std", +] embassy-runtime = [ "aimdb-core/alloc", # Need alloc for collect_inbound_routes "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` @@ -25,7 +33,6 @@ embassy-runtime = [ "embassy-time", "embassy-sync", "embassy-net", - "embassy-futures", "static_cell", ] # Design 050 §10.4/§10.5: the facade reaches both destinations through @@ -72,7 +79,9 @@ futures-core = { version = "0.3", default-features = false } embassy-executor = { version = "0.10.0", optional = true } embassy-time = { version = "0.5.1", optional = true } embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } -embassy-futures = { version = "0.1.2", optional = true } +# Unconditional: its `[dependencies]` is empty bar optional defmt/log, so the +# std graph is unaffected and one select loop serves both runtimes. +embassy-futures = { version = "0.1.2" } embassy-net = { version = "0.9.0", optional = true, features = [ "tcp", "udp", @@ -81,6 +90,10 @@ embassy-net = { version = "0.9.0", optional = true, features = [ "proto-ipv4", ] } +# A `critical-section` impl must be linked wherever `CriticalSectionRawMutex` +# is used; `tokio-runtime` turns on the std one so no std user has to. +critical-section = { version = "1.1", optional = true } + # Embedded utilities (heapless is unconditional: the shared sans-io tunnel # engine uses stack-allocated frames on both runtimes) heapless = { workspace = true } diff --git a/aimdb-knx-connector/tests/shared_channel_on_std.rs b/aimdb-knx-connector/tests/shared_channel_on_std.rs new file mode 100644 index 00000000..bf07764a --- /dev/null +++ b/aimdb-knx-connector/tests/shared_channel_on_std.rs @@ -0,0 +1,52 @@ +//! The MCU's channel and select types must work in a **linked** std binary, so +//! one connection task can serve both runtimes. +//! +//! `CriticalSectionRawMutex` is the only `Sync` raw mutex `embassy-sync` offers +//! — `NoopRawMutex` is `!Sync` and cannot back a shared channel at all — and +//! using it pulls in `_critical_section_1_0_acquire`/`_release`, which nothing +//! defines on std. The `tokio-runtime` feature enables `critical-section/std` +//! so no downstream user meets that link error. These tests fail to *link*, not +//! to compile, if that ever comes undone. +#![cfg(feature = "tokio-runtime")] + +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; + +type Cmd = (u8, u16); + +/// A send and receive through the channel, exercising the critical section. +#[tokio::test] +async fn embassy_channel_carries_a_value_on_std() { + let channel: Channel = Channel::new(); + + channel.send((1, 0x0a0b)).await; + assert_eq!(channel.receive().await, (1, 0x0a0b)); +} + +/// The channel is `Send + Sync`, so a spawned task can enqueue while the +/// protocol loop drains — what the unified connection task relies on. +#[tokio::test] +async fn embassy_channel_is_shareable_across_tasks_on_std() { + static CHANNEL: Channel = Channel::new(); + + let producer = tokio::spawn(async { + CHANNEL.send((2, 0x0c0d)).await; + }); + + assert_eq!(CHANNEL.receive().await, (2, 0x0c0d)); + producer.await.expect("producer task"); +} + +/// `embassy-futures` drives the same select on std as on the MCU. +#[tokio::test] +async fn embassy_select_resolves_on_std() { + use embassy_futures::select::{select, Either}; + + let channel: Channel = Channel::new(); + channel.send((3, 0x0e0f)).await; + + match select(channel.receive(), core::future::pending::<()>()).await { + Either::First(cmd) => assert_eq!(cmd, (3, 0x0e0f)), + Either::Second(()) => panic!("pending future must never win"), + } +} From a64e999faa72026b989c12c9eab61e55c89cc4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 20:20:53 +0000 Subject: [PATCH 11/19] feat(serial-connector): add neutral COBS framer and tests for Tokio byte source --- aimdb-serial-connector/Cargo.toml | 17 +-- .../src/embassy_transport.rs | 2 +- aimdb-serial-connector/src/lib.rs | 11 +- aimdb-serial-connector/src/neutral.rs | 91 ++++++++++++++++ .../tests/neutral_framed.rs | 103 ++++++++++++++++++ 5 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 aimdb-serial-connector/src/neutral.rs create mode 100644 aimdb-serial-connector/tests/neutral_framed.rs diff --git a/aimdb-serial-connector/Cargo.toml b/aimdb-serial-connector/Cargo.toml index 0e333684..fa3f1375 100644 --- a/aimdb-serial-connector/Cargo.toml +++ b/aimdb-serial-connector/Cargo.toml @@ -29,6 +29,10 @@ tokio-runtime = [ "aimdb-core/remote", "dep:tokio", "dep:tokio-serial", + # The adapter owns the byte source, as it does on Embassy; this crate + # contributes only the COBS framer. + "dep:aimdb-tokio-adapter", + "aimdb-tokio-adapter/net", ] # Embedded half: `no_std + alloc`, generic over `embedded-io-async` UART halves. @@ -41,6 +45,8 @@ embassy-runtime = [ "aimdb-core/remote", "dep:aimdb-embassy-adapter", "aimdb-embassy-adapter/connector-io", + # `EmbassyUart`, the neutral byte source the COBS framer rides. + "aimdb-embassy-adapter/net", # `embassy-time`/`-sync` give the `EmbassyAdapter` clock the smoke test runs on. "aimdb-embassy-adapter/embassy-time", "aimdb-embassy-adapter/embassy-sync", @@ -55,13 +61,10 @@ tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] defmt = ["dep:defmt", "aimdb-core/defmt"] -# Internal: host tokio integration tests need a concrete adapter. Kept off the -# public `tokio-runtime` feature (a connector shouldn't pull an adapter in -# production) and out of `[dev-dependencies]` (an unconditional tokio adapter -# would force `aimdb-core/std` into the no_std `embassy-runtime` test build, where -# the no_std `aimdb-embassy-adapter` can't compile against it). Run the tokio -# tests with `--features _test-tokio`. -_test-tokio = ["tokio-runtime", "dep:aimdb-tokio-adapter"] +# Retained as an alias: `tokio-runtime` now carries the adapter itself, but the +# host integration test and `serial_demo` still gate on this name. Kept out of +# `[dev-dependencies]` so the no_std `embassy-runtime` test build never sees it. +_test-tokio = ["tokio-runtime"] [dependencies] # AimX protocol (codec + dispatch) and the generic session connectors live in diff --git a/aimdb-serial-connector/src/embassy_transport.rs b/aimdb-serial-connector/src/embassy_transport.rs index e982371f..96ee9035 100644 --- a/aimdb-serial-connector/src/embassy_transport.rs +++ b/aimdb-serial-connector/src/embassy_transport.rs @@ -6,7 +6,7 @@ //! dialer/listener/cell, and the force-`Send` plumbing all live in //! [`aimdb_embassy_adapter::connectors`]. So this module carries **no `unsafe`** //! (down from the seven `unsafe impl`s this half used to hand-roll) — the Embassy -//! half is now structurally a sibling of the [Tokio half](crate::tokio_transport), +//! half is now structurally a sibling of the Tokio half, //! both thin sugar over a shared spine. //! //! Generic over the `embedded-io-async` `Read`/`Write` halves (the common Embassy diff --git a/aimdb-serial-connector/src/lib.rs b/aimdb-serial-connector/src/lib.rs index 6b25218a..d67da022 100644 --- a/aimdb-serial-connector/src/lib.rs +++ b/aimdb-serial-connector/src/lib.rs @@ -17,12 +17,12 @@ //! - **`tokio-runtime`** (std, host/gateway): real serial via `tokio-serial`, //! riding the generic [`SessionClientConnector`](aimdb_core::session::SessionClientConnector) //! / [`SessionServerConnector`](aimdb_core::session::SessionServerConnector). -//! See [`tokio_transport`]. +//! See `tokio_transport`. //! - **`embassy-runtime`** (`no_std + alloc`, MCU): generic over -//! [`embedded_io_async`] UART halves; the COBS `Framer` plus thin sugar over the +//! `embedded-io-async` UART halves; the COBS `Framer` plus thin sugar over the //! centralized Embassy session spine in `aimdb-embassy-adapter`, which owns the //! force-`Send` plumbing, the framed connection, and all the `unsafe` — this -//! crate carries none. See [`embassy_transport`]. +//! crate carries none. See `embassy_transport`. //! //! Both speak the `serial://` scheme by default ([`DEFAULT_SCHEME`]). @@ -32,6 +32,11 @@ extern crate alloc; pub mod framing; +// The COBS framer against core's `Framer`, plus one `ByteStream` per byte +// source — the runtime-neutral replacement for the two transport modules. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub mod neutral; + #[cfg(feature = "tokio-runtime")] pub mod tokio_transport; diff --git a/aimdb-serial-connector/src/neutral.rs b/aimdb-serial-connector/src/neutral.rs new file mode 100644 index 00000000..7006cf52 --- /dev/null +++ b/aimdb-serial-connector/src/neutral.rs @@ -0,0 +1,91 @@ +//! The serial connector reduced to framing: [`CobsFramer`] plus core's +//! `FramedConnection` serve both runtimes. +//! +//! The byte sources come from the adapters — `TokioByteStream` and +//! `EmbassyUart` — so this crate contributes only the framer and names no +//! socket or UART type of its own. + +use aimdb_core::session::Framer; +use alloc::vec::Vec; + +use crate::framing::{encode_frame, FrameAccumulator}; + +/// Per-`read` chunk, matching the UART ring size. +pub const READ_CHUNK: usize = 64; +/// Per-`write_all` chunk: some HAL `BufferedUart::write` rejects a single write +/// larger than its TX ring. +pub const WRITE_CHUNK: usize = 64; + +/// COBS framing against core's [`Framer`], so one framer serves both runtimes. +/// +/// `encode` COBS-encodes a frame and appends the `0x00` sentinel; the +/// accumulator yields one frame per sentinel, skipping a malformed run (COBS is +/// self-synchronizing). +#[derive(Default)] +pub struct CobsFramer { + acc: FrameAccumulator, +} + +impl CobsFramer { + /// A fresh COBS framer. + pub fn new() -> Self { + Self::default() + } +} + +impl Framer for CobsFramer { + fn encode(&self, frame: &[u8], out: &mut Vec) { + encode_frame(frame, out); + } + + fn push_bytes(&mut self, bytes: &[u8]) { + self.acc.push_bytes(bytes); + } + + fn next_frame(&mut self) -> Option, ()>> { + // `FrameError` collapses to `()`: the connection only distinguishes + // "got a frame" from "skip and resync". + self.acc.next_frame().map(|r| r.map_err(|_| ())) + } +} + +/// A framed connection over the Embassy adapter's UART halves. +#[cfg(feature = "embassy-runtime")] +pub type EmbassyFramed = aimdb_core::session::FramedConnection< + aimdb_embassy_adapter::net::EmbassyUart, + CobsFramer, + READ_CHUNK, + WRITE_CHUNK, +>; + +/// A framed connection over any Tokio byte source — a `tokio_serial::SerialStream` +/// in production, a `tokio::io::duplex()` pipe in tests. +#[cfg(feature = "tokio-runtime")] +pub type TokioFramed = aimdb_core::session::FramedConnection< + aimdb_tokio_adapter::net::TokioByteStream, + CobsFramer, + READ_CHUNK, + WRITE_CHUNK, +>; + +/// The same framer and the same core connection over the Embassy UART, boxed as +/// the runner takes it. +/// +/// Type-checking this on `thumbv7em` is what "one connector module, no runtime +/// `cfg` on the code path" means concretely: if the two paths diverge, the +/// embedded check fails here rather than in an example. +#[cfg(feature = "embassy-runtime")] +#[allow(dead_code)] +fn _same_framed_connection_serves_the_uart(rx: Rd, tx: Wr) +where + Rd: embedded_io_async::Read + Send + 'static, + Wr: embedded_io_async::Write + Send + 'static, +{ + use aimdb_core::session::Connection; + use aimdb_embassy_adapter::net::EmbassyUart; + use alloc::boxed::Box; + + let conn: EmbassyFramed = + EmbassyFramed::new(EmbassyUart::new(rx, tx), CobsFramer::new()); + let _boxed: Box = Box::new(conn); +} diff --git a/aimdb-serial-connector/tests/neutral_framed.rs b/aimdb-serial-connector/tests/neutral_framed.rs new file mode 100644 index 00000000..f0c1cabe --- /dev/null +++ b/aimdb-serial-connector/tests/neutral_framed.rs @@ -0,0 +1,103 @@ +//! The COBS framer and core's `FramedConnection` over the Tokio adapter's byte +//! stream — the same pairing the Embassy side gets from `EmbassyUart`. +#![cfg(feature = "tokio-runtime")] + +use aimdb_core::session::Connection; +use aimdb_serial_connector::neutral::{CobsFramer, TokioFramed, WRITE_CHUNK}; +use aimdb_tokio_adapter::net::TokioByteStream; + +/// A duplex pipe standing in for a `SerialStream`, framed at both ends. +fn pipe() -> ( + TokioFramed, + TokioFramed, +) { + let (a, b) = tokio::io::duplex(8 * 1024); + ( + TokioFramed::new(TokioByteStream(a), CobsFramer::new()), + TokioFramed::new(TokioByteStream(b), CobsFramer::new()), + ) +} + +#[tokio::test] +async fn frames_round_trip_in_both_directions() { + let (mut a, mut b) = pipe(); + + a.send(b"{\"m\":\"hello\"}").await.expect("a send"); + assert_eq!( + b.recv().await.expect("b recv"), + Some(b"{\"m\":\"hello\"}".to_vec()) + ); + + b.send(b"{\"m\":\"pong\"}").await.expect("b send"); + assert_eq!( + a.recv().await.expect("a recv"), + Some(b"{\"m\":\"pong\"}".to_vec()) + ); +} + +/// Frame boundaries survive back-to-back sends: COBS delimits on `0x00`, so +/// several frames can arrive in one read. +#[tokio::test] +async fn back_to_back_frames_stay_separate() { + let (mut a, mut b) = pipe(); + + for i in 0u8..4 { + a.send(&[i, i, i]).await.expect("send"); + } + for i in 0u8..4 { + assert_eq!(b.recv().await.expect("recv"), Some(vec![i, i, i])); + } +} + +/// A payload larger than `WRITE_CHUNK` is split across writes and reassembled +/// across reads — the chunking loops on both sides of the connection. +#[tokio::test] +async fn a_payload_larger_than_the_chunk_survives() { + let (mut a, mut b) = pipe(); + + let payload: Vec = (0..(WRITE_CHUNK * 5 + 7)) + .map(|i| (i % 251) as u8) + .collect(); + a.send(&payload).await.expect("send"); + assert_eq!(b.recv().await.expect("recv"), Some(payload)); +} + +/// A payload full of `0x00` — the COBS delimiter — must not be mistaken for +/// frame boundaries. +#[tokio::test] +async fn a_payload_of_delimiters_is_not_split() { + let (mut a, mut b) = pipe(); + + let payload = vec![0u8; 200]; + a.send(&payload).await.expect("send"); + assert_eq!(b.recv().await.expect("recv"), Some(payload)); +} + +/// A closed peer reads as `Ok(None)`, which is how the session engines detect +/// a hangup. +#[tokio::test] +async fn a_closed_peer_reads_as_end_of_stream() { + let (a, mut b) = pipe(); + drop(a); + assert_eq!(b.recv().await.expect("recv"), None); +} + +/// The connection crosses a `tokio::spawn` as a boxed `dyn Connection` — the +/// shape `ConnectorBuilder::build` hands the runner. +#[tokio::test] +async fn a_boxed_connection_crosses_a_spawn() { + let (mut a, b) = pipe(); + let mut boxed: Box = Box::new(b); + + let echo = tokio::spawn(async move { + let frame = boxed.recv().await.expect("recv").expect("frame"); + boxed.send(&frame).await.expect("send"); + }); + + a.send(b"across-threads").await.expect("send"); + assert_eq!( + a.recv().await.expect("recv"), + Some(b"across-threads".to_vec()) + ); + echo.await.expect("echo task"); +} From b64df52bbea3d85d401847e2faa00f2f1c336586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 20:33:40 +0000 Subject: [PATCH 12/19] feat(knx-connector): add neutral connection task for runtime-independent datagram handling --- aimdb-knx-connector/Cargo.toml | 2 + aimdb-knx-connector/src/lib.rs | 5 + aimdb-knx-connector/src/neutral.rs | 435 +++++++++++++++++++++++++++++ 3 files changed, 442 insertions(+) create mode 100644 aimdb-knx-connector/src/neutral.rs diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 8125a06b..5c340442 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -107,6 +107,8 @@ tokio = { workspace = true, features = ["full"] } tokio-test = "0.4" aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", + # `TokioNet`/`TokioDelay`, the neutral transports the unified task runs on. + "net", ] } [package.metadata.docs.rs] diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index da47c0cc..c5750676 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -150,6 +150,11 @@ pub use knx_pico::dpt::{Dpt1, Dpt5, Dpt9, DptDecode, DptEncode}; pub mod tunnel; // Platform-specific implementations +// One connection task generic over core's neutral datagram traits — the +// runtime-independent replacement for the two client modules. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub mod neutral; + #[cfg(feature = "tokio-runtime")] pub mod tokio_client; diff --git a/aimdb-knx-connector/src/neutral.rs b/aimdb-knx-connector/src/neutral.rs new file mode 100644 index 00000000..1fa62af3 --- /dev/null +++ b/aimdb-knx-connector/src/neutral.rs @@ -0,0 +1,435 @@ +//! One KNX connection task, generic over core's [`DatagramBinder`] and +//! [`Delay`], replacing the two hand-written socket loops. +//! +//! The clock stays `RuntimeOps::now_nanos`, a plain call; only *sleeping* goes +//! through [`Delay`], so nothing is boxed per loop iteration. +//! +//! The `embassy-sync` and `embassy-futures` types below are executor-independent +//! — neither pulls an executor, and both build on std — so they back this task +//! on either runtime. + +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; +use core::future::Future; +use core::net::SocketAddr; +use core::time::Duration; + +use aimdb_core::session::{Datagram, DatagramBinder, Delay, Payload}; +use aimdb_core::{log_debug, log_error, log_warn, RuntimeOps}; + +use crate::tunnel::{ + drain_actions, GroupWrite, LocalEndpoint, TunnelConfig, TunnelEngine, TunnelIo, +}; +use crate::GroupAddress; + +/// Backoff before retrying a bind that failed. +const BIND_RETRY: Duration = Duration::from_secs(5); +/// Per-datagram receive buffer; a KNXnet/IP frame fits comfortably. +const RECV_BUF: usize = 512; + +/// Where parsed telegrams go — an `embassy_sync` channel on either runtime. +/// +/// Non-blocking by contract: a full sink drops rather than stalling the +/// protocol loop. +pub trait TelegramSink { + /// Enqueue one `(group-address, payload)`. `false` if it was dropped. + fn try_send(&self, topic: String, payload: Payload) -> bool; +} + +/// Where outbound commands come from, the dual of [`TelegramSink`]. +pub trait CommandSource { + /// Yield the next command. Parks forever once no producer remains, so the + /// select arm goes quiet instead of ending the task. + fn recv(&mut self) -> impl Future + Send + '_; +} + +/// The socket-side glue for [`drain_actions`], written once against +/// [`Datagram`] instead of once per runtime. +struct NeutralIo<'a, U, S> { + socket: &'a mut U, + gateway: SocketAddr, + sink: &'a S, +} + +impl TunnelIo for NeutralIo<'_, U, S> +where + U: Datagram + Send, + S: TelegramSink + Sync, +{ + // A plain `async fn` satisfies the trait's `+ Send` return bound — the + // adapter's `Datagram` impl already carries whatever force-`Send` its + // runtime needs. + async fn send(&mut self, frame: &[u8]) -> bool { + // Log-and-continue: a transient send error must not tear down the + // tunnel; a persistently dead send path surfaces through the engine's + // heartbeat-response timeout. + match self.socket.send_to(frame, self.gateway).await { + Ok(()) => true, + Err(_) => { + log_error!("KNX send failed"); + false + } + } + } + + fn forward(&mut self, addr: GroupAddress, payload: Vec) { + log_debug!("KNX telegram: {} ({} bytes)", addr, payload.len()); + if !self.sink.try_send(addr.to_string(), Payload::from(payload)) { + log_warn!("KNX inbound: dropping telegram for {} (sink full)", addr); + } + } + + fn warn_ack_timeout(&mut self, _seq: u8) { + log_warn!("KNX outbound: no ACK for sequence {}", _seq); + } +} + +/// Drive the engine over one socket's lifetime; returns when it asks for a reset. +async fn drive_connection( + engine: &mut TunnelEngine, + socket: &mut U, + gateway: SocketAddr, + runtime: &Arc, + delay: &D, + sink: &S, + commands: &mut C, +) where + U: Datagram + Send, + D: Delay, + S: TelegramSink + Sync, + C: CommandSource, +{ + // Executor-independent despite the name: `embassy-futures` has no + // dependencies and its select is pure `core::task`. + use embassy_futures::select::{select3, Either3}; + + let now_ms = || runtime.now_nanos() / 1_000_000; + + loop { + engine.poll(now_ms()); + + { + let mut io = NeutralIo { + socket, + gateway, + sink, + }; + if drain_actions(engine, &mut io).await { + return; + } + } + + let sleep_ms = engine.next_deadline().saturating_sub(now_ms()); + let deadline = delay.sleep(Duration::from_millis(sleep_ms)); + let mut recv_buf = [0u8; RECV_BUF]; + + // Only drain commands while connected: during connect and backoff the + // arm stays pending, so commands queue and flush once the handshake + // completes — as both hand-written clients do. + let connected = engine.is_connected(); + let cmd_arm = async { + if connected { + commands.recv().await + } else { + core::future::pending().await + } + }; + + match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { + Either3::First(Ok((len, _peer))) => { + engine.handle_datagram(&recv_buf[..len], now_ms()); + } + Either3::First(Err(_)) => engine.handle_socket_error(now_ms()), + Either3::Second(cmd) => { + let _ = engine.handle_command(cmd, now_ms()); + } + // Woken for the engine deadline; `poll` at the loop top fires it. + Either3::Third(()) => {} + } + } +} + +/// The unified connection task: one body, both runtimes. +/// +/// Binds a socket, advertises its real local endpoint when the stack exposes +/// one, drives the shared [`TunnelEngine`] over that socket's lifetime, then +/// rebinds after the engine's backoff. +pub async fn connection_task( + binder: B, + gateway: SocketAddr, + runtime: Arc, + delay: D, + sink: S, + mut commands: C, +) where + B: DatagramBinder, + D: Delay, + S: TelegramSink + Sync, + C: CommandSource, +{ + let now_ms = || runtime.now_nanos() / 1_000_000; + let mut engine = TunnelEngine::new(TunnelConfig::default(), now_ms()); + + loop { + let mut socket = match binder.bind(0).await { + Ok(socket) => socket, + Err(_) => { + log_error!("KNX bind failed; retrying"); + delay.sleep(BIND_RETRY).await; + continue; + } + }; + + // The handshake advertises the client's own endpoint (HPAI). Gateways + // that reject the NAT-style `0.0.0.0:0` form need the real address. + if let Some(SocketAddr::V4(addr)) = socket.local_addr() { + engine.set_local_endpoint(LocalEndpoint::Explicit { + ip: addr.ip().octets(), + port: addr.port(), + }); + } + + drive_connection( + &mut engine, + &mut socket, + gateway, + &runtime, + &delay, + &sink, + &mut commands, + ) + .await; + + // Dropping the socket releases it to the binder, so the next iteration + // rebinds — `Action::ResetSocket`, honoured neutrally. + drop(socket); + + let wait_ms = engine.next_deadline().saturating_sub(now_ms()); + delay.sleep(Duration::from_millis(wait_ms)).await; + } +} + +/// Channel bridges over `embassy_sync`, which is executor-independent, so the +/// same types back the task on both runtimes. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub mod shared_channel { + use super::{CommandSource, GroupWrite, Payload, TelegramSink}; + use alloc::string::String; + use core::future::Future; + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; + use embassy_sync::channel::{Receiver, Sender}; + + /// Sending half of the inbound-telegram channel. + pub struct ChannelSink<'a, const N: usize>( + pub Sender<'a, CriticalSectionRawMutex, (String, Payload), N>, + ); + + impl TelegramSink for ChannelSink<'_, N> { + fn try_send(&self, topic: String, payload: Payload) -> bool { + self.0.try_send((topic, payload)).is_ok() + } + } + + /// Receiving half of the outbound-command channel. + pub struct ChannelCommands<'a, const N: usize>( + pub Receiver<'a, CriticalSectionRawMutex, GroupWrite, N>, + ); + + impl CommandSource for ChannelCommands<'_, N> { + fn recv(&mut self) -> impl Future + Send + '_ { + self.0.receive() + } + } +} + +#[cfg(all(test, feature = "tokio-runtime"))] +mod tests { + use super::*; + use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; + use aimdb_tokio_adapter::TokioAdapter; + use core::pin::Pin; + use std::net::Ipv4Addr; + use std::sync::Mutex; + + /// Collects forwarded telegrams; `Sync`, as [`TelegramSink`] requires. + #[derive(Default)] + struct VecSink(Mutex>); + + impl TelegramSink for VecSink { + fn try_send(&self, topic: String, payload: Payload) -> bool { + self.0.lock().expect("sink mutex").push((topic, payload)); + true + } + } + + /// No outbound producer: the command arm never fires. + struct NoCommands; + + impl CommandSource for NoCommands { + fn recv(&mut self) -> impl Future + Send + '_ { + core::future::pending() + } + } + + fn runtime() -> Arc { + Arc::new(TokioAdapter::new().expect("tokio adapter")) + } + + const RECV_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); + + /// `ConnectorBuilder::build` hands the runner a + /// `Pin + Send + 'static>>`. Everything that + /// declares `+ Send` on a return type does so to make this line compile for + /// a *generic* task. + #[test] + fn unified_task_is_boxable_as_the_runners_send_future() { + let task = connection_task( + TokioNet::udp(Ipv4Addr::LOCALHOST), + "127.0.0.1:3671".parse().expect("gateway addr"), + runtime(), + TokioDelay, + VecSink::default(), + NoCommands, + ); + let _boxed: Pin + Send + 'static>> = Box::pin(task); + } + + /// The handshake must advertise the socket's real bound address, not the + /// NAT-style `0.0.0.0:0` some gateways reject. Control HPAI is + /// `[len, proto, ip(4), port(2)]` at offset 6, so the address is + /// `buf[8..12]` and the port `buf[12..14]`. + #[tokio::test] + async fn unified_task_advertises_the_real_local_endpoint() { + let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind fake gateway"); + let gateway_addr = gateway.local_addr().expect("gateway addr"); + + let task = tokio::spawn(connection_task( + TokioNet::udp(Ipv4Addr::LOCALHOST), + gateway_addr, + runtime(), + TokioDelay, + VecSink::default(), + NoCommands, + )); + + let mut buf = [0u8; 128]; + let (len, from) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("gateway received no CONNECT_REQUEST") + .expect("recv_from"); + + assert!(len >= 14, "CONNECT_REQUEST should carry both HPAIs"); + assert_ne!( + &buf[8..12], + &[0, 0, 0, 0], + "advertised the NAT-style HPAI: local_addr did not reach the wire" + ); + assert_eq!(&buf[8..12], &[127, 0, 0, 1], "advertised IP"); + assert_eq!( + u16::from_be_bytes([buf[12], buf[13]]), + from.port(), + "advertised port must be the socket's real bound port" + ); + + task.abort(); + } + + /// The unified task on Tokio, moving real telegrams through the *same* + /// `embassy_sync` channel types the MCU uses: a full handshake, an inbound + /// telegram with its ACK, and an outbound command. + #[tokio::test] + async fn shared_embassy_channels_carry_telegrams_on_tokio() { + use super::shared_channel::{ChannelCommands, ChannelSink}; + use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; + use embassy_sync::channel::Channel; + + const N: usize = 8; + // Leaked for `'static` borrows, as a `StaticCell` gives on the MCU. + let inbound: &'static Channel = + Box::leak(Box::new(Channel::new())); + let commands: &'static Channel = + Box::leak(Box::new(Channel::new())); + + let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind gateway"); + let gateway_addr = gateway.local_addr().expect("gateway addr"); + + let task = tokio::spawn(connection_task( + TokioNet::udp(Ipv4Addr::LOCALHOST), + gateway_addr, + runtime(), + TokioDelay, + ChannelSink::(inbound.sender()), + ChannelCommands::(commands.receiver()), + )); + + let mut buf = [0u8; 1024]; + + // Handshake. + let (_, client_addr) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no CONNECT_REQUEST") + .expect("recv_from"); + assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0205); + + let mut connect_response = vec![0x06, 0x10, 0x02, 0x06, 0x00, 0x14]; + connect_response.extend_from_slice(&[7, 0]); + connect_response.extend_from_slice(&[0x08, 0x01, 0, 0, 0, 0, 0, 0]); + connect_response.extend_from_slice(&[0x04, 0x04, 0x02, 0x00]); + gateway + .send_to(&connect_response, client_addr) + .await + .expect("send CONNECT_RESPONSE"); + + // Inbound telegram -> ACK on the wire, payload on the shared channel. + let cemi = [ + 0x29, 0x00, 0xBC, 0xE0, 0x00, 0x00, 0x08, 0x07, 0x01, 0x00, 0x81, + ]; + let total = 6 + 4 + cemi.len() as u16; + let mut telegram = vec![0x06, 0x10, 0x04, 0x20]; + telegram.extend_from_slice(&total.to_be_bytes()); + telegram.extend_from_slice(&[0x04, 7, 42, 0x00]); + telegram.extend_from_slice(&cemi); + gateway + .send_to(&telegram, client_addr) + .await + .expect("send telegram"); + + tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_ACK") + .expect("recv_from"); + assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0421); + assert_eq!(buf[8], 42, "sequence echoed"); + + let (topic, payload) = tokio::time::timeout(RECV_TIMEOUT, inbound.receive()) + .await + .expect("no telegram reached the embassy-sync channel"); + assert_eq!(topic, "1/0/7"); + assert_eq!(&payload[..], &[0x01]); + + // Outbound: a command through the shared channel reaches the wire. + let mut data = heapless::Vec::new(); + data.push(0x01).expect("push"); + commands + .send(GroupWrite { + group_addr: "1/0/8".parse().expect("group address"), + data, + }) + .await; + + let (len, _) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_REQUEST") + .expect("recv_from"); + assert_eq!(u16::from_be_bytes([buf[2], buf[3]]), 0x0420); + assert_eq!(&buf[16..18], &[0x08, 0x08], "cEMI destination = 1/0/8"); + assert_eq!(buf[len - 1], 0x81, "APCI GroupValueWrite | value 1"); + + task.abort(); + } +} From df28a7c02e1994274621d91ccf75b48de02a9186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 20:47:49 +0000 Subject: [PATCH 13/19] feat(mqtt-connector): enhance TLS support with Send trait for RNG and update Makefile for MQTT checks --- Makefile | 2 ++ aimdb-mqtt-connector/src/embassy_client.rs | 23 +++++++--------------- aimdb-mqtt-connector/src/embassy_tls.rs | 7 ++++--- 3 files changed, 13 insertions(+), 19 deletions(-) diff --git a/Makefile b/Makefile index d0513754..26ed72eb 100644 --- a/Makefile +++ b/Makefile @@ -465,6 +465,8 @@ test-embedded: cargo check --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" @printf "$(YELLOW) → Checking aimdb-sync (no_std) on thumbv7em-none-eabihf target$(NC)\n" cargo check --package aimdb-sync --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy + TLS) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,embassy-tls" ## Example projects examples: diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index cc32f097..2ec8dab4 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -278,19 +278,10 @@ impl EmbassySourceRaw for MqttSource { /// neither `Sync` nor takeable through the `&self` that /// [`ConnectorBuilder::build`] receives without interior mutability. /// -/// SAFETY invariant: same single-core cooperative-executor invariant as -/// [`NetStack`](aimdb_embassy_adapter::connectors::NetStack); the slot is -/// written by `with_tls` and taken exactly once inside `build()`, both on -/// that executor. +/// Core's cell supplies both without `unsafe`: it is `Send + Sync` for any +/// `T: Send`, which is what the `+ Send` on [`TlsOptions`]'s RNG buys. #[cfg(feature = "embassy-tls")] -struct TlsSlot(core::cell::RefCell>); - -// SAFETY: see the struct-level invariant. -#[cfg(feature = "embassy-tls")] -unsafe impl Send for TlsSlot {} -// SAFETY: see the struct-level invariant. -#[cfg(feature = "embassy-tls")] -unsafe impl Sync for TlsSlot {} +type TlsSlot = aimdb_core::session::OneShot; /// MQTT connector builder for Embassy with router-based dispatch. /// @@ -322,7 +313,7 @@ impl MqttConnectorBuilder { client_id: "aimdb-client".to_string(), credentials: None, #[cfg(feature = "embassy-tls")] - tls: TlsSlot(core::cell::RefCell::new(None)), + tls: TlsSlot::default(), // SAFETY: AimDB's Embassy integration requires a single-core // cooperative executor (the adapter's module-level invariant); // every future touching this stack — including the broker task @@ -354,8 +345,8 @@ impl MqttConnectorBuilder { /// /// Required for `mqtts://` URLs; rejected at `build()` for `mqtt://`. #[cfg(feature = "embassy-tls")] - pub fn with_tls(self, options: TlsOptions) -> Self { - *self.tls.0.borrow_mut() = Some(options); + pub fn with_tls(mut self, options: TlsOptions) -> Self { + self.tls = TlsSlot::new(options); self } } @@ -395,7 +386,7 @@ impl ConnectorBuilder for MqttConnectorBuilder { // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] let (action_sender, event_receiver, manager_tasks) = { - let tls_options = self.tls.0.borrow_mut().take(); + let tls_options = self.tls.take(); match (broker.tls, tls_options) { (true, Some(options)) => setup_tls_manager( &broker, diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 67edb9fc..07d35a0f 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -68,7 +68,7 @@ pub(crate) const READ_BUF_MIN: usize = 16_640; /// and the RNG live in `StaticCell`s (or equivalents) owned by the /// application — the one party that knows the board's memory budget. pub struct TlsOptions { - pub(crate) rng: &'static mut dyn CryptoRngCore, + pub(crate) rng: &'static mut (dyn CryptoRngCore + Send), pub(crate) ca_der: &'static [u8], pub(crate) read_buf: &'static mut [u8], pub(crate) write_buf: &'static mut [u8], @@ -79,14 +79,15 @@ impl TlsOptions { /// TLS with certificate verification against `ca_der` (the root CA, DER). /// /// * `rng` — CSPRNG for the handshake; on STM32 the hardware TRNG - /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). + /// (`embassy_stm32::rng::Rng` implements `CryptoRngCore`). Must be + /// `Send`, which every concrete CSPRNG satisfies. /// * `read_buf` — TLS record read buffer. At least 16 640 bytes (a /// TLS 1.3 peer may send full-size records regardless of our /// `max_fragment_length` offer); `build()` rejects smaller buffers. /// * `write_buf` — TLS record write buffer; 4 096 bytes is plenty for /// MQTT-sized writes. pub fn new( - rng: &'static mut dyn CryptoRngCore, + rng: &'static mut (dyn CryptoRngCore + Send), ca_der: &'static [u8], read_buf: &'static mut [u8], write_buf: &'static mut [u8], From 4aba4aa594b3c12db6a1e6bc82f446378a4ae5d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 20:58:32 +0000 Subject: [PATCH 14/19] feat: update toolchain and configurations for thumbv8m.main target support --- .devcontainer/Dockerfile | 1 + Makefile | 8 ++++---- .../embassy-bench-stm32h5/rust-toolchain.toml | 2 +- examples/embassy-bench-stm32h5/src/main.rs | 20 +++++++++---------- .../rust-toolchain.toml | 2 +- .../embassy-knx-connector-demo/src/main.rs | 20 +++++++++---------- .../rust-toolchain.toml | 2 +- .../embassy-mqtt-connector-demo/src/main.rs | 20 +++++++++---------- .../rust-toolchain.toml | 2 +- .../embassy-serial-connector-demo/src/main.rs | 20 +++++++++---------- rust-toolchain.toml | 2 +- 11 files changed, 50 insertions(+), 49 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 888e1d41..b682d630 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -100,6 +100,7 @@ ENV PATH="/home/$USERNAME/.cargo/bin:${PATH}" # download on first use; the thumbv6m/thumbv7m pair is a devcontainer-only # convenience for MCU work outside what the workspace builds. RUN rustup target add thumbv7em-none-eabihf \ + && rustup target add thumbv8m.main-none-eabihf \ && rustup target add wasm32-unknown-unknown \ && rustup target add thumbv6m-none-eabi \ && rustup target add thumbv7m-none-eabi diff --git a/Makefile b/Makefile index 26ed72eb..9d075a06 100644 --- a/Makefile +++ b/Makefile @@ -478,17 +478,17 @@ examples: @printf "$(YELLOW) → Building tokio-mqtt-connector-demo (native, tokio runtime)$(NC)\n" cargo build --package tokio-mqtt-connector-demo @printf "$(YELLOW) → Building embassy-mqtt-connector-demo (embedded, embassy runtime)$(NC)\n" - cargo build --package embassy-mqtt-connector-demo --target thumbv7em-none-eabihf + cargo build --package embassy-mqtt-connector-demo --target thumbv8m.main-none-eabihf @printf "$(YELLOW) → Building knx-connector-demo-common (shared KNX demo code, runtime-agnostic)$(NC)\n" cargo build --package knx-connector-demo-common @printf "$(YELLOW) → Building tokio-knx-connector-demo (native, tokio runtime)$(NC)\n" cargo build --package tokio-knx-connector-demo @printf "$(YELLOW) → Building embassy-knx-connector-demo (embedded, embassy runtime)$(NC)\n" - cargo build --package embassy-knx-connector-demo --target thumbv7em-none-eabihf + cargo build --package embassy-knx-connector-demo --target thumbv8m.main-none-eabihf @printf "$(YELLOW) → Building embassy-serial-connector-demo (embedded, embassy runtime)$(NC)\n" - cargo build --package embassy-serial-connector-demo --target thumbv7em-none-eabihf + cargo build --package embassy-serial-connector-demo --target thumbv8m.main-none-eabihf @printf "$(YELLOW) → Building embassy-bench-stm32h5 (B3 on-target profiling, embassy runtime)$(NC)\n" - cargo build --package embassy-bench-stm32h5 --target thumbv7em-none-eabihf + cargo build --package embassy-bench-stm32h5 --target thumbv8m.main-none-eabihf @printf "$(YELLOW) → Building weather-mesh-demo: weather-mesh-common$(NC)\n" cargo build --package weather-mesh-common @printf "$(YELLOW) → Building weather-mesh-demo: weather-hub (cloud aggregator)$(NC)\n" diff --git a/examples/embassy-bench-stm32h5/rust-toolchain.toml b/examples/embassy-bench-stm32h5/rust-toolchain.toml index 750ee6f7..8aef252d 100644 --- a/examples/embassy-bench-stm32h5/rust-toolchain.toml +++ b/examples/embassy-bench-stm32h5/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "1.98" components = ["rust-src", "rustfmt", "llvm-tools"] targets = ["thumbv8m.main-none-eabihf"] diff --git a/examples/embassy-bench-stm32h5/src/main.rs b/examples/embassy-bench-stm32h5/src/main.rs index 57675392..f9863949 100644 --- a/examples/embassy-bench-stm32h5/src/main.rs +++ b/examples/embassy-bench-stm32h5/src/main.rs @@ -168,18 +168,18 @@ async fn main(_spawner: Spawner) { mode: HseMode::BypassDigital, }); config.rcc.pll1 = Some(Pll { - source: PllSource::Hse, - prediv: PllPreDiv::Div2, - mul: PllMul::Mul125, - divp: Some(PllDiv::Div2), - divq: Some(PllDiv::Div2), + source: PllSource::HSE, + prediv: PllPreDiv::DIV2, + mul: PllMul::MUL125, + divp: Some(PllDiv::DIV2), + divq: Some(PllDiv::DIV2), divr: None, }); - config.rcc.ahb_pre = AHBPrescaler::Div1; - config.rcc.apb1_pre = APBPrescaler::Div1; - config.rcc.apb2_pre = APBPrescaler::Div1; - config.rcc.apb3_pre = APBPrescaler::Div1; - config.rcc.sys = Sysclk::Pll1P; + config.rcc.ahb_pre = AHBPrescaler::DIV1; + config.rcc.apb1_pre = APBPrescaler::DIV1; + config.rcc.apb2_pre = APBPrescaler::DIV1; + config.rcc.apb3_pre = APBPrescaler::DIV1; + config.rcc.sys = Sysclk::PLL1_P; config.rcc.voltage_scale = VoltageScale::Scale0; } let _p = embassy_stm32::init(config); diff --git a/examples/embassy-knx-connector-demo/rust-toolchain.toml b/examples/embassy-knx-connector-demo/rust-toolchain.toml index 750ee6f7..8aef252d 100644 --- a/examples/embassy-knx-connector-demo/rust-toolchain.toml +++ b/examples/embassy-knx-connector-demo/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "1.98" components = ["rust-src", "rustfmt", "llvm-tools"] targets = ["thumbv8m.main-none-eabihf"] diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 0f10f6da..84e4c032 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -163,18 +163,18 @@ async fn main(spawner: Spawner) { mode: HseMode::BypassDigital, }); config.rcc.pll1 = Some(Pll { - source: PllSource::Hse, - prediv: PllPreDiv::Div2, - mul: PllMul::Mul125, - divp: Some(PllDiv::Div2), - divq: Some(PllDiv::Div2), + source: PllSource::HSE, + prediv: PllPreDiv::DIV2, + mul: PllMul::MUL125, + divp: Some(PllDiv::DIV2), + divq: Some(PllDiv::DIV2), divr: None, }); - config.rcc.ahb_pre = AHBPrescaler::Div1; - config.rcc.apb1_pre = APBPrescaler::Div1; - config.rcc.apb2_pre = APBPrescaler::Div1; - config.rcc.apb3_pre = APBPrescaler::Div1; - config.rcc.sys = Sysclk::Pll1P; + config.rcc.ahb_pre = AHBPrescaler::DIV1; + config.rcc.apb1_pre = APBPrescaler::DIV1; + config.rcc.apb2_pre = APBPrescaler::DIV1; + config.rcc.apb3_pre = APBPrescaler::DIV1; + config.rcc.sys = Sysclk::PLL1_P; config.rcc.voltage_scale = VoltageScale::Scale0; } let p = embassy_stm32::init(config); diff --git a/examples/embassy-mqtt-connector-demo/rust-toolchain.toml b/examples/embassy-mqtt-connector-demo/rust-toolchain.toml index 750ee6f7..8aef252d 100644 --- a/examples/embassy-mqtt-connector-demo/rust-toolchain.toml +++ b/examples/embassy-mqtt-connector-demo/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "1.98" components = ["rust-src", "rustfmt", "llvm-tools"] targets = ["thumbv8m.main-none-eabihf"] diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 18de5fed..abd996c8 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -244,18 +244,18 @@ async fn main(spawner: Spawner) { mode: HseMode::BypassDigital, }); config.rcc.pll1 = Some(Pll { - source: PllSource::Hse, - prediv: PllPreDiv::Div2, - mul: PllMul::Mul125, - divp: Some(PllDiv::Div2), - divq: Some(PllDiv::Div2), + source: PllSource::HSE, + prediv: PllPreDiv::DIV2, + mul: PllMul::MUL125, + divp: Some(PllDiv::DIV2), + divq: Some(PllDiv::DIV2), divr: None, }); - config.rcc.ahb_pre = AHBPrescaler::Div1; - config.rcc.apb1_pre = APBPrescaler::Div1; - config.rcc.apb2_pre = APBPrescaler::Div1; - config.rcc.apb3_pre = APBPrescaler::Div1; - config.rcc.sys = Sysclk::Pll1P; + config.rcc.ahb_pre = AHBPrescaler::DIV1; + config.rcc.apb1_pre = APBPrescaler::DIV1; + config.rcc.apb2_pre = APBPrescaler::DIV1; + config.rcc.apb3_pre = APBPrescaler::DIV1; + config.rcc.sys = Sysclk::PLL1_P; config.rcc.voltage_scale = VoltageScale::Scale0; } let p = embassy_stm32::init(config); diff --git a/examples/embassy-serial-connector-demo/rust-toolchain.toml b/examples/embassy-serial-connector-demo/rust-toolchain.toml index 750ee6f7..8aef252d 100644 --- a/examples/embassy-serial-connector-demo/rust-toolchain.toml +++ b/examples/embassy-serial-connector-demo/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.95" +channel = "1.98" components = ["rust-src", "rustfmt", "llvm-tools"] targets = ["thumbv8m.main-none-eabihf"] diff --git a/examples/embassy-serial-connector-demo/src/main.rs b/examples/embassy-serial-connector-demo/src/main.rs index 50a18d17..7ad33b52 100644 --- a/examples/embassy-serial-connector-demo/src/main.rs +++ b/examples/embassy-serial-connector-demo/src/main.rs @@ -101,18 +101,18 @@ async fn main(spawner: Spawner) { mode: HseMode::BypassDigital, }); config.rcc.pll1 = Some(Pll { - source: PllSource::Hse, - prediv: PllPreDiv::Div2, - mul: PllMul::Mul125, - divp: Some(PllDiv::Div2), - divq: Some(PllDiv::Div2), + source: PllSource::HSE, + prediv: PllPreDiv::DIV2, + mul: PllMul::MUL125, + divp: Some(PllDiv::DIV2), + divq: Some(PllDiv::DIV2), divr: None, }); - config.rcc.ahb_pre = AHBPrescaler::Div1; - config.rcc.apb1_pre = APBPrescaler::Div1; - config.rcc.apb2_pre = APBPrescaler::Div1; - config.rcc.apb3_pre = APBPrescaler::Div1; - config.rcc.sys = Sysclk::Pll1P; + config.rcc.ahb_pre = AHBPrescaler::DIV1; + config.rcc.apb1_pre = APBPrescaler::DIV1; + config.rcc.apb2_pre = APBPrescaler::DIV1; + config.rcc.apb3_pre = APBPrescaler::DIV1; + config.rcc.sys = Sysclk::PLL1_P; config.rcc.voltage_scale = VoltageScale::Scale0; } let p = embassy_stm32::init(config); diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 6603f02f..1664f31a 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -12,4 +12,4 @@ channel = "1.98.0" profile = "minimal" components = ["rustfmt", "clippy"] -targets = ["thumbv7em-none-eabihf", "wasm32-unknown-unknown"] +targets = ["thumbv7em-none-eabihf", "thumbv8m.main-none-eabihf", "wasm32-unknown-unknown"] From ec3844cfd9fbf31ad8d29922604ed18767ef7e63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 21:06:06 +0000 Subject: [PATCH 15/19] feat: update weather-station-gamma target to thumbv8m.main and standardize PLL configuration constants --- Makefile | 2 +- .../weather-station-gamma/src/main.rs | 20 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index 9d075a06..c9990f1e 100644 --- a/Makefile +++ b/Makefile @@ -498,7 +498,7 @@ examples: @printf "$(YELLOW) → Building weather-mesh-demo: weather-station-beta (edge, synthetic)$(NC)\n" cargo build --package weather-station-beta @printf "$(YELLOW) → Building weather-station-gamma (embedded, embassy runtime)$(NC)\n" - cargo build --package weather-station-gamma --target thumbv7em-none-eabihf + cargo build --package weather-station-gamma --target thumbv8m.main-none-eabihf @printf "$(YELLOW) → Building remote-access-demo (AimX server + client)$(NC)\n" cargo build --package remote-access-demo @printf "$(YELLOW) → Building hello-mailbox (sync)$(NC)\n" 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 6c0eeb4f..7202efd0 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -156,18 +156,18 @@ async fn main(spawner: Spawner) { mode: HseMode::BypassDigital, }); config.rcc.pll1 = Some(Pll { - source: PllSource::Hse, - prediv: PllPreDiv::Div2, - mul: PllMul::Mul125, - divp: Some(PllDiv::Div2), - divq: Some(PllDiv::Div2), + source: PllSource::HSE, + prediv: PllPreDiv::DIV2, + mul: PllMul::MUL125, + divp: Some(PllDiv::DIV2), + divq: Some(PllDiv::DIV2), divr: None, }); - config.rcc.ahb_pre = AHBPrescaler::Div1; - config.rcc.apb1_pre = APBPrescaler::Div1; - config.rcc.apb2_pre = APBPrescaler::Div1; - config.rcc.apb3_pre = APBPrescaler::Div1; - config.rcc.sys = Sysclk::Pll1P; + config.rcc.ahb_pre = AHBPrescaler::DIV1; + config.rcc.apb1_pre = APBPrescaler::DIV1; + config.rcc.apb2_pre = APBPrescaler::DIV1; + config.rcc.apb3_pre = APBPrescaler::DIV1; + config.rcc.sys = Sysclk::PLL1_P; config.rcc.voltage_scale = VoltageScale::Scale0; } let p = embassy_stm32::init(config); From 0219d31b6b9ce9eb01f8cdccc9d8b0fb7f2eb528 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Thu, 3 Sep 2026 21:06:16 +0000 Subject: [PATCH 16/19] feat: update changelogs to document new features and changes across connectors --- aimdb-core/CHANGELOG.md | 6 ++++++ aimdb-embassy-adapter/CHANGELOG.md | 6 ++++++ aimdb-knx-connector/CHANGELOG.md | 10 ++++++++++ aimdb-mqtt-connector/CHANGELOG.md | 5 +++++ aimdb-serial-connector/CHANGELOG.md | 8 ++++++++ aimdb-tcp-connector/CHANGELOG.md | 4 ++++ aimdb-tokio-adapter/CHANGELOG.md | 5 +++++ 7 files changed, 44 insertions(+) diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 519b50d0..1fa27c53 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Runtime-neutral I/O layer (`session::io`, feature `connector-session`).** + `ByteStream`/`StreamDialer`/`StreamListener`/`Datagram`/`DatagramBinder`/`Delay` + sit below `Connection`, so an adapter owns sockets and clocks while a connector + owns framing. `FramedConnection` plus `FramingDialer`/`FramingListener` lift a + byte stream into the existing `Dialer`/`Listener`, and `OneShot` is a + `Send + Sync` cell for moved-in resources with no `unsafe`. - **A panic is a bug, not an error channel — checked.** The crate is compiled under `deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)` outside its own tests. Four sites fixed: poisoned-mutex recovery in the diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index f41bb3ab..74d96297 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -13,6 +13,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`net` feature — Embassy behind core's neutral I/O traits.** `EmbassyNet::tcp`, + `listen::`, `udp` and `EmbassyUart` supply the sockets and UART halves; + `EmbassyDelay` is gated on `embassy-time` separately so a sockets-only consumer + does not pull in `defmt-timestamp-uptime`'s `_defmt_timestamp`. The listener + stores one pending accept per slot, so every socket stays in `LISTEN` between + accepts and no SYN is lost — proven against two real stacks. - **`RuntimeOps` implemented for `EmbassyAdapter` (Issue #130, design 034 Phase 2).** The dyn-safe capability surface from `aimdb-executor`, gated on `embassy-time` like `TimeOps`: `now_nanos()` is boot-anchored uptime at microsecond granularity (the portable lower bound), `sleep` boxes `embassy_time::Timer::after`, `unix_time` rides the `set_unix_time` anchor, `log` forwards to the defmt-backed `Logger`. Covered by the shared contract test on the host (the test time driver now wakes immediately on `schedule_wake`, so already-expired timers complete; non-zero sleeps remain unusable on the pinned-at-0 host clock). - **M17 — centralized Embassy connector spines: the one audited home for the single-core `unsafe` ([Design 033](../docs/design/033-M17-unify-connectors-drop-send.md)).** New `connectors` module (features `connectors` / `connector-io`) collecting the force-`Send` plumbing every Embassy connector used to hand-roll, so a connector crate carries **no `unsafe` and no `SendFutureWrapper`**: - **Session spine** — `EmbassySessionClient` / `EmbassySessionServer` (the Embassy duals of core's `SessionClientConnector` / `SessionServerConnector`), the one-shot `OneShotDialer` / `OneShotListener` over a moved-in peripheral connection (the listener parks forever after the first accept — point-to-point), and the force-`Send + Sync` `OneShotCell` for builders holding a moved-in value. `EmbassySessionClient::new` defaults to `reconnect: false` (unlike `ClientConfig::default`): a one-shot dialer can't redial, so the engine would otherwise spin on `TransportError::Io` forever; a re-dialable transport opts back in via `with_config`. diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 9fb2139b..38a0a17d 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`tokio-runtime` gains `embassy-sync` and `critical-section/std`; + `embassy-futures` is unconditional.** Both are executor-independent, so one + channel and select type serves either runtime. The connector carries the + `critical-section` link obligation itself, so no std user meets + `undefined symbol: _critical_section_1_0_acquire`. - **Reports through the `log_*` facade instead of `tracing::` directly** (design 050 §10.5), so a `log` destination — an FFI layer's, say — sees this crate's events too. Each call site also shed the hand-written @@ -18,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`neutral::connection_task` — one connection task for both runtimes.** Generic + over core's `DatagramBinder` and `Delay`, it binds, advertises the socket's real + local endpoint (HPAI) instead of the NAT-style `0.0.0.0:0`, drives the shared + `TunnelEngine`, and rebinds on socket reset. `shared_channel` bridges it to the + `embassy_sync` channels, which now back the task on std too. - **Spec-conformant TUNNELING_REQUEST retransmission — `TunnelConfig::ack_retransmits` (036 W4, default `1`).** When a tracked outbound telegram's ACK does not arrive within `ack_timeout_ms`, the engine now retransmits the byte-identical frame (same sequence counter, buffered in the pending-ACK slot per KNXnet/IP 3.8.4) and, when the repeat also goes unanswered, reports `Action::AckTimeout` **and tears the connection down** — so subsequent commands queue for the re-handshake instead of being sent into a dead tunnel. Hardware-bench evidence motivating this: ten button-press writes issued during a link outage's heartbeat-detection window (up to ~65 s) were silently lost with only warnings; with retransmission the loss window shrinks to ~2× `ack_timeout_ms`. `ack_retransmits: 0` restores the previous expire-and-warn behavior (no retransmit, no disconnect, no frame buffering — though the 16-slot frame capacity, ~4.5 KiB, is statically reserved either way on `heapless`). The retransmit delay is `ack_timeout_ms` (default 3 s, the constant both pre-engine implementations used); set it to `1_000` for strict spec timing. Covered by engine unit tests and a fake-gateway test that drops the first ACK and asserts the identical repeat. ### Fixed diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index e26038f9..a08977a8 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -47,6 +47,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`TlsOptions::new` requires a `Send` RNG** — + `&'static mut (dyn CryptoRngCore + Send)`. Every concrete CSPRNG already + satisfies it (`embassy_stm32::rng::Rng` included), so callers are unchanged + textually. With it, `TlsSlot` becomes core's `OneShot` and this + crate carries **zero `unsafe impl`s** (was two). - **Issue #131:** the Embassy `MqttConnectorBuilder::new` takes the network stack — `MqttConnectorBuilder::new(broker_url, stack)` — since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls and the `MqttLinkExt`/`MqttOutboundLinkExt` link-builder ext traits are non-generic over the runtime. ### Added diff --git a/aimdb-serial-connector/CHANGELOG.md b/aimdb-serial-connector/CHANGELOG.md index 562588ef..da68727c 100644 --- a/aimdb-serial-connector/CHANGELOG.md +++ b/aimdb-serial-connector/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **`tokio-runtime` now depends on `aimdb-tokio-adapter`** (feature `net`), so the + byte source is the adapter's on both runtimes rather than duplicated here. This + reverses the earlier decision to keep a concrete adapter off the public feature; + `_test-tokio` remains as an alias. - **Reports through the `log_*` facade instead of `tracing::` directly** (design 050 §10.5), so a `log` destination — an FFI layer's, say — sees this crate's events too. Each call site also shed the hand-written @@ -18,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`neutral` module — the connector reduced to framing.** `CobsFramer` against + core's `Framer` plus core's `FramedConnection` serve both runtimes; the byte + sources come from the adapters (`TokioByteStream`, `EmbassyUart`), so this crate + names no socket or UART type of its own. - **New crate — the COBS-framed serial/UART transport for AimDB remote access (Issue #122, follow-up to #39).** The serial sibling of `aimdb-uds-connector`: it contributes only the `Dialer`/`Listener`/`Connection` triple plus thin sugar; the AimX codec + dispatch and the runtime-neutral session engines (`run_client`/`serve`) are reused from `aimdb-core`. The wire is the same compact AimX JSON, framed with **COBS** (Consistent Overhead Byte Stuffing) and a `0x00` sentinel instead of a newline — self-synchronizing on a lossy/unframed serial medium, so a receiver that joins mid-stream resynchronizes on the next sentinel. Default scheme `"serial"`. Two runtime halves: - **`tokio-runtime`** (std, host/gateway) — `TokioSerialConnection` over any `AsyncRead + AsyncWrite` (a real `tokio_serial::SerialStream` in production, a `tokio::io::duplex()` in tests), with `SerialClient::new(path, baud)` (sugar over `SessionClientConnector`) and `SerialServer` (sugar over `SessionServerConnector` + `AimxDispatch`). The listener is one-shot (serial is point-to-point). - **`embassy-runtime`** (`no_std + alloc`, MCU) — thin sugar over the centralized Embassy session spine in `aimdb-embassy-adapter::connectors` (M17, [Design 033](../docs/design/033-M17-unify-connectors-drop-send.md)): this half contributes only the COBS `CobsFramer` (implementing the spine's `Framer`); the framed `EmbassyConnection` over `embedded-io-async` `Read`/`Write` halves (the common `BufferedUart::split()` shape), the one-shot dialer/listener/cell, and **all** the force-`Send` plumbing live in the adapter — this crate carries **no `unsafe`**. `SerialClient::new(rx, tx)` returns the spine's `EmbassySessionClient` (chain `.scheme(...)`/`.with_config(...)` on it); `SerialServer` stores the moved-in framed connection in the spine's `OneShotCell` and drives `serve`. The Embassy *server* half rides the `no_std` `AimxDispatch` landed in #120, so an MCU can answer `record.list`/`get`/`set`/`subscribe`/`drain` over a UART; the *client* half mirrors records to a gateway. Reconnect is disabled by default on Embassy (the spine's default — the UART peripheral is moved in and can't be re-acquired). diff --git a/aimdb-tcp-connector/CHANGELOG.md b/aimdb-tcp-connector/CHANGELOG.md index 93c3af2e..e3e8f6a6 100644 --- a/aimdb-tcp-connector/CHANGELOG.md +++ b/aimdb-tcp-connector/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`tests/neutral_pool.rs`** — the adapter's pooled `StreamListener` over two + crossover-wired `embassy-net` stacks, with a rebuild-and-cancel pool as the + negative control: it loses a SYN arriving between accepts, the stored-accept + pool does not. - **New crate — the length-prefixed TCP transport for AimDB remote access (AimX over TCP, refs #121).** Contributes the `Dialer`/`Listener`/`Connection` transport triple plus thin `TcpClient`/`TcpServer` sugar; the AimX codec + dispatch and the runtime-neutral session engines (`run_client`/`serve`) are reused from `aimdb-core`. Every AimX envelope is framed as a `u32` big-endian length prefix. Two runtime halves: - **`tokio-runtime`** (std, host/gateway) — TCP transport over `tokio::net`. - **`embassy-runtime`** (`no_std + alloc`, MCU) — an explicit pool of caller-buffered `embassy-net` sockets, one accept/session worker per slot (`TcpServer::::with_buffers`), with socket recycling across reconnects. A synchronous `accept()` failure (e.g. a port-0 endpoint rejected as `InvalidPort`) yields instead of spinning the cooperative executor. diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index ef506de1..ce01dde3 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`net` feature — Tokio behind core's neutral I/O traits.** `TokioNet::tcp`, + `listen`, `udp` and `delay()` supply `StreamDialer`/`StreamListener`/ + `DatagramBinder`/`Delay`, with `TokioByteStream` covering any + `AsyncRead + AsyncWrite`. Every future is a plain `async fn`, so the module + contains no `unsafe`. - **`RuntimeOps` implemented for `TokioAdapter` (Issue #130, design 034 Phase 2).** The dyn-safe capability surface from `aimdb-executor`: `now_nanos()` reports nanoseconds since a process-global `OnceLock` anchor (std `Instant` has no public epoch), `sleep` boxes `tokio::time::sleep`, `unix_time`/`log` forward to the existing `TimeOps`/`Logger` impls. Covered by the shared contract test plus a real-sleep monotonicity test. - **`TimeOps::unix_time()` implemented from the OS wall clock (Issue #120).** Returns `SystemTime::now()` since the Unix epoch as `(secs, subsec_nanos)`; `now()` stays monotonic for duration measurement. Supplies absolute timestamps to the runtime-neutral AimX server / remote-display paths. - **`TokioBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read backing AimX `record.get` / `TypedRecord::latest()`: `SingleLatest` (`Watch`) reads via `watch::Sender::borrow()`, `Mailbox` (`Notify`) clones the slot mutex, `SpmcRing` (`Broadcast`) returns `None` (no canonical latest). Unit tests cover all three buffer types (empty, populated, non-destructive, overwrite, drained). From 3cf2a4ab28d126bd96aad90de0722358a0090fc7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 19:18:10 +0000 Subject: [PATCH 17/19] fix(052): address review findings on runtime-neutral connector I/O MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from review of the wave-A branch. All are in the new code; the existing tokio_*/embassy_* connector paths are untouched. embassy-adapter: restore the cancel and yield guards lost in the port The neutral `net` module reproduced `embassy_transport.rs`'s happy paths but not the edge cases that module's guards and comments exist for. - `EmbassyTcpDialer::connect` took the socket out of its slot and returned it only on connect's `Err` branch. A dial cancelled mid-`connect()` (a select timeout, a task shutdown) dropped the socket with the future, leaving the slot permanently empty and every later dial failing with a bare `TransportError::Io`. Both the dial and accept paths now hold the socket in a `SlotReturn` drop guard, as the sibling module does. - Neither path yielded before reporting a synchronously-failing attempt. Core's `serve` logs an accept error and re-enters `accept()` with no backoff, so a port-0 `InvalidPort` spun a non-yielding loop and starved the single-core cooperative executor — a config typo hanging the device rather than warn-looping. `yield_now().await` restored on both, with the comment naming the case. knx-connector: a rebind that cannot learn its address falls back to NAT `engine` outlives the bind loop and `set_local_endpoint` was called only inside `if let Some(..) = local_addr()`. On Embassy `local_addr()` is `None` whenever the stack has no address — DHCP renewal, link flap, which is what causes the rebind — so the next CONNECT_REQUEST re-advertised the previous cycle's port, the gateway replied to a dead port and the tunnel could never re-establish. Strictly worse than the `0.0.0.0:0` the explicit HPAI exists to avoid, so the `None` case is now explicit and falls back. Covered by a regression test that fails against the previous code. knx-connector: restore the select fairness tokio gave us `embassy_futures::select3` polls in declaration order, where the `tokio::select!` it replaced chose among ready arms at random. Sustained inbound traffic meant the command arm was never reached and outbound `GroupWrite`s stalled until the channel dropped them. The two contended arms now swap each pass. knx-connector: leave the critical-section impl to the final binary `tokio-runtime` enabled `critical-section/std`. That impl is registered by symbol name and is global to the binary, so per critical-section's own docs only the final binary may pick one; a downstream binary that also linked an impl got duplicate symbols with no way to opt out. The choice moves to an opt-in `critical-section-std-impl` feature, with a dev-dependency covering this crate's own test binaries. Makefile: actually check the new module's docs `RUSTDOCFLAGS=-D warnings` was added and `net` reached the tokio adapter's `cargo doc` line but not the embassy adapter's, leaving the largest new file in the crate unchecked — and failing with three errors when run. Feature added and the broken doc links fixed, so `make doc` covers it. Verified: `make doc` and `make examples` end to end; clippy `-D warnings` on each touched feature configuration including `thumbv7em` cross-compiles; knx (37 lib + 35 integration), embassy-adapter `alloc,net`, tokio-adapter `net`, serial, and tcp `neutral_pool` against two real embassy stacks; `cargo fmt --check`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ --- Cargo.lock | 1 + Makefile | 2 +- aimdb-embassy-adapter/CHANGELOG.md | 8 +- aimdb-embassy-adapter/Cargo.toml | 12 +- aimdb-embassy-adapter/src/connectors.rs | 7 +- aimdb-embassy-adapter/src/lib.rs | 4 +- aimdb-embassy-adapter/src/net.rs | 92 ++++++-- aimdb-knx-connector/CHANGELOG.md | 23 +- aimdb-knx-connector/Cargo.toml | 23 +- aimdb-knx-connector/src/neutral.rs | 199 ++++++++++++++++-- .../tests/shared_channel_on_std.rs | 7 +- 11 files changed, 331 insertions(+), 47 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 47f85111..1251e840 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,6 +186,7 @@ dependencies = [ "critical-section", "defmt 1.1.1", "embassy-executor", + "embassy-futures 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", "embassy-net", "embassy-net-driver-channel", "embassy-sync", diff --git a/Makefile b/Makefile index c9990f1e..fb39cef2 100644 --- a/Makefile +++ b/Makefile @@ -385,7 +385,7 @@ doc: @cp -r target/doc/* target/doc-final/cloud/ @printf "$(YELLOW) → Building embedded documentation$(NC)\n" cargo doc --package aimdb-core --no-default-features --features alloc --no-deps - cargo doc --package aimdb-embassy-adapter --features "embassy-runtime" --no-deps + cargo doc --package aimdb-embassy-adapter --features "embassy-runtime,net" --no-deps cargo doc --package aimdb-mqtt-connector --no-default-features --features "embassy-runtime" --no-deps cargo doc --package aimdb-knx-connector --no-default-features --features "embassy-runtime" --no-deps @cp -r target/doc/* target/doc-final/embedded/ diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 74d96297..85f1b2c5 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -18,7 +18,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `EmbassyDelay` is gated on `embassy-time` separately so a sockets-only consumer does not pull in `defmt-timestamp-uptime`'s `_defmt_timestamp`. The listener stores one pending accept per slot, so every socket stays in `LISTEN` between - accepts and no SYN is lost — proven against two real stacks. + accepts and no SYN is lost — proven against two real stacks. Both the dial and + accept paths hold their socket in a drop guard, so a cancelled `connect()` or + `accept()` returns it to its slot instead of leaving the slot permanently + empty, and both yield before reporting a synchronously-failing attempt so a + misconfigured endpoint (port-0 `InvalidPort`) warn-loops rather than spinning + the single-core executor. - **`RuntimeOps` implemented for `EmbassyAdapter` (Issue #130, design 034 Phase 2).** The dyn-safe capability surface from `aimdb-executor`, gated on `embassy-time` like `TimeOps`: `now_nanos()` is boot-anchored uptime at microsecond granularity (the portable lower bound), `sleep` boxes `embassy_time::Timer::after`, `unix_time` rides the `set_unix_time` anchor, `log` forwards to the defmt-backed `Logger`. Covered by the shared contract test on the host (the test time driver now wakes immediately on `schedule_wake`, so already-expired timers complete; non-zero sleeps remain unusable on the pinned-at-0 host clock). - **M17 — centralized Embassy connector spines: the one audited home for the single-core `unsafe` ([Design 033](../docs/design/033-M17-unify-connectors-drop-send.md)).** New `connectors` module (features `connectors` / `connector-io`) collecting the force-`Send` plumbing every Embassy connector used to hand-roll, so a connector crate carries **no `unsafe` and no `SendFutureWrapper`**: - **Session spine** — `EmbassySessionClient` / `EmbassySessionServer` (the Embassy duals of core's `SessionClientConnector` / `SessionServerConnector`), the one-shot `OneShotDialer` / `OneShotListener` over a moved-in peripheral connection (the listener parks forever after the first accept — point-to-point), and the force-`Send + Sync` `OneShotCell` for builders holding a moved-in value. `EmbassySessionClient::new` defaults to `reconnect: false` (unlike `ClientConfig::default`): a one-shot dialer can't redial, so the engine would otherwise spin on `TransportError::Io` forever; a re-dialable transport opts back in via `with_config`. @@ -36,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`TypedRecord::latest()` no longer always returns `None` on Embassy (M15).** With `latest_snapshot` removed in `aimdb-core`, reads go straight to the buffer via `peek()`; the Embassy adapter now implements `peek()` (above) so `latest()` returns the current value on `SingleLatest` / `Mailbox` instead of `None`. - **Stale `EmbassyBuffer` doc example.** It imported the removed `BufferBackend` trait (now `Buffer` / `BufferReader`) and put a non-`const` `new_spmc()` in a `static`; it never compiled because doctests didn't build on host before. Now corrected and exercised by `cargo test`'s doctest pass. +- **Rustdoc links to feature-gated items.** The crate and `connectors` module docs linked `EmbassyBuffer` and `Framer` unconditionally, so `cargo doc` failed under any feature set that gates them out. `make doc` now passes `net` for this crate, so the new module's docs are covered by the `RUSTDOCFLAGS=-D warnings` CI already runs. ### Changed diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index da66b486..c1ebc926 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -28,7 +28,13 @@ connector-io = ["connectors", "dep:embedded-io-async"] # traits, so connector crates need no `embassy-net` dependency. Deliberately # does not imply `embassy-time`: that turns on `defmt-timestamp-uptime`, whose # `_defmt_timestamp` collides with every host test binary's `defmt::timestamp!`. -net = ["connectors", "embassy-net-support", "embassy-net/udp", "dep:embedded-io-async"] +net = [ + "connectors", + "embassy-net-support", + "embassy-net/udp", + "dep:embedded-io-async", + "dep:embassy-futures", +] # Observability features (no_std compatible) tracing = ["aimdb-core/tracing", "dep:tracing"] @@ -56,6 +62,10 @@ futures-core = { version = "0.3", default-features = false } # Generic framed `Connection` over async UART halves (feature `connector-io`). embedded-io-async = { workspace = true, optional = true } +# Executor-independent `yield_now` for the accept/connect retry paths (feature +# `net`). No dependencies of its own and pulls no executor. +embassy-futures = { workspace = true, optional = true } + # Embassy ecosystem for embedded async embassy-executor = { workspace = true, optional = true } embassy-time = { workspace = true, optional = true } diff --git a/aimdb-embassy-adapter/src/connectors.rs b/aimdb-embassy-adapter/src/connectors.rs index 413c1808..94e51c65 100644 --- a/aimdb-embassy-adapter/src/connectors.rs +++ b/aimdb-embassy-adapter/src/connectors.rs @@ -1,5 +1,5 @@ //! Centralized Embassy connector spines — the one audited home for the -//! single-core `unsafe` + [`SendFutureWrapper`](crate::SendFutureWrapper) that +//! single-core `unsafe` + [`SendFutureWrapper`] that //! every Embassy connector used to hand-roll. //! //! AimDB's connector contract is `Send`-everywhere (so a Tokio app can @@ -9,8 +9,9 @@ //! two requires force-`Send`ing the Embassy futures; this module does that //! **once**, so a connector crate carries **no `unsafe` and no wrapper**: //! -//! - **Session transports** (serial, TCP, …) contribute a [`Framer`] (or a -//! [`Connection`]) and wrap it in [`EmbassySessionClient`] / +//! - **Session transports** (serial, TCP, …) contribute a `Framer` +//! (`connector-io` feature) or a [`Connection`], and wrap it in +//! [`EmbassySessionClient`] / //! [`EmbassySessionServer`] — the Embassy duals of core's //! `SessionClientConnector` / `SessionServerConnector`. //! - **Data-plane transports** (MQTT, KNX) contribute an [`EmbassySinkRaw`] diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 2e489ef4..941d17a6 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -13,8 +13,8 @@ //! Embassy is a no_std async runtime, so this adapter is designed for embedded //! environments and works with the no_std version of aimdb-core by default. //! It provides the runtime ([`EmbassyAdapter`] implementing -//! `aimdb_core::RuntimeOps`), the buffer implementations ([`EmbassyBuffer`]), -//! and the connector spines (`connectors` feature). +//! `aimdb_core::RuntimeOps`), the buffer implementations (`EmbassyBuffer`, +//! `embassy-sync` feature), and the connector spines (`connectors` feature). #![no_std] diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index dd75c367..f110937b 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -25,6 +25,7 @@ use aimdb_core::session::{ TransportResult, }; +use embassy_futures::yield_now; use embassy_net::tcp::TcpSocket; use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, IpListenEndpoint, Stack}; @@ -93,6 +94,48 @@ impl TcpSocketSlot { } } +/// Holds a socket taken from a slot until it is either moved out on success or +/// returned to the slot. +/// +/// The `Drop` is the point: if the whole future is dropped while `connect()` or +/// `accept()` is still pending — a `select!` timeout, a task shutdown — the +/// socket would otherwise be dropped with it, leaving the slot permanently +/// empty and every later dial failing with a bare `TransportError::Io`. +/// Cancellation has no error path to observe, so the guard is the only hook. +struct SlotReturn<'a> { + slot: &'a Arc, + socket: Option>, +} + +impl<'a> SlotReturn<'a> { + fn new(slot: &'a Arc, socket: TcpSocket<'static>) -> Self { + Self { + slot, + socket: Some(socket), + } + } + + fn socket_mut(&mut self) -> &mut TcpSocket<'static> { + self.socket + .as_mut() + .expect("socket present until into_socket") + } + + /// Take the socket back, defusing the guard so its `Drop` becomes a no-op. + fn into_socket(mut self) -> TcpSocket<'static> { + self.socket.take().expect("socket taken exactly once") + } +} + +impl Drop for SlotReturn<'_> { + fn drop(&mut self) { + if let Some(mut socket) = self.socket.take() { + socket.abort(); + self.slot.put(socket); + } + } +} + // =========================================================================== // TCP. // =========================================================================== @@ -184,15 +227,28 @@ impl StreamDialer for EmbassyTcpDialer { let addr: core::net::IpAddr = host.parse().map_err(|_| TransportError::Io)?; let endpoint = IpEndpoint::new(addr.into(), port); - let Some(mut socket) = self.slot.take() else { + let Some(socket) = self.slot.take() else { return Err(TransportError::Io); }; - socket.abort(); - match socket.connect(endpoint).await { - Ok(()) => Ok(EmbassyTcpStream::recyclable(socket, self.slot.clone())), + // The guard owns the socket for the whole dial: on success it is + // defused and the socket moves into the stream, on failure *or + // cancellation* its `Drop` returns the socket to the slot. + let mut guard = SlotReturn::new(&self.slot, socket); + guard.socket_mut().abort(); + // Bind the result before matching so the `connect()` future's borrow + // of `guard` ends here, freeing `guard` for `into_socket` below. + let connected = guard.socket_mut().connect(endpoint).await; + match connected { + Ok(()) => Ok(EmbassyTcpStream::recyclable( + guard.into_socket(), + self.slot.clone(), + )), Err(_) => { - socket.abort(); - self.slot.put(socket); + // Dropping `guard` aborts the socket and returns it to the slot. + drop(guard); + // A synchronously-failing `connect()` gives the caller no + // yield point before it retries; see `arm` below. + yield_now().await; Err(TransportError::Io) } } @@ -236,13 +292,25 @@ impl EmbassyTcpListener { let endpoint = self.local_endpoint; self.pending[i] = Some(Box::pin(async move { // A live connection may still hold the socket. - let mut socket = slot.acquire().await; - socket.abort(); - match socket.accept(endpoint).await { - Ok(()) => Ok(socket), + let socket = slot.acquire().await; + // See `SlotReturn`: a dropped accept must not swallow the socket. + let mut guard = SlotReturn::new(&slot, socket); + guard.socket_mut().abort(); + // Bind the result before matching so the `accept()` future's borrow + // of `guard` ends here, freeing `guard` for `into_socket` below. + let accepted = guard.socket_mut().accept(endpoint).await; + match accepted { + Ok(()) => Ok(guard.into_socket()), Err(_) => { - socket.abort(); - slot.put(socket); + // Dropping `guard` aborts the socket and returns it to the slot. + drop(guard); + // `accept()` can fail synchronously (e.g. port-0 + // `InvalidPort`), and core's `serve` loop logs an accept + // error and re-enters `accept()` immediately. Without a + // yield point that spins forever on the single-core + // cooperative executor, starving every other task. Yield so + // a misconfig warn-loops instead of hanging the device. + yield_now().await; Err(TransportError::Io) } } diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 38a0a17d..2c2dee85 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -9,11 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **`tokio-runtime` gains `embassy-sync` and `critical-section/std`; - `embassy-futures` is unconditional.** Both are executor-independent, so one - channel and select type serves either runtime. The connector carries the - `critical-section` link obligation itself, so no std user meets - `undefined symbol: _critical_section_1_0_acquire`. +- **`tokio-runtime` gains `embassy-sync`; `embassy-futures` is unconditional.** + Both are executor-independent, so one channel and select type serves either + runtime. +- **Selecting a `critical-section` implementation is left to the final binary.** + `CriticalSectionRawMutex` needs one to link, but the impl is registered by + symbol name and is global to the binary, so a library that enables it hands + every downstream binary a duplicate-symbol error with no way to opt out — the + crate's own docs reserve the choice for the final binary. The new opt-in + `critical-section-std-impl` feature is available for a std binary that wants + it from here; this crate's tests get the impl from a dev-dependency. - **Reports through the `log_*` facade instead of `tracing::` directly** (design 050 §10.5), so a `log` destination — an FFI layer's, say — sees this crate's events too. Each call site also shed the hand-written @@ -25,8 +30,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`neutral::connection_task` — one connection task for both runtimes.** Generic over core's `DatagramBinder` and `Delay`, it binds, advertises the socket's real - local endpoint (HPAI) instead of the NAT-style `0.0.0.0:0`, drives the shared - `TunnelEngine`, and rebinds on socket reset. `shared_channel` bridges it to the + local endpoint (HPAI) instead of the NAT-style `0.0.0.0:0` — falling back to NAT + on a cycle whose stack exposes no address, so a rebind never re-advertises the + previous cycle's port — drives the shared `TunnelEngine`, and rebinds on socket + reset. Its select alternates the inbound and command arms each pass, since + `select3` polls in declaration order where the `tokio::select!` it replaces + chose among ready arms at random. `shared_channel` bridges it to the `embassy_sync` channels, which now back the task on std too. - **Spec-conformant TUNNELING_REQUEST retransmission — `TunnelConfig::ack_retransmits` (036 W4, default `1`).** When a tracked outbound telegram's ACK does not arrive within `ack_timeout_ms`, the engine now retransmits the byte-identical frame (same sequence counter, buffered in the pending-ACK slot per KNXnet/IP 3.8.4) and, when the repeat also goes unanswered, reports `Action::AckTimeout` **and tears the connection down** — so subsequent commands queue for the re-handshake instead of being sent into a dead tunnel. Hardware-bench evidence motivating this: ten button-press writes issued during a link outage's heartbeat-detection window (up to ~65 s) were silently lost with only warnings; with retransmission the loss window shrinks to ~2× `ack_timeout_ms`. `ack_retransmits: 0` restores the previous expire-and-warn behavior (no retransmit, no disconnect, no frame buffering — though the 16-slot frame capacity, ~4.5 KiB, is statically reserved either way on `heapless`). The retransmit delay is `ack_timeout_ms` (default 3 s, the constant both pre-engine implementations used); set it to `1_000` for strict spec timing. Covered by engine unit tests and a fake-gateway test that drops the first ACK and asserts the identical repeat. diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 5c340442..52c7b130 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -21,8 +21,22 @@ tokio-runtime = [ "async-stream", "futures-util", "embassy-sync", - "critical-section/std", ] + +# Selects `critical-section`'s std implementation. +# +# `embassy-sync`'s only `Sync` raw mutex is `CriticalSectionRawMutex`, which +# needs a `critical-section` impl to link. That impl is registered by symbol +# name and is global to the binary, so per `critical-section`'s own docs only +# the **final binary** may choose one: a library enabling it would hand every +# downstream binary a duplicate-symbol link error with no way to opt out. +# +# So this stays off by default and out of `tokio-runtime`. This crate's own +# tests get the impl through a dev-dependency; a std binary that instantiates a +# `CriticalSectionRawMutex` channel and has no other impl in its graph can +# either depend on `critical-section` with `features = ["std"]` directly (the +# documented way) or enable this feature. +critical-section-std-impl = ["critical-section/std"] embassy-runtime = [ "aimdb-core/alloc", # Need alloc for collect_inbound_routes "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` @@ -91,7 +105,9 @@ embassy-net = { version = "0.9.0", optional = true, features = [ ] } # A `critical-section` impl must be linked wherever `CriticalSectionRawMutex` -# is used; `tokio-runtime` turns on the std one so no std user has to. +# is used. Choosing one is the final binary's call, so it is reachable only +# through the opt-in `critical-section-std-impl` feature above; on Embassy the +# HAL (cortex-m / embassy-rp / …) already provides it. critical-section = { version = "1.1", optional = true } # Embedded utilities (heapless is unconditional: the shared sans-io tunnel @@ -104,6 +120,9 @@ defmt = { workspace = true, optional = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } +# Test binaries are binaries: selecting the impl here lets `CriticalSectionRawMutex` +# link for this crate's tests without imposing that choice on consumers. +critical-section = { version = "1.2", features = ["std"] } tokio-test = "0.4" aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", diff --git a/aimdb-knx-connector/src/neutral.rs b/aimdb-knx-connector/src/neutral.rs index 1fa62af3..d7da1a47 100644 --- a/aimdb-knx-connector/src/neutral.rs +++ b/aimdb-knx-connector/src/neutral.rs @@ -15,11 +15,11 @@ use core::future::Future; use core::net::SocketAddr; use core::time::Duration; -use aimdb_core::session::{Datagram, DatagramBinder, Delay, Payload}; +use aimdb_core::session::{Datagram, DatagramBinder, Delay, Payload, TransportResult}; use aimdb_core::{log_debug, log_error, log_warn, RuntimeOps}; use crate::tunnel::{ - drain_actions, GroupWrite, LocalEndpoint, TunnelConfig, TunnelEngine, TunnelIo, + drain_actions, GroupWrite, LocalEndpoint, Millis, TunnelConfig, TunnelEngine, TunnelIo, }; use crate::GroupAddress; @@ -104,8 +104,33 @@ async fn drive_connection( // dependencies and its select is pure `core::task`. use embassy_futures::select::{select3, Either3}; + /// Apply one received datagram, shared by the two arm orders below. + /// + /// A free function rather than a common `Event` enum: `GroupWrite` is large + /// enough that funnelling both arms through one value would park a second + /// copy of it in this task's state for the whole loop. + fn apply_inbound( + engine: &mut TunnelEngine, + buf: &[u8], + result: TransportResult<(usize, SocketAddr)>, + now: Millis, + ) { + match result { + Ok((len, _peer)) => engine.handle_datagram(&buf[..len], now), + Err(_) => engine.handle_socket_error(now), + } + } + let now_ms = || runtime.now_nanos() / 1_000_000; + // `select3` polls its arms in declaration order and takes the first ready + // one — unlike the `tokio::select!` this task replaces, which picked among + // the ready arms at random. With a fixed order, sustained inbound traffic + // means the first arm is ready on every pass and the command arm is never + // reached, so outbound `GroupWrite`s stall until the channel drops them. + // Swapping the two contended arms each pass restores that fairness. + let mut inbound_first = true; + loop { engine.poll(now_ms()); @@ -136,17 +161,27 @@ async fn drive_connection( } }; - match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { - Either3::First(Ok((len, _peer))) => { - engine.handle_datagram(&recv_buf[..len], now_ms()); + // The deadline arm stays last in both orders: it only ever asks for a + // `poll` the loop top would reach anyway. + if inbound_first { + match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { + Either3::First(r) => apply_inbound(engine, &recv_buf, r, now_ms()), + Either3::Second(cmd) => { + let _ = engine.handle_command(cmd, now_ms()); + } + // Woken for the engine deadline; `poll` at the loop top fires it. + Either3::Third(()) => {} } - Either3::First(Err(_)) => engine.handle_socket_error(now_ms()), - Either3::Second(cmd) => { - let _ = engine.handle_command(cmd, now_ms()); + } else { + match select3(cmd_arm, socket.recv_from(&mut recv_buf), deadline).await { + Either3::First(cmd) => { + let _ = engine.handle_command(cmd, now_ms()); + } + Either3::Second(r) => apply_inbound(engine, &recv_buf, r, now_ms()), + Either3::Third(()) => {} } - // Woken for the engine deadline; `poll` at the loop top fires it. - Either3::Third(()) => {} } + inbound_first = !inbound_first; } } @@ -183,11 +218,21 @@ pub async fn connection_task( // The handshake advertises the client's own endpoint (HPAI). Gateways // that reject the NAT-style `0.0.0.0:0` form need the real address. - if let Some(SocketAddr::V4(addr)) = socket.local_addr() { - engine.set_local_endpoint(LocalEndpoint::Explicit { - ip: addr.ip().octets(), - port: addr.port(), - }); + // + // `engine` outlives the loop, so this must be set on *every* cycle, not + // just the ones that can answer. On Embassy `local_addr()` is `None` + // whenever the stack has no address — DHCP renewal, link flap — which + // is exactly what causes a rebind. Leaving the previous cycle's value + // in place would advertise a port nothing is bound to any more and wedge + // the handshake for good; NAT is degraded but recovers. + match socket.local_addr() { + Some(SocketAddr::V4(addr)) => { + engine.set_local_endpoint(LocalEndpoint::Explicit { + ip: addr.ip().octets(), + port: addr.port(), + }); + } + _ => engine.set_local_endpoint(LocalEndpoint::Nat), } drive_connection( @@ -246,10 +291,12 @@ pub mod shared_channel { #[cfg(all(test, feature = "tokio-runtime"))] mod tests { use super::*; + use aimdb_core::session::TransportError; use aimdb_tokio_adapter::net::{TokioDelay, TokioNet}; use aimdb_tokio_adapter::TokioAdapter; use core::pin::Pin; use std::net::Ipv4Addr; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; /// Collects forwarded telegrams; `Sync`, as [`TelegramSink`] requires. @@ -432,4 +479,126 @@ mod tests { task.abort(); } + + /// A real socket that can be told to report no bound address, as an Embassy + /// stack does whenever `config_v4()` is `None` — DHCP renewal, link flap. + struct FlappingSocket { + inner: tokio::net::UdpSocket, + report_addr: bool, + fail_recv: bool, + } + + // Plain `async fn`s: on std the compiler discharges the traits' `+ Send` + // return bounds, exactly as the real `TokioNet` sockets do. + impl Datagram for FlappingSocket { + async fn send_to(&mut self, buf: &[u8], to: SocketAddr) -> TransportResult<()> { + self.inner + .send_to(buf, to) + .await + .map(|_| ()) + .map_err(|_| TransportError::Io) + } + + async fn recv_from(&mut self, buf: &mut [u8]) -> TransportResult<(usize, SocketAddr)> { + if self.fail_recv { + // Drives the engine to `ResetSocket`, so the task rebinds. + return Err(TransportError::Io); + } + self.inner + .recv_from(buf) + .await + .map_err(|_| TransportError::Io) + } + + fn local_addr(&self) -> Option { + self.report_addr + .then(|| self.inner.local_addr().ok()) + .flatten() + } + } + + /// Binds a socket that knows its address on the first cycle and, like a + /// stack mid-DHCP-renewal, does not on the cycles after it. + #[derive(Default)] + struct FlappingBinder(AtomicUsize); + + impl DatagramBinder for FlappingBinder { + type Socket = FlappingSocket; + + async fn bind(&self, port: u16) -> TransportResult { + let cycle = self.0.fetch_add(1, Ordering::SeqCst); + let inner = tokio::net::UdpSocket::bind((Ipv4Addr::LOCALHOST, port)) + .await + .map_err(|_| TransportError::Io)?; + Ok(FlappingSocket { + inner, + // Only the first cycle can answer `local_addr`. + report_addr: cycle == 0, + // ...and only the first cycle errors, to force the rebind. + fail_recv: cycle == 0, + }) + } + } + + /// A rebind that cannot learn its address must advertise the NAT-style + /// HPAI, never the previous cycle's port. + /// + /// `engine` outlives the bind loop, so an endpoint set on one cycle would + /// otherwise persist into the next. The gateway would then reply to a port + /// nothing is bound to any more and the handshake could never complete — + /// strictly worse than the `0.0.0.0:0` the explicit HPAI exists to avoid. + /// + /// The second request arrives after the engine's reconnect backoff, hence + /// the wider timeout. + #[tokio::test] + async fn a_rebind_that_cannot_learn_its_address_falls_back_to_nat() { + const BACKOFF_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); + + let gateway = tokio::net::UdpSocket::bind("127.0.0.1:0") + .await + .expect("bind fake gateway"); + let gateway_addr = gateway.local_addr().expect("gateway addr"); + + let task = tokio::spawn(connection_task( + FlappingBinder::default(), + gateway_addr, + runtime(), + TokioDelay, + VecSink::default(), + NoCommands, + )); + + // Cycle 1: the socket knows its address, so the HPAI is explicit. + let mut buf = [0u8; 128]; + let (len, _) = tokio::time::timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("gateway received no first CONNECT_REQUEST") + .expect("recv_from"); + assert!(len >= 14, "CONNECT_REQUEST should carry both HPAIs"); + let first_port = u16::from_be_bytes([buf[12], buf[13]]); + assert_ne!(first_port, 0, "first cycle should advertise a real port"); + + // Cycle 2: the recv error reset the socket and the rebind cannot answer + // `local_addr`, so the endpoint must fall back rather than persist. + let mut buf = [0u8; 128]; + let (len, _) = tokio::time::timeout(BACKOFF_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("gateway received no CONNECT_REQUEST after the rebind") + .expect("recv_from"); + assert!(len >= 14, "CONNECT_REQUEST should carry both HPAIs"); + + let second_port = u16::from_be_bytes([buf[12], buf[13]]); + assert_ne!( + second_port, first_port, + "rebind re-advertised the previous cycle's port: the endpoint went stale" + ); + assert_eq!( + &buf[8..12], + &[0, 0, 0, 0], + "a rebind with no known address must advertise the NAT-style HPAI" + ); + assert_eq!(second_port, 0, "NAT-style HPAI carries port 0"); + + task.abort(); + } } diff --git a/aimdb-knx-connector/tests/shared_channel_on_std.rs b/aimdb-knx-connector/tests/shared_channel_on_std.rs index bf07764a..f0e986e5 100644 --- a/aimdb-knx-connector/tests/shared_channel_on_std.rs +++ b/aimdb-knx-connector/tests/shared_channel_on_std.rs @@ -4,9 +4,10 @@ //! `CriticalSectionRawMutex` is the only `Sync` raw mutex `embassy-sync` offers //! — `NoopRawMutex` is `!Sync` and cannot back a shared channel at all — and //! using it pulls in `_critical_section_1_0_acquire`/`_release`, which nothing -//! defines on std. The `tokio-runtime` feature enables `critical-section/std` -//! so no downstream user meets that link error. These tests fail to *link*, not -//! to compile, if that ever comes undone. +//! defines on std. Selecting an impl is the final binary's call, so the library +//! does not make it: a test binary is a binary, and gets the std impl through +//! this crate's `critical-section` dev-dependency. These tests fail to *link*, +//! not to compile, if that ever comes undone. #![cfg(feature = "tokio-runtime")] use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; From 97e5549a03a1d6ad9c1511b2b239063084b18b9b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 19:33:39 +0000 Subject: [PATCH 18/19] refactor(052): name the neutral modules for what they hold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `neutral` named these modules by contrast with the per-runtime modules they displace, not by what they contain — both declarations said as much ("the runtime-independent replacement for the two client modules"). Wave B deletes that contrast partner, after which `neutral` distinguishes the module from nothing: everything left in each crate is runtime-neutral, as `tunnel.rs` already was without needing the word in its name. The decay had started: in `aimdb-knx-connector/src/lib.rs`, `pub mod neutral` sat under a `// Platform-specific implementations` header saying the opposite of what it is. Renaming now because `pub mod neutral` is public API. Wave A is additive and unreleased, so this is the last moment the change is free rather than breaking. aimdb-knx-connector/src/neutral.rs -> src/client.rs aimdb-serial-connector/src/neutral.rs -> src/framer.rs aimdb-tcp-connector/tests/neutral_pool.rs -> tests/accept_pool.rs aimdb-serial-connector/tests/neutral_framed.rs -> tests/framed.rs aimdb-embassy-adapter/tests/neutral_udp.rs -> tests/udp.rs This also settles on the convention core and the adapters already used for the same layer — `session::io` and `net`, both named for their contents. Pure rename: no content changes beyond the module declarations, one import, the misfiled header comment, the Makefile's `--test` target, and the changelog and design-doc pointers into these paths. Prose uses of the word where it is a genuine adjective ("runtime-neutral", "role-neutral") are untouched. Verified: clippy `-D warnings` on both connectors for tokio and the `thumbv7em` embassy cross-compile; knx (37 lib + 35 integration), serial, `accept_pool` over two real embassy stacks, `udp`; `make doc`; `cargo fmt --check`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ --- Makefile | 8 +++---- aimdb-embassy-adapter/CHANGELOG.md | 2 +- aimdb-embassy-adapter/Cargo.toml | 2 +- aimdb-embassy-adapter/src/net.rs | 2 +- .../tests/{neutral_udp.rs => udp.rs} | 0 aimdb-knx-connector/CHANGELOG.md | 2 +- .../src/{neutral.rs => client.rs} | 0 aimdb-knx-connector/src/lib.rs | 9 ++++---- aimdb-serial-connector/CHANGELOG.md | 2 +- .../src/{neutral.rs => framer.rs} | 0 aimdb-serial-connector/src/lib.rs | 7 +++--- .../tests/{neutral_framed.rs => framed.rs} | 2 +- aimdb-tcp-connector/CHANGELOG.md | 2 +- aimdb-tcp-connector/Cargo.toml | 2 +- .../tests/{neutral_pool.rs => accept_pool.rs} | 0 docs/design/052-runtime-neutral-connectors.md | 10 ++++----- docs/design/052-verification.md | 22 +++++++++---------- 17 files changed, 37 insertions(+), 35 deletions(-) rename aimdb-embassy-adapter/tests/{neutral_udp.rs => udp.rs} (100%) rename aimdb-knx-connector/src/{neutral.rs => client.rs} (100%) rename aimdb-serial-connector/src/{neutral.rs => framer.rs} (100%) rename aimdb-serial-connector/tests/{neutral_framed.rs => framed.rs} (97%) rename aimdb-tcp-connector/tests/{neutral_pool.rs => accept_pool.rs} (100%) diff --git a/Makefile b/Makefile index fb39cef2..97110e57 100644 --- a/Makefile +++ b/Makefile @@ -225,8 +225,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-tokio" @printf "$(YELLOW) → Testing TCP connector (embassy: socket recycle + concurrent slots + redial over an embassy-net loopback)$(NC)\n" cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback - @printf "$(YELLOW) → Testing TCP connector (neutral accept pool over two embassy-net stacks)$(NC)\n" - cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test neutral_pool + @printf "$(YELLOW) → Testing TCP connector (accept pool over two embassy-net stacks)$(NC)\n" + cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -354,8 +354,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on TCP connector (embassy-net loopback smoke, host)$(NC)\n" cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -D warnings - @printf "$(YELLOW) → Clippy on TCP connector (neutral accept pool, host)$(NC)\n" - cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test neutral_pool -- -D warnings + @printf "$(YELLOW) → Clippy on TCP connector (accept pool, host)$(NC)\n" + cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool -- -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 @printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n" diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 85f1b2c5..ec61f9fe 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -13,7 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`net` feature — Embassy behind core's neutral I/O traits.** `EmbassyNet::tcp`, +- **`net` feature — Embassy behind core's runtime-neutral I/O traits.** `EmbassyNet::tcp`, `listen::`, `udp` and `EmbassyUart` supply the sockets and UART halves; `EmbassyDelay` is gated on `embassy-time` separately so a sockets-only consumer does not pull in `defmt-timestamp-uptime`'s `_defmt_timestamp`. The listener diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index c1ebc926..10e4fdda 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -89,7 +89,7 @@ aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = fal ] } # Two crossover-wired embassy-net stacks for the host UDP test -# (`tests/neutral_udp.rs`), the same rig the TCP connector's loopback uses. +# (`tests/udp.rs`), the same rig the TCP connector's loopback uses. embassy-net = { workspace = true, features = [ "medium-ip", "proto-ipv4", diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index f110937b..cac48efe 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -271,7 +271,7 @@ type PendingAccept = Pin { local_endpoint: IpListenEndpoint, diff --git a/aimdb-embassy-adapter/tests/neutral_udp.rs b/aimdb-embassy-adapter/tests/udp.rs similarity index 100% rename from aimdb-embassy-adapter/tests/neutral_udp.rs rename to aimdb-embassy-adapter/tests/udp.rs diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 2c2dee85..12816867 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -28,7 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`neutral::connection_task` — one connection task for both runtimes.** Generic +- **`client::connection_task` — one connection task for both runtimes.** Generic over core's `DatagramBinder` and `Delay`, it binds, advertises the socket's real local endpoint (HPAI) instead of the NAT-style `0.0.0.0:0` — falling back to NAT on a cycle whose stack exposes no address, so a rebind never re-advertises the diff --git a/aimdb-knx-connector/src/neutral.rs b/aimdb-knx-connector/src/client.rs similarity index 100% rename from aimdb-knx-connector/src/neutral.rs rename to aimdb-knx-connector/src/client.rs diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index c5750676..9ba3b3e3 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -149,12 +149,13 @@ pub use knx_pico::dpt::{Dpt1, Dpt5, Dpt9, DptDecode, DptEncode}; // Runtime-neutral KNX/IP tunneling state machine shared by both transports. pub mod tunnel; -// Platform-specific implementations -// One connection task generic over core's neutral datagram traits — the -// runtime-independent replacement for the two client modules. +// The connection task: one body for both runtimes, generic over core's +// datagram and delay traits. Supersedes the two per-runtime client modules +// below, which it will replace outright. #[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub mod neutral; +pub mod client; +// Platform-specific implementations #[cfg(feature = "tokio-runtime")] pub mod tokio_client; diff --git a/aimdb-serial-connector/CHANGELOG.md b/aimdb-serial-connector/CHANGELOG.md index da68727c..eb59d0ef 100644 --- a/aimdb-serial-connector/CHANGELOG.md +++ b/aimdb-serial-connector/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`neutral` module — the connector reduced to framing.** `CobsFramer` against +- **`framer` module — the connector reduced to framing.** `CobsFramer` against core's `Framer` plus core's `FramedConnection` serve both runtimes; the byte sources come from the adapters (`TokioByteStream`, `EmbassyUart`), so this crate names no socket or UART type of its own. diff --git a/aimdb-serial-connector/src/neutral.rs b/aimdb-serial-connector/src/framer.rs similarity index 100% rename from aimdb-serial-connector/src/neutral.rs rename to aimdb-serial-connector/src/framer.rs diff --git a/aimdb-serial-connector/src/lib.rs b/aimdb-serial-connector/src/lib.rs index d67da022..27f82c79 100644 --- a/aimdb-serial-connector/src/lib.rs +++ b/aimdb-serial-connector/src/lib.rs @@ -32,10 +32,11 @@ extern crate alloc; pub mod framing; -// The COBS framer against core's `Framer`, plus one `ByteStream` per byte -// source — the runtime-neutral replacement for the two transport modules. +// The COBS framer against core's `Framer`, plus the `FramedConnection` aliases +// it forms with each adapter's byte source. Supersedes the two per-runtime +// transport modules below, which it will replace outright. #[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub mod neutral; +pub mod framer; #[cfg(feature = "tokio-runtime")] pub mod tokio_transport; diff --git a/aimdb-serial-connector/tests/neutral_framed.rs b/aimdb-serial-connector/tests/framed.rs similarity index 97% rename from aimdb-serial-connector/tests/neutral_framed.rs rename to aimdb-serial-connector/tests/framed.rs index f0c1cabe..275f2aae 100644 --- a/aimdb-serial-connector/tests/neutral_framed.rs +++ b/aimdb-serial-connector/tests/framed.rs @@ -3,7 +3,7 @@ #![cfg(feature = "tokio-runtime")] use aimdb_core::session::Connection; -use aimdb_serial_connector::neutral::{CobsFramer, TokioFramed, WRITE_CHUNK}; +use aimdb_serial_connector::framer::{CobsFramer, TokioFramed, WRITE_CHUNK}; use aimdb_tokio_adapter::net::TokioByteStream; /// A duplex pipe standing in for a `SerialStream`, framed at both ends. diff --git a/aimdb-tcp-connector/CHANGELOG.md b/aimdb-tcp-connector/CHANGELOG.md index e3e8f6a6..9f0de737 100644 --- a/aimdb-tcp-connector/CHANGELOG.md +++ b/aimdb-tcp-connector/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`tests/neutral_pool.rs`** — the adapter's pooled `StreamListener` over two +- **`tests/accept_pool.rs`** — the adapter's pooled `StreamListener` over two crossover-wired `embassy-net` stacks, with a rebuild-and-cancel pool as the negative control: it loses a SYN arriving between accepts, the stored-accept pool does not. diff --git a/aimdb-tcp-connector/Cargo.toml b/aimdb-tcp-connector/Cargo.toml index cde67686..563f8705 100644 --- a/aimdb-tcp-connector/Cargo.toml +++ b/aimdb-tcp-connector/Cargo.toml @@ -52,7 +52,7 @@ _test-tokio = ["tokio-runtime", "dep:aimdb-tokio-adapter"] # the `_test-tokio` build too). Run with `--features _test-embassy-loopback`. _test-embassy-loopback = [ "embassy-runtime", - # The adapter's neutral transports, exercised by `tests/neutral_pool.rs` + # The adapter's neutral transports, exercised by `tests/accept_pool.rs` # over the same two real stacks as `embassy_loopback.rs`. "aimdb-embassy-adapter/net", "embassy-net/medium-ip", diff --git a/aimdb-tcp-connector/tests/neutral_pool.rs b/aimdb-tcp-connector/tests/accept_pool.rs similarity index 100% rename from aimdb-tcp-connector/tests/neutral_pool.rs rename to aimdb-tcp-connector/tests/accept_pool.rs diff --git a/docs/design/052-runtime-neutral-connectors.md b/docs/design/052-runtime-neutral-connectors.md index bfdde7ad..db62b8c9 100644 --- a/docs/design/052-runtime-neutral-connectors.md +++ b/docs/design/052-runtime-neutral-connectors.md @@ -225,7 +225,7 @@ untouched, still in `LISTEN`. Nothing is ever cancelled, and no socket leaves `LISTEN` between calls — strictly better than today's behaviour, which has an `abort()`/re-`accept()` window. -`aimdb-tcp-connector/tests/neutral_pool.rs` proves both halves over the two +`aimdb-tcp-connector/tests/accept_pool.rs` proves both halves over the two crossover-wired `embassy-net` stacks the existing loopback smoke uses, driven in exactly the shape `serve` accepts in: @@ -252,13 +252,13 @@ separately. | Crate | Becomes | Deleted | |---|---|---| | `aimdb-tcp-connector` | `framing.rs` + `TcpClient::new(dialer)` / `TcpServer::new(listener)` generic over the traits | `tokio_transport.rs`, `embassy_transport.rs` | -| `aimdb-serial-connector` | `neutral.rs` (COBS `Framer` + one `ByteStream` per byte source) + sugar; the `tokio-serial` open helper stays under `std` | `embassy_transport.rs`, most of `tokio_transport.rs` | +| `aimdb-serial-connector` | `framer.rs` (COBS `Framer` + one `ByteStream` per byte source) + sugar; the `tokio-serial` open helper stays under `std` | `embassy_transport.rs`, most of `tokio_transport.rs` | | `aimdb-knx-connector` | `tunnel.rs` + one `connection_task` | `tokio_client.rs`, `embassy_client.rs` | | `aimdb-mqtt-connector` | One `MqttConnector` type with two backends: `Native` (`rumqttc`, `std`) and `Embedded` (`mountain-mqtt` over `handle_messages`, as `embassy_tls.rs` already does) | `embassy_client.rs`'s stack plumbing; `tokio_client.rs` shrinks to the backend | | `aimdb-uds-connector` | Unchanged (std-only by nature); could be `FramedConnection<…, NdjsonFramer>` for uniformity | — | | `aimdb-websocket-connector` | Unchanged (axum, std-only) | — | -**[verified] for serial.** `aimdb-serial-connector/src/neutral.rs` is the +**[verified] for serial.** `aimdb-serial-connector/src/framer.rs` is the shape: one `CobsFramer` written against core's `Framer`, one `ByteStream` per byte source, and core's `FramedConnection` doing the rest. It needs **no** `aimdb-tokio-adapter` dependency — the manifest deliberately avoids one — and @@ -273,7 +273,7 @@ compiles the *same* `FramedConnection<_, CobsFramer, _, _>` over `embedded-io-async` halves on `thumbv7em`, so "one connector module, no runtime `cfg` on the code path" is enforced by the compiler rather than asserted. -**[verified] for KNX.** `aimdb-knx-connector/src/neutral.rs` is the single +**[verified] for KNX.** `aimdb-knx-connector/src/client.rs` is the single `connection_task`, generic over `DatagramBinder + Delay`, compiling for Embassy on `thumbv7em` and for Tokio from one body. Two tests hold it to the contract that matters: `unified_task_is_boxable_as_the_runners_send_future` @@ -410,7 +410,7 @@ it. It is fully mitigable in one line, and the connector must carry it rather than push it downstream: the KNX crate's `tokio-runtime` feature enables `critical-section/std` itself, so no std user of the connector ever sees the -error. `neutral::tests::shared_embassy_channels_carry_telegrams_on_tokio` then +error. `client::tests::shared_embassy_channels_carry_telegrams_on_tokio` then runs this section's claim rather than asserting it — the unified task on Tokio, against a real UDP gateway, carrying a full handshake, an inbound telegram with its ACK, and an outbound command, all through the same `embassy_sync` channel diff --git a/docs/design/052-verification.md b/docs/design/052-verification.md index 4640cbf3..6fbd06a4 100644 --- a/docs/design/052-verification.md +++ b/docs/design/052-verification.md @@ -102,7 +102,7 @@ consumes only the one that completes; the other `N-1` stay pending, untouched, still listening. Nothing is ever cancelled and no socket leaves `LISTEN` between calls. -`aimdb-tcp-connector/tests/neutral_pool.rs` proves it over the same two +`aimdb-tcp-connector/tests/accept_pool.rs` proves it over the same two crossover-wired `embassy-net` stacks the existing loopback smoke uses, driven in exactly the shape `serve` accepts in: @@ -133,7 +133,7 @@ Embassy assembles the address from the socket's bound port and the stack's IPv4 config, and rebinds by `close()` + `bind()` on the same socket rather than recreating it (which would strand its buffers). -`neutral::tests::unified_task_advertises_the_real_local_endpoint` drives the +`client::tests::unified_task_advertises_the_real_local_endpoint` drives the unified task against a real UDP gateway socket and asserts the bytes: the CONNECT_REQUEST's control HPAI carries `127.0.0.1` and the socket's actual bound port, not `0.0.0.0:0`. Worth noting the Embassy half never set the @@ -201,7 +201,7 @@ it. It is also fully mitigable in one line, which the branch does: the KNX connector's `tokio-runtime` feature now enables `critical-section/std` itself, so no downstream std user ever sees the error. -`neutral::tests::shared_embassy_channels_carry_telegrams_on_tokio` then runs +`client::tests::shared_embassy_channels_carry_telegrams_on_tokio` then runs §5.2's claim rather than asserting it — the unified task on Tokio, against a real UDP gateway, carrying a full handshake, an inbound telegram with its ACK, and an outbound command, all through the same `embassy_sync` channel types the @@ -227,8 +227,8 @@ have to stay separable features — `net` does not imply `embassy-time`, and |---|---| | 1 | **Met.** Core carries the new traits, `FramedConnection`, `FramingDialer`/`FramingListener` and `OneShot`, and still cross-compiles to `thumbv7em-none-eabihf` with zero `unsafe`. | | 2 | Needs rewording (it overlooks `aimdb-wasm-adapter`'s 16 `unsafe impl`s and `aimdb-bench`'s one), but now reachable: MQTT is at zero, serial was already at zero, and the TCP connector's remaining three live in the module this design deletes — the adapter's `net` module already replaces them. | -| 3 | **Met for serial**, and the doubt was unfounded: `tokio` for `io-util` alone suffices, with no `aimdb-tokio-adapter` dependency. See §3 of `neutral_framed.rs`. | -| 4 | Still the sharpest criterion. `two_concurrent_sessions` and the three other loopback tests pass unchanged, and `neutral_pool.rs` adds the pooled-`StreamListener` equivalent. Note MQTT's `build_internal` tests are Tokio-only unit tests; there is still **no** host test for either Embassy MQTT path. | +| 3 | **Met for serial**, and the doubt was unfounded: `tokio` for `io-util` alone suffices, with no `aimdb-tokio-adapter` dependency. See §3 of `framed.rs`. | +| 4 | Still the sharpest criterion. `two_concurrent_sessions` and the three other loopback tests pass unchanged, and `accept_pool.rs` adds the pooled-`StreamListener` equivalent. Note MQTT's `build_internal` tests are Tokio-only unit tests; there is still **no** host test for either Embassy MQTT path. | | 5 | **Near-vacuous, unchanged.** `examples/embassy-bench-stm32h5` depends only on `aimdb-core`, `aimdb-embassy-adapter` and `aimdb-bench` — no connector — so it will show no change whatever the refactor does. Replace it with an allocation-counting test on a connector path. | | 6 | Still the most valuable, and still unbuilt. Build it first, not last (§6.3). | @@ -284,7 +284,7 @@ entirely in step 5. behaviour, not plumbing. Acceptance 6 is the mitigation and should be built **first**. - **Everything else is now de-risked by running code.** The traits, both - adapters, the accept pool, the unified KNX task and the neutral framed + adapters, the accept pool, the unified KNX task and the framed connection all exist and are tested. - **What is well covered:** CI cross-compiles every Embassy connector to `thumbv7em-none-eabihf` (`make test-embedded`, 8 connector configurations), @@ -304,13 +304,13 @@ New: for the TCP and UDP paths lives here. - `aimdb-tokio-adapter/src/net.rs` (feature `net`) — the std duals, every future a plain `async fn`, no `unsafe`. -- `aimdb-knx-connector/src/neutral.rs` — one `connection_task` generic over +- `aimdb-knx-connector/src/client.rs` — one `connection_task` generic over `DatagramBinder + Delay`, plus the `embassy_sync` channel bridges. -- `aimdb-serial-connector/src/neutral.rs` — the COBS framer against core's +- `aimdb-serial-connector/src/framer.rs` — the COBS framer against core's trait, and one `ByteStream` per byte source. -- Tests: `aimdb-tcp-connector/tests/neutral_pool.rs`, - `aimdb-serial-connector/tests/neutral_framed.rs`, and unit tests in - `neutral.rs` and `tunnel.rs`. +- Tests: `aimdb-tcp-connector/tests/accept_pool.rs`, + `aimdb-serial-connector/tests/framed.rs`, and unit tests in + `client.rs` and `tunnel.rs`. Changed: `TunnelIo::send` gains `+ Send`; `TlsOptions`'s RNG gains `+ Send` and `TlsSlot` becomes `OneShot`; the KNX `tokio-runtime` feature adopts From aa0ffdb7e72e9199b92d9c194359a03778fe3363 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 19:36:35 +0000 Subject: [PATCH 19/19] refactor(serial): fold framer.rs into framing.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `framer.rs` next to `framing.rs` was a confusable pair for one subject: the COBS codec and that codec behind core's `Framer` trait. They are now one module, with the module doc naming the two layers and why only the second is feature-gated. The gate moves from the module declaration onto the items that need it — `CobsFramer`, the chunk sizes and the `FramedConnection` aliases name `aimdb_core::session`, which core gates on `connector-session`. `encode_frame` and `FrameAccumulator` never needed it and stay ungated, so the codec still builds with no runtime feature at all. Public paths change from `framer::*` to `framing::*`; the items keep their names. Verified: clippy `-D warnings` for tokio, the `thumbv7em` embassy cross-compile (which type-checks `_same_framed_connection_serves_the_uart`, the assertion that the two runtime paths have not diverged), and no runtime feature at all; serial tests; rustdoc on both runtimes; `make doc`; `cargo fmt --check`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019H6kuPb5RKWRX1irYZwHrZ --- aimdb-serial-connector/CHANGELOG.md | 2 +- aimdb-serial-connector/src/framer.rs | 91 ---------------- aimdb-serial-connector/src/framing.rs | 102 ++++++++++++++++++ aimdb-serial-connector/src/lib.rs | 10 +- aimdb-serial-connector/tests/framed.rs | 2 +- docs/design/052-runtime-neutral-connectors.md | 4 +- docs/design/052-verification.md | 2 +- 7 files changed, 111 insertions(+), 102 deletions(-) delete mode 100644 aimdb-serial-connector/src/framer.rs diff --git a/aimdb-serial-connector/CHANGELOG.md b/aimdb-serial-connector/CHANGELOG.md index eb59d0ef..8c8b0116 100644 --- a/aimdb-serial-connector/CHANGELOG.md +++ b/aimdb-serial-connector/CHANGELOG.md @@ -22,7 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`framer` module — the connector reduced to framing.** `CobsFramer` against +- **`framing` gains core's `Framer` — the connector reduced to framing.** `CobsFramer` against core's `Framer` plus core's `FramedConnection` serve both runtimes; the byte sources come from the adapters (`TokioByteStream`, `EmbassyUart`), so this crate names no socket or UART type of its own. diff --git a/aimdb-serial-connector/src/framer.rs b/aimdb-serial-connector/src/framer.rs deleted file mode 100644 index 7006cf52..00000000 --- a/aimdb-serial-connector/src/framer.rs +++ /dev/null @@ -1,91 +0,0 @@ -//! The serial connector reduced to framing: [`CobsFramer`] plus core's -//! `FramedConnection` serve both runtimes. -//! -//! The byte sources come from the adapters — `TokioByteStream` and -//! `EmbassyUart` — so this crate contributes only the framer and names no -//! socket or UART type of its own. - -use aimdb_core::session::Framer; -use alloc::vec::Vec; - -use crate::framing::{encode_frame, FrameAccumulator}; - -/// Per-`read` chunk, matching the UART ring size. -pub const READ_CHUNK: usize = 64; -/// Per-`write_all` chunk: some HAL `BufferedUart::write` rejects a single write -/// larger than its TX ring. -pub const WRITE_CHUNK: usize = 64; - -/// COBS framing against core's [`Framer`], so one framer serves both runtimes. -/// -/// `encode` COBS-encodes a frame and appends the `0x00` sentinel; the -/// accumulator yields one frame per sentinel, skipping a malformed run (COBS is -/// self-synchronizing). -#[derive(Default)] -pub struct CobsFramer { - acc: FrameAccumulator, -} - -impl CobsFramer { - /// A fresh COBS framer. - pub fn new() -> Self { - Self::default() - } -} - -impl Framer for CobsFramer { - fn encode(&self, frame: &[u8], out: &mut Vec) { - encode_frame(frame, out); - } - - fn push_bytes(&mut self, bytes: &[u8]) { - self.acc.push_bytes(bytes); - } - - fn next_frame(&mut self) -> Option, ()>> { - // `FrameError` collapses to `()`: the connection only distinguishes - // "got a frame" from "skip and resync". - self.acc.next_frame().map(|r| r.map_err(|_| ())) - } -} - -/// A framed connection over the Embassy adapter's UART halves. -#[cfg(feature = "embassy-runtime")] -pub type EmbassyFramed = aimdb_core::session::FramedConnection< - aimdb_embassy_adapter::net::EmbassyUart, - CobsFramer, - READ_CHUNK, - WRITE_CHUNK, ->; - -/// A framed connection over any Tokio byte source — a `tokio_serial::SerialStream` -/// in production, a `tokio::io::duplex()` pipe in tests. -#[cfg(feature = "tokio-runtime")] -pub type TokioFramed = aimdb_core::session::FramedConnection< - aimdb_tokio_adapter::net::TokioByteStream, - CobsFramer, - READ_CHUNK, - WRITE_CHUNK, ->; - -/// The same framer and the same core connection over the Embassy UART, boxed as -/// the runner takes it. -/// -/// Type-checking this on `thumbv7em` is what "one connector module, no runtime -/// `cfg` on the code path" means concretely: if the two paths diverge, the -/// embedded check fails here rather than in an example. -#[cfg(feature = "embassy-runtime")] -#[allow(dead_code)] -fn _same_framed_connection_serves_the_uart(rx: Rd, tx: Wr) -where - Rd: embedded_io_async::Read + Send + 'static, - Wr: embedded_io_async::Write + Send + 'static, -{ - use aimdb_core::session::Connection; - use aimdb_embassy_adapter::net::EmbassyUart; - use alloc::boxed::Box; - - let conn: EmbassyFramed = - EmbassyFramed::new(EmbassyUart::new(rx, tx), CobsFramer::new()); - let _boxed: Box = Box::new(conn); -} diff --git a/aimdb-serial-connector/src/framing.rs b/aimdb-serial-connector/src/framing.rs index 6efa2c65..7a76ab22 100644 --- a/aimdb-serial-connector/src/framing.rs +++ b/aimdb-serial-connector/src/framing.rs @@ -9,6 +9,15 @@ //! //! This module is shared by both runtime halves and is pure `no_std + alloc`, so //! the round-trip is unit-tested on the host without any transport. +//! +//! Two layers live here. [`encode_frame`] and [`FrameAccumulator`] are the COBS +//! codec itself, with no dependency on the session substrate. [`CobsFramer`] +//! below is that codec behind core's `Framer` trait, plus the +//! `FramedConnection` aliases it forms with each adapter's byte source — the +//! whole of what this crate contributes to a session, since the byte sources +//! come from the adapters and this crate names no socket or UART type of its +//! own. That half needs `aimdb_core::session`, so it is gated on the runtime +//! features that enable core's `connector-session`. use alloc::vec::Vec; @@ -137,3 +146,96 @@ impl FrameAccumulator { } } } + +// =========================================================================== +// The codec above behind core's `Framer`. +// +// Gated on the runtime features: these items name `aimdb_core::session`, which +// core gates on `connector-session`, and both runtime features turn that on. +// =========================================================================== + +/// Per-`read` chunk, matching the UART ring size. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub const READ_CHUNK: usize = 64; +/// Per-`write_all` chunk: some HAL `BufferedUart::write` rejects a single write +/// larger than its TX ring. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub const WRITE_CHUNK: usize = 64; + +/// COBS framing against core's [`Framer`](aimdb_core::session::Framer), so one +/// framer serves both runtimes. +/// +/// `encode` COBS-encodes a frame and appends the [`DELIM`] sentinel; the +/// accumulator yields one frame per sentinel, skipping a malformed run (COBS is +/// self-synchronizing). +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +#[derive(Default)] +pub struct CobsFramer { + acc: FrameAccumulator, +} + +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +impl CobsFramer { + /// A fresh COBS framer. + pub fn new() -> Self { + Self::default() + } +} + +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +impl aimdb_core::session::Framer for CobsFramer { + fn encode(&self, frame: &[u8], out: &mut Vec) { + encode_frame(frame, out); + } + + fn push_bytes(&mut self, bytes: &[u8]) { + self.acc.push_bytes(bytes); + } + + fn next_frame(&mut self) -> Option, ()>> { + // `FrameError` collapses to `()`: the connection only distinguishes + // "got a frame" from "skip and resync". + self.acc.next_frame().map(|r| r.map_err(|_| ())) + } +} + +/// A framed connection over the Embassy adapter's UART halves. +#[cfg(feature = "embassy-runtime")] +pub type EmbassyFramed = aimdb_core::session::FramedConnection< + aimdb_embassy_adapter::net::EmbassyUart, + CobsFramer, + READ_CHUNK, + WRITE_CHUNK, +>; + +/// A framed connection over any Tokio byte source — a `tokio_serial::SerialStream` +/// in production, a `tokio::io::duplex()` pipe in tests. +#[cfg(feature = "tokio-runtime")] +pub type TokioFramed = aimdb_core::session::FramedConnection< + aimdb_tokio_adapter::net::TokioByteStream, + CobsFramer, + READ_CHUNK, + WRITE_CHUNK, +>; + +/// The same framer and the same core connection over the Embassy UART, boxed as +/// the runner takes it. +/// +/// Type-checking this on `thumbv7em` is what "one connector module, no runtime +/// `cfg` on the code path" means concretely: if the two paths diverge, the +/// embedded check fails here rather than in an example. +#[cfg(feature = "embassy-runtime")] +#[allow(dead_code)] +fn _same_framed_connection_serves_the_uart(rx: Rd, tx: Wr) +where + Rd: embedded_io_async::Read + Send + 'static, + Wr: embedded_io_async::Write + Send + 'static, +{ + use aimdb_core::session::Connection; + use aimdb_embassy_adapter::net::EmbassyUart; + use alloc::boxed::Box; + + let conn: EmbassyFramed = + EmbassyFramed::new(EmbassyUart::new(rx, tx), CobsFramer::new()); + let _boxed: Box = Box::new(conn); +} diff --git a/aimdb-serial-connector/src/lib.rs b/aimdb-serial-connector/src/lib.rs index 27f82c79..56e68aaf 100644 --- a/aimdb-serial-connector/src/lib.rs +++ b/aimdb-serial-connector/src/lib.rs @@ -30,14 +30,12 @@ extern crate alloc; +// The COBS codec, and (under either runtime feature) that codec behind core's +// `Framer` plus the `FramedConnection` aliases it forms with each adapter's +// byte source. Supersedes the two per-runtime transport modules below, which it +// will replace outright. pub mod framing; -// The COBS framer against core's `Framer`, plus the `FramedConnection` aliases -// it forms with each adapter's byte source. Supersedes the two per-runtime -// transport modules below, which it will replace outright. -#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub mod framer; - #[cfg(feature = "tokio-runtime")] pub mod tokio_transport; diff --git a/aimdb-serial-connector/tests/framed.rs b/aimdb-serial-connector/tests/framed.rs index 275f2aae..ad7eb64d 100644 --- a/aimdb-serial-connector/tests/framed.rs +++ b/aimdb-serial-connector/tests/framed.rs @@ -3,7 +3,7 @@ #![cfg(feature = "tokio-runtime")] use aimdb_core::session::Connection; -use aimdb_serial_connector::framer::{CobsFramer, TokioFramed, WRITE_CHUNK}; +use aimdb_serial_connector::framing::{CobsFramer, TokioFramed, WRITE_CHUNK}; use aimdb_tokio_adapter::net::TokioByteStream; /// A duplex pipe standing in for a `SerialStream`, framed at both ends. diff --git a/docs/design/052-runtime-neutral-connectors.md b/docs/design/052-runtime-neutral-connectors.md index db62b8c9..9524c7b4 100644 --- a/docs/design/052-runtime-neutral-connectors.md +++ b/docs/design/052-runtime-neutral-connectors.md @@ -252,13 +252,13 @@ separately. | Crate | Becomes | Deleted | |---|---|---| | `aimdb-tcp-connector` | `framing.rs` + `TcpClient::new(dialer)` / `TcpServer::new(listener)` generic over the traits | `tokio_transport.rs`, `embassy_transport.rs` | -| `aimdb-serial-connector` | `framer.rs` (COBS `Framer` + one `ByteStream` per byte source) + sugar; the `tokio-serial` open helper stays under `std` | `embassy_transport.rs`, most of `tokio_transport.rs` | +| `aimdb-serial-connector` | `framing.rs` (the COBS codec + core `Framer` + one `ByteStream` per byte source) + sugar; the `tokio-serial` open helper stays under `std` | `embassy_transport.rs`, most of `tokio_transport.rs` | | `aimdb-knx-connector` | `tunnel.rs` + one `connection_task` | `tokio_client.rs`, `embassy_client.rs` | | `aimdb-mqtt-connector` | One `MqttConnector` type with two backends: `Native` (`rumqttc`, `std`) and `Embedded` (`mountain-mqtt` over `handle_messages`, as `embassy_tls.rs` already does) | `embassy_client.rs`'s stack plumbing; `tokio_client.rs` shrinks to the backend | | `aimdb-uds-connector` | Unchanged (std-only by nature); could be `FramedConnection<…, NdjsonFramer>` for uniformity | — | | `aimdb-websocket-connector` | Unchanged (axum, std-only) | — | -**[verified] for serial.** `aimdb-serial-connector/src/framer.rs` is the +**[verified] for serial.** `aimdb-serial-connector/src/framing.rs` is the shape: one `CobsFramer` written against core's `Framer`, one `ByteStream` per byte source, and core's `FramedConnection` doing the rest. It needs **no** `aimdb-tokio-adapter` dependency — the manifest deliberately avoids one — and diff --git a/docs/design/052-verification.md b/docs/design/052-verification.md index 6fbd06a4..e671fe30 100644 --- a/docs/design/052-verification.md +++ b/docs/design/052-verification.md @@ -306,7 +306,7 @@ New: future a plain `async fn`, no `unsafe`. - `aimdb-knx-connector/src/client.rs` — one `connection_task` generic over `DatagramBinder + Delay`, plus the `embassy_sync` channel bridges. -- `aimdb-serial-connector/src/framer.rs` — the COBS framer against core's +- `aimdb-serial-connector/src/framing.rs` — the COBS framer against core's trait, and one `ByteStream` per byte source. - Tests: `aimdb-tcp-connector/tests/accept_pool.rs`, `aimdb-serial-connector/tests/framed.rs`, and unit tests in