From 4088960e3f7f4380b871706a79d1f0226671596f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:07:19 +0000 Subject: [PATCH 01/48] feat(mqtt): implement platform-agnostic MqttConnector with Native and Embedded backends --- aimdb-mqtt-connector/src/connector.rs | 139 ++++++++++++++++++ aimdb-mqtt-connector/src/lib.rs | 28 ++-- .../embassy-mqtt-connector-demo/src/main.rs | 4 +- .../weather-station-gamma/src/main.rs | 4 +- 4 files changed, 153 insertions(+), 22 deletions(-) create mode 100644 aimdb-mqtt-connector/src/connector.rs diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs new file mode 100644 index 00000000..5f36c3bd --- /dev/null +++ b/aimdb-mqtt-connector/src/connector.rs @@ -0,0 +1,139 @@ +//! One `MqttConnector` over two protocol backends. +//! +//! Unlike the other connectors, MQTT does not converge on a single protocol +//! implementation. `rumqttc` owns its socket, TLS and reconnect — its +//! `Transport` is a closed enum, so no stream can be injected — while +//! `mountain-mqtt` is generic over `embedded-io-async`. The two stay separate, +//! and this type is the seam between them: a backend can be swapped or removed +//! without touching the other. +//! +//! | Backend | Client | QoS | TLS | +//! |---|---|---|---| +//! | [`Native`] | `rumqttc` (std) | 0–2 | rustls | +//! | [`Embedded`] | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | + +use alloc::boxed::Box; +use alloc::vec::Vec; +use core::future::Future; +use core::pin::Pin; + +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::{AimDb, DbResult}; + +/// The `rumqttc` backend: a host client owning its own socket and TLS. +#[cfg(feature = "tokio-runtime")] +pub struct Native(crate::tokio_client::MqttConnectorBuilder); + +/// The `mountain-mqtt` backend: `no_std`, over the device's network stack. +#[cfg(feature = "embassy-runtime")] +pub struct Embedded(crate::embassy_client::MqttConnectorBuilder); + +/// An MQTT connector over the backend `B`. +pub struct MqttConnector { + backend: B, +} + +#[cfg(feature = "tokio-runtime")] +impl MqttConnector { + /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). + /// + /// Without [`with_client_id`](Self::with_client_id) a random UUID-based + /// client id is generated at build. + pub fn new(broker_url: impl Into) -> Self { + Self { + backend: Native(crate::tokio_client::MqttConnectorBuilder::new(broker_url)), + } + } + + /// Set the MQTT client id. + pub fn with_client_id(self, client_id: impl Into) -> Self { + Self { + backend: Native(self.backend.0.with_client_id(client_id)), + } + } +} + +#[cfg(feature = "embassy-runtime")] +impl MqttConnector { + /// Connect to `broker_url` over the device's network stack. + /// + /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS + /// (default 8883) and needs the `embassy-tls` feature plus + /// [`with_tls`](Self::with_tls). + pub fn new( + broker_url: impl Into, + stack: &'static embassy_net::Stack<'static>, + ) -> Self { + Self { + backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new( + broker_url, stack, + )), + } + } + + /// Set the MQTT client id (defaults to `aimdb-client`). + pub fn with_client_id(self, client_id: impl Into) -> Self { + Self { + backend: Embedded(self.backend.0.with_client_id(client_id)), + } + } + + /// Set the broker username and password. + pub fn with_credentials( + self, + username: impl Into, + password: impl Into, + ) -> Self { + Self { + backend: Embedded(self.backend.0.with_credentials(username, password)), + } + } + + /// Provide the TLS materials for an `mqtts://` broker. + #[cfg(feature = "embassy-tls")] + pub fn with_tls(self, options: crate::embassy_tls::TlsOptions) -> Self { + Self { + backend: Embedded(self.backend.0.with_tls(options)), + } + } +} + +#[cfg(feature = "tokio-runtime")] +impl ConnectorBuilder for MqttConnector { + fn build<'a>( + &'a self, + db: &'a AimDb, + ) -> Pin< + Box< + dyn Future + Send>>>>> + + Send + + 'a, + >, + > { + self.backend.0.build(db) + } + + fn scheme(&self) -> &str { + self.backend.0.scheme() + } +} + +#[cfg(feature = "embassy-runtime")] +impl ConnectorBuilder for MqttConnector { + fn build<'a>( + &'a self, + db: &'a AimDb, + ) -> Pin< + Box< + dyn Future + Send>>>>> + + Send + + 'a, + >, + > { + self.backend.0.build(db) + } + + fn scheme(&self) -> &str { + self.backend.0.scheme() + } +} diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 6e8c064c..1e597063 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -95,6 +95,10 @@ extern crate alloc; // MQTT knobs over core's generic link builders (works on every feature leg) +// One `MqttConnector` over the `Native` and `Embedded` protocol backends. +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub mod connector; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; @@ -116,21 +120,9 @@ pub mod embassy_tls; #[cfg(feature = "embassy-tls")] pub mod sntp; -// Re-export platform-specific types -// Both implementations use MqttConnectorBuilder for API consistency -// When both features are enabled (e.g., during testing), prefer tokio -#[cfg(all(feature = "tokio-runtime", not(feature = "embassy-runtime")))] -pub use tokio_client::MqttConnectorBuilder as MqttConnector; - -#[cfg(all(feature = "embassy-runtime", not(feature = "tokio-runtime")))] -pub use embassy_client::MqttConnectorBuilder as MqttConnector; - -// When both features are enabled, export both with different names -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use tokio_client::MqttConnectorBuilder as TokioMqttConnector; - -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use embassy_client::MqttConnectorBuilder as EmbassyMqttConnector; - -#[cfg(all(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use tokio_client::MqttConnectorBuilder as MqttConnector; // Default to tokio when both enabled +#[cfg(feature = "embassy-runtime")] +pub use connector::Embedded; +#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] +pub use connector::MqttConnector; +#[cfg(feature = "tokio-runtime")] +pub use connector::Native; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index abd996c8..4aa7e5cb 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -90,7 +90,7 @@ use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] use aimdb_mqtt_connector::embassy_client::TlsOptions; @@ -385,7 +385,7 @@ async fn main(spawner: Spawner) { // Read-only: each record has a single writer (a sensor source, or MQTT for the // command records), so remote `record.set` is refused — peers can // list/drain/subscribe, not write. - let mqtt = MqttConnectorBuilder::new(&broker_url, stack).with_client_id("embassy-demo-001"); + let mqtt = MqttConnector::new(&broker_url, stack).with_client_id("embassy-demo-001"); // TLS materials: the board's TRNG, the broker's root CA, and the record // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer 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 7202efd0..7baa0967 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -28,7 +28,7 @@ use aimdb_core::{AimDbBuilder, RecordKey}; #[cfg(feature = "sim")] use aimdb_data_contracts::{RandomWalkParams, SimProfile, SimulatableRegistrarExt}; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; use defmt::*; use embassy_executor::Spawner; use embassy_net::StackResources; @@ -251,7 +251,7 @@ async fn main(spawner: Spawner) { let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); let mut builder = AimDbBuilder::new().runtime(runtime.clone()).with_connector( - MqttConnectorBuilder::new(&broker_url, stack).with_client_id("weather-station-gamma"), + MqttConnector::new(&broker_url, stack).with_client_id("weather-station-gamma"), ); // Configure temperature record From ce28e5320f9cb1e4e2b96121ee6b1fb8611f48b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:21:41 +0000 Subject: [PATCH 02/48] feat(mqtt): implement embedded backend transport for MQTT connector --- aimdb-embassy-adapter/src/net.rs | 52 ++++++++++++++++++++ aimdb-mqtt-connector/Cargo.toml | 2 + aimdb-mqtt-connector/src/lib.rs | 4 ++ aimdb-mqtt-connector/src/transport.rs | 71 +++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) create mode 100644 aimdb-mqtt-connector/src/transport.rs diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index cac48efe..a2e4d74d 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -208,6 +208,58 @@ impl ByteStream for EmbassyTcpStream { } } +// `embedded-io-async` by delegation, so a protocol client that consumes those +// traits (mountain-mqtt, embedded-tls) sees the type it expects. `ReadReady` is +// the one `ByteStream` cannot express, and the socket has it. +impl embedded_io_async::ErrorType for EmbassyTcpStream { + type Error = embedded_io_async::ErrorKind; +} + +impl embedded_io_async::Read for EmbassyTcpStream { + async fn read(&mut self, buf: &mut [u8]) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Read::read(socket, buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +impl embedded_io_async::Write for EmbassyTcpStream { + async fn write(&mut self, buf: &[u8]) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Write::write(socket, buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::Write::flush(socket) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + +impl embedded_io_async::ReadReady for EmbassyTcpStream { + fn read_ready(&mut self) -> Result { + let socket = self + .socket + .as_mut() + .ok_or(embedded_io_async::ErrorKind::BrokenPipe)?; + embedded_io_async::ReadReady::read_ready(socket) + .map_err(|_| embedded_io_async::ErrorKind::Other) + } +} + /// Dials TCP connections over one caller-owned socket. pub struct EmbassyTcpDialer { slot: Arc, diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 42189d9c..b1e5548c 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -46,6 +46,8 @@ embassy-runtime = [ "embassy-net", "mountain-mqtt", "mountain-mqtt-embassy", + # The `SocketTransport` bridge names these traits in its bounds. + "dep:embedded-io-async", "heapless", "static_cell", ] diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 1e597063..7e61adfc 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -99,6 +99,10 @@ extern crate alloc; #[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] pub mod connector; +// The broker transport seam for the `Embedded` backend. +#[cfg(feature = "embassy-runtime")] +pub mod transport; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs new file mode 100644 index 00000000..50d28b3a --- /dev/null +++ b/aimdb-mqtt-connector/src/transport.rs @@ -0,0 +1,71 @@ +//! The broker transport seam for the [`Embedded`](crate::connector::Embedded) +//! backend. +//! +//! Built on `mountain-mqtt`'s own [`Connection`] rather than core's +//! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs +//! `receive_if_ready` — a non-blocking peek — which a byte stream does not +//! express and a TLS session cannot provide (its readiness is two-layered; +//! see [`crate::embassy_tls`]). Wrapping core's trait would mean every +//! TLS-like transport faking a capability, so the client's own seam is the +//! honest one. +//! +//! A new runtime supplies MQTT by implementing this once. Anything offering +//! `embedded_io_async::{Read, Write}` plus `ReadReady` — an lwIP socket, say — +//! gets there through `mountain_mqtt::embedded_io_async::ConnectionEmbedded` +//! with no protocol code to touch. + +use aimdb_core::session::TransportResult; +use core::future::Future; +use mountain_mqtt::packet_client::Connection; + +/// Opens one broker connection per session. +/// +/// The connector calls this once per reconnect cycle, so an implementation +/// must be able to produce a fresh connection each time. +pub trait BrokerTransport { + /// The connection this transport produces. + type Connection: Connection; + + /// Open a connection to the broker. + fn connect(&self) -> impl Future> + Send; +} + +/// Bridges core's [`StreamDialer`](aimdb_core::session::StreamDialer) to +/// [`BrokerTransport`] for any adapter whose stream also offers the +/// `embedded-io-async` trio. +/// +/// This is the path a new runtime takes: implement `StreamDialer` and delegate +/// `Read`/`Write`/`ReadReady` on the stream, and MQTT follows with no code +/// here. TLS does not come this way — its readiness is two-layered, so it +/// implements [`BrokerTransport`] directly. +pub struct SocketTransport { + dialer: D, + host: alloc::string::String, + port: u16, +} + +impl SocketTransport { + /// Dial `host:port` through `dialer` for each broker session. + pub fn new(dialer: D, host: impl Into, port: u16) -> Self { + Self { + dialer, + host: host.into(), + port, + } + } +} + +impl BrokerTransport for SocketTransport +where + D: aimdb_core::session::StreamDialer + Sync, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ + type Connection = mountain_mqtt::embedded_io_async::ConnectionEmbedded; + + async fn connect(&self) -> TransportResult { + let stream = self.dialer.connect(&self.host, self.port).await?; + Ok(mountain_mqtt::embedded_io_async::ConnectionEmbedded::new( + stream, + )) + } +} From 781fc1c18cc90ad7f9fb9c77a4f2d24e253671b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 11:38:51 +0000 Subject: [PATCH 03/48] feat(mqtt): enhance embassy runtime support and improve documentation --- aimdb-mqtt-connector/Cargo.toml | 1 + aimdb-mqtt-connector/src/connector.rs | 6 +- aimdb-mqtt-connector/src/embassy_client.rs | 71 ++++++++--------- aimdb-mqtt-connector/src/embassy_tls.rs | 16 ++-- aimdb-mqtt-connector/src/sntp.rs | 2 +- aimdb-mqtt-connector/src/transport.rs | 89 +++++++++++++++++++++- 6 files changed, 136 insertions(+), 49 deletions(-) diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index b1e5548c..8071be10 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -40,6 +40,7 @@ embassy-runtime = [ "dep:aimdb-embassy-adapter", # Enable the optional dependency "aimdb-embassy-adapter/embassy-net-support", # Enable EmbassyNetwork trait for network stack access "aimdb-embassy-adapter/connectors", # `EmbassySink`/`EmbassySource`/`into_box_future` spine + "aimdb-embassy-adapter/net", # `EmbassyNet::tcp` — the adapter owns the socket "embassy-executor", "embassy-time", "embassy-sync", diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 5f36c3bd..8af3b25b 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -9,8 +9,8 @@ //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | [`Native`] | `rumqttc` (std) | 0–2 | rustls | -//! | [`Embedded`] | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | `Native` (feature `tokio-runtime`) | `rumqttc` (std) | 0–2 | rustls | +//! | `Embedded` (feature `embassy-runtime`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; use alloc::vec::Vec; @@ -59,7 +59,7 @@ impl MqttConnector { /// /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS /// (default 8883) and needs the `embassy-tls` feature plus - /// [`with_tls`](Self::with_tls). + /// `with_tls` (feature `embassy-tls`). pub fn new( broker_url: impl Into, stack: &'static embassy_net::Stack<'static>, diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 2ec8dab4..ca0d9419 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -68,7 +68,7 @@ use static_cell::StaticCell; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use mountain_mqtt_embassy::mqtt_manager::{self, MqttEvent, Settings}; +use mountain_mqtt_embassy::mqtt_manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] pub use crate::embassy_tls::TlsOptions; @@ -504,9 +504,10 @@ fn static_connection_settings( } /// Sender half of the event channel (used by the broker manager tasks). -type EventSender = Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; +pub(crate) type EventSender = + Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; /// Receiver half of the action channel (drained by the broker manager tasks). -type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; +pub(crate) type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; /// Initialise the static action/event channels shared by both transports /// (one MQTT connector per firmware — `StaticCell` enforces single init). @@ -527,12 +528,11 @@ fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) ) } -/// Set up the plain-TCP broker manager -/// (mountain-mqtt-embassy's `run_with_subscriptions`), returning the action -/// sender (outbound), the event receiver (inbound), and the manager task -/// future. The manager re-subscribes the inbound topics on every connection, -/// so routing survives reconnects. Synchronous — no `.await` — so the caller's -/// `build` future stays `Send`. +/// Set up the plain-TCP broker session loop, returning the action sender +/// (outbound), the event receiver (inbound), and the task future. The loop +/// re-subscribes the inbound topics on every connection, so routing survives +/// reconnects. Synchronous — no `.await` — so the caller's `build` future +/// stays `Send`. fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, @@ -550,36 +550,37 @@ fn setup_manager( let settings = Settings::new(broker_addr, broker.port); let network = stack.get(); - // Manager task: run the broker loop (never returns). The manager - // re-subscribes these topics on every connection, so inbound routing - // survives reconnects (unlike queuing subscribe actions once at startup). - let manager_task = into_box_future(async move { - let subscribe_topics: Vec<(&str, QualityOfService)> = topics - .iter() - .map(|topic| (topic.as_str(), QualityOfService::Qos1)) - .collect(); + // The socket buffers the dialer owns for the process lifetime. `StaticCell` + // enforces one MQTT connector per firmware, as the channels above do. + static SOCKET_RX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); + static SOCKET_TX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); + + // The transport the session loop dials each cycle. Sockets come from the + // adapter; `run_with_subscriptions` is gone because it binds the stack and + // cannot take one. + let transport = crate::transport::SocketTransport::new( + aimdb_embassy_adapter::net::EmbassyNet::tcp( + *network, + SOCKET_RX.init([0; BUFFER_SIZE]), + SOCKET_TX.init([0; BUFFER_SIZE]), + ), + broker.host.clone(), + broker.port, + ); + let manager_task = into_box_future(async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); - #[allow(unreachable_code)] - { - let _: () = mqtt_manager::run_with_subscriptions::< - AimdbMqttAction, - AimdbMqttEvent, - MAX_PROPERTIES, - BUFFER_SIZE, - CHANNEL_SIZE, - >( - *network, - connection_settings, - settings, - &subscribe_topics, - event_sender, - action_receiver, - ) - .await; - } + crate::transport::run_sessions( + transport, + topics, + connection_settings, + settings, + event_sender, + action_receiver, + ) + .await }); Ok((action_sender, event_receiver, alloc::vec![manager_task])) diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 07d35a0f..14038ca6 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -1,16 +1,14 @@ //! TLS transport for the Embassy MQTT client. //! //! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy -//! TCP socket, wrapped in mountain-mqtt's [`ConnectionEmbedded`] so the MQTT -//! layer is identical to the plain path. Certificate verification is -//! `rustpki` (pure Rust) against the application-embedded root CA, with time -//! from the [`sntp`](crate::sntp) task; entropy comes from the -//! application-injected TRNG ([`TlsOptions::new`]). +//! TCP socket, presented to the MQTT layer as its own `Connection` — not +//! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give +//! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) +//! against the application-embedded root CA, with time from the [`sntp`] task; +//! entropy comes from the application-injected TRNG ([`TlsOptions::new`]). //! -//! The session loop is mountain-mqtt-embassy's own public -//! [`handle_messages`](mountain_mqtt_embassy::mqtt_manager::handle_messages) -//! (with [`State`](mountain_mqtt_embassy::mqtt_manager::State) / -//! [`ChannelEventHandler`](mountain_mqtt_embassy::mqtt_manager::ChannelEventHandler)): +//! The session loop is mountain-mqtt-embassy's own public `handle_messages` +//! (with `State` / `ChannelEventHandler`): //! it is transport-agnostic (generic over `Client`), so the only thing this //! module supplies is the transport — resolve → TCP → TLS handshake → session. //! Upstream `run()` shares that exact loop, keeping the plain and TLS paths in diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/sntp.rs index cdd28bf7..8c6e97a1 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/sntp.rs @@ -4,7 +4,7 @@ //! certificate's validity window needs the current Unix time. This module //! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch //! (boot), written after each SNTP sync and read through [`unix_now`] / -//! [`SntpClock`]. The TLS manager spawns [`run`] alongside its broker loop +//! [`SntpClock`]. The TLS manager spawns `run` alongside its broker loop //! and holds the first handshake until the first sync lands. use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs index 50d28b3a..2d5d4bed 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/transport.rs @@ -5,7 +5,7 @@ //! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs //! `receive_if_ready` — a non-blocking peek — which a byte stream does not //! express and a TLS session cannot provide (its readiness is two-layered; -//! see [`crate::embassy_tls`]). Wrapping core's trait would mean every +//! see the `embassy_tls` module). Wrapping core's trait would mean every //! TLS-like transport faking a capability, so the client's own seam is the //! honest one. //! @@ -69,3 +69,90 @@ where )) } } + +/// The broker session loop: connect, run MQTT until the session ends, wait, +/// repeat. Never returns. +/// +/// One implementation for every transport. `handle_messages` re-subscribes +/// `subscribe_topics` on each connection, so inbound routing survives a +/// reconnect — the property `run_with_subscriptions` used to provide, now +/// explicit here because injecting a transport means giving that helper up. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_sessions( + transport: T, + topics: alloc::vec::Vec, + connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, + settings: mountain_mqtt_embassy::mqtt_manager::Settings, + event_sender: crate::embassy_client::EventSender, + mut action_receiver: crate::embassy_client::ActionReceiver, +) -> ! +where + T: BrokerTransport, +{ + use core::cell::RefCell; + use mountain_mqtt::client::ClientNoQueue; + use mountain_mqtt::data::quality_of_service::QualityOfService; + use mountain_mqtt::mqtt_manager::ConnectionId; + use mountain_mqtt_embassy::mqtt_manager::{ + handle_messages, ChannelEventHandler, MqttEvent, State, + }; + + // Built once and borrowed for the loop; re-sent on every connection. + let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics + .iter() + .map(|topic| (topic.as_str(), QualityOfService::Qos1)) + .collect(); + + let mut mqtt_buffer = [0u8; crate::embassy_client::BUFFER_SIZE]; + let mut connection_index = 0u32; + + loop { + let connection = match transport.connect().await { + Ok(connection) => connection, + Err(_e) => { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: connect failed, will retry"); + embassy_time::Timer::after(settings.reconnection_delay).await; + continue; + } + }; + + let state: RefCell> = + RefCell::new(State::new()); + let connection_id = ConnectionId::new(connection_index); + connection_index += 1; + + let event_handler = ChannelEventHandler::new(connection_id, &event_sender, &state); + let mut client = ClientNoQueue::new( + connection, + &mut mqtt_buffer, + mountain_mqtt::embedded_hal_async::DelayEmbedded::new(embassy_time::Delay), + settings.response_timeout.as_millis() as u32, + event_handler, + ); + + if let Err(error) = handle_messages( + connection_id, + &mut client, + &state, + &connection_settings, + &subscribe_topics, + &event_sender, + &mut action_receiver, + &settings, + ) + .await + { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: session errored: {:?}", error); + event_sender + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + } + + embassy_time::Timer::after(settings.reconnection_delay).await; + } +} From e19d1eb72d6da788d5c2839d197e0a7ee9194880 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 12:22:36 +0000 Subject: [PATCH 04/48] feat(mqtt): add internal test for Embassy broker session loop and update dependencies --- Cargo.lock | 4 + Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 25 ++ aimdb-mqtt-connector/tests/embassy_broker.rs | 356 +++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 aimdb-mqtt-connector/tests/embassy_broker.rs diff --git a/Cargo.lock b/Cargo.lock index 1251e840..ef97da55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,13 +295,17 @@ dependencies = [ "aimdb-mountain-mqtt-embassy", "aimdb-tokio-adapter", "async-stream", + "critical-section", "defmt 1.1.1", "embassy-executor", "embassy-net", + "embassy-net-driver-channel", "embassy-sync", "embassy-time", + "embassy-time-driver", "embedded-io-async 0.7.0", "embedded-tls", + "futures", "futures-core", "futures-util", "heapless 0.8.0", diff --git a/Makefile b/Makefile index 97110e57..2c378dbf 100644 --- a/Makefile +++ b/Makefile @@ -227,6 +227,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback @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 + @printf "$(YELLOW) → Testing MQTT connector (broker session loop against a fake broker)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -356,6 +358,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -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 MQTT connector (broker session loop, host)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -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-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 8071be10..4bd1edb2 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -72,6 +72,23 @@ tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] defmt = ["dep:defmt", "aimdb-core/defmt"] +# Internal: the Embassy broker session loop's host smoke +# (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an +# in-memory driver-channel crossover, with a fake broker on one side. Kept off +# `embassy-runtime` (production pulls no network device or critical-section +# impl). Run with `--features _test-embassy-broker`. +_test-embassy-broker = [ + "embassy-runtime", + # The test builds an `AimDb`; `EmbassyAdapter`'s `RuntimeOps` impl is gated + # on the adapter's own clock feature, which production never needs here. + "aimdb-embassy-adapter/embassy-time", + "aimdb-embassy-adapter/embassy-sync", + "embassy-net/medium-ip", + "embassy-net/proto-ipv4", + "dep:embassy-net-driver-channel", + "dep:critical-section", +] + [dependencies] aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false } aimdb-embassy-adapter = { version = "0.6.0", path = "../aimdb-embassy-adapter", default-features = false, optional = true } @@ -137,8 +154,16 @@ static_cell = { version = "2.0", optional = true } # Optional observability defmt = { workspace = true, optional = true } +embassy-net-driver-channel = { version = "0.4.0", optional = true } +critical-section = { version = "1.1", features = ["std"], optional = true } + [dev-dependencies] tokio = { workspace = true, features = ["full"] } +heapless = { workspace = true } +futures = "0.3" +embassy-time-driver = "0.2.2" +# The loopback harness must supply the defmt symbols smoltcp references. +defmt = { workspace = true } tokio-test = "0.4" serde = { workspace = true } aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = false, features = [ diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs new file mode 100644 index 00000000..2f01cdb4 --- /dev/null +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -0,0 +1,356 @@ +//! Host smoke for the Embassy broker session loop (`_test-embassy-broker`). +//! +//! The loop is what replaced mountain-mqtt-embassy's `run_with_subscriptions` +//! when the transport became injectable, so reconnect-and-resubscribe is this +//! crate's behaviour now rather than the helper's. Two `embassy-net` stacks +//! wired by an in-memory driver-channel crossover drive it against a fake +//! broker that speaks just enough MQTT: CONNECT/CONNACK, SUBSCRIBE/SUBACK, and +//! a server-initiated PUBLISH. +#![cfg(feature = "_test-embassy-broker")] + +extern crate alloc; + +use core::future::Future; + +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 defines 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") +} +// No `defmt::timestamp!` here: this config enables the adapter's `embassy-time`, +// whose `defmt-timestamp-uptime` already defines `_defmt_timestamp`. + +/// Real wall-clock time; a frozen `now()` stalls the stack's timers and the +/// session loop's reconnection delay. +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 BROKER_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const CLIENT_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); +const BROKER_PORT: u16 = 1883; + +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; 2048].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: Default::default(), + }); + 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 (broker_stack, mut broker_net, broker_ch) = make_stack(BROKER_IP, 0x1111_2222); + let (client_stack, mut client_net, client_ch) = make_stack(CLIENT_IP, 0x3333_4444); + + let (broker_state, broker_rx, broker_tx) = broker_ch.split(); + let (client_state, client_rx, client_tx) = client_ch.split(); + broker_state.set_link_state(LinkState::Up); + client_state.set_link_state(LinkState::Up); + + let background = join4( + broker_net.run(), + client_net.run(), + cable(broker_tx, client_rx), + cable(client_tx, broker_rx), + ); + let foreground = foreground(broker_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"), + } + }) +} + +// --------------------------------------------------------------------------- +// A fake broker: just enough MQTT 5 to complete a session. +// --------------------------------------------------------------------------- + +/// Accept one TCP connection and answer CONNECT and SUBSCRIBE, then push a +/// PUBLISH. Records what it saw so the test can assert on the wire, not on +/// side effects. +#[derive(Default)] +struct Seen { + connect: bool, + subscribed_topics: alloc::vec::Vec, +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet( + socket: &mut embassy_net::tcp::TcpSocket<'_>, + buf: &mut alloc::vec::Vec, +) -> Option<(u8, alloc::vec::Vec)> { + use embedded_io_async::Read; + + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut alloc::vec::Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +async fn fake_broker(stack: Stack<'static>, seen: &core::cell::RefCell) { + use embedded_io_async::Write; + + let mut socket = embassy_net::tcp::TcpSocket::new(stack, buf(), buf()); + socket.set_timeout(None); + if socket.accept(BROKER_PORT).await.is_err() { + return; + } + + let mut payload = alloc::vec::Vec::new(); + loop { + let Some((first, body)) = read_packet(&mut socket, &mut payload).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK (session present = 0, reason = success, no props) + 1 => { + seen.borrow_mut().connect = true; + let _ = socket.write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]).await; + } + // SUBSCRIBE -> SUBACK granting QoS 1 for each requested topic. + 8 => { + // body: packet id (2) + property length (varint, 0 here) + payload + let packet_id = [body[0], body[1]]; + let mut i = 2; + // Skip the property length varint. + while i < body.len() && body[i] & 0x80 != 0 { + i += 1; + } + i += 1; + let mut granted = alloc::vec::Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + seen.borrow_mut().subscribed_topics.push( + alloc::string::String::from_utf8_lossy(&body[i..i + len]).into_owned(), + ); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + let mut ack = alloc::vec::Vec::new(); + let mut rest = alloc::vec::Vec::new(); + rest.extend_from_slice(&packet_id); + rest.push(0x00); // no properties + rest.extend_from_slice(&granted); + ack.push(0x90); + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + let _ = socket.write_all(&ack).await; + } + // PINGREQ -> PINGRESP + 12 => { + let _ = socket.write_all(&[0xD0, 0x00]).await; + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// The test. +// --------------------------------------------------------------------------- + +/// The session loop completes a broker session over the injected transport: +/// CONNECT is answered, and the inbound topics are **subscribed on the wire**. +/// +/// That subscribe is the property `run_with_subscriptions` used to provide and +/// this crate now owns — without it, inbound routing dies silently on the first +/// reconnect. +#[test] +fn the_session_loop_connects_and_subscribes() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + use alloc::sync::Arc; + use core::cell::RefCell; + + let seen = RefCell::new(Seen::default()); + + let outcome = drive(|broker_stack, client_stack| { + let seen = &seen; + async move { + let stack: &'static Stack<'static> = leak(client_stack); + + let connector = MqttConnector::new( + alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT), + stack, + ) + .with_client_id("host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| alloc::string::String::from("bad payload")) + }) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + + // Drive the runner (which owns the session loop) and the broker + // together until the broker has seen a subscribe. + let session = runner.run(); + let broker = fake_broker(broker_stack, seen); + let until_subscribed = async { + loop { + if !seen.borrow().subscribed_topics.is_empty() { + return; + } + embassy_time::Timer::after(embassy_time::Duration::from_millis(10)).await; + } + }; + + futures::pin_mut!(session); + futures::pin_mut!(broker); + futures::pin_mut!(until_subscribed); + let running = futures::future::select(session, broker); + futures::pin_mut!(running); + let _ = futures::future::select(running, until_subscribed).await; + } + }); + + assert_eq!(outcome, Ok(())); + let seen = seen.borrow(); + assert!(seen.connect, "the broker never saw a CONNECT"); + assert!( + seen.subscribed_topics + .iter() + .any(|t| t == "sensors/temperature"), + "the session must subscribe the inbound topic on the wire; saw {:?}", + seen.subscribed_topics + ); +} From 6ddc1842ff34c23651c20f7717944cd129d0f572 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 12:53:17 +0000 Subject: [PATCH 05/48] feat(mqtt): enhance transport flexibility by allowing caller-supplied StreamDialer --- aimdb-embassy-adapter/src/net.rs | 1 + aimdb-mqtt-connector/src/connector.rs | 56 +++--- aimdb-mqtt-connector/src/embassy_client.rs | 167 +++++++++++------- aimdb-mqtt-connector/tests/embassy_broker.rs | 13 +- .../embassy-mqtt-connector-demo/src/main.rs | 41 +++-- .../weather-station-gamma/src/main.rs | 13 +- 6 files changed, 190 insertions(+), 101 deletions(-) diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index a2e4d74d..7ac8a5d6 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -261,6 +261,7 @@ impl embedded_io_async::ReadReady for EmbassyTcpStream { } /// Dials TCP connections over one caller-owned socket. +#[derive(Clone)] pub struct EmbassyTcpDialer { slot: Arc, } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 8af3b25b..3ce1684f 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -24,9 +24,11 @@ use aimdb_core::{AimDb, DbResult}; #[cfg(feature = "tokio-runtime")] pub struct Native(crate::tokio_client::MqttConnectorBuilder); -/// The `mountain-mqtt` backend: `no_std`, over the device's network stack. +/// The `mountain-mqtt` backend: `no_std`, over a caller-supplied transport. #[cfg(feature = "embassy-runtime")] -pub struct Embedded(crate::embassy_client::MqttConnectorBuilder); +pub struct Embedded( + crate::embassy_client::MqttConnectorBuilder, +); /// An MQTT connector over the backend `B`. pub struct MqttConnector { @@ -55,22 +57,38 @@ impl MqttConnector { #[cfg(feature = "embassy-runtime")] impl MqttConnector { - /// Connect to `broker_url` over the device's network stack. - /// - /// `mqtt://` is plain TCP (default port 1883); `mqtts://` is TLS - /// (default 8883) and needs the `embassy-tls` feature plus - /// `with_tls` (feature `embassy-tls`). - pub fn new( - broker_url: impl Into, + /// Connect to `broker_url`, then supply the transport with + /// [`transport`](Self::transport) (`mqtt://`) or [`tls`](Self::tls) + /// (`mqtts://`, feature `embassy-tls`). + pub fn new(broker_url: impl Into) -> Self { + Self { + backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), + } + } + + /// Dial plain sessions through an adapter's stream dialer — the same call + /// on any runtime's adapter, with no change in this crate. + pub fn transport(self, dialer: D) -> MqttConnector> { + MqttConnector { + backend: Embedded(self.backend.0.transport(dialer)), + } + } + + /// Provide the network stack and TLS materials for an `mqtts://` broker. + #[cfg(feature = "embassy-tls")] + pub fn tls( + self, stack: &'static embassy_net::Stack<'static>, + options: crate::embassy_tls::TlsOptions, ) -> Self { Self { - backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new( - broker_url, stack, - )), + backend: Embedded(self.backend.0.tls(stack, options)), } } +} +#[cfg(feature = "embassy-runtime")] +impl MqttConnector> { /// Set the MQTT client id (defaults to `aimdb-client`). pub fn with_client_id(self, client_id: impl Into) -> Self { Self { @@ -88,14 +106,6 @@ impl MqttConnector { backend: Embedded(self.backend.0.with_credentials(username, password)), } } - - /// Provide the TLS materials for an `mqtts://` broker. - #[cfg(feature = "embassy-tls")] - pub fn with_tls(self, options: crate::embassy_tls::TlsOptions) -> Self { - Self { - backend: Embedded(self.backend.0.with_tls(options)), - } - } } #[cfg(feature = "tokio-runtime")] @@ -119,7 +129,11 @@ impl ConnectorBuilder for MqttConnector { } #[cfg(feature = "embassy-runtime")] -impl ConnectorBuilder for MqttConnector { +impl ConnectorBuilder for MqttConnector> +where + D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a AimDb, diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index ca0d9419..efca8d6e 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -290,38 +290,94 @@ type TlsSlot = aimdb_core::session::OneShot; /// transport: `mqtt://` is plain TCP (default port 1883), `mqtts://` is TLS /// (default port 8883) and requires both the `embassy-tls` feature and the /// `with_tls` method it gates. -pub struct MqttConnectorBuilder { +/// Where the broker connection comes from. +/// +/// Plain sessions dial through a caller-supplied [`StreamDialer`], so a new +/// runtime supplies MQTT by passing its own. TLS keeps the stack: it resolves +/// DNS itself and owns buffers across sessions, which a per-session dialer +/// cannot express. +pub(crate) enum Transport { + Plain(D), + #[cfg(feature = "embassy-tls")] + Tls(aimdb_embassy_adapter::connectors::NetStack, TlsSlot), +} + +/// A dialer placeholder for TLS-only connectors, which never dial through one. +#[derive(Clone, Copy, Default)] +pub struct NoTransport; + +impl aimdb_core::session::StreamDialer for NoTransport { + type Stream = aimdb_embassy_adapter::net::EmbassyTcpStream; + + async fn connect( + &self, + _host: &str, + _port: u16, + ) -> aimdb_core::session::TransportResult { + Err(aimdb_core::session::TransportError::Io) + } +} + +pub struct MqttConnectorBuilder { broker_url: String, client_id: String, credentials: Option<(String, String)>, - #[cfg(feature = "embassy-tls")] - tls: TlsSlot, - stack: aimdb_embassy_adapter::connectors::NetStack, + pub(crate) transport: Transport, } -impl MqttConnectorBuilder { +impl MqttConnectorBuilder { /// Create a new MQTT connector builder for Embassy. /// - /// # Arguments - /// * `broker_url` - Broker URL in format `mqtt://host:port` (plain TCP) - /// 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 { + /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, + /// or [`tls`](Self::tls) for `mqtts://`. + pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), client_id: "aimdb-client".to_string(), credentials: None, - #[cfg(feature = "embassy-tls")] - tls: TlsSlot::default(), + transport: Transport::Plain(NoTransport), + } + } + + /// Dial plain `mqtt://` sessions through an adapter's stream dialer. + /// + /// `EmbassyNet::tcp(stack, rx, tx)` on Embassy; the same call on any other + /// runtime's adapter, with no change here. + pub fn transport(self, dialer: D) -> MqttConnectorBuilder { + MqttConnectorBuilder { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + transport: Transport::Plain(dialer), + } + } + + /// Provide the network stack and TLS materials for an `mqtts://` broker. + /// + /// TLS keeps the stack rather than taking a dialer: it resolves DNS itself + /// and owns buffers across sessions. + #[cfg(feature = "embassy-tls")] + pub fn tls( + self, + stack: &'static embassy_net::Stack<'static>, + options: TlsOptions, + ) -> MqttConnectorBuilder { + MqttConnectorBuilder { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, // 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 - // built from this builder — is polled on that executor. - stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + // every future touching this stack is polled on that executor. + transport: Transport::Tls( + unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + TlsSlot::new(options), + ), } } +} +impl MqttConnectorBuilder { /// Set the MQTT client ID (should be unique per device). pub fn with_client_id(mut self, client_id: impl Into) -> Self { self.client_id = client_id.into(); @@ -340,15 +396,6 @@ impl MqttConnectorBuilder { self.credentials = Some((username.into(), password.into())); self } - - /// Provide the TLS materials for an `mqtts://` broker. - /// - /// Required for `mqtts://` URLs; rejected at `build()` for `mqtt://`. - #[cfg(feature = "embassy-tls")] - pub fn with_tls(mut self, options: TlsOptions) -> Self { - self.tls = TlsSlot::new(options); - self - } } /// Implement ConnectorBuilder trait for Embassy. @@ -356,7 +403,11 @@ impl MqttConnectorBuilder { /// The network stack is taken at construction (see /// [`MqttConnectorBuilder::new`]), so the builder needs nothing from the /// runtime beyond the dyn-safe capabilities the database already holds. -impl ConnectorBuilder for MqttConnectorBuilder { +impl ConnectorBuilder for MqttConnectorBuilder +where + D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a aimdb_core::builder::AimDb, @@ -385,25 +436,21 @@ impl ConnectorBuilder for MqttConnectorBuilder { // Broker manager task(s) + the channel ends for the pumps. // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] - let (action_sender, event_receiver, manager_tasks) = { - let tls_options = self.tls.take(); - match (broker.tls, tls_options) { - (true, Some(options)) => setup_tls_manager( - &broker, - options, - connection_settings, - self.stack, - topics, - )?, - (true, None) => { - return Err(build_err("mqtts:// broker URLs require .with_tls(...)")) - } - (false, Some(_)) => { - return Err(build_err(".with_tls(...) requires an mqtts:// broker URL")) - } - (false, None) => { - setup_manager(&broker, connection_settings, self.stack, topics)? - } + let (action_sender, event_receiver, manager_tasks) = match &self.transport { + Transport::Tls(stack, slot) if broker.tls => { + let options = slot.take().ok_or_else(|| { + build_err("TLS materials already taken; build() ran twice") + })?; + setup_tls_manager(&broker, options, connection_settings, *stack, topics)? + } + Transport::Tls(..) => { + return Err(build_err(".tls(...) requires an mqtts:// broker URL")) + } + Transport::Plain(_) if broker.tls => { + return Err(build_err("mqtts:// broker URLs require .tls(...)")) + } + Transport::Plain(dialer) => { + setup_manager(&broker, connection_settings, dialer.clone(), topics)? } }; #[cfg(not(feature = "embassy-tls"))] @@ -413,7 +460,8 @@ impl ConnectorBuilder for MqttConnectorBuilder { "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", )); } - setup_manager(&broker, connection_settings, self.stack, topics)? + let Transport::Plain(dialer) = &self.transport; + setup_manager(&broker, connection_settings, dialer.clone(), topics)? }; // Outbound publishes + inbound routing ride core's pumps. @@ -533,12 +581,16 @@ fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) /// re-subscribes the inbound topics on every connection, so routing survives /// reconnects. Synchronous — no `.await` — so the caller's `build` future /// stays `Send`. -fn setup_manager( +fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, + dialer: D, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { +) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> +where + D: aimdb_core::session::StreamDialer + Send + Sync + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ let broker_ip = Ipv4Addr::from_str(&broker.host).map_err(|_| { build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") })?; @@ -548,25 +600,12 @@ fn setup_manager( let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); let settings = Settings::new(broker_addr, broker.port); - let network = stack.get(); - - // The socket buffers the dialer owns for the process lifetime. `StaticCell` - // enforces one MQTT connector per firmware, as the channels above do. - static SOCKET_RX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); - static SOCKET_TX: StaticCell<[u8; BUFFER_SIZE]> = StaticCell::new(); // The transport the session loop dials each cycle. Sockets come from the // adapter; `run_with_subscriptions` is gone because it binds the stack and // cannot take one. - let transport = crate::transport::SocketTransport::new( - aimdb_embassy_adapter::net::EmbassyNet::tcp( - *network, - SOCKET_RX.init([0; BUFFER_SIZE]), - SOCKET_TX.init([0; BUFFER_SIZE]), - ), - broker.host.clone(), - broker.port, - ); + let transport = + crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); let manager_task = into_box_future(async move { #[cfg(feature = "defmt")] diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs index 2f01cdb4..25b997e6 100644 --- a/aimdb-mqtt-connector/tests/embassy_broker.rs +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -299,11 +299,14 @@ fn the_session_loop_connects_and_subscribes() { async move { let stack: &'static Stack<'static> = leak(client_stack); - let connector = MqttConnector::new( - alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT), - stack, - ) - .with_client_id("host-smoke"); + let connector = + MqttConnector::new(alloc::format!("mqtt://{}:{}", BROKER_IP, BROKER_PORT)) + .transport(aimdb_embassy_adapter::net::EmbassyNet::tcp( + *stack, + buf(), + buf(), + )) + .with_client_id("host-smoke"); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 4aa7e5cb..248b7ca0 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -90,6 +90,7 @@ use embassy_time::{Duration, Timer}; use static_cell::StaticCell; use {defmt_rtt as _, panic_probe as _}; +use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] use aimdb_mqtt_connector::embassy_client::TlsOptions; @@ -385,21 +386,41 @@ async fn main(spawner: Spawner) { // Read-only: each record has a single writer (a sensor source, or MQTT for the // command records), so remote `record.set` is refused — peers can // list/drain/subscribe, not write. - let mqtt = MqttConnector::new(&broker_url, stack).with_client_id("embassy-demo-001"); + // Plain `mqtt://`: the adapter owns the socket, so the buffers are the + // caller's and visible here. The same line on another runtime's adapter + // needs no change in the connector. + #[cfg(not(feature = "tls"))] + let mqtt = { + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); + MqttConnector::new(&broker_url) + .transport(EmbassyNet::tcp( + *stack, + MQTT_RX.init([0; 4096]), + MQTT_TX.init([0; 4096]), + )) + .with_client_id("embassy-demo-001") + }; - // TLS materials: the board's TRNG, the broker's root CA, and the record - // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer - // may send full-size records). `init_with` keeps the arrays off the stack. + // `mqtts://` keeps the stack: TLS resolves DNS itself and owns its buffers + // across sessions. The board's TRNG, the broker's root CA, and the record + // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer may + // send full-size records). `init_with` keeps the arrays off the stack. #[cfg(feature = "tls")] let mqtt = { static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); - let mqtt = mqtt.with_tls(TlsOptions::new( - rng, - MQTT_CA_DER, - TLS_READ_BUF.init_with(|| [0; 16_640]), - TLS_WRITE_BUF.init_with(|| [0; 4_096]), - )); + let mqtt = MqttConnector::new(&broker_url) + .tls( + stack, + TlsOptions::new( + rng, + MQTT_CA_DER, + TLS_READ_BUF.init_with(|| [0; 16_640]), + TLS_WRITE_BUF.init_with(|| [0; 4_096]), + ), + ) + .with_client_id("embassy-demo-001"); match MQTT_CREDENTIALS { Some((username, password)) => mqtt.with_credentials(username, password), None => mqtt, 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 7baa0967..1623c374 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -27,6 +27,7 @@ extern crate alloc; use aimdb_core::{AimDbBuilder, RecordKey}; #[cfg(feature = "sim")] use aimdb_data_contracts::{RandomWalkParams, SimProfile, SimulatableRegistrarExt}; +use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; use aimdb_mqtt_connector::MqttConnector; use defmt::*; @@ -250,8 +251,18 @@ async fn main(spawner: Spawner) { use alloc::format; let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + // The adapter owns the socket, so its buffers are the caller's and visible + // here; the same line works on any runtime's adapter. + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); let mut builder = AimDbBuilder::new().runtime(runtime.clone()).with_connector( - MqttConnector::new(&broker_url, stack).with_client_id("weather-station-gamma"), + MqttConnector::new(&broker_url) + .transport(EmbassyNet::tcp( + *stack, + MQTT_RX.init([0; 4096]), + MQTT_TX.init([0; 4096]), + )) + .with_client_id("weather-station-gamma"), ); // Configure temperature record From 237e3cdf3ca594f2ecbfa0925e6c0d7d32e083bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 14:50:32 +0000 Subject: [PATCH 06/48] docs(mqtt-connector): record the runtime-neutral migration Co-Authored-By: Claude Opus 5 --- aimdb-mqtt-connector/CHANGELOG.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a08977a8..a507fc00 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **One `MqttConnector` over two protocol backends (breaking on Embassy).** + `Native` is `rumqttc` (QoS 0–2, rustls); `Embedded` is `mountain-mqtt` over + a caller-supplied transport. The Tokio path is unchanged; Embassy callers now + write `MqttConnector::new(url).transport(EmbassyNet::tcp(..))` or + `.tls(stack, opts)` instead of passing the stack to `new`. The + `Tokio*`/`Embassy*` aliases and `MqttConnectorBuilder` are gone. +- **`run_with_subscriptions` replaced by an owned session loop.** It binds + `embassy_net::Stack` and cannot take a transport, so reconnect-and-resubscribe + is now explicit in `transport::run_sessions` — one loop for both plain and + TLS, extracted from the TLS path already running it. - **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 +28,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`transport` — the broker transport seam.** `BrokerTransport` over + `mountain-mqtt`'s own `Connection` (the client needs a non-blocking peek that + a byte stream cannot express and TLS cannot provide), plus `SocketTransport` + bridging from core's `StreamDialer`. A new runtime supplies MQTT by + implementing that dialer — no code here. +- **`tests/embassy_broker.rs`** — the connector against a fake broker over two + crossover-wired `embassy-net` stacks, asserting CONNECT *and* SUBSCRIBE reach + the wire. - **Tokio client: the TLS backend for `mqtts://` is now a build-time choice.** Two new features — `tokio-native-tls` (system OpenSSL, what this crate linked before) and `tokio-rustls` (pure Rust, no `libssl`/`libcrypto`) — plus the From be27274ee934347e43426572172de93c56220494 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 6 Sep 2026 15:01:02 +0000 Subject: [PATCH 07/48] docs(mqtt-connector): unlink feature-gated items from ungated docs `Self::tls` is `embassy-tls`-gated, but `make doc` builds this crate with `embassy-runtime` only, so the link failed the docs gate in CI. Co-Authored-By: Claude Opus 5 --- aimdb-mqtt-connector/src/connector.rs | 4 ++-- aimdb-mqtt-connector/src/embassy_client.rs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 3ce1684f..dc990f12 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -58,8 +58,8 @@ impl MqttConnector { #[cfg(feature = "embassy-runtime")] impl MqttConnector { /// Connect to `broker_url`, then supply the transport with - /// [`transport`](Self::transport) (`mqtt://`) or [`tls`](Self::tls) - /// (`mqtts://`, feature `embassy-tls`). + /// [`transport`](Self::transport) for `mqtt://`, or `tls` (feature + /// `embassy-tls`) for `mqtts://`. pub fn new(broker_url: impl Into) -> Self { Self { backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index efca8d6e..33b4f115 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -329,7 +329,7 @@ impl MqttConnectorBuilder { /// Create a new MQTT connector builder for Embassy. /// /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, - /// or [`tls`](Self::tls) for `mqtts://`. + /// or `tls` (feature `embassy-tls`) for `mqtts://`. pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), From 19caee96631bb375a1dfb1b32f1ed3d19a1edb79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:11:56 +0000 Subject: [PATCH 08/48] feat(tokio-adapter): add embedded-io support for async streams and enhance documentation --- Cargo.lock | 1 + Makefile | 8 ++- aimdb-tokio-adapter/CHANGELOG.md | 5 ++ aimdb-tokio-adapter/Cargo.toml | 9 +++ aimdb-tokio-adapter/src/net.rs | 114 +++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index ef97da55..36dbb0a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-uds-connector", + "embedded-io-async 0.7.0", "futures", "log", "serde", diff --git a/Makefile b/Makefile index 2c378dbf..bc8241f4 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,8 @@ build: 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 tokio adapter (embedded-io streams)$(NC)\n" + cargo build --package aimdb-tokio-adapter --features "embedded-io" @printf "$(YELLOW) → Building sync wrapper$(NC)\n" cargo build --package aimdb-sync @printf "$(YELLOW) → Building sync wrapper (no_std)$(NC)\n" @@ -175,6 +177,8 @@ test: 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 tokio adapter (embedded-io streams)$(NC)\n" + cargo test --package aimdb-tokio-adapter --features "embedded-io" @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 transports, UART + UDP over two embassy-net stacks)$(NC)\n" @@ -284,6 +288,8 @@ clippy: 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 tokio adapter (embedded-io streams)$(NC)\n" + cargo clippy --package aimdb-tokio-adapter --features "embedded-io" --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" @@ -376,7 +382,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,net" --no-deps + cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net,embedded-io" --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/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index ce01dde3..8cadf8a6 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 +- **`embedded-io` feature — the `embedded-io-async` trio on the `net` streams.** + `TokioByteStream` implements `Read`/`Write` for any + `AsyncRead`/`AsyncWrite`, and `ReadReady` on `TokioByteStream` via + a non-destructive `poll_peek`. Lets `mountain-mqtt` and `embedded-tls` run on + a host over `TokioNet::tcp()`. - **`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 diff --git a/aimdb-tokio-adapter/Cargo.toml b/aimdb-tokio-adapter/Cargo.toml index 84c5b9a2..5ca5ba96 100644 --- a/aimdb-tokio-adapter/Cargo.toml +++ b/aimdb-tokio-adapter/Cargo.toml @@ -25,6 +25,11 @@ tokio-runtime = ["tokio", "tokio-util", "std"] # runtime-neutral I/O traits, so connector crates need no tokio dependency. net = ["tokio-runtime", "aimdb-core/connector-session", "tokio/net", "tokio/io-util"] +# `embedded_io_async::{Read, Write, ReadReady}` on the `net` streams, so a +# protocol client written against those traits (mountain-mqtt, embedded-tls) +# runs on a host over `TokioNet::tcp()`. +embedded-io = ["net", "dep:embedded-io-async"] + # Observability features tracing = ["aimdb-core/tracing", "dep:tracing"] observability = ["aimdb-core/observability", "tokio-runtime"] @@ -51,6 +56,10 @@ tokio = { workspace = true, optional = true, features = [ # reader round-trips the receiver through a stored, reused future. tokio-util = { version = "0.7", optional = true, default-features = false } +# `std` supplies `From`, so Tokio's error detail survives +# instead of collapsing to `Other`. +embedded-io-async = { workspace = true, optional = true, features = ["std"] } + # `RuntimeOps::log` forwards to the `log` facade; the binary picks the backend. log = "0.4" diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index 574d6f09..c90d1221 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -114,6 +114,61 @@ impl StreamListener for TokioTcpListener { } } +// `embedded-io-async` by delegation, so a protocol client written against those +// traits (mountain-mqtt, embedded-tls) runs on a host. `ReadReady` is a +// synchronous probe, so it takes the concrete `TcpStream` and its `poll_peek`. +#[cfg(feature = "embedded-io")] +mod embedded_io_impls { + use super::TokioByteStream; + use core::task::{Context, Poll, Waker}; + use embedded_io_async::ErrorKind; + use tokio::io::{AsyncReadExt, AsyncWriteExt, ReadBuf}; + use tokio::net::TcpStream; + + impl embedded_io_async::ErrorType for TokioByteStream { + type Error = ErrorKind; + } + + impl embedded_io_async::Read for TokioByteStream + where + S: tokio::io::AsyncRead + Unpin, + { + async fn read(&mut self, buf: &mut [u8]) -> Result { + self.0.read(buf).await.map_err(|e| e.kind().into()) + } + } + + impl embedded_io_async::Write for TokioByteStream + where + S: tokio::io::AsyncWrite + Unpin, + { + async fn write(&mut self, buf: &[u8]) -> Result { + self.0.write(buf).await.map_err(|e| e.kind().into()) + } + + async fn flush(&mut self) -> Result<(), Self::Error> { + self.0.flush().await.map_err(|e| e.kind().into()) + } + } + + impl embedded_io_async::ReadReady for TokioByteStream { + fn read_ready(&mut self) -> Result { + let mut byte = [0u8; 1]; + let mut buf = ReadBuf::new(&mut byte); + // MSG_PEEK leaves the byte queued. `Ok(0)` is EOF, which counts as + // ready: a read returns immediately rather than blocking. + match self + .0 + .poll_peek(&mut Context::from_waker(Waker::noop()), &mut buf) + { + Poll::Ready(Ok(_)) => Ok(true), + Poll::Ready(Err(e)) => Err(e.kind().into()), + Poll::Pending => Ok(false), + } + } + } +} + // =========================================================================== // Datagrams. // =========================================================================== @@ -306,6 +361,65 @@ mod tests { assert_eq!(second.local_addr().unwrap().port(), port); } + /// The probe must not consume what it reports. + #[cfg(feature = "embedded-io")] + #[tokio::test] + async fn the_embedded_io_trio_round_trips_and_probes_without_consuming() { + use embedded_io_async::{Read, ReadReady, Write}; + + 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, _) = listener.accept().await.unwrap(); + let mut buf = [0u8; 16]; + let n = Read::read(&mut stream, &mut buf).await.unwrap(); + Write::write(&mut stream, &buf[..n]).await.unwrap(); + Write::flush(&mut stream).await.unwrap(); + }); + + let mut client = TokioNet::tcp().connect("127.0.0.1", port).await.unwrap(); + assert!( + !client.read_ready().unwrap(), + "nothing sent yet, so the probe must not claim readiness" + ); + + Write::write(&mut client, b"ping").await.unwrap(); + Write::flush(&mut client).await.unwrap(); + server.await.unwrap(); + + // The peek must leave the byte queued: the read below is what proves it. + assert!(client.read_ready().unwrap(), "the echo is waiting"); + assert!( + client.read_ready().unwrap(), + "and probing did not consume it" + ); + + let mut buf = [0u8; 16]; + let n = Read::read(&mut client, &mut buf).await.unwrap(); + assert_eq!(&buf[..n], b"ping"); + } + + /// EOF counts as ready: a read returns `Ok(0)` without blocking. + #[cfg(feature = "embedded-io")] + #[tokio::test] + async fn the_readiness_probe_reports_eof_as_ready() { + use embedded_io_async::ReadReady; + + 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(); + // Give the FIN a moment to land, then the probe must say "ready". + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(client.read_ready().unwrap()); + } + #[tokio::test] async fn delay_sleeps_without_boxing() { let start = std::time::Instant::now(); From 664e4fedd68dd81096de445d7f24fef47af55db1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:21:09 +0000 Subject: [PATCH 09/48] feat(Cargo.toml): enhance defmt dependencies for mountain-mqtt integration --- aimdb-mqtt-connector/Cargo.toml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 4bd1edb2..08a10e90 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -70,7 +70,12 @@ embassy-tls = [ # where it expands, so without them this crate would emit nothing. tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] -defmt = ["dep:defmt", "aimdb-core/defmt"] +defmt = [ + "dep:defmt", + "aimdb-core/defmt", + "mountain-mqtt?/defmt", + "mountain-mqtt-embassy?/defmt", +] # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an @@ -134,9 +139,8 @@ embassy-net = { version = "0.9.0", optional = true, features = [ mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.2.1", default-features = false, optional = true, features = [ "embedded-io-async", "embedded-hal-async", - "defmt", ] } -mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", optional = true } +mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ From cc65e6320e27ec58c72ee7088fbccc8306233918 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 19:32:50 +0000 Subject: [PATCH 10/48] feat(tests): add TokioNet embedded backend smoke test with reconnect logic --- Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 11 + aimdb-mqtt-connector/tests/tokio_broker.rs | 257 +++++++++++++++++++++ aimdb-tokio-adapter/src/net.rs | 1 + 4 files changed, 273 insertions(+) create mode 100644 aimdb-mqtt-connector/tests/tokio_broker.rs diff --git a/Makefile b/Makefile index bc8241f4..9138efee 100644 --- a/Makefile +++ b/Makefile @@ -233,6 +233,8 @@ test: cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool @printf "$(YELLOW) → Testing MQTT connector (broker session loop against a fake broker)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker + @printf "$(YELLOW) → Testing MQTT connector (embedded backend over TokioNet, reconnect)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -366,6 +368,8 @@ clippy: cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test accept_pool -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (broker session loop, host)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (embedded backend over TokioNet)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -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-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 08a10e90..2c48f7e8 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -77,6 +77,16 @@ defmt = [ "mountain-mqtt-embassy?/defmt", ] +# Internal: the embedded backend's host smoke over `TokioNet::tcp()` +# (`tests/tokio_broker.rs`) — a real TCP socket and a fake broker, no network +# stack. Run with `--features _test-tokio-broker`. +_test-tokio-broker = [ + "embassy-runtime", + "aimdb-embassy-adapter/embassy-time", + "aimdb-embassy-adapter/embassy-sync", + "dep:critical-section", +] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off @@ -175,6 +185,7 @@ aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = fa ] } aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", + "embedded-io", ] } [package.metadata.docs.rs] diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs new file mode 100644 index 00000000..8febd97e --- /dev/null +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -0,0 +1,257 @@ +//! Host smoke for the embedded MQTT backend over `TokioNet::tcp()` +//! (`_test-tokio-broker`). +//! +//! The same session loop the Embassy smoke drives, but over a real TCP socket +//! and a fake broker on the same host — no network stack to stand up. What it +//! adds over that smoke is the reconnect: the broker hangs up after the first +//! SUBACK, and the loop must dial again and re-subscribe. +#![cfg(feature = "_test-tokio-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +// Each test binary defines 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") +} + +/// Real wall-clock time; the session loop's delays are `embassy_time`'s until +/// it takes core's `Delay`. +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); + +// --------------------------------------------------------------------------- +// A fake broker: just enough MQTT 5 to complete a session. +// --------------------------------------------------------------------------- + +/// What the broker saw, one entry per accepted connection. +#[derive(Default)] +struct Seen { + connects: usize, + subscribes: Vec>, +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +/// Collect the topics out of a SUBSCRIBE body and answer with a SUBACK +/// granting QoS 1 for each. +fn suback(body: &[u8], topics: &mut Vec) -> Vec { + let packet_id = [body[0], body[1]]; + let mut i = 2; + // Skip the property length varint. + while i < body.len() && body[i] & 0x80 != 0 { + i += 1; + } + i += 1; + + let mut granted = Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + + let mut rest = Vec::new(); + rest.extend_from_slice(&packet_id); + rest.push(0x00); // no properties + rest.extend_from_slice(&granted); + let mut ack = vec![0x90]; + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + ack +} + +/// Serve one connection. `hang_up_after_suback` closes it the moment the +/// subscribe is acknowledged, which is what forces the reconnect. +async fn serve(socket: &mut TcpStream, seen: &Mutex, hang_up_after_suback: bool) { + let mut buf = Vec::new(); + loop { + let Some((first, body)) = read_packet(socket, &mut buf).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK (session present = 0, reason = success, no props) + 1 => { + seen.lock().unwrap().connects += 1; + if socket + .write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]) + .await + .is_err() + { + return; + } + } + // SUBSCRIBE -> SUBACK + 8 => { + let mut topics = Vec::new(); + let ack = suback(&body, &mut topics); + seen.lock().unwrap().subscribes.push(topics); + if socket.write_all(&ack).await.is_err() || hang_up_after_suback { + return; + } + } + // PINGREQ -> PINGRESP + 12 => { + if socket.write_all(&[0xD0, 0x00]).await.is_err() { + return; + } + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +/// Accept forever, hanging up on the first `hang_ups` connections. +async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: usize) { + let mut accepted = 0usize; + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + accepted += 1; + serve(&mut socket, &seen, accepted <= hang_ups).await; + } +} + +// --------------------------------------------------------------------------- +// The test. +// --------------------------------------------------------------------------- + +/// The session loop re-subscribes after the broker hangs up. +/// +/// Losing that is silent: publishes keep working and inbound routing simply +/// stops, so this is the assertion the reconnect loop exists for. +#[tokio::test(flavor = "current_thread")] +async fn the_session_loop_reconnects_and_resubscribes() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + + let broker = fake_broker(listener, seen.clone(), 1); + let until_resubscribed = async { + while seen.lock().unwrap().subscribes.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + _ = until_resubscribed => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {} subscribes", + seen.connects, + seen.subscribes.len() + ); + } + } + + let seen = seen.lock().unwrap(); + assert!( + seen.connects >= 2, + "the loop must redial after the hang-up; saw {} connects", + seen.connects + ); + for (n, topics) in seen.subscribes.iter().enumerate() { + assert!( + topics.iter().any(|t| t == "sensors/temperature"), + "connection {n} did not subscribe the inbound topic; saw {topics:?}" + ); + } +} diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index c90d1221..abd246f7 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -78,6 +78,7 @@ where } /// Dials TCP connections. +#[derive(Clone, Copy, Default)] pub struct TokioTcpDialer; impl StreamDialer for TokioTcpDialer { From fef39fc7ebe6fc54e260fe4783389d9d15078881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:05:06 +0000 Subject: [PATCH 11/48] feat: enhance MQTT connector with session management and event handling - Implemented a new `manager` module to handle per-session broker state, event handling, and message pumping. - Updated `MqttConnector` to support `Delay` and `StreamDialer` traits for embedded systems. - Refactored `MqttSink` and `MqttSource` to use action and event channels directly, removing unnecessary wrappers. - Introduced `ClientDelay` to bridge core's `Delay` with the MQTT client's requirements. - Enhanced the `run_sessions` function to manage MQTT sessions more effectively, ensuring reconnections and resubscriptions. - Updated tests to ensure proper session reconnection and resubscription behavior. - Adjusted Tokio adapter to implement `Delay` for seamless integration with the connector. --- Cargo.lock | 16 +- aimdb-embassy-adapter/src/net.rs | 9 + aimdb-mqtt-connector/Cargo.toml | 20 +- aimdb-mqtt-connector/src/connector.rs | 7 +- aimdb-mqtt-connector/src/embassy_client.rs | 341 +++++++++--------- aimdb-mqtt-connector/src/embassy_tls.rs | 48 ++- aimdb-mqtt-connector/src/lib.rs | 4 + aimdb-mqtt-connector/src/manager.rs | 392 +++++++++++++++++++++ aimdb-mqtt-connector/src/transport.rs | 96 +++-- aimdb-mqtt-connector/tests/tokio_broker.rs | 2 +- aimdb-tokio-adapter/src/net.rs | 8 + 11 files changed, 704 insertions(+), 239 deletions(-) create mode 100644 aimdb-mqtt-connector/src/manager.rs diff --git a/Cargo.lock b/Cargo.lock index 36dbb0a1..bf9f7bda 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -271,19 +271,6 @@ dependencies = [ "heapless 0.8.0", ] -[[package]] -name = "aimdb-mountain-mqtt-embassy" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69ab4d7bdbef7a5e8a95edda95652887c2de8898a4fe1771345d8791ec127d3c" -dependencies = [ - "aimdb-mountain-mqtt", - "defmt 1.1.1", - "embassy-net", - "embassy-sync", - "embassy-time", -] - [[package]] name = "aimdb-mqtt-connector" version = "0.6.0" @@ -292,7 +279,6 @@ dependencies = [ "aimdb-data-contracts", "aimdb-embassy-adapter", "aimdb-mountain-mqtt", - "aimdb-mountain-mqtt-embassy", "aimdb-tokio-adapter", "async-stream", "critical-section", @@ -303,6 +289,7 @@ dependencies = [ "embassy-sync", "embassy-time", "embassy-time-driver", + "embedded-hal-async", "embedded-io-async 0.7.0", "embedded-tls", "futures", @@ -313,7 +300,6 @@ dependencies = [ "rumqttc", "rustls-native-certs", "serde", - "static_cell", "thiserror 2.0.17", "tokio", "tokio-test", diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 7ac8a5d6..5df10dcc 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -634,6 +634,15 @@ impl EmbassyNet { } } +/// The dialer is also the clock, so a connector generic over it needs no +/// separate handle. +#[cfg(feature = "embassy-time")] +impl aimdb_core::session::Delay for EmbassyTcpDialer { + fn sleep(&self, d: core::time::Duration) -> impl Future + Send { + EmbassyDelay.sleep(d) + } +} + /// [`Delay`](aimdb_core::session::Delay) over `embassy_time::Timer`, which is /// `Send` and allocates nothing. /// diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 2c48f7e8..2ca60497 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -46,11 +46,11 @@ embassy-runtime = [ "embassy-sync", "embassy-net", "mountain-mqtt", - "mountain-mqtt-embassy", - # The `SocketTransport` bridge names these traits in its bounds. + # The `SocketTransport` bridge names these traits in its bounds, and the + # session loop bridges core's `Delay` to the client's `DelayNs`. "dep:embedded-io-async", + "dep:embedded-hal-async", "heapless", - "static_cell", ] # TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 # session over the Embassy TCP socket, pure-Rust certificate verification @@ -68,13 +68,16 @@ embassy-tls = [ # `aimdb_core::__private`, so neither dependency is declared here any more. # The *features* stay: a `#[cfg]` in a `#[macro_export]`ed macro is resolved # where it expands, so without them this crate would emit nothing. +# The session channels use `CriticalSectionRawMutex`, so a std binary must link +# a `critical-section` impl. Off by default: an MCU's HAL already provides one. +critical-section-std-impl = ["dep:critical-section", "critical-section/std"] + tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] defmt = [ "dep:defmt", "aimdb-core/defmt", "mountain-mqtt?/defmt", - "mountain-mqtt-embassy?/defmt", ] # Internal: the embedded backend's host smoke over `TokioNet::tcp()` @@ -84,7 +87,7 @@ _test-tokio-broker = [ "embassy-runtime", "aimdb-embassy-adapter/embassy-time", "aimdb-embassy-adapter/embassy-sync", - "dep:critical-section", + "critical-section-std-impl", ] # Internal: the Embassy broker session loop's host smoke @@ -101,7 +104,7 @@ _test-embassy-broker = [ "embassy-net/medium-ip", "embassy-net/proto-ipv4", "dep:embassy-net-driver-channel", - "dep:critical-section", + "critical-section-std-impl", ] [dependencies] @@ -150,7 +153,6 @@ mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.2.1", default-fe "embedded-io-async", "embedded-hal-async", ] } -mountain-mqtt-embassy = { package = "aimdb-mountain-mqtt-embassy", version = "0.2.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ @@ -159,17 +161,17 @@ embedded-tls = { version = "0.19", default-features = false, optional = true, fe "p384", ] } embedded-io-async = { workspace = true, optional = true } +embedded-hal-async = { workspace = true, optional = true } rand_core = { version = "0.6", default-features = false, optional = true } # Embedded utilities heapless = { workspace = true, optional = true } -static_cell = { version = "2.0", optional = true } # Optional observability defmt = { workspace = true, optional = true } embassy-net-driver-channel = { version = "0.4.0", optional = true } -critical-section = { version = "1.1", features = ["std"], optional = true } +critical-section = { version = "1.1", optional = true } [dev-dependencies] tokio = { workspace = true, features = ["full"] } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index dc990f12..cf466b13 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -131,7 +131,12 @@ impl ConnectorBuilder for MqttConnector { #[cfg(feature = "embassy-runtime")] impl ConnectorBuilder for MqttConnector> where - D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 33b4f115..39f47394 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -1,20 +1,10 @@ -//! Embassy MQTT client implementation using mountain-mqtt-embassy +//! The `mountain-mqtt` backend: broker session plus the data-plane bridges. //! -//! This module provides production-ready MQTT connectivity for Embassy-based -//! embedded systems using mountain-mqtt-embassy's `run()` function. -//! -//! # Architecture -//! -//! The data-flow (outbound publish, inbound routing) rides core's -//! [`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 -//! `MqttOperations`/`FromApplicationMessage` glue. The single `unsafe` block -//! is the [`NetStack`](aimdb_embassy_adapter::connectors::NetStack) -//! construction in [`MqttConnectorBuilder::new`], acknowledging the adapter's -//! single-core executor invariant. +//! Outbound publishes and inbound routing ride core's [`pump_sink`] / +//! [`pump_source`] directly — the session channels are `Sync`, so nothing +//! force-`Send` stands between them and the runner. This module contributes +//! the connector builder, the `MqttSink`/`MqttSource` over those channels, and +//! the `MqttOperations`/`FromApplicationMessage` glue. //! //! # Usage //! @@ -56,19 +46,15 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; -use aimdb_embassy_adapter::connectors::{ - into_box_future, EmbassySink, EmbassySinkRaw, EmbassySource, EmbassySourceRaw, -}; -use embassy_net::Ipv4Address; -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use embassy_sync::channel::{Channel, Receiver, Sender}; +#[cfg(feature = "embassy-tls")] +use aimdb_embassy_adapter::connectors::into_box_future; use embassy_sync::once_lock::OnceLock; -use static_cell::StaticCell; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use mountain_mqtt_embassy::mqtt_manager::{MqttEvent, Settings}; + +use crate::manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] pub use crate::embassy_tls::TlsOptions; @@ -87,10 +73,14 @@ pub(crate) const MAX_PROPERTIES: usize = 16; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; -/// Sender half of the action channel (outbound publishes + subscriptions). -type ActionSender = Sender<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; -/// Receiver half of the event channel (inbound messages from the broker). -type EventReceiver = Receiver<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; +/// What a transport's setup hands back: the two channel ends the pumps ride, +/// plus the tasks that serve them. +type ManagerSetup = (Arc, Arc, Vec); + +/// Outbound publishes and subscriptions: pumps to broker session. +pub(crate) type ActionChannel = crate::manager::ActionChannel; +/// Inbound messages: broker session to pumps. +pub(crate) type EventChannel = crate::manager::EventChannel; /// MQTT actions that can be performed /// @@ -193,9 +183,7 @@ pub enum AimdbMqttEvent { }, } -impl mountain_mqtt_embassy::mqtt_manager::FromApplicationMessage - for AimdbMqttEvent -{ +impl crate::manager::FromApplicationMessage for AimdbMqttEvent { fn from_application_message( message: &mountain_mqtt::packets::publish::ApplicationMessage, ) -> Result { @@ -214,62 +202,68 @@ impl mountain_mqtt_embassy::mqtt_manager::FromApplicationMessage } // =========================================================================== -// Data-plane bridges — ride core's pumps via the adapter's force-`Send` wrappers. +// Data-plane bridges — core's pumps drive these directly. The channels are +// `Sync` (their mutex is `CriticalSectionRawMutex`), so no force-`Send` +// wrapper stands between them and the runner. // =========================================================================== -/// Outbound sink: turns a `pump_sink` publish into an `AimdbMqttAction::Publish` -/// enqueued onto the manager's action channel. Wrapped in -/// [`EmbassySink`] so it drives core's `pump_sink` despite the `!Send` channel. +/// Outbound sink: turns a `pump_sink` publish into an +/// `AimdbMqttAction::Publish` enqueued onto the session's action channel. struct MqttSink { - sender: ActionSender, + actions: Arc, } -impl EmbassySinkRaw for MqttSink { - async fn publish( +impl aimdb_core::transport::Connector for MqttSink { + fn publish( &self, - destination: String, - config: ConnectorConfig, - payload: Vec, - ) -> Result<(), PublishError> { + destination: &str, + config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { // `qos`/`retain` arrive via the URL query (passed through in // `protocol_options`); default to QoS 1 (legacy behaviour), no retain. - let qos = opt_u8(&config, "qos") + let qos = opt_u8(config, "qos") .map(map_qos) .unwrap_or(QualityOfService::Qos1); - let retain = opt_bool(&config, "retain").unwrap_or(false); + let retain = opt_bool(config, "retain").unwrap_or(false); + let topic = destination.to_string(); + let payload = payload.to_vec(); - self.sender - .send(AimdbMqttAction::Publish { - topic: destination, - payload, - qos, - retain, - }) - .await; - Ok(()) + Box::pin(async move { + self.actions + .send(AimdbMqttAction::Publish { + topic, + payload, + qos, + retain, + }) + .await; + Ok(()) + }) } } -/// Inbound source: drains the manager's event channel, yielding each received -/// message as `(topic, payload)`. Wrapped in [`EmbassySource`] so it drives -/// core's `pump_source` (which fans out to the matching record producers). +/// Inbound source: drains the session's event channel, yielding each received +/// message as `(topic, payload)` for `pump_source` to fan out. struct MqttSource { - receiver: EventReceiver, + events: Arc, } -impl EmbassySourceRaw for MqttSource { - async fn next(&mut self) -> Option<(String, Payload)> { - loop { - match self.receiver.receive().await { - MqttEvent::ApplicationEvent { - event: AimdbMqttEvent::MessageReceived { topic, payload }, - .. - } => return Some((topic, Payload::from(payload))), - // Connection lifecycle events (Connected/Disconnected/…) carry no - // record data; skip and keep draining. - _ => continue, +impl aimdb_core::session::Source for MqttSource { + fn next(&mut self) -> aimdb_core::BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { + loop { + match self.events.receive().await { + MqttEvent::ApplicationEvent { + event: AimdbMqttEvent::MessageReceived { topic, payload }, + .. + } => return Some((topic, Payload::from(payload))), + // Connection lifecycle events carry no record data; skip + // and keep draining. + _ => continue, + } } - } + }) } } @@ -405,7 +399,12 @@ impl MqttConnectorBuilder { /// runtime beyond the dyn-safe capabilities the database already holds. impl ConnectorBuilder for MqttConnectorBuilder where - D: aimdb_core::session::StreamDialer + Clone + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( @@ -413,9 +412,6 @@ where db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { - // No `.await` in this body, so the future is `Send` without a wrapper: the - // `!Send` channel ends are immediately moved into the force-`Send` - // `EmbassySink`/`EmbassySource`/manager-task and never held across a suspend. Box::pin(async move { // Inbound topics to subscribe to (the manager sends `Subscribe` for each). let inbound_routes = db.collect_inbound_routes("mqtt"); @@ -436,12 +432,19 @@ where // Broker manager task(s) + the channel ends for the pumps. // The URL scheme selects the transport. #[cfg(feature = "embassy-tls")] - let (action_sender, event_receiver, manager_tasks) = match &self.transport { + let (actions, events, manager_tasks) = match &self.transport { Transport::Tls(stack, slot) if broker.tls => { let options = slot.take().ok_or_else(|| { build_err("TLS materials already taken; build() ran twice") })?; - setup_tls_manager(&broker, options, connection_settings, *stack, topics)? + setup_tls_manager( + &broker, + options, + connection_settings, + *stack, + topics, + db.runtime_ops(), + )? } Transport::Tls(..) => { return Err(build_err(".tls(...) requires an mqtts:// broker URL")) @@ -449,38 +452,35 @@ where Transport::Plain(_) if broker.tls => { return Err(build_err("mqtts:// broker URLs require .tls(...)")) } - Transport::Plain(dialer) => { - setup_manager(&broker, connection_settings, dialer.clone(), topics)? - } + Transport::Plain(dialer) => setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )?, }; #[cfg(not(feature = "embassy-tls"))] - let (action_sender, event_receiver, manager_tasks) = { + let (actions, events, manager_tasks) = { if broker.tls { return Err(build_err( "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", )); } let Transport::Plain(dialer) = &self.transport; - setup_manager(&broker, connection_settings, dialer.clone(), topics)? + setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )? }; // Outbound publishes + inbound routing ride core's pumps. - let mut futures = pump_sink( - db, - "mqtt", - Arc::new(EmbassySink(MqttSink { - sender: action_sender, - })), - ); - futures.extend(pump_source( - db, - "mqtt", - EmbassySource(MqttSource { - receiver: event_receiver, - }), - )); - // The broker manager protocol loop (plus the SNTP time-source - // task on the TLS path), force-`Send` via the adapter. + let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); + futures.extend(pump_source(db, "mqtt", MqttSource { events })); + // The broker session loop, plus the SNTP time source on TLS. futures.extend(manager_tasks); Ok(futures) @@ -551,33 +551,8 @@ fn static_connection_settings( } } -/// Sender half of the event channel (used by the broker manager tasks). -pub(crate) type EventSender = - Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>; -/// Receiver half of the action channel (drained by the broker manager tasks). -pub(crate) type ActionReceiver = Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>; - -/// Initialise the static action/event channels shared by both transports -/// (one MQTT connector per firmware — `StaticCell` enforces single init). -fn init_channels() -> (ActionSender, ActionReceiver, EventSender, EventReceiver) { - static ACTION_CHANNEL: StaticCell> = - StaticCell::new(); - static EVENT_CHANNEL: StaticCell< - Channel, CHANNEL_SIZE>, - > = StaticCell::new(); - let action_channel = ACTION_CHANNEL.init(Channel::new()); - let event_channel = EVENT_CHANNEL.init(Channel::new()); - - ( - action_channel.sender(), - action_channel.receiver(), - event_channel.sender(), - event_channel.receiver(), - ) -} - -/// Set up the plain-TCP broker session loop, returning the action sender -/// (outbound), the event receiver (inbound), and the task future. The loop +/// Set up the plain-TCP broker session loop, returning the action channel +/// (outbound), the event channel (inbound), and the task future. The loop /// re-subscribes the inbound topics on every connection, so routing survives /// reconnects. Synchronous — no `.await` — so the caller's `build` future /// stays `Send`. @@ -586,43 +561,57 @@ fn setup_manager( connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> + runtime: Arc, +) -> Result where - D: aimdb_core::session::StreamDialer + Send + Sync + 'static, + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { - let broker_ip = Ipv4Addr::from_str(&broker.host).map_err(|_| { + Ipv4Addr::from_str(&broker.host).map_err(|_| { build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") })?; - let octets = broker_ip.octets(); - let broker_addr = Ipv4Address::new(octets[0], octets[1], octets[2], octets[3]); - let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); - let settings = Settings::new(broker_addr, broker.port); - - // The transport the session loop dials each cycle. Sockets come from the - // adapter; `run_with_subscriptions` is gone because it binds the stack and - // cannot take one. + // The transport the session loop dials each cycle, and the clock it runs + // on — both come from the caller-supplied dialer. + let delay = dialer.clone(); let transport = crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); - let manager_task = into_box_future(async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT background task starting"); - - crate::transport::run_sessions( - transport, - topics, - connection_settings, - settings, - event_sender, - action_receiver, - ) - .await + // SAFETY: every value the session holds is `Send` — `StreamDialer` + // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` + // and the state cell is a blocking mutex. See `SendSession`. + let manager_task: EmbassyBoxFuture = Box::pin(unsafe { + crate::transport::SendSession::new({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT background task starting"); + + crate::transport::run_sessions( + transport, + topics, + connection_settings, + Settings::default(), + events, + actions, + delay, + runtime, + ) + .await + } + }) }); - Ok((action_sender, event_receiver, alloc::vec![manager_task])) + Ok((actions, events, alloc::vec![manager_task])) } /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source @@ -635,7 +624,8 @@ fn setup_tls_manager( connection_settings: ConnectionSettings<'static>, stack: aimdb_embassy_adapter::connectors::NetStack, topics: Vec, -) -> Result<(ActionSender, EventReceiver, Vec), aimdb_core::DbError> { + runtime: Arc, +) -> Result { match host_ip_literal(&broker.host) { Some(core::net::IpAddr::V6(_)) => { return Err(build_err( @@ -658,32 +648,37 @@ fn setup_tls_manager( )); } - let (action_sender, action_receiver, event_sender, event_receiver) = init_channels(); + let actions: Arc = Arc::new(ActionChannel::new()); + let events: Arc = Arc::new(EventChannel::new()); - // `Settings` supplies the session cadence and port; its address field is - // unused on the TLS path (the host is resolved per attempt instead). - let settings = Settings::new(Ipv4Address::UNSPECIFIED, broker.port); let network = stack.get(); let host = broker.host.clone(); + let port = broker.port; let sntp_server = options.sntp_server; - let manager_task = into_box_future(async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS background task starting"); - - #[allow(unreachable_code)] - { - let _: () = run_tls( - *network, - options, - host, - topics, - connection_settings, - settings, - event_sender, - action_receiver, - ) - .await; + let manager_task = into_box_future({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS background task starting"); + + #[allow(unreachable_code)] + { + let _: () = run_tls( + *network, + options, + host, + port, + topics, + connection_settings, + Settings::default(), + events, + actions, + runtime, + ) + .await; + } } }); let sntp_task = into_box_future(async move { @@ -693,11 +688,7 @@ fn setup_tls_manager( } }); - Ok(( - action_sender, - event_receiver, - alloc::vec![manager_task, sntp_task], - )) + Ok((actions, events, alloc::vec![manager_task, sntp_task])) } /// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embassy_tls.rs index 14038ca6..58cdaa6b 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embassy_tls.rs @@ -19,11 +19,10 @@ use alloc::vec::Vec; use core::cell::RefCell; use core::net::IpAddr; +use alloc::sync::Arc; use embassy_net::dns::DnsQueryType; use embassy_net::tcp::TcpSocket; use embassy_net::{IpAddress, Stack}; -use embassy_sync::blocking_mutex::raw::NoopRawMutex; -use embassy_sync::channel::{Receiver, Sender}; use embassy_time::{Delay, Timer}; use embedded_tls::pki::CertVerifier; @@ -34,15 +33,15 @@ use embedded_tls::{ use embedded_io_async::Write as _; +use crate::manager::{ + handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, Settings, +}; use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::embedded_hal_async::DelayEmbedded; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use mountain_mqtt_embassy::mqtt_manager::{ - handle_messages, ChannelEventHandler, MqttEvent, Settings, State, -}; use crate::embassy_client::{ AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, @@ -241,11 +240,13 @@ pub(crate) async fn run_tls( stack: Stack<'static>, options: TlsOptions, host: String, + port: u16, topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - event_sender: Sender<'static, NoopRawMutex, MqttEvent, CHANNEL_SIZE>, - mut action_receiver: Receiver<'static, NoopRawMutex, AimdbMqttAction, CHANNEL_SIZE>, + events: Arc, + actions: Arc, + runtime: Arc, ) -> ! { let TlsOptions { rng, @@ -287,7 +288,8 @@ pub(crate) async fn run_tls( "MQTT-TLS: DNS lookup for {} failed, will retry", host.as_str() ); - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay) + .await; continue; } }; @@ -302,12 +304,12 @@ pub(crate) async fn run_tls( address, settings.port ); - if let Err(e) = socket.connect((address, settings.port)).await { + if let Err(e) = socket.connect((address, port)).await { #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: socket connect error, will retry: {:?}", e); #[cfg(not(feature = "defmt"))] let _ = e; - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; continue; } @@ -328,7 +330,7 @@ pub(crate) async fn run_tls( ); #[cfg(not(feature = "defmt"))] let _ = e; - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; continue; } #[cfg(feature = "defmt")] @@ -342,7 +344,7 @@ pub(crate) async fn run_tls( let delay = DelayEmbedded::new(Delay); let timeout_millis = settings.response_timeout.as_millis() as u32; - let state: RefCell> = RefCell::new(State::new()); + let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; @@ -353,7 +355,7 @@ pub(crate) async fn run_tls( AimdbMqttEvent, MAX_PROPERTIES, CHANNEL_SIZE, - > = ChannelEventHandler::new(connection_id, &event_sender, &state); + > = ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); let mut client = ClientNoQueue::new( connection, @@ -369,15 +371,17 @@ pub(crate) async fn run_tls( &state, &connection_settings, &subscribe_topics, - &event_sender, - &mut action_receiver, + &events, + &actions, &settings, + &EmbassyCoreDelay, + runtime.as_ref(), ) .await { #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: session errored: {:?}", error); - event_sender + events .send(MqttEvent::Disconnected { connection_id, error, @@ -385,7 +389,17 @@ pub(crate) async fn run_tls( .await; } - Timer::after(settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; + } +} + +/// The TLS path keeps `embassy_time` for its own waits, so it supplies core's +/// [`Delay`](aimdb_core::session::Delay) to the shared message pump. +struct EmbassyCoreDelay; + +impl aimdb_core::session::Delay for EmbassyCoreDelay { + fn sleep(&self, d: core::time::Duration) -> impl core::future::Future + Send { + Timer::after(embassy_time::Duration::from_micros(d.as_micros() as u64)) } } diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 7e61adfc..77bea841 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -103,6 +103,10 @@ pub mod connector; #[cfg(feature = "embassy-runtime")] pub mod transport; +// Session state, event handler and message pump for the `Embedded` backend. +#[cfg(feature = "embassy-runtime")] +pub mod manager; + pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; diff --git a/aimdb-mqtt-connector/src/manager.rs b/aimdb-mqtt-connector/src/manager.rs new file mode 100644 index 00000000..7dc4df60 --- /dev/null +++ b/aimdb-mqtt-connector/src/manager.rs @@ -0,0 +1,392 @@ +//! Per-session broker state, the event handler that feeds the event channel, +//! and the pump that keeps one connection alive. +//! +//! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and +//! source are plain `Connector`/`Source` impls with no force-`Send` wrapper. +//! Time comes from core's [`Delay`] and the runtime's monotonic clock, so the +//! pump names no executor. + +use core::cell::RefCell; +use core::time::Duration; + +use aimdb_core::session::Delay; +use aimdb_core::RuntimeOps; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; +use embassy_sync::channel::Channel; +use mountain_mqtt::client::{ + Client, ClientError, ClientReceivedEvent, ConnectionSettings, EventHandler, EventHandlerError, +}; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; +use mountain_mqtt::packets::publish::ApplicationMessage; + +/// The event channel: broker session to `pump_source`. +pub(crate) type EventChannel = Channel, Q>; + +/// The action channel: `pump_sink` to broker session. +pub(crate) type ActionChannel = Channel; + +/// Monotonic milliseconds. Only differences are meaningful. +pub(crate) fn now_ms(runtime: &dyn RuntimeOps) -> u64 { + runtime.now_nanos() / 1_000_000 +} + +/// Convert a received [`ApplicationMessage`] into an application event. +pub trait FromApplicationMessage: Sized { + /// Build the event, or reject the message. + fn from_application_message(message: &ApplicationMessage

) + -> Result; +} + +/// Why a session ended. +#[derive(Debug, PartialEq, Clone, Copy)] +pub enum Error { + /// The MQTT client reported an error. + Client(ClientError), + /// No acknowledgement arrived within `connection_event_max_interval`. + MqttServerUnresponsive, +} + +impl From for Error { + fn from(value: ClientError) -> Self { + Self::Client(value) + } +} + +#[cfg(feature = "defmt")] +impl defmt::Format for Error { + fn format(&self, f: defmt::Formatter) { + match self { + Error::Client(e) => defmt::write!(f, "Client({})", e), + Error::MqttServerUnresponsive => defmt::write!(f, "MqttServerUnresponsive"), + } + } +} + +/// Session cadence: how often to ping, how long to wait, when to give up. +#[derive(Debug, Clone, Copy)] +pub struct Settings { + /// Minimum interval between pings. + pub ping_interval: Duration, + /// Maximum silence from the broker before the session is declared dead. + pub connection_event_max_interval: Duration, + /// Wait between a failed session and the next dial. + pub reconnection_delay: Duration, + /// Delay applied to each pump iteration. + pub poll_interval: Duration, + /// Maximum round-trip wait for a packet that expects a response. + pub response_timeout: Duration, + /// How long a connection must hold before it counts as stable. + pub stabilisation_interval: Duration, +} + +impl Default for Settings { + fn default() -> Self { + Self { + ping_interval: Duration::from_millis(2_000), + connection_event_max_interval: Duration::from_millis(10_000), + reconnection_delay: Duration::from_millis(2_000), + poll_interval: Duration::from_millis(10), + response_timeout: Duration::from_millis(5_000), + stabilisation_interval: Duration::from_millis(5_000), + } + } +} + +/// What the session reports to the event channel. +#[derive(Debug, Clone)] +pub enum MqttEvent { + /// An application message arrived and converted to `E`. + ApplicationEvent { + /// The connection it arrived on. + connection_id: ConnectionId, + /// The converted message. + event: E, + }, + /// A new connection was established. + Connected { + /// The new connection. + connection_id: ConnectionId, + }, + /// A connection held for `stabilisation_interval`. + ConnectionStable { + /// The connection that stabilised. + connection_id: ConnectionId, + }, + /// A connection ended; the next one is dialled automatically. + Disconnected { + /// The connection that ended. + connection_id: ConnectionId, + /// Why it ended. + error: Error, + }, + /// A subscription was granted below the QoS requested. + SubscriptionGrantedBelowMaximumQos { + /// The connection it was granted on. + connection_id: ConnectionId, + /// What the broker granted. + granted_qos: QualityOfService, + /// What was asked for. + maximum_qos: QualityOfService, + }, + /// A published message reached no subscriber. + PublishedMessageHadNoMatchingSubscribers { + /// The connection it was published on. + connection_id: ConnectionId, + }, + /// An unsubscribe named a subscription the broker did not hold. + NoSubscriptionExisted { + /// The connection it was sent on. + connection_id: ConnectionId, + }, +} + +/// Per-connection bookkeeping, shared between the pump and its event handler. +/// +/// The blocking mutex is what makes `&SessionState` `Send`: a bare `RefCell` +/// is not `Sync`, so a session future holding one could not be boxed as the +/// runner requires. Every lock is a straight-line read or write, never held +/// across an `await`. +pub(crate) struct SessionState { + inner: BlockingMutex>>, +} + +struct Inner { + /// When the broker last proved it was alive. + last_connection_event_ms: u64, + /// An action whose `perform` failed, to retry on the next iteration. + pending_action: Option, +} + +impl SessionState { + /// Fresh state for a new connection; the liveness window starts now. + pub(crate) fn new(now_ms: u64) -> Self { + Self { + inner: BlockingMutex::new(RefCell::new(Inner { + last_connection_event_ms: now_ms, + pending_action: None, + })), + } + } + + fn record_connection_event(&self, now_ms: u64) { + self.inner + .lock(|state| state.borrow_mut().last_connection_event_ms = now_ms); + } + + fn last_connection_event_ms(&self) -> u64 { + self.inner + .lock(|state| state.borrow().last_connection_event_ms) + } + + fn take_pending_action(&self) -> Option { + self.inner + .lock(|state| state.borrow_mut().pending_action.take()) + } + + fn set_pending_action(&self, action: A) { + self.inner + .lock(|state| state.borrow_mut().pending_action = Some(action)); + } +} + +/// Forwards received MQTT events onto the event channel and refreshes the +/// liveness timestamp on every broker acknowledgement. +pub(crate) struct ChannelEventHandler<'a, A, E, const P: usize, const Q: usize> +where + E: FromApplicationMessage

+ Clone, +{ + connection_id: ConnectionId, + events: &'a EventChannel, + state: &'a SessionState, + runtime: &'a dyn RuntimeOps, +} + +impl<'a, A, E, const P: usize, const Q: usize> ChannelEventHandler<'a, A, E, P, Q> +where + E: FromApplicationMessage

+ Clone, +{ + pub(crate) fn new( + connection_id: ConnectionId, + events: &'a EventChannel, + state: &'a SessionState, + runtime: &'a dyn RuntimeOps, + ) -> Self { + Self { + connection_id, + events, + state, + runtime, + } + } +} + +impl EventHandler

for ChannelEventHandler<'_, A, E, P, Q> +where + E: FromApplicationMessage

+ Clone, +{ + async fn handle_event( + &mut self, + event: ClientReceivedEvent<'_, P>, + ) -> Result<(), EventHandlerError> { + let connection_id = self.connection_id; + match event { + ClientReceivedEvent::ApplicationMessage(message) => { + let event = E::from_application_message(&message)?; + self.events + .send(MqttEvent::ApplicationEvent { + connection_id, + event, + }) + .await; + } + ClientReceivedEvent::Ack => { + self.state.record_connection_event(now_ms(self.runtime)); + } + ClientReceivedEvent::SubscriptionGrantedBelowMaximumQos { + granted_qos, + maximum_qos, + } => { + self.events + .send(MqttEvent::SubscriptionGrantedBelowMaximumQos { + connection_id, + granted_qos, + maximum_qos, + }) + .await + } + ClientReceivedEvent::PublishedMessageHadNoMatchingSubscribers => { + self.events + .send(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) + .await + } + ClientReceivedEvent::NoSubscriptionExisted => { + self.events + .send(MqttEvent::NoSubscriptionExisted { connection_id }) + .await + } + } + Ok(()) + } +} + +/// Perform one action, parking it for retry if the client rejects it. +async fn try_action<'a, A, C>( + connection_id: ConnectionId, + client: &mut C, + state: &SessionState, + connection_settings: &ConnectionSettings<'static>, + mut action: A, + is_retry: bool, +) -> Result<(), ClientError> +where + C: Client<'a>, + A: MqttOperations + Clone, +{ + if let Err(e) = action + .perform( + client, + connection_settings.client_id(), + connection_id, + is_retry, + ) + .await + { + state.set_pending_action(action); + return Err(e); + } + Ok(()) +} + +/// Drive one MQTT session until an error ends it: connect, subscribe +/// `subscribe_topics`, then keep it alive while dispatching actions and +/// forwarding events. +/// +/// `subscribe_topics` is re-sent on every call, i.e. once per connection, so +/// inbound routing survives a reconnect. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn handle_messages<'a, A, C, E, D, const P: usize, const Q: usize>( + connection_id: ConnectionId, + client: &mut C, + state: &SessionState, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Result<(), Error> +where + C: Client<'a>, + A: MqttOperations + Clone, + E: FromApplicationMessage

+ Clone, + D: Delay, +{ + client.connect(connection_settings).await?; + events.send(MqttEvent::Connected { connection_id }).await; + + for (topic, qos) in subscribe_topics { + client.subscribe(topic, *qos).await?; + } + + let ping_interval = settings.ping_interval.as_millis() as u64; + let stabilisation_interval = settings.stabilisation_interval.as_millis() as u64; + let max_silence = settings.connection_event_max_interval.as_millis() as u64; + + let mut connected_at = Some(now_ms(runtime)); + let mut last_ping_ms = now_ms(runtime); + + loop { + delay.sleep(settings.poll_interval).await; + let now = now_ms(runtime); + + if now.saturating_sub(last_ping_ms) > ping_interval { + last_ping_ms = now; + client.send_ping().await?; + } + + if let Some(since) = connected_at { + if now.saturating_sub(since) > stabilisation_interval { + connected_at = None; + events + .send(MqttEvent::ConnectionStable { connection_id }) + .await; + } + } + + if now.saturating_sub(state.last_connection_event_ms()) > max_silence { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: broker unresponsive"); + return Err(Error::MqttServerUnresponsive); + } + + // Poll with no delay while packets are waiting. + while client.poll(false).await? {} + + if let Some(action) = state.take_pending_action() { + try_action( + connection_id, + client, + state, + connection_settings, + action, + true, + ) + .await?; + } + + while let Ok(action) = actions.try_receive() { + try_action( + connection_id, + client, + state, + connection_settings, + action, + false, + ) + .await?; + } + } +} diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/transport.rs index 2d5d4bed..47467117 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/transport.rs @@ -70,32 +70,83 @@ where } } +/// Bridges core's [`Delay`](aimdb_core::session::Delay) to the `DelayNs` the +/// MQTT client wants, so the client's timeouts run on the adapter's clock. +pub(crate) struct ClientDelay<'a, D>(pub(crate) &'a D); + +impl embedded_hal_async::delay::DelayNs for ClientDelay<'_, D> +where + D: aimdb_core::session::Delay, +{ + async fn delay_ns(&mut self, ns: u32) { + self.0 + .sleep(core::time::Duration::from_nanos(u64::from(ns))) + .await + } +} + +/// Asserts that a broker session future is `Send`. +/// +/// Everything the session holds is `Send`: [`StreamDialer`] guarantees +/// `Stream: Send`, the channels use `CriticalSectionRawMutex`, and the state +/// cell is a blocking mutex. What the compiler cannot see through is +/// `embedded-io-async` — its traits put no `Send` bound on their futures, and +/// the loop reaches them through a generic transport, so naming the bound needs +/// return-type notation, still unstable on the pinned toolchain. +/// +/// This is weaker than an executor assumption, not stronger: it rests on a +/// trait guarantee, so it holds under a preemptive scheduler too. +pub(crate) struct SendSession(F); + +// SAFETY: upheld by the caller of `SendSession::new`. +unsafe impl Send for SendSession {} + +impl SendSession { + /// # Safety + /// + /// Every value `f` holds across a suspend point must actually be `Send`. + pub(crate) unsafe fn new(f: F) -> Self { + Self(f) + } +} + +impl Future for SendSession { + type Output = F::Output; + + fn poll( + self: core::pin::Pin<&mut Self>, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + // SAFETY: a transparent projection; `SendSession` is never moved out of. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } +} + /// The broker session loop: connect, run MQTT until the session ends, wait, /// repeat. Never returns. /// -/// One implementation for every transport. `handle_messages` re-subscribes -/// `subscribe_topics` on each connection, so inbound routing survives a -/// reconnect — the property `run_with_subscriptions` used to provide, now -/// explicit here because injecting a transport means giving that helper up. +/// One implementation for every transport. The manager re-subscribes +/// `topics` on each connection, so inbound routing survives a reconnect. #[allow(clippy::too_many_arguments)] -pub(crate) async fn run_sessions( +pub(crate) async fn run_sessions( transport: T, topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, - settings: mountain_mqtt_embassy::mqtt_manager::Settings, - event_sender: crate::embassy_client::EventSender, - mut action_receiver: crate::embassy_client::ActionReceiver, + settings: crate::manager::Settings, + events: alloc::sync::Arc, + actions: alloc::sync::Arc, + delay: D, + runtime: alloc::sync::Arc, ) -> ! where T: BrokerTransport, + D: aimdb_core::session::Delay, { - use core::cell::RefCell; use mountain_mqtt::client::ClientNoQueue; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use mountain_mqtt_embassy::mqtt_manager::{ - handle_messages, ChannelEventHandler, MqttEvent, State, - }; + + use crate::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -112,21 +163,22 @@ where Err(_e) => { #[cfg(feature = "defmt")] defmt::warn!("MQTT: connect failed, will retry"); - embassy_time::Timer::after(settings.reconnection_delay).await; + delay.sleep(settings.reconnection_delay).await; continue; } }; - let state: RefCell> = - RefCell::new(State::new()); + let state: SessionState = + SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler = ChannelEventHandler::new(connection_id, &event_sender, &state); + let event_handler = + ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); let mut client = ClientNoQueue::new( connection, &mut mqtt_buffer, - mountain_mqtt::embedded_hal_async::DelayEmbedded::new(embassy_time::Delay), + mountain_mqtt::embedded_hal_async::DelayEmbedded::new(ClientDelay(&delay)), settings.response_timeout.as_millis() as u32, event_handler, ); @@ -137,15 +189,17 @@ where &state, &connection_settings, &subscribe_topics, - &event_sender, - &mut action_receiver, + &events, + &actions, &settings, + &delay, + runtime.as_ref(), ) .await { #[cfg(feature = "defmt")] defmt::warn!("MQTT: session errored: {:?}", error); - event_sender + events .send(MqttEvent::Disconnected { connection_id, error, @@ -153,6 +207,6 @@ where .await; } - embassy_time::Timer::after(settings.reconnection_delay).await; + delay.sleep(settings.reconnection_delay).await; } } diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 8febd97e..770b6412 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -189,7 +189,7 @@ async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: us /// /// Losing that is silent: publishes keep working and inbound routing simply /// stops, so this is the assertion the reconnect loop exists for. -#[tokio::test(flavor = "current_thread")] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn the_session_loop_reconnects_and_resubscribes() { use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index abd246f7..f1894899 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -92,6 +92,14 @@ impl StreamDialer for TokioTcpDialer { } } +/// The dialer is also the clock, so a connector generic over it needs no +/// separate handle. +impl Delay for TokioTcpDialer { + fn sleep(&self, d: std::time::Duration) -> impl std::future::Future + Send { + TokioDelay.sleep(d) + } +} + /// Accepts TCP connections. pub struct TokioTcpListener(TcpListener); From ba9c435dd58b14e073fcb4adce3cf545d6dbff95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:25:16 +0000 Subject: [PATCH 12/48] feat(tests): add backend parity test for MQTT connectors and enhance test coverage --- Makefile | 4 + aimdb-mqtt-connector/Cargo.toml | 4 + aimdb-mqtt-connector/src/embassy_client.rs | 25 +- aimdb-mqtt-connector/tests/backend_parity.rs | 182 +++++++++++ aimdb-mqtt-connector/tests/common/mod.rs | 294 ++++++++++++++++++ aimdb-mqtt-connector/tests/tokio_broker.rs | 301 ++++++++++--------- 6 files changed, 656 insertions(+), 154 deletions(-) create mode 100644 aimdb-mqtt-connector/tests/backend_parity.rs create mode 100644 aimdb-mqtt-connector/tests/common/mod.rs diff --git a/Makefile b/Makefile index 9138efee..b218aed3 100644 --- a/Makefile +++ b/Makefile @@ -235,6 +235,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker @printf "$(YELLOW) → Testing MQTT connector (embedded backend over TokioNet, reconnect)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker + @printf "$(YELLOW) → Testing MQTT connector (both backends, one broker, one process)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -370,6 +372,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-embassy-broker" --test embassy_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embedded backend over TokioNet)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (backend parity)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -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-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 2ca60497..911e6a58 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -90,6 +90,10 @@ _test-tokio-broker = [ "critical-section-std-impl", ] +# Internal: both backends against one fake broker in one process +# (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. +_test-backend-parity = ["_test-tokio-broker", "tokio-runtime"] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 39f47394..aa00fa59 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -48,7 +48,6 @@ use core::str::FromStr; #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; -use embassy_sync::once_lock::OnceLock; use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; @@ -527,25 +526,23 @@ fn parse_broker_url(broker_url: &str) -> Result }) } -/// Build the `ConnectionSettings<'static>` for MQTT CONNECT, parking the -/// identity strings in statics for the `'static` lifetime requirement. +/// Build the `ConnectionSettings<'static>` for MQTT CONNECT. +/// +/// The identity strings are leaked to reach `'static`: one small, bounded leak +/// per connector at build. A shared cell would be smaller but would hand every +/// connector after the first the identity of the first. fn static_connection_settings( client_id: &str, credentials: Option<&(String, String)>, ) -> ConnectionSettings<'static> { - static CLIENT_ID_STORAGE: OnceLock = OnceLock::new(); - static CREDENTIALS_STORAGE: OnceLock<(String, String)> = OnceLock::new(); + fn leak(s: &str) -> &'static str { + Box::leak(s.to_string().into_boxed_str()) + } - let client_id: &'static str = CLIENT_ID_STORAGE.get_or_init(|| client_id.to_string()); + let client_id = leak(client_id); match credentials { - Some(credentials) => { - let credentials: &'static (String, String) = - CREDENTIALS_STORAGE.get_or_init(|| credentials.clone()); - ConnectionSettings::authenticated( - client_id, - credentials.0.as_str(), - credentials.1.as_bytes(), - ) + Some((username, password)) => { + ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) } None => ConnectionSettings::unauthenticated(client_id), } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs new file mode 100644 index 00000000..e7fa2ec5 --- /dev/null +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -0,0 +1,182 @@ +//! Both backends against the same broker, in one process +//! (`_test-backend-parity`). +//! +//! `Native` is `rumqttc` over MQTT 3.1.1; `Embedded` is `mountain-mqtt` over +//! MQTT 5 and `TokioNet::tcp()`. The point is that the two are interchangeable +//! from a record's point of view: same link URLs, same payloads on the wire. +#![cfg(feature = "_test-backend-parity")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::AimDbBuilder; +use aimdb_mqtt_connector::connector::{Embedded, Native}; +use aimdb_mqtt_connector::MqttConnector; +use aimdb_tokio_adapter::net::TokioNet; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + +mod common; +use common::{fake_broker_concurrent, Seen}; + +// Each test binary defines 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") +} + +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 INBOUND: &str = "mqtt://parity/inbound"; +const OUTBOUND: &str = "mqtt://parity/outbound"; + +/// One database with one inbound and one outbound record, so both backends are +/// exercised through identical registrations. +fn build_db( + connector: impl aimdb_core::ConnectorBuilder + 'static, + value: u64, +) -> impl std::future::Future { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("inbound", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(INBOUND) + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + builder.configure::("outbound", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + loop { + producer.produce(value); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .link_to(OUTBOUND) + .with_serializer(|_ctx, v: &u64| Ok(v.to_string().into_bytes())) + .finish(); + }); + + async move { builder.build().await.expect("build db") } +} + +/// Both backends complete a session against the same broker at the same time, +/// and a record round-trips through each. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn both_backends_round_trip_against_one_broker() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + // The turbofish is what disambiguates the two `new`s while both backends + // are compiled in. + let native = MqttConnector::::new(url.clone()).with_client_id("parity-native"); + let embedded = MqttConnector::::new(url) + .transport(TokioNet::tcp()) + .with_client_id("parity-embedded"); + + let (native_db, native_runner) = build_db(native, 1).await; + let (embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let mut native_in = native_db + .consumer::("inbound") + .expect("native consumer") + .subscribe(); + let mut embedded_in = embedded_db + .consumer::("inbound") + .expect("embedded consumer") + .subscribe(); + + let broker = fake_broker_concurrent(listener, seen.clone(), Some(("parity/inbound", b"7"))); + let seen_for_wait = seen.clone(); + + let (native_value, embedded_value) = tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + values = async { + let values = ( + native_in.recv().await.expect("native inbound"), + embedded_in.recv().await.expect("embedded inbound"), + ); + // Both outbound links must land before the assertions below. + while seen_for_wait.lock().unwrap().published.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + values + } => values, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {:?} subscribed, {} published", + seen.connects, + seen.subscribed_topics(), + seen.published.len() + ); + } + }; + + assert_eq!(native_value, 7, "the broker's PUBLISH must reach Native"); + assert_eq!( + embedded_value, 7, + "the broker's PUBLISH must reach Embedded" + ); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.connects, 2, "both backends must connect"); + assert_eq!( + seen.subscribed_topics() + .iter() + .filter(|t| **t == "parity/inbound") + .count(), + 2, + "both backends must subscribe the inbound topic" + ); + + // Same record, same serializer, same bytes — whichever backend carried it. + let mut payloads: Vec<&[u8]> = seen + .published + .iter() + .filter(|(topic, _)| topic == "parity/outbound") + .map(|(_, payload)| payload.as_slice()) + .collect(); + payloads.sort_unstable(); + payloads.dedup(); + assert_eq!( + payloads, + vec![b"1".as_slice(), b"2".as_slice()], + "each backend must publish its own record's bytes" + ); +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs new file mode 100644 index 00000000..f79dcab8 --- /dev/null +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -0,0 +1,294 @@ +//! A fake MQTT broker over a real TCP socket, speaking just enough of both +//! dialects to complete a session: 3.1.1 for `rumqttc`, 5 for `mountain-mqtt`. +//! +//! The version is read off the CONNECT packet, so one broker serves both +//! backends and a parity test needs only one listener. +//! +//! Compiled into each test binary, so not every item is used by all of them. +#![allow(dead_code)] + +use std::sync::{Arc, Mutex}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// What the broker saw, accumulated across every connection. +#[derive(Default)] +pub struct Seen { + pub connects: usize, + pub client_ids: Vec, + pub subscribes: Vec>, + pub published: Vec<(String, Vec)>, +} + +impl Seen { + /// Every topic subscribed on any connection. + pub fn subscribed_topics(&self) -> Vec<&str> { + self.subscribes + .iter() + .flatten() + .map(String::as_str) + .collect() + } +} + +/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then +/// that many bytes. +async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + buf.clear(); + buf.resize(remaining, 0); + socket.read_exact(buf).await.ok()?; + Some((first, buf.clone())) +} + +/// Encode a remaining-length varint. +fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 0x80; + } + out.push(byte); + if n == 0 { + break; + } + } +} + +/// Step `i` past a varint. +fn skip_varint(body: &[u8], i: &mut usize) { + while *i < body.len() && body[*i] & 0x80 != 0 { + *i += 1; + } + *i += 1; +} + +/// The protocol level a CONNECT declares: 4 is 3.1.1, 5 is MQTT 5. +fn is_v5(body: &[u8]) -> bool { + body.get(6).is_some_and(|level| *level >= 5) +} + +/// The client id a CONNECT carries. It opens the payload, which follows the +/// 10-byte variable header plus, on MQTT 5, a property block. +fn connect_client_id(body: &[u8], v5: bool) -> Option { + let mut i = 10; + if v5 { + let start = i; + skip_varint(body, &mut i); + // The varint is the property block's length, which follows it. + i += *body.get(start)? as usize; + } + let len = u16::from_be_bytes([*body.get(i)?, *body.get(i + 1)?]) as usize; + Some(String::from_utf8_lossy(body.get(i + 2..i + 2 + len)?).into_owned()) +} + +/// Collect the topics from a SUBSCRIBE body and build the matching SUBACK. +fn suback(body: &[u8], v5: bool, topics: &mut Vec) -> Vec { + let packet_id = [body[0], body[1]]; + let mut i = 2; + if v5 { + skip_varint(body, &mut i); + } + + let mut granted = Vec::new(); + while i + 2 <= body.len() { + let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; + i += 2; + if i + len > body.len() { + break; + } + topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); + i += len + 1; // topic + subscription options byte + granted.push(0x01); + } + + let mut rest = Vec::new(); + rest.extend_from_slice(&packet_id); + if v5 { + rest.push(0x00); // no properties + } + rest.extend_from_slice(&granted); + + let mut ack = vec![0x90]; + varint(rest.len(), &mut ack); + ack.extend_from_slice(&rest); + ack +} + +/// Encode a QoS-0 PUBLISH for the broker to push at the client. +fn publish(topic: &str, payload: &[u8], v5: bool) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + if v5 { + rest.push(0x00); // no properties + } + rest.extend_from_slice(payload); + + let mut packet = vec![0x30]; + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet +} + +/// Decode a PUBLISH the client sent: topic, payload, and the packet id that is +/// present only above QoS 0. +fn parse_publish(first: u8, body: &[u8], v5: bool) -> Option<(String, Vec, Option<[u8; 2]>)> { + let topic_len = u16::from_be_bytes([*body.first()?, *body.get(1)?]) as usize; + let topic = String::from_utf8_lossy(body.get(2..2 + topic_len)?).into_owned(); + let mut i = 2 + topic_len; + + let packet_id = if (first >> 1) & 0x03 > 0 { + let id = [*body.get(i)?, *body.get(i + 1)?]; + i += 2; + Some(id) + } else { + None + }; + + if v5 { + skip_varint(body, &mut i); + } + Some((topic, body.get(i..)?.to_vec(), packet_id)) +} + +/// How a connection should behave once it has acknowledged a subscribe. +#[derive(Clone, Copy, Default)] +pub struct AfterSuback<'a> { + /// Close the connection, forcing the client to reconnect. + pub hang_up: bool, + /// Push this message at the client. + pub push: Option<(&'a str, &'a [u8])>, +} + +/// Serve one connection until it closes. +async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_>) { + let mut buf = Vec::new(); + let mut v5 = true; + + loop { + let Some((first, body)) = read_packet(socket, &mut buf).await else { + return; + }; + match first >> 4 { + // CONNECT -> CONNACK. MQTT 5 carries a property length; 3.1.1 does not. + 1 => { + v5 = is_v5(&body); + { + let mut seen = seen.lock().unwrap(); + seen.connects += 1; + if let Some(id) = connect_client_id(&body, v5) { + seen.client_ids.push(id); + } + } + let ack: &[u8] = if v5 { + &[0x20, 0x03, 0x00, 0x00, 0x00] + } else { + &[0x20, 0x02, 0x00, 0x00] + }; + if socket.write_all(ack).await.is_err() { + return; + } + } + // SUBSCRIBE -> SUBACK granting QoS 1 for each requested topic. + 8 => { + let mut topics = Vec::new(); + let ack = suback(&body, v5, &mut topics); + seen.lock().unwrap().subscribes.push(topics); + if socket.write_all(&ack).await.is_err() || after.hang_up { + return; + } + if let Some((topic, payload)) = after.push { + if socket + .write_all(&publish(topic, payload, v5)) + .await + .is_err() + { + return; + } + } + } + // PUBLISH from the client: record it, and PUBACK above QoS 0. + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body, v5) else { + return; + }; + seen.lock().unwrap().published.push((topic, payload)); + if let Some(id) = packet_id { + if socket.write_all(&[0x40, 0x02, id[0], id[1]]).await.is_err() { + return; + } + } + } + // PINGREQ -> PINGRESP + 12 => { + if socket.write_all(&[0xD0, 0x00]).await.is_err() { + return; + } + } + // DISCONNECT + 14 => return, + _ => {} + } + } +} + +/// Accept forever. `hang_ups` connections are dropped after their SUBACK; +/// every later one is served normally. +pub async fn fake_broker( + listener: TcpListener, + seen: Arc>, + hang_ups: usize, + push: Option<(&str, &[u8])>, +) { + let mut accepted = 0usize; + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + accepted += 1; + let after = AfterSuback { + hang_up: accepted <= hang_ups, + push, + }; + serve(&mut socket, &seen, after).await; + } +} + +/// Serve several clients at once, which a parity test needs: both backends +/// hold a connection simultaneously. +pub async fn fake_broker_concurrent( + listener: TcpListener, + seen: Arc>, + push: Option<(&'static str, &'static [u8])>, +) { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + return; + }; + let seen = seen.clone(); + tokio::spawn(async move { + let after = AfterSuback { + hang_up: false, + push, + }; + serve(&mut socket, &seen, after).await; + }); + } +} diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 770b6412..62cde2ed 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -10,8 +10,10 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::{TcpListener, TcpStream}; +use tokio::net::TcpListener; + +mod common; +use common::{fake_broker, Seen}; // Each test binary defines these exactly once. #[defmt::global_logger] @@ -44,143 +46,6 @@ impl embassy_time_driver::Driver for HostClock { } embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); -// --------------------------------------------------------------------------- -// A fake broker: just enough MQTT 5 to complete a session. -// --------------------------------------------------------------------------- - -/// What the broker saw, one entry per accepted connection. -#[derive(Default)] -struct Seen { - connects: usize, - subscribes: Vec>, -} - -/// Read one MQTT packet: a fixed header byte, a varint remaining-length, then -/// that many bytes. -async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { - let mut byte = [0u8; 1]; - socket.read_exact(&mut byte).await.ok()?; - let first = byte[0]; - - let mut remaining = 0usize; - let mut shift = 0; - loop { - socket.read_exact(&mut byte).await.ok()?; - remaining |= ((byte[0] & 0x7F) as usize) << shift; - if byte[0] & 0x80 == 0 { - break; - } - shift += 7; - } - - buf.clear(); - buf.resize(remaining, 0); - socket.read_exact(buf).await.ok()?; - Some((first, buf.clone())) -} - -/// Encode a remaining-length varint. -fn varint(mut n: usize, out: &mut Vec) { - loop { - let mut byte = (n % 128) as u8; - n /= 128; - if n > 0 { - byte |= 0x80; - } - out.push(byte); - if n == 0 { - break; - } - } -} - -/// Collect the topics out of a SUBSCRIBE body and answer with a SUBACK -/// granting QoS 1 for each. -fn suback(body: &[u8], topics: &mut Vec) -> Vec { - let packet_id = [body[0], body[1]]; - let mut i = 2; - // Skip the property length varint. - while i < body.len() && body[i] & 0x80 != 0 { - i += 1; - } - i += 1; - - let mut granted = Vec::new(); - while i + 2 <= body.len() { - let len = u16::from_be_bytes([body[i], body[i + 1]]) as usize; - i += 2; - if i + len > body.len() { - break; - } - topics.push(String::from_utf8_lossy(&body[i..i + len]).into_owned()); - i += len + 1; // topic + subscription options byte - granted.push(0x01); - } - - let mut rest = Vec::new(); - rest.extend_from_slice(&packet_id); - rest.push(0x00); // no properties - rest.extend_from_slice(&granted); - let mut ack = vec![0x90]; - varint(rest.len(), &mut ack); - ack.extend_from_slice(&rest); - ack -} - -/// Serve one connection. `hang_up_after_suback` closes it the moment the -/// subscribe is acknowledged, which is what forces the reconnect. -async fn serve(socket: &mut TcpStream, seen: &Mutex, hang_up_after_suback: bool) { - let mut buf = Vec::new(); - loop { - let Some((first, body)) = read_packet(socket, &mut buf).await else { - return; - }; - match first >> 4 { - // CONNECT -> CONNACK (session present = 0, reason = success, no props) - 1 => { - seen.lock().unwrap().connects += 1; - if socket - .write_all(&[0x20, 0x03, 0x00, 0x00, 0x00]) - .await - .is_err() - { - return; - } - } - // SUBSCRIBE -> SUBACK - 8 => { - let mut topics = Vec::new(); - let ack = suback(&body, &mut topics); - seen.lock().unwrap().subscribes.push(topics); - if socket.write_all(&ack).await.is_err() || hang_up_after_suback { - return; - } - } - // PINGREQ -> PINGRESP - 12 => { - if socket.write_all(&[0xD0, 0x00]).await.is_err() { - return; - } - } - // DISCONNECT - 14 => return, - _ => {} - } - } -} - -/// Accept forever, hanging up on the first `hang_ups` connections. -async fn fake_broker(listener: TcpListener, seen: Arc>, hang_ups: usize) { - let mut accepted = 0usize; - loop { - let Ok((mut socket, _)) = listener.accept().await else { - return; - }; - accepted += 1; - serve(&mut socket, &seen, accepted <= hang_ups).await; - } -} - // --------------------------------------------------------------------------- // The test. // --------------------------------------------------------------------------- @@ -221,7 +86,7 @@ async fn the_session_loop_reconnects_and_resubscribes() { }); let (_db, runner) = builder.build().await.expect("build db"); - let broker = fake_broker(listener, seen.clone(), 1); + let broker = fake_broker(listener, seen.clone(), 1, None); let until_resubscribed = async { while seen.lock().unwrap().subscribes.len() < 2 { tokio::time::sleep(Duration::from_millis(5)).await; @@ -255,3 +120,159 @@ async fn the_session_loop_reconnects_and_resubscribes() { ); } } + +/// The embedded backend carries records both ways over `TokioNet::tcp()`, on a +/// multi-thread runtime: an inbound PUBLISH reaches a record, and a record's +/// outbound link reaches the broker. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_embedded_backend_round_trips_records_on_a_multi_thread_runtime() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(TokioNet::tcp()) + .with_client_id("round-trip"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + // Inbound: the broker's PUBLISH lands here. + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + // Outbound: this record's producer publishes to the broker. + builder.configure::("uptime", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(|_ctx, producer| async move { + loop { + producer.produce(42u64); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .link_to("mqtt://sensors/uptime") + .with_serializer(|_ctx, v: &u64| Ok(v.to_string().into_bytes())) + .finish(); + }); + + let (db, runner) = builder.build().await.expect("build db"); + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let broker = fake_broker( + listener, + seen.clone(), + 0, + Some(("sensors/temperature", b"23")), + ); + let seen_for_wait = seen.clone(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + received = async { + let value = inbound.recv().await.expect("inbound record"); + while seen_for_wait.lock().unwrap().published.is_empty() { + tokio::time::sleep(Duration::from_millis(5)).await; + } + value + } => received, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {} subscribes, {} publishes", + seen.connects, + seen.subscribes.len(), + seen.published.len() + ); + } + }; + + assert_eq!(received, 23, "the broker's PUBLISH must reach the record"); + + let seen = seen.lock().unwrap(); + let (topic, payload) = seen + .published + .first() + .expect("the outbound link must reach the broker"); + assert_eq!(topic, "sensors/uptime"); + assert_eq!(payload, b"42", "the serializer's bytes must arrive intact"); +} + +/// Two connectors in one process keep their own identities. +/// +/// They shared a process-global cell before the channels moved to `Arc`, so the +/// second silently connected under the first's client id. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn two_connectors_in_one_process_keep_their_own_client_ids() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let mut runners = Vec::new(); + for id in ["first-node", "second-node"] { + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector( + MqttConnector::new(url.clone()) + .transport(TokioNet::tcp()) + .with_client_id(id), + ); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, _data: &[u8]| Ok(0u64)) + .finish(); + }); + let (_db, runner) = builder.build().await.expect("build db"); + runners.push(runner); + } + + let broker = common::fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + let second = runners.pop().unwrap(); + let first = runners.pop().unwrap(); + + tokio::select! { + _ = first.run() => panic!("the first runner returned"), + _ = second.run() => panic!("the second runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().client_ids.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().client_ids); + } + } + + let mut ids = seen.lock().unwrap().client_ids.clone(); + ids.sort(); + assert_eq!(ids, vec!["first-node", "second-node"]); +} From 84d2b72f15b0bd060de8529481b04789779f1767 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:44:44 +0000 Subject: [PATCH 13/48] Refactor MQTT Connector to unify backend handling and enhance credential support - Consolidated the MqttConnector structure to allow seamless switching between Native and Embedded backends without separate builders. - Introduced credential handling in the Native backend, allowing credentials to be set via the MqttConnector interface. - Updated the Embassy client to streamline the transport setup and improve TLS handling. - Enhanced tests to verify credential transmission for both backends, ensuring consistent behavior across different configurations. - Removed deprecated builder patterns and unnecessary complexity in the connector implementation. --- aimdb-mqtt-connector/src/connector.rs | 220 ++++++++----- aimdb-mqtt-connector/src/embassy_client.rs | 310 ++++++------------- aimdb-mqtt-connector/src/lib.rs | 8 +- aimdb-mqtt-connector/src/tokio_client.rs | 180 +++++------ aimdb-mqtt-connector/tests/backend_parity.rs | 63 +++- aimdb-mqtt-connector/tests/common/mod.rs | 37 ++- aimdb-mqtt-connector/tests/tokio_broker.rs | 3 + 7 files changed, 403 insertions(+), 418 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index cf466b13..a91a2eaa 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -4,15 +4,18 @@ //! implementation. `rumqttc` owns its socket, TLS and reconnect — its //! `Transport` is a closed enum, so no stream can be injected — while //! `mountain-mqtt` is generic over `embedded-io-async`. The two stay separate, -//! and this type is the seam between them: a backend can be swapped or removed -//! without touching the other. +//! and this type is the seam between them. +//! +//! Broker URL, client id and credentials live here rather than in a backend, so +//! there is one constructor and one set of setters whichever backend runs. //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | `Native` (feature `tokio-runtime`) | `rumqttc` (std) | 0–2 | rustls | -//! | `Embedded` (feature `embassy-runtime`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | [`Native`] (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | +//! | [`Embedded`] (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; +use alloc::string::String; use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; @@ -20,57 +23,65 @@ use core::pin::Pin; use aimdb_core::connector::ConnectorBuilder; use aimdb_core::{AimDb, DbResult}; -/// The `rumqttc` backend: a host client owning its own socket and TLS. -#[cfg(feature = "tokio-runtime")] -pub struct Native(crate::tokio_client::MqttConnectorBuilder); +/// The runner's collected future type. +type BoxFuture = Pin + Send + 'static>>; +/// What [`ConnectorBuilder::build`] returns. +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +/// The `rumqttc` backend: it owns its socket, TLS and reconnect, so there is +/// nothing here to configure. Selected by supplying no transport. +#[derive(Clone, Copy, Default)] +pub struct Native; -/// The `mountain-mqtt` backend: `no_std`, over a caller-supplied transport. +/// The `mountain-mqtt` backend over a caller-supplied transport. #[cfg(feature = "embassy-runtime")] -pub struct Embedded( - crate::embassy_client::MqttConnectorBuilder, -); +pub struct Embedded { + pub(crate) dialer: D, +} + +/// The `mountain-mqtt` backend over `embedded-tls`. +/// +/// TLS keeps the network stack rather than taking a dialer: it resolves DNS +/// itself and owns buffers across sessions, which a per-session dialer cannot +/// express. +#[cfg(feature = "embassy-tls")] +pub struct EmbeddedTls { + pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, + pub(crate) options: crate::embassy_client::TlsSlot, +} /// An MQTT connector over the backend `B`. -pub struct MqttConnector { - backend: B, +pub struct MqttConnector { + pub(crate) broker_url: String, + pub(crate) client_id: Option, + pub(crate) credentials: Option<(String, String)>, + pub(crate) backend: B, } -#[cfg(feature = "tokio-runtime")] impl MqttConnector { /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). /// - /// Without [`with_client_id`](Self::with_client_id) a random UUID-based - /// client id is generated at build. - pub fn new(broker_url: impl Into) -> Self { - Self { - backend: Native(crate::tokio_client::MqttConnectorBuilder::new(broker_url)), - } - } - - /// Set the MQTT client id. - pub fn with_client_id(self, client_id: impl Into) -> Self { - Self { - backend: Native(self.backend.0.with_client_id(client_id)), - } - } -} - -#[cfg(feature = "embassy-runtime")] -impl MqttConnector { - /// Connect to `broker_url`, then supply the transport with - /// [`transport`](Self::transport) for `mqtt://`, or `tls` (feature - /// `embassy-tls`) for `mqtts://`. - pub fn new(broker_url: impl Into) -> Self { + /// Without a transport this is the `rumqttc` backend, and without + /// [`with_client_id`](Self::with_client_id) it generates a UUID-based + /// client id at build. + pub fn new(broker_url: impl Into) -> Self { Self { - backend: Embedded(crate::embassy_client::MqttConnectorBuilder::new(broker_url)), + broker_url: broker_url.into(), + client_id: None, + credentials: None, + backend: Native, } } /// Dial plain sessions through an adapter's stream dialer — the same call /// on any runtime's adapter, with no change in this crate. + #[cfg(feature = "embassy-runtime")] pub fn transport(self, dialer: D) -> MqttConnector> { MqttConnector { - backend: Embedded(self.backend.0.transport(dialer)), + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: Embedded { dialer }, } } @@ -80,56 +91,88 @@ impl MqttConnector { self, stack: &'static embassy_net::Stack<'static>, options: crate::embassy_tls::TlsOptions, - ) -> Self { - Self { - backend: Embedded(self.backend.0.tls(stack, options)), + ) -> MqttConnector { + MqttConnector { + broker_url: self.broker_url, + client_id: self.client_id, + credentials: self.credentials, + backend: EmbeddedTls { + // SAFETY: AimDB's Embassy integration requires a single-core + // cooperative executor (the adapter's module-level invariant); + // every future touching this stack is polled on that executor. + stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + options: crate::embassy_client::TlsSlot::new(options), + }, } } } -#[cfg(feature = "embassy-runtime")] -impl MqttConnector> { - /// Set the MQTT client id (defaults to `aimdb-client`). - pub fn with_client_id(self, client_id: impl Into) -> Self { - Self { - backend: Embedded(self.backend.0.with_client_id(client_id)), - } +impl MqttConnector { + /// Set the MQTT client id (should be unique per device). + pub fn with_client_id(mut self, client_id: impl Into) -> Self { + self.client_id = Some(client_id.into()); + self } - /// Set the broker username and password. + /// Authenticate with the broker (MQTT CONNECT username/password). + /// + /// Over `mqtt://` the credential transits in cleartext — pair it with + /// `mqtts://` outside a trusted LAN. pub fn with_credentials( - self, - username: impl Into, - password: impl Into, + mut self, + username: impl Into, + password: impl Into, ) -> Self { - Self { - backend: Embedded(self.backend.0.with_credentials(username, password)), - } + self.credentials = Some((username.into(), password.into())); + self } } -#[cfg(feature = "tokio-runtime")] -impl ConnectorBuilder for MqttConnector { +mod sealed { + pub trait Sealed {} + impl Sealed for super::Native {} + #[cfg(feature = "embassy-runtime")] + impl Sealed for super::Embedded {} + #[cfg(feature = "embassy-tls")] + impl Sealed for super::EmbeddedTls {} +} + +/// A backend with a build path compiled in. +/// +/// Implemented for [`Native`] only under `tokio-runtime`, so a `no_std` build +/// that forgets `.transport(..)` fails here with a message naming the fix +/// rather than on core's `ConnectorBuilder`. +#[diagnostic::on_unimplemented( + message = "`MqttConnector<{Self}>` has no MQTT backend compiled in", + label = "no backend for this configuration", + note = "supply a transport — `.transport(dialer)` — for the mountain-mqtt backend, or enable this crate's `tokio-runtime` feature for the rumqttc one" +)] +pub trait Backend: sealed::Sealed + Send + Sync { + /// Connect and collect this backend's data-plane futures. fn build<'a>( &'a self, db: &'a AimDb, - ) -> Pin< - Box< - dyn Future + Send>>>>> - + Send - + 'a, - >, - > { - self.backend.0.build(db) - } + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a>; +} - fn scheme(&self) -> &str { - self.backend.0.scheme() +#[cfg(feature = "tokio-runtime")] +impl Backend for Native { + fn build<'a>( + &'a self, + db: &'a AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::tokio_client::build(db, broker_url, client_id, credentials) } } #[cfg(feature = "embassy-runtime")] -impl ConnectorBuilder for MqttConnector> +impl Backend for Embedded where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -142,17 +185,38 @@ where fn build<'a>( &'a self, db: &'a AimDb, - ) -> Pin< - Box< - dyn Future + Send>>>>> - + Send - + 'a, - >, - > { - self.backend.0.build(db) + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embassy_client::build_plain(db, broker_url, client_id, credentials, &self.dialer) + } +} + +#[cfg(feature = "embassy-tls")] +impl Backend for EmbeddedTls { + fn build<'a>( + &'a self, + db: &'a AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + ) -> BuildFuture<'a> { + crate::embassy_client::build_tls(db, broker_url, client_id, credentials, self) + } +} + +impl ConnectorBuilder for MqttConnector { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + self.backend.build( + db, + &self.broker_url, + self.client_id.as_deref(), + self.credentials.as_ref(), + ) } fn scheme(&self) -> &str { - self.backend.0.scheme() + "mqtt" } } diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index aa00fa59..d4322613 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -8,25 +8,16 @@ //! //! # Usage //! -//! Illustrative (not compiled: requires the `embassy-runtime` feature and a -//! device network stack): -//! //! ```rust,ignore -//! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; -//! use aimdb_core::AimDbBuilder; -//! -//! // `stack: &'static embassy_net::Stack<'static>` — the device's network stack. //! let db = AimDbBuilder::new() //! .runtime(embassy_adapter) //! .with_connector( -//! MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack) +//! MqttConnector::new("mqtt://192.168.1.100:1883") +//! .transport(EmbassyNet::tcp(stack, rx, tx)) //! .with_client_id("my-unique-device-id"), //! ) -//! .configure::("temperature", |reg| { -//! reg.link_to("mqtt://sensors/temperature").finish(); -//! reg.link_from("mqtt://commands/temperature").finish(); -//! }) -//! .build().await?; +//! .build() +//! .await?; //! ``` extern crate alloc; @@ -35,7 +26,6 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::RouterBuilder; use aimdb_core::session::{pump_sink, pump_source, Payload}; use aimdb_core::transport::{ConnectorConfig, PublishError}; -use aimdb_core::ConnectorBuilder; use alloc::boxed::Box; use alloc::format; use alloc::string::{String, ToString}; @@ -274,129 +264,16 @@ impl aimdb_core::session::Source for MqttSource { /// 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")] -type TlsSlot = aimdb_core::session::OneShot; - -/// MQTT connector builder for Embassy with router-based dispatch. -/// -/// 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 the -/// `with_tls` method it gates. -/// Where the broker connection comes from. -/// -/// Plain sessions dial through a caller-supplied [`StreamDialer`], so a new -/// runtime supplies MQTT by passing its own. TLS keeps the stack: it resolves -/// DNS itself and owns buffers across sessions, which a per-session dialer -/// cannot express. -pub(crate) enum Transport { - Plain(D), - #[cfg(feature = "embassy-tls")] - Tls(aimdb_embassy_adapter::connectors::NetStack, TlsSlot), -} - -/// A dialer placeholder for TLS-only connectors, which never dial through one. -#[derive(Clone, Copy, Default)] -pub struct NoTransport; - -impl aimdb_core::session::StreamDialer for NoTransport { - type Stream = aimdb_embassy_adapter::net::EmbassyTcpStream; - - async fn connect( - &self, - _host: &str, - _port: u16, - ) -> aimdb_core::session::TransportResult { - Err(aimdb_core::session::TransportError::Io) - } -} - -pub struct MqttConnectorBuilder { - broker_url: String, - client_id: String, - credentials: Option<(String, String)>, - pub(crate) transport: Transport, -} - -impl MqttConnectorBuilder { - /// Create a new MQTT connector builder for Embassy. - /// - /// Supply the transport with [`transport`](Self::transport) for `mqtt://`, - /// or `tls` (feature `embassy-tls`) for `mqtts://`. - pub fn new(broker_url: impl Into) -> Self { - Self { - broker_url: broker_url.into(), - client_id: "aimdb-client".to_string(), - credentials: None, - transport: Transport::Plain(NoTransport), - } - } - - /// Dial plain `mqtt://` sessions through an adapter's stream dialer. - /// - /// `EmbassyNet::tcp(stack, rx, tx)` on Embassy; the same call on any other - /// runtime's adapter, with no change here. - pub fn transport(self, dialer: D) -> MqttConnectorBuilder { - MqttConnectorBuilder { - broker_url: self.broker_url, - client_id: self.client_id, - credentials: self.credentials, - transport: Transport::Plain(dialer), - } - } - - /// Provide the network stack and TLS materials for an `mqtts://` broker. - /// - /// TLS keeps the stack rather than taking a dialer: it resolves DNS itself - /// and owns buffers across sessions. - #[cfg(feature = "embassy-tls")] - pub fn tls( - self, - stack: &'static embassy_net::Stack<'static>, - options: TlsOptions, - ) -> MqttConnectorBuilder { - MqttConnectorBuilder { - broker_url: self.broker_url, - client_id: self.client_id, - credentials: self.credentials, - // SAFETY: AimDB's Embassy integration requires a single-core - // cooperative executor (the adapter's module-level invariant); - // every future touching this stack is polled on that executor. - transport: Transport::Tls( - unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, - TlsSlot::new(options), - ), - } - } -} - -impl MqttConnectorBuilder { - /// Set the MQTT client ID (should be unique per device). - pub fn with_client_id(mut self, client_id: impl Into) -> Self { - self.client_id = client_id.into(); - self - } - - /// Authenticate with the broker (MQTT CONNECT username/password). - /// - /// Works on both transports, but note that over `mqtt://` the credential - /// transits in cleartext — pair it with `mqtts://` outside a trusted LAN. - pub fn with_credentials( - mut self, - username: impl Into, - password: impl Into, - ) -> Self { - self.credentials = Some((username.into(), password.into())); - self - } -} - -/// Implement ConnectorBuilder trait for Embassy. -/// -/// The network stack is taken at construction (see -/// [`MqttConnectorBuilder::new`]), so the builder needs nothing from the -/// runtime beyond the dyn-safe capabilities the database already holds. -impl ConnectorBuilder for MqttConnectorBuilder +pub(crate) type TlsSlot = aimdb_core::session::OneShot; + +/// Connect and collect the data-plane futures for a plain `mqtt://` session. +pub(crate) fn build_plain<'a, D>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + dialer: &'a D, +) -> Pin>> + Send + 'a>> where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay @@ -406,89 +283,86 @@ where + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { - fn build<'a>( - &'a self, - db: &'a aimdb_core::builder::AimDb, - ) -> Pin>> + Send + 'a>> - { - Box::pin(async move { - // Inbound topics to subscribe to (the manager sends `Subscribe` for each). - let inbound_routes = db.collect_inbound_routes("mqtt"); - let topics: Vec = RouterBuilder::from_routes(inbound_routes) - .build() - .resource_ids() - .iter() - .map(|t| t.to_string()) - .collect(); + Box::pin(async move { + let topics = inbound_topics(db); + let broker = parse_broker_url(broker_url)?; + if broker.tls { + return Err(build_err("mqtts:// broker URLs require .tls(...)")); + } + let connection_settings = static_connection_settings(client_id, credentials); + + let (actions, events, manager_tasks) = setup_manager( + &broker, + connection_settings, + dialer.clone(), + topics, + db.runtime_ops(), + )?; + Ok(collect_pumps(db, actions, events, manager_tasks)) + }) +} - #[cfg(feature = "defmt")] - defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); - - let broker = parse_broker_url(&self.broker_url)?; - let connection_settings = - static_connection_settings(&self.client_id, self.credentials.as_ref()); - - // Broker manager task(s) + the channel ends for the pumps. - // The URL scheme selects the transport. - #[cfg(feature = "embassy-tls")] - let (actions, events, manager_tasks) = match &self.transport { - Transport::Tls(stack, slot) if broker.tls => { - let options = slot.take().ok_or_else(|| { - build_err("TLS materials already taken; build() ran twice") - })?; - setup_tls_manager( - &broker, - options, - connection_settings, - *stack, - topics, - db.runtime_ops(), - )? - } - Transport::Tls(..) => { - return Err(build_err(".tls(...) requires an mqtts:// broker URL")) - } - Transport::Plain(_) if broker.tls => { - return Err(build_err("mqtts:// broker URLs require .tls(...)")) - } - Transport::Plain(dialer) => setup_manager( - &broker, - connection_settings, - dialer.clone(), - topics, - db.runtime_ops(), - )?, - }; - #[cfg(not(feature = "embassy-tls"))] - let (actions, events, manager_tasks) = { - if broker.tls { - return Err(build_err( - "mqtts:// broker URLs require the `embassy-tls` feature of aimdb-mqtt-connector", - )); - } - let Transport::Plain(dialer) = &self.transport; - setup_manager( - &broker, - connection_settings, - dialer.clone(), - topics, - db.runtime_ops(), - )? - }; +/// Connect and collect the data-plane futures for an `mqtts://` session. +#[cfg(feature = "embassy-tls")] +pub(crate) fn build_tls<'a>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, + backend: &'a crate::connector::EmbeddedTls, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { + let topics = inbound_topics(db); + let broker = parse_broker_url(broker_url)?; + if !broker.tls { + return Err(build_err(".tls(...) requires an mqtts:// broker URL")); + } + let options = backend + .options + .take() + .ok_or_else(|| build_err("TLS materials already taken; build() ran twice"))?; + let connection_settings = static_connection_settings(client_id, credentials); + + let (actions, events, manager_tasks) = setup_tls_manager( + &broker, + options, + connection_settings, + backend.stack, + topics, + db.runtime_ops(), + )?; + Ok(collect_pumps(db, actions, events, manager_tasks)) + }) +} - // Outbound publishes + inbound routing ride core's pumps. - let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); - futures.extend(pump_source(db, "mqtt", MqttSource { events })); - // The broker session loop, plus the SNTP time source on TLS. - futures.extend(manager_tasks); +/// The inbound topics the session must subscribe on every connection. +fn inbound_topics(db: &aimdb_core::builder::AimDb) -> Vec { + let inbound_routes = db.collect_inbound_routes("mqtt"); + let topics: Vec = RouterBuilder::from_routes(inbound_routes) + .build() + .resource_ids() + .iter() + .map(|t| t.to_string()) + .collect(); - Ok(futures) - }) - } + #[cfg(feature = "defmt")] + defmt::info!("MQTT: subscribing to {} inbound topics", topics.len()); - fn scheme(&self) -> &str { - "mqtt" - } + topics +} + +/// Outbound publishes and inbound routing ride core's pumps; the session tasks +/// join them. +fn collect_pumps( + db: &aimdb_core::builder::AimDb, + actions: Arc, + events: Arc, + manager_tasks: Vec, +) -> Vec { + let mut futures = pump_sink(db, "mqtt", Arc::new(MqttSink { actions })); + futures.extend(pump_source(db, "mqtt", MqttSource { events })); + futures.extend(manager_tasks); + futures } /// Parsed broker endpoint: transport + authority. @@ -532,14 +406,14 @@ fn parse_broker_url(broker_url: &str) -> Result /// per connector at build. A shared cell would be smaller but would hand every /// connector after the first the identity of the first. fn static_connection_settings( - client_id: &str, + client_id: Option<&str>, credentials: Option<&(String, String)>, ) -> ConnectionSettings<'static> { fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } - let client_id = leak(client_id); + let client_id = leak(client_id.unwrap_or("aimdb-client")); match credentials { Some((username, password)) => { ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 77bea841..9a9ff0a8 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -96,7 +96,6 @@ extern crate alloc; // MQTT knobs over core's generic link builders (works on every feature leg) // One `MqttConnector` over the `Native` and `Embedded` protocol backends. -#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] pub mod connector; // The broker transport seam for the `Embedded` backend. @@ -130,7 +129,6 @@ pub mod sntp; #[cfg(feature = "embassy-runtime")] pub use connector::Embedded; -#[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub use connector::MqttConnector; -#[cfg(feature = "tokio-runtime")] -pub use connector::Native; +#[cfg(feature = "embassy-tls")] +pub use connector::EmbeddedTls; +pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 51a076c0..8ca4954b 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -10,116 +10,69 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use aimdb_core::{log_debug, log_error, log_info}; -use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; +use aimdb_core::{pump_sink, pump_source, BoxFut, Payload, Source}; use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; -/// MQTT connector for a single broker connection with router-based dispatch -/// -/// Each connector manages ONE MQTT broker connection. The router determines -/// how incoming messages are dispatched to AimDB producers. -/// -/// # Usage Pattern -/// -/// The connector collects routes from the database during build() and -/// automatically subscribes to all required MQTT topics. -pub struct MqttConnectorBuilder { - broker_url: String, - client_id: Option, -} +type BoxFuture = Pin + Send + 'static>>; -impl MqttConnectorBuilder { - /// Create a new MQTT connector builder - /// - /// If no client ID is explicitly set via `with_client_id()`, a random - /// UUID-based client ID will be generated automatically when the connector - /// is built. - /// - /// # Arguments - /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) - pub fn new(broker_url: impl Into) -> Self { - Self { - broker_url: broker_url.into(), - client_id: None, - } - } +/// Connect, subscribe, and collect the data-plane futures for the `rumqttc` +/// backend. +pub(crate) fn build<'a>( + db: &'a aimdb_core::builder::AimDb, + broker_url: &'a str, + client_id: Option<&'a str>, + credentials: Option<&'a (String, String)>, +) -> Pin>> + Send + 'a>> { + Box::pin(async move { + // Build a router from the inbound routes purely to drive the MQTT + // subscriptions + channel-capacity sizing in `build_internal`. The + // routing `Router` that fans incoming frames out to producers is + // (re)built by `pump_source` from the same `collect_inbound_routes`. + let inbound_routes = db.collect_inbound_routes("mqtt"); + let router = RouterBuilder::from_routes(inbound_routes).build(); + + log_info!("MQTT subscribing to {} topics", router.resource_ids().len()); + + // Connect, subscribe, and hand back the raw event loop. + let (client, event_loop) = + MqttConnectorImpl::build_internal(broker_url, client_id, credentials, router) + .await + .map_err(|e| { + aimdb_core::DbError::runtime_error(format!( + "Failed to build MQTT connector: {}", + e + )) + })?; - /// Set the MQTT client ID - /// - /// The client ID should be unique for each client connecting to the broker. - /// It's used for session persistence and message delivery guarantees. - /// - /// If not set, a random UUID-based client ID will be generated automatically. - /// - /// # Arguments - /// * `client_id` - Unique identifier for this client - pub fn with_client_id(mut self, client_id: impl Into) -> Self { - self.client_id = Some(client_id.into()); - self - } -} + let mut futures: Vec = Vec::new(); -type BoxFuture = Pin + Send + 'static>>; + // Inbound: one multiplexed reader future fanning publishes out to producers. + futures.extend(pump_source( + db, + "mqtt", + MqttEventLoopSource { + event_loop, + broker_key: broker_url.to_string(), + }, + )); -impl ConnectorBuilder for MqttConnectorBuilder { - fn build<'a>( - &'a self, - db: &'a aimdb_core::builder::AimDb, - ) -> Pin>> + Send + 'a>> { - Box::pin(async move { - // Build a router from the inbound routes purely to drive the MQTT - // subscriptions + channel-capacity sizing in `build_internal`. The - // routing `Router` that fans incoming frames out to producers is - // (re)built by `pump_source` from the same `collect_inbound_routes`. - let inbound_routes = db.collect_inbound_routes("mqtt"); - let router = RouterBuilder::from_routes(inbound_routes).build(); - - log_info!("MQTT subscribing to {} topics", router.resource_ids().len()); - - // Connect, subscribe, and hand back the raw event loop. - let (client, event_loop) = - MqttConnectorImpl::build_internal(&self.broker_url, self.client_id.clone(), router) - .await - .map_err(|e| { - aimdb_core::DbError::runtime_error(format!( - "Failed to build MQTT connector: {}", - e - )) - })?; - - let mut futures: Vec = Vec::new(); - - // Inbound: one multiplexed reader future fanning publishes out to producers. - futures.extend(pump_source( - db, - "mqtt", - MqttEventLoopSource { - event_loop, - broker_key: self.broker_url.clone(), - }, - )); - - // Outbound: one publisher future per outbound route. - futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); - - Ok(futures) - }) - } + // Outbound: one publisher future per outbound route. + futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); - fn scheme(&self) -> &str { - "mqtt" - } + Ok(futures) + }) } /// Internal MQTT connector build helpers. /// -/// 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. +/// A namespace for the broker-connection setup invoked from [`build`]; the +/// data-plane loops themselves live in the reusable `pump_sink` / +/// `pump_source` helpers + the `MqttSink` / `MqttEventLoopSource` adapters +/// below. pub struct MqttConnectorImpl; impl MqttConnectorImpl { @@ -136,7 +89,8 @@ impl MqttConnectorImpl { /// * `router` - Routes used only for the subscription list + capacity sizing async fn build_internal( broker_url: &str, - client_id: Option, + client_id: Option<&str>, + credentials: Option<&(String, String)>, router: Router, ) -> Result<(Arc, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic @@ -162,17 +116,28 @@ impl MqttConnectorImpl { log_info!("Creating MQTT client for {}:{}", host, port); // Use provided client_id or generate a UUID-based one - let client_id = client_id.unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); + let client_id = client_id + .map(ToString::to_string) + .unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); let mut mqtt_opts = MqttOptions::new(client_id, host, port); mqtt_opts.set_keep_alive(Duration::from_secs(30)); - // Add credentials if provided - if let (Some(ref username), Some(ref password)) = - (&connector_url.username, &connector_url.password) - { - mqtt_opts.set_credentials(username, password); + // `with_credentials` wins over anything in the URL's authority, which + // is the only way to name a password that is not URL-safe. + match ( + credentials, + &connector_url.username, + &connector_url.password, + ) { + (Some((username, password)), _, _) => { + mqtt_opts.set_credentials(username, password); + } + (None, Some(username), Some(password)) => { + mqtt_opts.set_credentials(username, password); + } + _ => {} } // mqtts:// selects the TLS transport; rumqttc otherwise speaks plain TCP @@ -396,7 +361,7 @@ mod tests { async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router).await; + MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, None, router).await; assert!(connector.is_ok()); } @@ -404,14 +369,15 @@ mod tests { async fn test_connector_with_port() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router).await; + MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, None, router).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_invalid_url() { let router = RouterBuilder::new().build(); - let connector = MqttConnectorImpl::build_internal("not-a-valid-url", None, router).await; + let connector = + MqttConnectorImpl::build_internal("not-a-valid-url", None, None, router).await; assert!(connector.is_err()); } @@ -423,6 +389,7 @@ mod tests { let connector = MqttConnectorImpl::build_internal( "mqtts://hub-sub:secret@broker.example.com:8883", None, + None, router, ) .await; @@ -451,7 +418,8 @@ mod tests { async fn test_connector_mqtt_url_needs_no_tls_backend() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.example.com:1883", None, router).await; + MqttConnectorImpl::build_internal("mqtt://broker.example.com:1883", None, None, router) + .await; assert!(connector.is_ok()); } } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index e7fa2ec5..5d20c437 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -13,7 +13,6 @@ use tokio::net::TcpListener; use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; -use aimdb_mqtt_connector::connector::{Embedded, Native}; use aimdb_mqtt_connector::MqttConnector; use aimdb_tokio_adapter::net::TokioNet; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; @@ -34,6 +33,9 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } +// Nothing else defines `_defmt_timestamp` now that the connector pulls no +// crate enabling `embassy-time/defmt-timestamp-uptime`. +defmt::timestamp!("{=u64:us}", 0); struct HostClock; impl embassy_time_driver::Driver for HostClock { @@ -100,10 +102,10 @@ async fn both_backends_round_trip_against_one_broker() { let url = format!("mqtt://127.0.0.1:{port}"); let seen = Arc::new(Mutex::new(Seen::default())); - // The turbofish is what disambiguates the two `new`s while both backends - // are compiled in. - let native = MqttConnector::::new(url.clone()).with_client_id("parity-native"); - let embedded = MqttConnector::::new(url) + // One `new` whichever backends are compiled in: the transport, or its + // absence, picks the backend. + let native = MqttConnector::new(url.clone()).with_client_id("parity-native"); + let embedded = MqttConnector::new(url) .transport(TokioNet::tcp()) .with_client_id("parity-embedded"); @@ -180,3 +182,54 @@ async fn both_backends_round_trip_against_one_broker() { "each backend must publish its own record's bytes" ); } + +/// `with_credentials` reaches the wire on both backends. +/// +/// It is new plumbing on `Native` — `rumqttc` previously took credentials only +/// from the URL authority — so a setter that was accepted and dropped would +/// look exactly like success. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn with_credentials_reaches_the_wire_on_both_backends() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()) + .with_client_id("creds-native") + .with_credentials("hub", "s3cret"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("creds-embedded") + .with_credentials("hub", "s3cret"); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().credentials.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().credentials); + } + } + + let seen = seen.lock().unwrap(); + let expected = Some((String::from("hub"), String::from("s3cret"))); + for (n, credentials) in seen.credentials.iter().enumerate() { + assert_eq!( + *credentials, expected, + "connection {n} ({}) dropped the credentials", + seen.client_ids[n] + ); + } +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index f79dcab8..a19af195 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -17,6 +17,8 @@ use tokio::net::{TcpListener, TcpStream}; pub struct Seen { pub connects: usize, pub client_ids: Vec, + /// The username/password each CONNECT carried, when it carried any. + pub credentials: Vec>, pub subscribes: Vec>, pub published: Vec<(String, Vec)>, } @@ -84,9 +86,19 @@ fn is_v5(body: &[u8]) -> bool { body.get(6).is_some_and(|level| *level >= 5) } -/// The client id a CONNECT carries. It opens the payload, which follows the -/// 10-byte variable header plus, on MQTT 5, a property block. -fn connect_client_id(body: &[u8], v5: bool) -> Option { +/// Read a length-prefixed field and step past it. +fn take_field(body: &[u8], i: &mut usize) -> Option { + let len = u16::from_be_bytes([*body.get(*i)?, *body.get(*i + 1)?]) as usize; + let field = String::from_utf8_lossy(body.get(*i + 2..*i + 2 + len)?).into_owned(); + *i += 2 + len; + Some(field) +} + +/// The identity a CONNECT carries: client id, then the credentials its flags +/// advertise. The payload follows the 10-byte variable header plus, on MQTT 5, +/// a property block. Nothing here sets a will, so the fields are contiguous. +fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, String)>)> { + let flags = *body.get(7)?; let mut i = 10; if v5 { let start = i; @@ -94,8 +106,20 @@ fn connect_client_id(body: &[u8], v5: bool) -> Option { // The varint is the property block's length, which follows it. i += *body.get(start)? as usize; } - let len = u16::from_be_bytes([*body.get(i)?, *body.get(i + 1)?]) as usize; - Some(String::from_utf8_lossy(body.get(i + 2..i + 2 + len)?).into_owned()) + + let client_id = take_field(body, &mut i)?; + let credentials = if flags & 0x80 != 0 { + let username = take_field(body, &mut i)?; + let password = if flags & 0x40 != 0 { + take_field(body, &mut i)? + } else { + String::new() + }; + Some((username, password)) + } else { + None + }; + Some((client_id, credentials)) } /// Collect the topics from a SUBSCRIBE body and build the matching SUBACK. @@ -193,8 +217,9 @@ async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_ { let mut seen = seen.lock().unwrap(); seen.connects += 1; - if let Some(id) = connect_client_id(&body, v5) { + if let Some((id, credentials)) = connect_identity(&body, v5) { seen.client_ids.push(id); + seen.credentials.push(credentials); } } let ack: &[u8] = if v5 { diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index 62cde2ed..f2a9fd37 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -28,6 +28,9 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } +// Nothing else defines `_defmt_timestamp` now that the connector pulls no +// crate enabling `embassy-time/defmt-timestamp-uptime`. +defmt::timestamp!("{=u64:us}", 0); /// Real wall-clock time; the session loop's delays are `embassy_time`'s until /// it takes core's `Delay`. From e20e360095d2259c365ccdcc7b001736ea9dcb88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 20:55:12 +0000 Subject: [PATCH 14/48] feat: add SNTP codec and TLS transport for MQTT client - Implement SNTP codec for encoding and parsing SNTP packets, including request and reply handling. - Introduce TLS transport for the Embassy MQTT client, supporting secure connections with certificate verification. - Refactor the library structure to separate native and embedded implementations, enhancing modularity. - Update the MQTT connector to support both plain and secure MQTT connections, with appropriate error handling and logging. --- Cargo.lock | 1 - Makefile | 33 ++++++++--- aimdb-mqtt-connector/Cargo.toml | 59 +++++++++++-------- aimdb-mqtt-connector/src/connector.rs | 22 +++---- .../src/{ => embedded}/manager.rs | 0 .../{embassy_client.rs => embedded/mod.rs} | 44 +++++++++----- .../src/{transport.rs => embedded/session.rs} | 12 ++-- .../src/{ => embedded}/sntp.rs | 2 +- .../src/{ => embedded}/sntp_codec.rs | 2 +- .../src/{embassy_tls.rs => embedded/tls.rs} | 10 ++-- aimdb-mqtt-connector/src/lib.rs | 43 ++++++-------- .../src/{tokio_client.rs => native.rs} | 0 12 files changed, 133 insertions(+), 95 deletions(-) rename aimdb-mqtt-connector/src/{ => embedded}/manager.rs (100%) rename aimdb-mqtt-connector/src/{embassy_client.rs => embedded/mod.rs} (93%) rename aimdb-mqtt-connector/src/{transport.rs => embedded/session.rs} (94%) rename aimdb-mqtt-connector/src/{ => embedded}/sntp.rs (99%) rename aimdb-mqtt-connector/src/{ => embedded}/sntp_codec.rs (98%) rename aimdb-mqtt-connector/src/{embassy_tls.rs => embedded/tls.rs} (98%) rename aimdb-mqtt-connector/src/{tokio_client.rs => native.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index bf9f7bda..d3b1d209 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -283,7 +283,6 @@ dependencies = [ "async-stream", "critical-section", "defmt 1.1.1", - "embassy-executor", "embassy-net", "embassy-net-driver-channel", "embassy-sync", diff --git a/Makefile b/Makefile index b218aed3..4e809a7f 100644 --- a/Makefile +++ b/Makefile @@ -27,6 +27,9 @@ RED := \033[0;31m # pthread_atfork fork detector) silently un-no_std's the crate if it is not # marked optional and gated behind `std`. SYNC_NO_STD_FORBIDDEN := tokio|libc +# The embedded MQTT backend runs on any target with a `StreamDialer`, so no +# executor, network stack, adapter or logger may reach its graph. +MQTT_EMBEDDED_FORBIDDEN := embassy-net|embassy-executor|embassy-time|static_cell|aimdb-embassy-adapter|defmt NC := \033[0m # No Color ## Show available commands @@ -208,11 +211,11 @@ test: @printf "$(YELLOW) → Testing persistence SQLite backend$(NC)\n" cargo test --package aimdb-persistence-sqlite @printf "$(YELLOW) → Testing MQTT connector (tokio, no TLS backend)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime" + cargo test --package aimdb-mqtt-connector --features "std" @printf "$(YELLOW) → Testing MQTT connector (tokio + native-tls)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-native-tls" + cargo test --package aimdb-mqtt-connector --features "std,tokio-native-tls" @printf "$(YELLOW) → Testing MQTT connector (tokio + rustls)$(NC)\n" - cargo test --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-rustls" + cargo test --package aimdb-mqtt-connector --features "std,tokio-rustls" @printf "$(YELLOW) → Testing KNX connector$(NC)\n" cargo test --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Testing WebSocket connector (server + client: unit, real-socket e2e, AimDB round-trip)$(NC)\n" @@ -337,12 +340,14 @@ clippy: @printf "$(YELLOW) → Clippy on KNX connector (embassy)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio, no TLS backend)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio + native-tls)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-native-tls" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std,tokio-native-tls" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio + rustls)$(NC)\n" - cargo clippy --package aimdb-mqtt-connector --features "std,tokio-runtime,tokio-rustls" --all-targets -- -D warnings + cargo clippy --package aimdb-mqtt-connector --features "std,tokio-rustls" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded" -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (Embassy bundle + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings @@ -392,7 +397,7 @@ doc: cargo doc --package aimdb-core --features "std,tracing,observability" --no-deps cargo doc --package aimdb-tokio-adapter --features "tokio-runtime,tracing,observability,net,embedded-io" --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-mqtt-connector --features "std" --no-deps cargo doc --package aimdb-knx-connector --features "std,tokio-runtime" --no-deps cargo doc --package aimdb-codegen --no-deps cargo doc --package aimdb-cli --no-deps @@ -465,7 +470,19 @@ test-embedded: @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" + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (runtime-neutral embedded backend) 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 "embedded" + @printf "$(YELLOW) → Asserting no runtime crates in the embedded MQTT backend$(NC)\n" + @out=$$(cargo tree -p aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded" -e features,no-dev 2>&1) || { \ + printf "$(RED)✗ cargo tree failed — refusing to pass vacuously:$(NC)\n"; \ + printf '%s\n' "$$out"; exit 1; \ + }; \ + if printf '%s\n' "$$out" | grep -qiE '$(MQTT_EMBEDDED_FORBIDDEN)'; then \ + printf "$(RED)✗ a runtime crate leaked into the embedded MQTT graph$(NC)\n"; \ + printf '%s\n' "$$out" | grep -iE '$(MQTT_EMBEDDED_FORBIDDEN)'; exit 1; \ + fi + @printf "$(BLUE)✓ embedded MQTT graph is free of $(MQTT_EMBEDDED_FORBIDDEN)$(NC)\n" + @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy bundle) 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" cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 911e6a58..1a9c5684 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -15,15 +15,20 @@ categories = ["network-programming", "embedded", "asynchronous"] default = ["aimdb-core/alloc"] # `aimdb-core/connector-session` provides the data-plane `pump_sink`/`pump_source` # helpers the tokio client builds on (re-exported there; `std` implies it too). -std = ["aimdb-core/std", "aimdb-core/alloc", "aimdb-core/connector-session", "thiserror"] -tokio-runtime = [ - "std", +# The `rumqttc` backend, which owns its socket, TLS and reconnect. +std = [ + "aimdb-core/std", + "aimdb-core/alloc", + "aimdb-core/connector-session", + "thiserror", "tokio", "rumqttc", "uuid", "async-stream", "futures-util", ] +# Deprecated alias for `std`, kept so existing manifests keep working. +tokio-runtime = ["std"] # TLS backend for the tokio client (`mqtts://`). Pick one, or neither. # # Neither is a real choice rather than an oversight: a deployment that speaks @@ -31,27 +36,38 @@ tokio-runtime = [ # library is the difference between inheriting a system OpenSSL ABI and # inheriting nothing. `mqtts://` then fails at connect time with a message # naming the missing feature, rather than at the linker. -tokio-native-tls = ["tokio-runtime", "rumqttc/use-native-tls"] -tokio-rustls = ["tokio-runtime", "rumqttc/use-rustls", "dep:rustls-native-certs"] +tokio-native-tls = ["std", "rumqttc/use-native-tls"] +tokio-rustls = ["std", "rumqttc/use-rustls", "dep:rustls-native-certs"] + +# The `mountain-mqtt` backend over a caller-supplied transport. `alloc` only: +# no executor, no network stack, no adapter — any target with a `StreamDialer` +# that also offers `embedded-io-async` can run it. +embedded = [ + "aimdb-core/alloc", + "aimdb-core/connector-session", + "mountain-mqtt", + # The transport bridge names these traits in its bounds, and the session + # loop bridges core's `Delay` to the client's `DelayNs`. + "dep:embedded-io-async", + "dep:embedded-hal-async", + # Executor-independent: channels only. + "embassy-sync", +] +# Convenience bundle: `embedded` plus the Embassy transport and clock. The +# connector itself no longer knows what a runtime is. embassy-runtime = [ - "aimdb-core/alloc", # Need alloc for collect_inbound_routes - "aimdb-core/connector-session", # `pump_sink`/`pump_source`/`Source`/`Payload` - "dep:aimdb-embassy-adapter", # Enable the optional dependency - "aimdb-embassy-adapter/embassy-net-support", # Enable EmbassyNetwork trait for network stack access - "aimdb-embassy-adapter/connectors", # `EmbassySink`/`EmbassySource`/`into_box_future` spine - "aimdb-embassy-adapter/net", # `EmbassyNet::tcp` — the adapter owns the socket - "embassy-executor", + "embedded", + "dep:aimdb-embassy-adapter", + "aimdb-embassy-adapter/embassy-net-support", + "aimdb-embassy-adapter/connectors", + "aimdb-embassy-adapter/net", + # `EmbassyTcpDialer` supplies the session clock, which needs this. + "aimdb-embassy-adapter/embassy-time", "embassy-time", - "embassy-sync", "embassy-net", - "mountain-mqtt", - # The `SocketTransport` bridge names these traits in its bounds, and the - # session loop bridges core's `Delay` to the client's `DelayNs`. - "dep:embedded-io-async", - "dep:embedded-hal-async", - "heapless", ] + # TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 # session over the Embassy TCP socket, pure-Rust certificate verification # (`rustpki`; `rsa`/`p384` so public CA chains verify out of the box), broker @@ -92,7 +108,7 @@ _test-tokio-broker = [ # Internal: both backends against one fake broker in one process # (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. -_test-backend-parity = ["_test-tokio-broker", "tokio-runtime"] +_test-backend-parity = ["_test-tokio-broker", "std"] # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an @@ -140,7 +156,6 @@ futures-core = { version = "0.3", default-features = false } # Embassy runtime dependencies (no_std). Only embassy-sync still comes from the # local checkout — see the workspace `[patch.crates-io]` for why. -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-net = { version = "0.9.0", optional = true, features = [ @@ -168,8 +183,6 @@ embedded-io-async = { workspace = true, optional = true } embedded-hal-async = { workspace = true, optional = true } rand_core = { version = "0.6", default-features = false, optional = true } -# Embedded utilities -heapless = { workspace = true, optional = true } # Optional observability defmt = { workspace = true, optional = true } diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index a91a2eaa..062f69bd 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -34,7 +34,7 @@ type BuildFuture<'a> = Pin>> + S pub struct Native; /// The `mountain-mqtt` backend over a caller-supplied transport. -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] pub struct Embedded { pub(crate) dialer: D, } @@ -47,7 +47,7 @@ pub struct Embedded { #[cfg(feature = "embassy-tls")] pub struct EmbeddedTls { pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, - pub(crate) options: crate::embassy_client::TlsSlot, + pub(crate) options: crate::embedded::TlsSlot, } /// An MQTT connector over the backend `B`. @@ -75,7 +75,7 @@ impl MqttConnector { /// Dial plain sessions through an adapter's stream dialer — the same call /// on any runtime's adapter, with no change in this crate. - #[cfg(feature = "embassy-runtime")] + #[cfg(feature = "embedded")] pub fn transport(self, dialer: D) -> MqttConnector> { MqttConnector { broker_url: self.broker_url, @@ -90,7 +90,7 @@ impl MqttConnector { pub fn tls( self, stack: &'static embassy_net::Stack<'static>, - options: crate::embassy_tls::TlsOptions, + options: crate::embedded::tls::TlsOptions, ) -> MqttConnector { MqttConnector { broker_url: self.broker_url, @@ -101,7 +101,7 @@ impl MqttConnector { // cooperative executor (the adapter's module-level invariant); // every future touching this stack is polled on that executor. stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, - options: crate::embassy_client::TlsSlot::new(options), + options: crate::embedded::TlsSlot::new(options), }, } } @@ -131,7 +131,7 @@ impl MqttConnector { mod sealed { pub trait Sealed {} impl Sealed for super::Native {} - #[cfg(feature = "embassy-runtime")] + #[cfg(feature = "embedded")] impl Sealed for super::Embedded {} #[cfg(feature = "embassy-tls")] impl Sealed for super::EmbeddedTls {} @@ -158,7 +158,7 @@ pub trait Backend: sealed::Sealed + Send + Sync { ) -> BuildFuture<'a>; } -#[cfg(feature = "tokio-runtime")] +#[cfg(feature = "std")] impl Backend for Native { fn build<'a>( &'a self, @@ -167,11 +167,11 @@ impl Backend for Native { client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::tokio_client::build(db, broker_url, client_id, credentials) + crate::native::build(db, broker_url, client_id, credentials) } } -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] impl Backend for Embedded where D: aimdb_core::session::StreamDialer @@ -189,7 +189,7 @@ where client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::embassy_client::build_plain(db, broker_url, client_id, credentials, &self.dialer) + crate::embedded::build_plain(db, broker_url, client_id, credentials, &self.dialer) } } @@ -202,7 +202,7 @@ impl Backend for EmbeddedTls { client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, ) -> BuildFuture<'a> { - crate::embassy_client::build_tls(db, broker_url, client_id, credentials, self) + crate::embedded::build_tls(db, broker_url, client_id, credentials, self) } } diff --git a/aimdb-mqtt-connector/src/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs similarity index 100% rename from aimdb-mqtt-connector/src/manager.rs rename to aimdb-mqtt-connector/src/embedded/manager.rs diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embedded/mod.rs similarity index 93% rename from aimdb-mqtt-connector/src/embassy_client.rs rename to aimdb-mqtt-connector/src/embedded/mod.rs index d4322613..27b942d1 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -20,6 +20,20 @@ //! .await?; //! ``` +pub mod manager; +pub mod session; + +// SNTP wire codec — pure and feature-independent so it is unit-tested on the +// host; only the TLS I/O task consumes it. +#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] +pub(crate) mod sntp_codec; + +// TLS transport + SNTP time source. +#[cfg(feature = "embassy-tls")] +pub mod sntp; +#[cfg(feature = "embassy-tls")] +pub mod tls; + extern crate alloc; use aimdb_core::connector::ConnectorUrl; @@ -43,12 +57,12 @@ use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; -use crate::manager::{MqttEvent, Settings}; +use crate::embedded::manager::{MqttEvent, Settings}; #[cfg(feature = "embassy-tls")] -pub use crate::embassy_tls::TlsOptions; +pub use crate::embedded::tls::TlsOptions; #[cfg(feature = "embassy-tls")] -use crate::embassy_tls::{host_ip_literal, run_tls, READ_BUF_MIN}; +use crate::embedded::tls::{host_ip_literal, run_tls, READ_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -67,9 +81,9 @@ type EmbassyBoxFuture = Pin + Send + 'static>>; type ManagerSetup = (Arc, Arc, Vec); /// Outbound publishes and subscriptions: pumps to broker session. -pub(crate) type ActionChannel = crate::manager::ActionChannel; +pub(crate) type ActionChannel = crate::embedded::manager::ActionChannel; /// Inbound messages: broker session to pumps. -pub(crate) type EventChannel = crate::manager::EventChannel; +pub(crate) type EventChannel = crate::embedded::manager::EventChannel; /// MQTT actions that can be performed /// @@ -167,12 +181,12 @@ pub enum AimdbMqttEvent { MessageReceived { /// The topic the message was received on topic: String, - /// The message payload - payload: Vec, + /// The message payload, built once from the wire bytes. + payload: Payload, }, } -impl crate::manager::FromApplicationMessage for AimdbMqttEvent { +impl crate::embedded::manager::FromApplicationMessage for AimdbMqttEvent { fn from_application_message( message: &mountain_mqtt::packets::publish::ApplicationMessage, ) -> Result { @@ -185,7 +199,9 @@ impl crate::manager::FromApplicationMessage for AimdbMqttEvent { Ok(Self::MessageReceived { topic: message.topic_name.to_string(), - payload: message.payload.to_vec(), + // Straight to `Payload` — one allocation and one copy, where a + // `Vec` here would be converted again on the way out. + payload: Payload::from(message.payload), }) } } @@ -246,7 +262,7 @@ impl aimdb_core::session::Source for MqttSource { MqttEvent::ApplicationEvent { event: AimdbMqttEvent::MessageReceived { topic, payload }, .. - } => return Some((topic, Payload::from(payload))), + } => return Some((topic, payload)), // Connection lifecycle events carry no record data; skip // and keep draining. _ => continue, @@ -454,20 +470,20 @@ where // on — both come from the caller-supplied dialer. let delay = dialer.clone(); let transport = - crate::transport::SocketTransport::new(dialer, broker.host.clone(), broker.port); + crate::embedded::session::SocketTransport::new(dialer, broker.host.clone(), broker.port); // SAFETY: every value the session holds is `Send` — `StreamDialer` // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` // and the state cell is a blocking mutex. See `SendSession`. let manager_task: EmbassyBoxFuture = Box::pin(unsafe { - crate::transport::SendSession::new({ + crate::embedded::session::SendSession::new({ let actions = actions.clone(); let events = events.clone(); async move { #[cfg(feature = "defmt")] defmt::info!("MQTT background task starting"); - crate::transport::run_sessions( + crate::embedded::session::run_sessions( transport, topics, connection_settings, @@ -555,7 +571,7 @@ fn setup_tls_manager( let sntp_task = into_box_future(async move { #[allow(unreachable_code)] { - let _: () = crate::sntp::run(*network, sntp_server).await; + let _: () = crate::embedded::sntp::run(*network, sntp_server).await; } }); diff --git a/aimdb-mqtt-connector/src/transport.rs b/aimdb-mqtt-connector/src/embedded/session.rs similarity index 94% rename from aimdb-mqtt-connector/src/transport.rs rename to aimdb-mqtt-connector/src/embedded/session.rs index 47467117..e9e2af0b 100644 --- a/aimdb-mqtt-connector/src/transport.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -132,9 +132,9 @@ pub(crate) async fn run_sessions( transport: T, topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, - settings: crate::manager::Settings, - events: alloc::sync::Arc, - actions: alloc::sync::Arc, + settings: crate::embedded::manager::Settings, + events: alloc::sync::Arc, + actions: alloc::sync::Arc, delay: D, runtime: alloc::sync::Arc, ) -> ! @@ -146,7 +146,7 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; + use crate::embedded::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -154,7 +154,7 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); - let mut mqtt_buffer = [0u8; crate::embassy_client::BUFFER_SIZE]; + let mut mqtt_buffer = [0u8; crate::embedded::BUFFER_SIZE]; let mut connection_index = 0u32; loop { @@ -168,7 +168,7 @@ where } }; - let state: SessionState = + let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; diff --git a/aimdb-mqtt-connector/src/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs similarity index 99% rename from aimdb-mqtt-connector/src/sntp.rs rename to aimdb-mqtt-connector/src/embedded/sntp.rs index 8c6e97a1..d887cc8b 100644 --- a/aimdb-mqtt-connector/src/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -14,7 +14,7 @@ use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, Stack}; use embassy_time::{with_timeout, Duration, Instant, Timer}; -use crate::sntp_codec; +use crate::embedded::sntp_codec; /// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is /// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit diff --git a/aimdb-mqtt-connector/src/sntp_codec.rs b/aimdb-mqtt-connector/src/embedded/sntp_codec.rs similarity index 98% rename from aimdb-mqtt-connector/src/sntp_codec.rs rename to aimdb-mqtt-connector/src/embedded/sntp_codec.rs index 6b37a40a..1f555c2b 100644 --- a/aimdb-mqtt-connector/src/sntp_codec.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp_codec.rs @@ -1,7 +1,7 @@ //! SNTPv4 wire format (RFC 4330 subset) — pure encode/parse, no I/O. //! //! Feature-independent so the codec is unit-tested on the host; the Embassy -//! I/O task around it lives in [`sntp`](crate::sntp) (`embassy-tls` only). +//! I/O task around it lives in [`sntp`](crate::embedded::sntp) (`embassy-tls` only). /// Seconds between the NTP epoch (1900-01-01) and the Unix epoch (1970-01-01). const NTP_UNIX_OFFSET: u64 = 2_208_988_800; diff --git a/aimdb-mqtt-connector/src/embassy_tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs similarity index 98% rename from aimdb-mqtt-connector/src/embassy_tls.rs rename to aimdb-mqtt-connector/src/embedded/tls.rs index 58cdaa6b..42b123bc 100644 --- a/aimdb-mqtt-connector/src/embassy_tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -33,7 +33,7 @@ use embedded_tls::{ use embedded_io_async::Write as _; -use crate::manager::{ +use crate::embedded::manager::{ handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, Settings, }; use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; @@ -43,10 +43,10 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embassy_client::{ +use crate::embedded::{ AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, }; -use crate::sntp::{self, SntpClock}; +use crate::embedded::sntp::{self, SntpClock}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. @@ -244,8 +244,8 @@ pub(crate) async fn run_tls( topics: Vec, connection_settings: ConnectionSettings<'static>, settings: Settings, - events: Arc, - actions: Arc, + events: Arc, + actions: Arc, runtime: Arc, ) -> ! { let TlsOptions { diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 9a9ff0a8..9537c0e2 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -94,41 +94,34 @@ extern crate alloc; -// MQTT knobs over core's generic link builders (works on every feature leg) // One `MqttConnector` over the `Native` and `Embedded` protocol backends. pub mod connector; -// The broker transport seam for the `Embedded` backend. -#[cfg(feature = "embassy-runtime")] -pub mod transport; - -// Session state, event handler and message pump for the `Embedded` backend. -#[cfg(feature = "embassy-runtime")] -pub mod manager; - +// MQTT knobs over core's generic link builders (works on every feature leg). pub mod link_ext; pub use link_ext::{MqttLinkExt, MqttOutboundLinkExt}; -// Platform-specific implementations -#[cfg(feature = "tokio-runtime")] -pub mod tokio_client; - -#[cfg(feature = "embassy-runtime")] -pub mod embassy_client; +// The `rumqttc` backend. +#[cfg(feature = "std")] +pub mod native; -// SNTP wire codec — pure and feature-independent so it is unit-tested on the -// host; only the `embassy-tls` I/O task consumes it. -#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] -pub(crate) mod sntp_codec; +// The `mountain-mqtt` backend: session loop, manager, and the TLS transport. +#[cfg(feature = "embedded")] +pub mod embedded; -// TLS transport + SNTP time source for the Embassy client -#[cfg(feature = "embassy-tls")] -pub mod embassy_tls; -#[cfg(feature = "embassy-tls")] -pub mod sntp; +// Deprecated module names, kept for one release so existing imports keep +// working. The modules no longer name a runtime. +#[cfg(feature = "std")] +#[deprecated(since = "0.7.0", note = "renamed to `native`")] +pub use crate::native as tokio_client; +#[cfg(feature = "embedded")] +#[deprecated(since = "0.7.0", note = "renamed to `embedded`")] +pub use crate::embedded as embassy_client; -#[cfg(feature = "embassy-runtime")] +#[cfg(feature = "embedded")] pub use connector::Embedded; #[cfg(feature = "embassy-tls")] pub use connector::EmbeddedTls; +#[cfg(feature = "embassy-tls")] +pub use embedded::tls::TlsOptions; pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/native.rs similarity index 100% rename from aimdb-mqtt-connector/src/tokio_client.rs rename to aimdb-mqtt-connector/src/native.rs From e770173fd86c6e53a5a0e493b87ecce44bdfb8d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:03:39 +0000 Subject: [PATCH 15/48] feat: reorganize embedded module structure and add SNTP codec implementation --- aimdb-mqtt-connector/src/embedded/mod.rs | 8 ++------ aimdb-mqtt-connector/src/embedded/session.rs | 4 +++- aimdb-mqtt-connector/src/embedded/sntp.rs | 2 +- aimdb-mqtt-connector/src/embedded/tls.rs | 8 +++----- aimdb-mqtt-connector/src/lib.rs | 13 +++++++++---- aimdb-mqtt-connector/src/native.rs | 10 ++++------ .../src/{embedded => }/sntp_codec.rs | 0 aimdb-mqtt-connector/tests/link_ext_tests.rs | 2 +- aimdb-mqtt-connector/tests/topic_provider_tests.rs | 2 +- 9 files changed, 24 insertions(+), 25 deletions(-) rename aimdb-mqtt-connector/src/{embedded => }/sntp_codec.rs (100%) diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 27b942d1..ce8a5b79 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -23,11 +23,6 @@ pub mod manager; pub mod session; -// SNTP wire codec — pure and feature-independent so it is unit-tested on the -// host; only the TLS I/O task consumes it. -#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] -pub(crate) mod sntp_codec; - // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] pub mod sntp; @@ -81,7 +76,8 @@ type EmbassyBoxFuture = Pin + Send + 'static>>; type ManagerSetup = (Arc, Arc, Vec); /// Outbound publishes and subscriptions: pumps to broker session. -pub(crate) type ActionChannel = crate::embedded::manager::ActionChannel; +pub(crate) type ActionChannel = + crate::embedded::manager::ActionChannel; /// Inbound messages: broker session to pumps. pub(crate) type EventChannel = crate::embedded::manager::EventChannel; diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index e9e2af0b..dd431881 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -146,7 +146,9 @@ where use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::embedded::manager::{handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState}; + use crate::embedded::manager::{ + handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, + }; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index d887cc8b..8c6e97a1 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -14,7 +14,7 @@ use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpEndpoint, Stack}; use embassy_time::{with_timeout, Duration, Instant, Timer}; -use crate::embedded::sntp_codec; +use crate::sntp_codec; /// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is /// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 42b123bc..39c20b45 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,6 +1,6 @@ -//! TLS transport for the Embassy MQTT client. +//! The TLS transport for the embedded backend. //! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the Embassy +//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over an Embassy //! TCP socket, presented to the MQTT layer as its own `Connection` — not //! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give //! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) @@ -43,10 +43,8 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embedded::{ - AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES, -}; use crate::embedded::sntp::{self, SntpClock}; +use crate::embedded::{AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 9537c0e2..d4a3da72 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -109,19 +109,24 @@ pub mod native; #[cfg(feature = "embedded")] pub mod embedded; +// SNTP wire codec — pure and feature-independent so it is unit-tested on the +// host; only the TLS I/O task consumes it. +#[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] +pub(crate) mod sntp_codec; + // Deprecated module names, kept for one release so existing imports keep // working. The modules no longer name a runtime. -#[cfg(feature = "std")] -#[deprecated(since = "0.7.0", note = "renamed to `native`")] -pub use crate::native as tokio_client; #[cfg(feature = "embedded")] #[deprecated(since = "0.7.0", note = "renamed to `embedded`")] pub use crate::embedded as embassy_client; +#[cfg(feature = "std")] +#[deprecated(since = "0.7.0", note = "renamed to `native`")] +pub use crate::native as tokio_client; #[cfg(feature = "embedded")] pub use connector::Embedded; #[cfg(feature = "embassy-tls")] pub use connector::EmbeddedTls; +pub use connector::{MqttConnector, Native}; #[cfg(feature = "embassy-tls")] pub use embedded::tls::TlsOptions; -pub use connector::{MqttConnector, Native}; diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index 8ca4954b..72a19307 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -1,10 +1,8 @@ -//! MQTT client management and lifecycle +//! The `rumqttc` backend: one broker connection, QoS 0–2, platform trust roots. //! -//! This module provides a client pool that: -//! - Manages a single MQTT broker connection -//! - Automatic event loop spawning -//! - Thread-safe access from multiple consumers -//! - Explicit lifecycle management (user controls when clients are created) +//! `rumqttc` owns its socket, TLS and reconnect, so this module contributes +//! only the connect-and-subscribe step and the `MqttSink`/`MqttEventLoopSource` +//! adapters that core's pumps drive. use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; diff --git a/aimdb-mqtt-connector/src/embedded/sntp_codec.rs b/aimdb-mqtt-connector/src/sntp_codec.rs similarity index 100% rename from aimdb-mqtt-connector/src/embedded/sntp_codec.rs rename to aimdb-mqtt-connector/src/sntp_codec.rs diff --git a/aimdb-mqtt-connector/tests/link_ext_tests.rs b/aimdb-mqtt-connector/tests/link_ext_tests.rs index 60a78cef..686efd4a 100644 --- a/aimdb-mqtt-connector/tests/link_ext_tests.rs +++ b/aimdb-mqtt-connector/tests/link_ext_tests.rs @@ -4,7 +4,7 @@ //! the extension methods push exactly the `("qos", …)` / `("retain", …)` //! option keys the MQTT clients read from `protocol_options`. -#![cfg(feature = "tokio-runtime")] +#![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index aa271903..14b04680 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -6,7 +6,7 @@ //! //! The tests use mock data and don't require a running MQTT broker. -#![cfg(feature = "tokio-runtime")] +#![cfg(feature = "std")] use aimdb_core::buffer::BufferCfg; use aimdb_core::connector::TopicProvider; From 7e95b31e3741039b34db0925128cb77472881d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:04:25 +0000 Subject: [PATCH 16/48] fix(tls): correct variable name for port in connection log --- aimdb-mqtt-connector/src/embedded/tls.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 39c20b45..992d9aee 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -300,7 +300,7 @@ pub(crate) async fn run_tls( "MQTT-TLS: connecting to {} ({}) port {}...", host.as_str(), address, - settings.port + port ); if let Err(e) = socket.connect((address, port)).await { #[cfg(feature = "defmt")] From b234e85acb685793672f8972de4c843c8975b5d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:09:13 +0000 Subject: [PATCH 17/48] docs: update feature descriptions and clarify backend distinctions in lib.rs --- aimdb-mqtt-connector/src/lib.rs | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index d4a3da72..414ff788 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -6,14 +6,20 @@ //! //! ## Features //! -//! - `tokio-runtime`: Tokio-based connector using `rumqttc` -//! - `embassy-runtime`: Embassy connector for embedded systems using `mountain-mqtt` -//! - `embassy-tls`: TLS (`mqtts://`), broker authentication, DNS, and the -//! SNTP time source for the Embassy connector -//! - `tracing`: Debug logging support (std) -//! - `defmt`: Debug logging support (no_std) -//! -//! ## Tokio Usage (Standard Library) +//! The split is std vs `no_std`, not Tokio vs Embassy: the embedded backend +//! runs on any target that can supply a `StreamDialer`. +//! +//! - `std`: the `rumqttc` backend (QoS 0–2, platform trust roots) +//! - `embedded`: the `mountain-mqtt` backend over a caller-supplied transport; +//! `alloc` only, with no executor, network stack or adapter +//! - `embassy-runtime`: `embedded` plus the Embassy transport and clock +//! - `embassy-tls`: TLS (`mqtts://`), DNS and the SNTP time source, on Embassy +//! - `critical-section-std-impl`: links a `critical-section` impl for std +//! binaries, which the session channels need +//! - `tokio-runtime`: deprecated alias for `std` +//! - `tracing` / `defmt`: logging destinations +//! +//! ## Std Usage //! //! ```no_run //! use aimdb_core::AimDbBuilder; From c5a2db0579b0a59bf0532bfe70a4b33fc0654f65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 21:56:15 +0000 Subject: [PATCH 18/48] feat: add embedded TLS support for MQTT connector - Introduced a new feature `_test-tls-broker` for testing MQTT over TLS with a pinned self-signed root CA. - Updated the Makefile to include new test commands for the MQTT connector with TLS. - Modified `Cargo.toml` to add dependencies for embedded TLS and SNTP. - Refactored the `EmbeddedTls` struct to use a caller-supplied transport instead of owning the network stack. - Implemented a new `WallClock` for certificate validity checks in the absence of an RTC. - Created a new test `tls_broker.rs` to validate the MQTT handshake over TLS. - Updated the example to demonstrate the use of MQTT over TLS with SNTP for time synchronization. --- Cargo.lock | 40 ++++ Makefile | 8 + aimdb-mqtt-connector/Cargo.toml | 26 ++- aimdb-mqtt-connector/src/connector.rs | 48 ++-- aimdb-mqtt-connector/src/embedded/mod.rs | 120 ++++++---- aimdb-mqtt-connector/src/embedded/sntp.rs | 14 +- aimdb-mqtt-connector/src/embedded/tls.rs | 215 ++++++++++-------- aimdb-mqtt-connector/src/lib.rs | 4 +- aimdb-mqtt-connector/tests/common/mod.rs | 13 +- aimdb-mqtt-connector/tests/tls_broker.rs | 176 ++++++++++++++ .../embassy-mqtt-connector-demo/src/main.rs | 12 +- 11 files changed, 489 insertions(+), 187 deletions(-) create mode 100644 aimdb-mqtt-connector/tests/tls_broker.rs diff --git a/Cargo.lock b/Cargo.lock index d3b1d209..4a64a5c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -295,12 +295,15 @@ dependencies = [ "futures-core", "futures-util", "heapless 0.8.0", + "rand 0.8.6", "rand_core 0.6.4", + "rcgen", "rumqttc", "rustls-native-certs", "serde", "thiserror 2.0.17", "tokio", + "tokio-rustls", "tokio-test", "uuid", ] @@ -3191,6 +3194,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3370,6 +3383,7 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -3420,6 +3434,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] [[package]] name = "rand_core" @@ -3436,6 +3453,19 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +[[package]] +name = "rcgen" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "yasna", +] + [[package]] name = "readme-quickstart" version = "1.1.0" @@ -4360,6 +4390,7 @@ dependencies = [ "deranged", "num-conv", "powerfmt", + "serde_core", "time-core", ] @@ -5680,6 +5711,15 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yasna" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd" +dependencies = [ + "time", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Makefile b/Makefile index 4e809a7f..79b962da 100644 --- a/Makefile +++ b/Makefile @@ -240,6 +240,8 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker @printf "$(YELLOW) → Testing MQTT connector (both backends, one broker, one process)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity + @printf "$(YELLOW) → Testing MQTT connector (mqtts:// against a pinned self-signed root)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -350,6 +352,8 @@ clippy: @printf "$(YELLOW) → Clippy on MQTT connector (Embassy bundle + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded-tls" -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (Embassy + TLS + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings @printf "$(YELLOW) → Clippy on KNX connector (embassy + defmt)$(NC)\n" cargo clippy --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings @@ -379,6 +383,8 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test tokio_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (backend parity)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (mqtts:// host smoke)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -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" @@ -500,6 +506,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 (runtime-neutral 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 "embedded-tls" @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" diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 1a9c5684..5d63844d 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -68,15 +68,18 @@ embassy-runtime = [ "embassy-net", ] -# TLS (`mqtts://`) for the Embassy client — design 044. embedded-tls 1.3 -# session over the Embassy TCP socket, pure-Rust certificate verification -# (`rustpki`; `rsa`/`p384` so public CA chains verify out of the box), broker -# hostname resolution (embassy-net DNS), and the SNTP time source (UDP). +# TLS (`mqtts://`) for the embedded backend — design 044. An `embedded-tls` +# 1.3 session over the caller's transport, with pure-Rust certificate +# verification (`rustpki`; `rsa`/`p384` so public CA chains verify out of the +# box). Runtime-neutral: the dialer resolves the host and the runtime's wall +# clock dates the certificate. +embedded-tls = ["embedded", "dep:embedded-tls", "dep:rand_core"] + +# `embedded-tls` plus the SNTP time source, for a board with no RTC. Needs a +# network stack of its own, which is why it is the Embassy half. embassy-tls = [ + "embedded-tls", "embassy-runtime", - "dep:embedded-tls", - "dep:embedded-io-async", - "dep:rand_core", "embassy-net/dns", "embassy-net/udp", ] @@ -110,6 +113,11 @@ _test-tokio-broker = [ # (`tests/backend_parity.rs`). Run with `--features _test-backend-parity`. _test-backend-parity = ["_test-tokio-broker", "std"] +# Internal: the embedded backend's `mqtts://` host smoke against a local broker +# with a self-signed certificate pinned as the root CA +# (`tests/tls_broker.rs`). Run with `--features _test-tls-broker`. +_test-tls-broker = ["_test-tokio-broker", "embedded-tls"] + # Internal: the Embassy broker session loop's host smoke # (`tests/embassy_broker.rs`) stands up two `embassy-net` stacks wired by an # in-memory driver-channel crossover, with a fake broker on one side. Kept off @@ -191,6 +199,10 @@ embassy-net-driver-channel = { version = "0.4.0", optional = true } critical-section = { version = "1.1", optional = true } [dev-dependencies] +# The `mqtts://` host smoke: a self-signed certificate and a real TLS server. +rand = "0.8" +rcgen = "0.13" +tokio-rustls = "0.26" tokio = { workspace = true, features = ["full"] } heapless = { workspace = true } futures = "0.3" diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 062f69bd..32511190 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -39,14 +39,11 @@ pub struct Embedded { pub(crate) dialer: D, } -/// The `mountain-mqtt` backend over `embedded-tls`. -/// -/// TLS keeps the network stack rather than taking a dialer: it resolves DNS -/// itself and owns buffers across sessions, which a per-session dialer cannot -/// express. -#[cfg(feature = "embassy-tls")] -pub struct EmbeddedTls { - pub(crate) stack: aimdb_embassy_adapter::connectors::NetStack, +/// The `mountain-mqtt` backend over `embedded-tls`, on the same +/// caller-supplied transport as the plain path. +#[cfg(feature = "embedded-tls")] +pub struct EmbeddedTls { + pub(crate) dialer: D, pub(crate) options: crate::embedded::TlsSlot, } @@ -85,22 +82,22 @@ impl MqttConnector { } } - /// Provide the network stack and TLS materials for an `mqtts://` broker. - #[cfg(feature = "embassy-tls")] - pub fn tls( + /// Dial `mqtts://` sessions through an adapter's stream dialer, with + /// `options` supplying the trust root, buffers and entropy. + /// + /// The dialer resolves the host, so TLS needs no network stack of its own. + #[cfg(feature = "embedded-tls")] + pub fn tls( self, - stack: &'static embassy_net::Stack<'static>, + dialer: D, options: crate::embedded::tls::TlsOptions, - ) -> MqttConnector { + ) -> MqttConnector> { MqttConnector { broker_url: self.broker_url, client_id: self.client_id, credentials: self.credentials, backend: EmbeddedTls { - // SAFETY: AimDB's Embassy integration requires a single-core - // cooperative executor (the adapter's module-level invariant); - // every future touching this stack is polled on that executor. - stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + dialer, options: crate::embedded::TlsSlot::new(options), }, } @@ -133,8 +130,8 @@ mod sealed { impl Sealed for super::Native {} #[cfg(feature = "embedded")] impl Sealed for super::Embedded {} - #[cfg(feature = "embassy-tls")] - impl Sealed for super::EmbeddedTls {} + #[cfg(feature = "embedded-tls")] + impl Sealed for super::EmbeddedTls {} } /// A backend with a build path compiled in. @@ -193,8 +190,17 @@ where } } -#[cfg(feature = "embassy-tls")] -impl Backend for EmbeddedTls { +#[cfg(feature = "embedded-tls")] +impl Backend for EmbeddedTls +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ fn build<'a>( &'a self, db: &'a AimDb, diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index ce8a5b79..8e080261 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -26,7 +26,7 @@ pub mod session; // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] pub mod sntp; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub mod tls; extern crate alloc; @@ -45,6 +45,7 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; +#[cfg(feature = "embedded-tls")] #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; @@ -54,10 +55,10 @@ use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; use crate::embedded::manager::{MqttEvent, Settings}; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use crate::embedded::tls::TlsOptions; -#[cfg(feature = "embassy-tls")] -use crate::embedded::tls::{host_ip_literal, run_tls, READ_BUF_MIN}; +#[cfg(feature = "embedded-tls")] +use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -275,7 +276,7 @@ impl aimdb_core::session::Source for MqttSource { /// /// 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")] +#[cfg(feature = "embedded-tls")] pub(crate) type TlsSlot = aimdb_core::session::OneShot; /// Connect and collect the data-plane futures for a plain `mqtt://` session. @@ -315,14 +316,23 @@ where } /// Connect and collect the data-plane futures for an `mqtts://` session. -#[cfg(feature = "embassy-tls")] -pub(crate) fn build_tls<'a>( +#[cfg(feature = "embedded-tls")] +pub(crate) fn build_tls<'a, D>( db: &'a aimdb_core::builder::AimDb, broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, - backend: &'a crate::connector::EmbeddedTls, -) -> Pin>> + Send + 'a>> { + backend: &'a crate::connector::EmbeddedTls, +) -> Pin>> + Send + 'a>> +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ Box::pin(async move { let topics = inbound_topics(db); let broker = parse_broker_url(broker_url)?; @@ -339,7 +349,7 @@ pub(crate) fn build_tls<'a>( &broker, options, connection_settings, - backend.stack, + backend.dialer.clone(), topics, db.runtime_ops(), )?; @@ -500,15 +510,24 @@ where /// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source /// task. Synchronous — no `.await` — so the caller's `build` future stays /// `Send`. -#[cfg(feature = "embassy-tls")] -fn setup_tls_manager( +#[cfg(feature = "embedded-tls")] +fn setup_tls_manager( broker: &BrokerUrl, options: TlsOptions, connection_settings: ConnectionSettings<'static>, - stack: aimdb_embassy_adapter::connectors::NetStack, + dialer: D, topics: Vec, runtime: Arc, -) -> Result { +) -> Result +where + D: aimdb_core::session::StreamDialer + + aimdb_core::session::Delay + + Clone + + Send + + Sync + + 'static, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ match host_ip_literal(&broker.host) { Some(core::net::IpAddr::V6(_)) => { return Err(build_err( @@ -534,44 +553,57 @@ fn setup_tls_manager( let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); - let network = stack.get(); let host = broker.host.clone(); let port = broker.port; - let sntp_server = options.sntp_server; + #[cfg(feature = "embassy-tls")] + let sntp = options.sntp; - let manager_task = into_box_future({ - let actions = actions.clone(); - let events = events.clone(); - async move { - #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS background task starting"); + let delay = dialer.clone(); + // SAFETY: as for the plain path — `StreamDialer` guarantees `Stream: Send`, + // the channels are `CriticalSectionRawMutex`, and `TlsOptions` is `Send` + // (its RNG carries the bound). See `session::SendSession`. + #[cfg_attr(not(feature = "embassy-tls"), allow(unused_mut))] + let mut tasks: Vec = alloc::vec![Box::pin(unsafe { + crate::embedded::session::SendSession::new({ + let actions = actions.clone(); + let events = events.clone(); + async move { + #[cfg(feature = "defmt")] + defmt::info!("MQTT-TLS background task starting"); + + #[allow(unreachable_code)] + { + let _: () = crate::embedded::tls::run_tls( + dialer, + options, + host, + port, + topics, + connection_settings, + Settings::default(), + events, + actions, + delay, + runtime, + ) + .await; + } + } + }) + }) as EmbassyBoxFuture]; + // Only a runtime with no wall clock of its own needs this. + #[cfg(feature = "embassy-tls")] + if let Some((stack, server)) = sntp { + tasks.push(into_box_future(async move { #[allow(unreachable_code)] { - let _: () = run_tls( - *network, - options, - host, - port, - topics, - connection_settings, - Settings::default(), - events, - actions, - runtime, - ) - .await; + let _: () = crate::embedded::sntp::run(*stack.get(), server).await; } - } - }); - let sntp_task = into_box_future(async move { - #[allow(unreachable_code)] - { - let _: () = crate::embedded::sntp::run(*network, sntp_server).await; - } - }); + })); + } - Ok((actions, events, alloc::vec![manager_task, sntp_task])) + Ok((actions, events, tasks)) } /// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index 8c6e97a1..7414d7c5 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -46,17 +46,6 @@ pub fn unix_now() -> Option { } } -/// `embedded-tls` clock over the SNTP-synced time; `None` before the first -/// sync (the TLS manager never handshakes in that state, so certificate -/// validity is always actually checked). -pub struct SntpClock; - -impl embedded_tls::TlsClock for SntpClock { - fn now() -> Option { - unix_now() - } -} - /// Keep the clock synced: query `server` until the first success, then /// re-sync hourly. Runs forever; spawned by the TLS connector build. pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { @@ -69,6 +58,9 @@ pub(crate) async fn run(stack: Stack<'static>, server: &'static str) -> ! { match u32::try_from(unix_secs.saturating_sub(Instant::now().as_secs())) { Ok(boot @ 1..) => { BOOT_UNIX_SECS.store(boot, Ordering::Relaxed); + // The TLS handshake reads the certificate-validity + // clock, which a board with no RTC has only from here. + crate::embedded::tls::WallClock::set_unix_secs(unix_secs as u32); #[cfg(feature = "defmt")] defmt::info!("SNTP: synced, unix time {}", unix_secs); Timer::after(RESYNC_INTERVAL).await; diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 992d9aee..3c9090bb 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -20,10 +20,6 @@ use core::cell::RefCell; use core::net::IpAddr; use alloc::sync::Arc; -use embassy_net::dns::DnsQueryType; -use embassy_net::tcp::TcpSocket; -use embassy_net::{IpAddress, Stack}; -use embassy_time::{Delay, Timer}; use embedded_tls::pki::CertVerifier; use embedded_tls::{ @@ -43,7 +39,6 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embedded::sntp::{self, SntpClock}; use crate::embedded::{AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB @@ -67,7 +62,10 @@ pub struct TlsOptions { pub(crate) ca_der: &'static [u8], pub(crate) read_buf: &'static mut [u8], pub(crate) write_buf: &'static mut [u8], - pub(crate) sntp_server: &'static str, + /// Where the certificate-validity clock comes from on a board with no RTC. + /// `None` means the runtime's own wall clock answers. + #[cfg(feature = "embassy-tls")] + pub(crate) sntp: Option<(aimdb_embassy_adapter::connectors::NetStack, &'static str)>, } impl TlsOptions { @@ -92,56 +90,70 @@ impl TlsOptions { ca_der, read_buf, write_buf, - sntp_server: "pool.ntp.org", + #[cfg(feature = "embassy-tls")] + sntp: None, } } - /// Override the SNTP server used as the certificate-validation time - /// source (default `pool.ntp.org`). - pub fn with_sntp_server(mut self, server: &'static str) -> Self { - self.sntp_server = server; + /// Take the certificate-validity clock from SNTP over `stack`. + /// + /// Needed only where the runtime has no wall clock of its own — an MCU + /// with no RTC. A host runtime answers `unix_time()` and needs no task. + #[cfg(feature = "embassy-tls")] + pub fn with_sntp( + mut self, + stack: &'static embassy_net::Stack<'static>, + server: &'static str, + ) -> Self { + // SAFETY: AimDB's Embassy integration requires a single-core + // cooperative executor (the adapter's module-level invariant); the + // SNTP task touching this stack is polled on that executor. + self.sntp = Some(( + unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, + server, + )); self } } -/// The TCP socket shared between the TLS session (its transport) and the +/// The stream shared between the TLS session (its transport) and the /// MQTT-level readiness probe ([`TlsSession::receive_if_ready`]), which needs -/// `can_recv()` after the socket has been handed to `embedded-tls`. +/// to ask the wire after the stream has been handed to `embedded-tls`. /// /// Borrow discipline: the session task drives exactly one client operation at /// a time, so a `borrow_mut` held across an I/O `.await` can never overlap /// the probe's short `borrow` — both are called sequentially from the same /// loop. -struct SharedTcp<'r, 'a>(&'r RefCell>); +struct SharedStream<'r, S>(&'r RefCell); -impl Clone for SharedTcp<'_, '_> { +impl Clone for SharedStream<'_, S> { fn clone(&self) -> Self { Self(self.0) } } -impl SharedTcp<'_, '_> { +impl SharedStream<'_, S> { fn can_recv(&self) -> bool { - self.0.borrow().can_recv() + self.0.borrow_mut().read_ready().unwrap_or(false) } } -impl embedded_io_async::ErrorType for SharedTcp<'_, '_> { - type Error = embassy_net::tcp::Error; +impl embedded_io_async::ErrorType for SharedStream<'_, S> { + type Error = S::Error; } // The held-across-await borrows below are safe by the struct-level borrow // discipline (sequential single-task use); a panic would mean a second client // operation ran concurrently, which the session loop cannot do. #[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Read for SharedTcp<'_, '_> { +impl embedded_io_async::Read for SharedStream<'_, S> { async fn read(&mut self, buf: &mut [u8]) -> Result { self.0.borrow_mut().read(buf).await } } #[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Write for SharedTcp<'_, '_> { +impl embedded_io_async::Write for SharedStream<'_, S> { async fn write(&mut self, buf: &[u8]) -> Result { self.0.borrow_mut().write(buf).await } @@ -164,14 +176,20 @@ impl embedded_io_async::Write for SharedTcp<'_, '_> { /// (unsolicited session tickets, KeyUpdate) make `receive` wait for the next /// real record; if the broker stays silent, the keep-alive lapse tears the /// session down and the manager reconnects. -struct TlsSession<'r, 'a, 'b> { - tls: TlsConnection<'b, SharedTcp<'r, 'a>, Aes128GcmSha256>, - socket: SharedTcp<'r, 'a>, +struct TlsSession<'r, 'b, S> +where + S: embedded_io_async::Read + embedded_io_async::Write, +{ + tls: TlsConnection<'b, SharedStream<'r, S>, Aes128GcmSha256>, + socket: SharedStream<'r, S>, /// Decrypted-but-unread plaintext left in the TLS record buffer. plaintext_remaining: usize, } -impl Connection for TlsSession<'_, '_, '_> { +impl Connection for TlsSession<'_, '_, S> +where + S: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ async fn send(&mut self, buf: &[u8]) -> Result<(), PacketWriteError> { self.tls .write_all(buf) @@ -206,13 +224,47 @@ impl Connection for TlsSession<'_, '_, '_> { } } +/// Unix seconds for certificate validity, refreshed before each handshake. +/// +/// `embedded_tls::TlsClock::now` is a static method, so the reading has to +/// reach it through a global. The source is whatever the runtime's wall clock +/// reports; a runtime with no clock of its own (an MCU without an RTC) gets +/// one from the SNTP task instead. +static UNIX_SECS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); + +/// The certificate-validity clock. `u32` is unambiguous until 2106 and stays a +/// single atomic on Cortex-M, which has no 64-bit atomics. +pub(crate) struct WallClock; + +impl WallClock { + /// Record a wall-clock reading. Ignores a zero, which means "unknown". + pub(crate) fn set_unix_secs(secs: u32) { + if secs != 0 { + UNIX_SECS.store(secs, core::sync::atomic::Ordering::Relaxed); + } + } + + fn unix_secs() -> Option { + match UNIX_SECS.load(core::sync::atomic::Ordering::Relaxed) { + 0 => None, + secs => Some(u64::from(secs)), + } + } +} + +impl embedded_tls::TlsClock for WallClock { + fn now() -> Option { + Self::unix_secs() + } +} + /// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate -/// verification (time from [`SntpClock`]). Client-certificate signing is +/// verification (time from [`WallClock`]). Client-certificate signing is /// deliberately absent — the mesh authenticates with MQTT credentials /// instead. struct TrngProvider<'a> { - rng: &'a mut dyn CryptoRngCore, - verifier: CertVerifier<'static, Aes128GcmSha256, SntpClock, CERT_BUFFER_SIZE>, + rng: &'a mut (dyn CryptoRngCore + Send), + verifier: CertVerifier<'static, Aes128GcmSha256, WallClock, CERT_BUFFER_SIZE>, } impl CryptoProvider for TrngProvider<'_> { @@ -229,13 +281,14 @@ impl CryptoProvider for TrngProvider<'_> { } } -/// The TLS broker manager: resolve → TCP → TLS handshake → MQTT session, -/// reconnecting forever with the same [`Settings`] cadence as the plain -/// path's `mqtt_manager::run` (`settings.address` is unused — the TLS path -/// resolves `host` per attempt instead). +/// The TLS broker manager: dial → TLS handshake → MQTT session, reconnecting +/// forever with the same [`Settings`] cadence as the plain path. +/// +/// The dialer resolves the host, so there is no DNS here and no network stack: +/// any runtime whose streams offer the `embedded-io-async` trio can run this. #[allow(clippy::too_many_arguments)] -pub(crate) async fn run_tls( - stack: Stack<'static>, +pub(crate) async fn run_tls( + dialer: D, options: TlsOptions, host: String, port: u16, @@ -244,8 +297,13 @@ pub(crate) async fn run_tls( settings: Settings, events: Arc, actions: Arc, + delay: D, runtime: Arc, -) -> ! { +) -> ! +where + D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, + D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, +{ let TlsOptions { rng, ca_der, @@ -254,8 +312,6 @@ pub(crate) async fn run_tls( .. } = options; - let mut rx_buffer = [0u8; BUFFER_SIZE]; - let mut tx_buffer = [0u8; BUFFER_SIZE]; let mut mqtt_buffer = [0u8; BUFFER_SIZE]; // Re-subscribed by `handle_messages` on every (re)connection, so inbound @@ -268,51 +324,36 @@ pub(crate) async fn run_tls( let mut connection_index = 0u32; loop { - // Certificate validity needs real time — hold the first handshake - // until SNTP has synced. - if sntp::unix_now().is_none() { + // Certificate validity needs real time. Take it from the runtime when + // it has a wall clock; otherwise wait for whatever feeds `WallClock` + // (the SNTP task, on a board with no RTC). + if let Some((secs, _)) = runtime.unix_time() { + WallClock::set_unix_secs(secs as u32); + } + if WallClock::unix_secs().is_none() { #[cfg(feature = "defmt")] - defmt::info!("MQTT-TLS: waiting for SNTP time sync..."); - while sntp::unix_now().is_none() { - Timer::after_millis(500).await; + defmt::info!("MQTT-TLS: waiting for a wall-clock reading..."); + while WallClock::unix_secs().is_none() { + if let Some((secs, _)) = runtime.unix_time() { + WallClock::set_unix_secs(secs as u32); + } + aimdb_core::session::Delay::sleep(&delay, core::time::Duration::from_millis(500)) + .await; } } - let address = match resolve(stack, &host).await { - Some(address) => address, - None => { + let stream = match dialer.connect(&host, port).await { + Ok(stream) => stream, + Err(_e) => { #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT-TLS: DNS lookup for {} failed, will retry", - host.as_str() - ); - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay) - .await; + defmt::warn!("MQTT-TLS: connect failed, will retry"); + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; continue; } }; - let mut socket = TcpSocket::new(stack, &mut rx_buffer, &mut tx_buffer); - socket.set_timeout(None); - - #[cfg(feature = "defmt")] - defmt::info!( - "MQTT-TLS: connecting to {} ({}) port {}...", - host.as_str(), - address, - port - ); - if let Err(e) = socket.connect((address, port)).await { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT-TLS: socket connect error, will retry: {:?}", e); - #[cfg(not(feature = "defmt"))] - let _ = e; - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; - continue; - } - - let socket = RefCell::new(socket); - let shared = SharedTcp(&socket); + let stream = RefCell::new(stream); + let shared = SharedStream(&stream); let tls_config = TlsConfig::new().with_server_name(&host); let mut tls = TlsConnection::new(shared.clone(), &mut *read_buf, &mut *write_buf); @@ -328,7 +369,7 @@ pub(crate) async fn run_tls( ); #[cfg(not(feature = "defmt"))] let _ = e; - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; continue; } #[cfg(feature = "defmt")] @@ -339,7 +380,6 @@ pub(crate) async fn run_tls( socket: shared, plaintext_remaining: 0, }; - let delay = DelayEmbedded::new(Delay); let timeout_millis = settings.response_timeout.as_millis() as u32; let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); @@ -358,7 +398,7 @@ pub(crate) async fn run_tls( let mut client = ClientNoQueue::new( connection, &mut mqtt_buffer, - delay, + DelayEmbedded::new(crate::embedded::session::ClientDelay(&delay)), timeout_millis, event_handler, ); @@ -372,7 +412,7 @@ pub(crate) async fn run_tls( &events, &actions, &settings, - &EmbassyCoreDelay, + &delay, runtime.as_ref(), ) .await @@ -387,26 +427,7 @@ pub(crate) async fn run_tls( .await; } - aimdb_core::session::Delay::sleep(&EmbassyCoreDelay, settings.reconnection_delay).await; - } -} - -/// The TLS path keeps `embassy_time` for its own waits, so it supplies core's -/// [`Delay`](aimdb_core::session::Delay) to the shared message pump. -struct EmbassyCoreDelay; - -impl aimdb_core::session::Delay for EmbassyCoreDelay { - fn sleep(&self, d: core::time::Duration) -> impl core::future::Future + Send { - Timer::after(embassy_time::Duration::from_micros(d.as_micros() as u64)) - } -} - -/// Resolve the broker host to its first A record (IP literals short-circuit -/// inside `dns_query` without a network round trip). -async fn resolve(stack: Stack<'static>, host: &str) -> Option { - match stack.dns_query(host, DnsQueryType::A).await { - Ok(addresses) => addresses.first().copied(), - Err(_) => None, + aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; } } diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 414ff788..92ded46f 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -131,8 +131,8 @@ pub use crate::native as tokio_client; #[cfg(feature = "embedded")] pub use connector::Embedded; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use connector::EmbeddedTls; pub use connector::{MqttConnector, Native}; -#[cfg(feature = "embassy-tls")] +#[cfg(feature = "embedded-tls")] pub use embedded::tls::TlsOptions; diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index a19af195..7bf2b61b 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -36,7 +36,10 @@ impl Seen { /// Read one MQTT packet: a fixed header byte, a varint remaining-length, then /// that many bytes. -async fn read_packet(socket: &mut TcpStream, buf: &mut Vec) -> Option<(u8, Vec)> { +async fn read_packet(socket: &mut S, buf: &mut Vec) -> Option<(u8, Vec)> +where + S: tokio::io::AsyncRead + Unpin, +{ let mut byte = [0u8; 1]; socket.read_exact(&mut byte).await.ok()?; let first = byte[0]; @@ -203,6 +206,14 @@ pub struct AfterSuback<'a> { /// Serve one connection until it closes. async fn serve(socket: &mut TcpStream, seen: &Mutex, after: AfterSuback<'_>) { + serve_stream(socket, seen, after).await +} + +/// The broker loop over any stream, so a TLS session drives the same code. +pub async fn serve_stream(socket: &mut S, seen: &Mutex, after: AfterSuback<'_>) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ let mut buf = Vec::new(); let mut v5 = true; diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs new file mode 100644 index 00000000..541b26e7 --- /dev/null +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -0,0 +1,176 @@ +//! `mqtts://` on the host: the embedded backend against a local broker whose +//! self-signed certificate is pinned as the root CA (`_test-tls-broker`). +//! +//! The first host coverage the TLS path has had. It runs the same +//! `embedded-tls` session an MCU runs, over `TokioNet::tcp()`, with the clock +//! from the runtime's wall clock and no SNTP task. +#![cfg(feature = "_test-tls-broker")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use tokio::net::TcpListener; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; + +mod common; +use common::{serve_stream, AfterSuback, Seen}; + +// Each test binary defines 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:us}", 0); + +/// The name the certificate is issued for, and the name the client verifies. +/// A hostname rather than an IP literal: `rustpki` matches an IP only through +/// the CN fallback, which is a narrower path than this test should depend on. +const BROKER_HOST: &str = "localhost"; + +/// A self-signed certificate for `localhost`, returned as (server chain, +/// server key, root CA in DER) — the same bytes on both sides, which is what +/// "pinned root" means. +fn self_signed() -> ( + CertificateDer<'static>, + PrivateKeyDer<'static>, + &'static [u8], +) { + let cert = rcgen::generate_simple_self_signed(vec![BROKER_HOST.to_string()]) + .expect("generate self-signed certificate"); + let der = cert.cert.der().to_vec(); + let key = PrivateKeyDer::try_from(cert.key_pair.serialize_der()).expect("server key"); + // `&'static` because `TlsOptions` holds the trust root for the session's + // whole life; one leak per test process. + let ca: &'static [u8] = Box::leak(der.clone().into_boxed_slice()); + (CertificateDer::from(der), key, ca) +} + +/// Accept TLS connections and serve the same fake MQTT broker over them. +async fn tls_broker( + listener: TcpListener, + acceptor: TlsAcceptor, + seen: Arc>, + push: Option<(&'static str, &'static [u8])>, +) { + loop { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let acceptor = acceptor.clone(); + let seen = seen.clone(); + tokio::spawn(async move { + let Ok(mut stream) = acceptor.accept(socket).await else { + return; + }; + let after = AfterSuback { + hang_up: false, + push, + }; + serve_stream(&mut stream, &seen, after).await; + }); + } +} + +/// A `mqtts://` session completes and round-trips a record, with the +/// certificate verified against the pinned root and the clock from +/// `SystemTime` — no SNTP anywhere. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_embedded_backend_completes_an_mqtts_handshake_against_a_pinned_root() { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, TlsOptions}; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let (chain, key, ca_der) = self_signed(); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![chain], key) + .expect("server config"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Seen::default())); + + // `TlsOptions` holds `&'static mut` buffers and RNG: on a board these are + // `StaticCell`s, here one leak apiece. + let rng: &'static mut (dyn embedded_tls::CryptoRngCore + Send) = + Box::leak(Box::new(rand::rngs::StdRng::from_entropy())); + let read_buf: &'static mut [u8] = Box::leak(vec![0u8; 16_640].into_boxed_slice()); + let write_buf: &'static mut [u8] = Box::leak(vec![0u8; 4_096].into_boxed_slice()); + + let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) + .tls( + TokioNet::tcp(), + TlsOptions::new(rng, ca_der, read_buf, write_buf), + ) + .with_client_id("tls-host-smoke"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + let (db, runner) = builder.build().await.expect("build db"); + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let broker = tls_broker( + listener, + acceptor, + seen.clone(), + Some(("sensors/temperature", b"23")), + ); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = broker => panic!("the broker returned"), + value = inbound.recv() => value.expect("inbound record"), + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: {} connects, {:?} subscribed — the handshake never completed", + seen.connects, + seen.subscribed_topics() + ); + } + }; + + assert_eq!( + received, 23, + "the message must arrive through the TLS session" + ); + + let seen = seen.lock().unwrap(); + assert_eq!(seen.connects, 1, "exactly one MQTT session over TLS"); + assert!( + seen.subscribed_topics().contains(&"sensors/temperature"), + "the session must subscribe over TLS; saw {:?}", + seen.subscribed_topics() + ); +} + +use rand::SeedableRng as _; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 248b7ca0..3d703b5e 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -402,23 +402,27 @@ async fn main(spawner: Spawner) { .with_client_id("embassy-demo-001") }; - // `mqtts://` keeps the stack: TLS resolves DNS itself and owns its buffers - // across sessions. The board's TRNG, the broker's root CA, and the record + // `mqtts://` dials through the same transport as `mqtt://`; the adapter + // resolves the host. The board's TRNG, the broker's root CA, and the record // buffers (16 640 bytes read is the enforced minimum — a TLS 1.3 peer may // send full-size records). `init_with` keeps the arrays off the stack. + // This board has no RTC, so the validity clock comes from SNTP. #[cfg(feature = "tls")] let mqtt = { + static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); + static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); let mqtt = MqttConnector::new(&broker_url) .tls( - stack, + EmbassyNet::tcp(*stack, MQTT_RX.init([0; 4096]), MQTT_TX.init([0; 4096])), TlsOptions::new( rng, MQTT_CA_DER, TLS_READ_BUF.init_with(|| [0; 16_640]), TLS_WRITE_BUF.init_with(|| [0; 4_096]), - ), + ) + .with_sntp(stack, "pool.ntp.org"), ) .with_client_id("embassy-demo-001"); match MQTT_CREDENTIALS { From 3b4423c76dbad1b6f3b6880dacd9d9eb33497959 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 7 Sep 2026 22:04:51 +0000 Subject: [PATCH 19/48] feat: update aimdb-mqtt-connector to version 0.7.0 with breaking changes and enhanced features for std and no_std runtimes --- Cargo.lock | 2 +- aimdb-embassy-adapter/CHANGELOG.md | 6 + aimdb-mqtt-connector/CHANGELOG.md | 64 ++++++ aimdb-mqtt-connector/Cargo.toml | 4 +- aimdb-mqtt-connector/README.md | 83 ++++--- aimdb-tokio-adapter/CHANGELOG.md | 3 + .../012-M5-connector-development-guide.md | 210 ++++++++++++------ 7 files changed, 270 insertions(+), 102 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a64a5c0..8468ee1b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,7 +273,7 @@ dependencies = [ [[package]] name = "aimdb-mqtt-connector" -version = "0.6.0" +version = "0.7.0" dependencies = [ "aimdb-core", "aimdb-data-contracts", diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index ec61f9fe..444f092c 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **`Delay` for `EmbassyTcpDialer`** (feature `embassy-time`). The dialer + supplies the session clock, so a connector generic over it needs no separate + handle — which is what keeps the MQTT call sites unchanged. + ### Changed (breaking) - **Issue #131 — `EmbassyAdapter` is a stateless unit type; network capability moves to connector construction.** The `EmbassyNetwork` trait and `EmbassyAdapter::new_with_network` are deleted (an `Arc` runtime can't surface adapter-specific capabilities); network connectors take the `embassy_net::Stack` at construction, wrapped in the new force-`Send + Sync` `connectors::NetStack` so the single-core `unsafe` stays in the audited `connectors` module — the adapter itself now carries **zero `unsafe`**. `EmbassyAdapter::new()` returns `Self` (was a never-failing `ExecutorResult` forcing `.unwrap()` at every call site) and `new_db_result()` is deleted. `NetStack::new` is an `unsafe fn`: the force-`Send + Sync` rests on the single-core cooperative-executor invariant, which the constructor cannot check, so each connector constructing one acknowledges it with a `SAFETY` comment (constructing on a multicore / multi-executor setup is UB). `EmbassyRecordRegistrarExt` shrinks to `.buffer(cfg)`; `EmbassyRecordRegistrarExtCustom` (`buffer_sized`, `source_with_context`) re-targets the non-generic `RecordRegistrar<'a, T>` with the concrete `RuntimeContext`, and `source_with_context` drops its needless `Sync` bounds (`Ctx: Send`, `F: Send`, matching core's relaxed `source`). `join_queue.rs` (`EmbassyJoinQueue`) is deleted with the `JoinFanInRuntime` family; the core join queue closes when forwarders exit (the Embassy queue previously never closed) and its capacity is 16 (was 8). diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a507fc00..cd9763a8 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,70 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **The backend split is std vs `no_std`, not Tokio vs Embassy.** The embedded + backend runs on any target whose adapter supplies a `StreamDialer`, so a new + platform costs one adapter crate and no change here. Features rename + accordingly: `std` carries the `rumqttc` backend (`tokio-runtime` is a + deprecated alias), `embedded` carries `mountain-mqtt` with `alloc` only — no + executor, network stack, adapter or logger in its graph — and `embassy-runtime` + becomes a convenience bundle over it. TLS splits the same way: `embedded-tls` + is runtime-neutral, `embassy-tls` adds the SNTP time source a board with no + RTC needs. Modules follow: `tokio_client` → `native`, `embassy_client` → + `embedded` (both kept as deprecated re-exports for one release). +- **One constructor.** `MqttConnector::new(url)` is unconditional, and the + transport — or its absence — picks the backend, so both compile into one + binary. Previously the two inherent `new`s collided with `E0034` whenever + both features were on. Broker URL, client id and credentials moved onto + `MqttConnector` itself, so `with_client_id` / `with_credentials` work on + either backend; `with_credentials` now reaches `rumqttc` too, taking + precedence over the URL authority. +- **`.tls(dialer, options)` replaces `.tls(stack, options)`.** The dialer + resolves the host, so TLS needs no network stack: DNS, the socket buffers and + the SNTP task all leave the TLS path. The certificate-validity clock comes + from `RuntimeOps::unix_time()`; SNTP is opt-in via `TlsOptions::with_sntp` + for a runtime with no wall clock of its own. +- **The `mountain-mqtt-embassy` fork is absorbed and dropped.** Its state, + event handler and message pump live in `embedded::manager`, with the mutex + and the clock as this crate's choices rather than the fork's. +- **Session channels use `CriticalSectionRawMutex` in an `Arc`.** They are + therefore `Sync`, so `MqttSink` and `MqttSource` are plain `Connector` / + `Source` impls and the `EmbassySink`/`EmbassySource` force-`Send` spine is + gone from the data plane. std binaries need a `critical-section` impl; the + `critical-section-std-impl` feature supplies one, mirroring the KNX connector. + A single documented `unsafe impl Send` remains on the session future: + `embedded-io-async` puts no `Send` bound on its futures and the loop reaches + them through a generic transport, which needs return-type notation to express + — still unstable on the pinned toolchain. It rests on `StreamDialer`'s + `Stream: Send` guarantee rather than on a single-core executor, so it holds + under a preemptive scheduler. +- **Time comes from core's `Delay`**, supplied by the dialer, so the session + loop names no executor. `Settings` is `core::time::Duration` and lost its + dead `address`/`port` fields. + +### Fixed + +- **A second connector in one process no longer steals the first's identity.** + Client id and credentials were parked in process-global `OnceLock`s, so every + connector after the first connected as the first. +- **One allocation per inbound message instead of two.** The payload is built + as a `Payload` on arrival rather than as a `Vec` that is converted again. +- **`defmt` is no longer forced on `mountain-mqtt`**, and is absent from the + `embedded` graph entirely. + +### Added + +- **Host coverage for the embedded backend**, which previously had none. A fake + MQTT broker over real sockets drives the session loop on a multi-thread Tokio + runtime: reconnect-and-resubscribe, record round-trip both ways, both backends + against one broker in one process, and — the first test the TLS path has ever + had — an `mqtts://` handshake against a self-signed certificate pinned as the + root CA, with no SNTP. +- **`#[diagnostic::on_unimplemented]` for a missing backend.** A `no_std` build + that forgets `.transport(..)` now gets a message naming the fix instead of an + unsatisfied `ConnectorBuilder` bound. + ### Changed - **One `MqttConnector` over two protocol backends (breaking on Embassy).** diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 5d63844d..4b5de4d6 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "aimdb-mqtt-connector" -version = "0.6.0" +version = "0.7.0" edition = "2021" rust-version.workspace = true authors.workspace = true license.workspace = true repository.workspace = true homepage.workspace = true -description = "MQTT connector for AimDB - bidirectional pub/sub for Tokio and Embassy runtimes" +description = "MQTT connector for AimDB - bidirectional pub/sub on std and no_std runtimes" keywords = ["mqtt", "connector", "iot", "embedded", "pubsub"] categories = ["network-programming", "embedded", "asynchronous"] diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index a29276a7..ee2888f7 100644 --- a/aimdb-mqtt-connector/README.md +++ b/aimdb-mqtt-connector/README.md @@ -10,30 +10,26 @@ Add to your `Cargo.toml`: ```toml [dependencies] -# For Tokio runtime (std) -aimdb-mqtt-connector = { version = "0.2", features = ["tokio-runtime"] } +# The rumqttc backend (std): QoS 0-2, platform trust roots +aimdb-mqtt-connector = { version = "0.7", features = ["std"] } -# For Embassy runtime (embedded) -aimdb-mqtt-connector = { version = "0.2", features = ["embassy-runtime"] } +# The mountain-mqtt backend: any target that can supply a transport +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embedded"] } -# REQUIRED for Embassy: Patch mountain-mqtt to match Embassy versions -[patch.crates-io] -mountain-mqtt = { git = "https://github.com/aimdb-dev/mountain-mqtt.git", branch = "main" } -mountain-mqtt-embassy = { git = "https://github.com/aimdb-dev/mountain-mqtt.git", branch = "main" } +# ... or the Embassy convenience bundle, which adds the transport and clock +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embassy-runtime"] } ``` -**Why the patch?** -- Embassy dependency version compatibility -- Our workspace uses a specific Embassy version that differs from crates.io - -**Tokio runtime users**: The patch is optional but recommended for consistency. +The split is **std vs `no_std`**, not Tokio vs Embassy: the embedded backend +runs on any runtime whose adapter supplies a `StreamDialer`, so a new platform +needs an adapter crate and no change here. ## Overview -`aimdb-mqtt-connector` provides MQTT publishing capabilities for AimDB records with automatic consumer registration. Works seamlessly across standard library (Tokio) and embedded (Embassy) environments. +`aimdb-mqtt-connector` provides MQTT publishing capabilities for AimDB records with automatic consumer registration. One `MqttConnector` covers both backends: supply no transport and it is `rumqttc`; supply one with `.transport(..)` and it is `mountain-mqtt` over whatever the adapter dials. **Key Features:** -- **Dual Runtime Support**: Works with both Tokio and Embassy +- **Two backends, one type**: `rumqttc` on std, `mountain-mqtt` anywhere else - **Automatic Consumer Registration**: Connects to records via builder pattern - **Topic Mapping**: Flexible record-to-topic configuration - **Custom Serialization**: Pluggable serializers (JSON, MessagePack, etc.) @@ -88,9 +84,9 @@ async fn main() -> Result<(), Box> { Add to your `Cargo.toml`: ```toml [dependencies] -aimdb-core = { version = "0.1", default-features = false } -aimdb-embassy-adapter = { version = "0.1", default-features = false } -aimdb-mqtt-connector = { version = "0.1", default-features = false, features = ["embassy-runtime"] } +aimdb-core = { version = "1", default-features = false } +aimdb-embassy-adapter = { version = "0.6", default-features = false } +aimdb-mqtt-connector = { version = "0.7", default-features = false, features = ["embassy-runtime"] } ``` Example: @@ -100,7 +96,7 @@ Example: use aimdb_core::AimDbBuilder; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt}; -use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +use aimdb_mqtt_connector::MqttConnector; use alloc::sync::Arc; #[embassy_executor::main] @@ -108,14 +104,17 @@ async fn main(spawner: Spawner) { // Initialize network stack let stack: &'static embassy_net::Stack<'static> = /* ... */; - // The adapter is a stateless unit type; the connector takes the - // network stack at construction. + // The adapter is a stateless unit type; the connector takes a transport + // from it, and nothing else about the runtime. let runtime = Arc::new(EmbassyAdapter::new()); // Build database with MQTT connector let mut builder = AimDbBuilder::new() .runtime(runtime) - .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack)); + .with_connector( + MqttConnector::new("mqtt://192.168.1.100:1883") + .transport(EmbassyNet::tcp(*stack, rx_buf, tx_buf)), + ); builder.configure::("sensor-data", |reg| { reg.buffer_sized::<4, 1>(EmbassyBufferType::SingleLatest) @@ -311,13 +310,16 @@ The connector automatically handles reconnection. Serialization errors will be l ## Features -```toml -[features] -tokio-runtime = ["dep:rumqttc", "dep:tokio"] # Tokio support -embassy-runtime = ["dep:mountain-mqtt"] # Embassy support -tracing = ["dep:tracing"] # Logging (std) -defmt = ["dep:defmt"] # Logging (embedded) -``` +| Feature | Backend | +|---|---| +| `std` | `rumqttc`: QoS 0-2, platform trust roots | +| `embedded` | `mountain-mqtt` over a caller-supplied transport; `alloc` only, no executor or network stack | +| `embedded-tls` | `mqtts://` via `embedded-tls`, on the same transport | +| `embassy-runtime` | `embedded` plus the Embassy transport and clock | +| `embassy-tls` | `embedded-tls` plus the SNTP time source, for a board with no RTC | +| `critical-section-std-impl` | links a `critical-section` impl, which a std binary needs | +| `tokio-runtime` | deprecated alias for `std` | +| `tracing` / `defmt` | logging destinations | ## Connection Management @@ -341,16 +343,29 @@ When broker is unavailable: docker run -d -p 1883:1883 eclipse-mosquitto # Run tests -cargo test -p aimdb-mqtt-connector --features tokio-runtime +cargo test -p aimdb-mqtt-connector --features std ``` -### Embassy Tests +### Embedded Tests + +The embedded backend runs on the host over the Tokio adapter's transport, so it +is covered by real tests rather than a cross-compile alone: + ```bash -# Cross-compile test -cargo build -p aimdb-mqtt-connector \ +# Host smoke: session loop, reconnect and record round-trip +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-tokio-broker --test tokio_broker + +# Both backends against one broker, in one process +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-backend-parity --test backend_parity + +# `mqtts://` against a pinned self-signed root +cargo test -p aimdb-mqtt-connector --no-default-features --features _test-tls-broker --test tls_broker + +# Cross-compile check +cargo check -p aimdb-mqtt-connector \ --target thumbv7em-none-eabihf \ --no-default-features \ - --features embassy-runtime + --features embedded ``` ## Examples diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index 8cadf8a6..aa484190 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`Delay` and `Clone` for `TokioTcpDialer`.** The dialer supplies the session + clock and can be handed to several sessions, which is what lets the embedded + MQTT backend run on a host unchanged. - **`embedded-io` feature — the `embedded-io-async` trio on the `net` streams.** `TokioByteStream` implements `Read`/`Write` for any `AsyncRead`/`AsyncWrite`, and `ReadReady` on `TokioByteStream` via diff --git a/docs/design/012-M5-connector-development-guide.md b/docs/design/012-M5-connector-development-guide.md index 05954656..d3d434da 100644 --- a/docs/design/012-M5-connector-development-guide.md +++ b/docs/design/012-M5-connector-development-guide.md @@ -151,69 +151,147 @@ fn publish(&self, dest: &str, config: &ConnectorConfig, payload: &[u8]) -> ... { --- -## Tokio Implementation Pattern +## Choosing the Transport Seam (do this first) -**Dependencies:** -```toml -[features] -tokio-runtime = ["std", "tokio", "protocol-client-crate"] +Before writing any integration code, answer one question about the protocol +library you are considering: -[dependencies] -tokio = { workspace = true, optional = true } -# Add protocol-specific client library -``` +> **Does it hand me bytes, or does it hand me a client?** + +The answer fixes the shape of your connector and it is not recoverable later. +A library that owns its own socket will not accept yours no matter how the +adapter layer is designed. This is a **library-selection** decision, not an +implementation decision. + +### The three tiers + +| Tier | Who owns the protocol | Example in this workspace | Shape you get | +|---|---|---|---| +| **1** | **AimDB** — you write the framing | TCP (`framing.rs`, length-prefix), serial (COBS `Framer`) | Symmetric. The adapter supplies bytes on both std and embedded; one implementation | +| **2** | **A sans-io library**, AimDB owns the lifecycle | KNX — `knx-pico` is sans-io, `tunnel.rs` owns tunnelling behind a three-method `TunnelIo` | Symmetric. Design 052 §2 found the two halves already 90 % shared | +| **3** | **A batteries-included client** — owns socket, TLS, reconnect | `rumqttc` (MQTT std half), `axum` / `tokio-tungstenite` (WebSocket) | **Asymmetric, or std-only.** The library dials; you cannot inject a stream | + +Tiers 1 and 2 are the good cases and they cost the same to build. Tier 3 is +sometimes the right trade, a mature client buys QoS 2, a hardened TLS stack, +platform trust roots, but buy it knowingly. + +### How to tell which tier a candidate library is + +Read its constructor and its transport type before anything else: + +- **Tier 1/2 signature** — takes a connection, a stream or nothing: + ```rust + ClientNoQueue::new(connection, buffer, delay, timeout, handler) // mountain-mqtt + ``` + Anything generic over `embedded_io_async::{Read, Write}`, or over its own + minimal `Connection` trait, is injectable. Good. +- **Tier 3 signature** — takes options and an address: + ```rust + AsyncClient::new(mqtt_options, capacity) // rumqttc + mqtt_options.set_transport(Transport::Tls(..)) // closed enum + ``` + If the transport is a **closed enum** with no "bring your own stream" variant, + the library dials internally and the seam is fixed above it. + +Also check: does it pull `tokio` (or any executor) in its own `[dependencies]`, +or only `embedded-io-async` / `embedded-hal-async`? An executor dependency in +the protocol crate is a reliable tier-3 signal. + +### What each tier means for you + +| | Tier 1 / 2 | Tier 3 | +|---|---|---| +| Runtime neutrality | Free — one module, no runtime `cfg` | Not achievable for that half | +| New runtime (FreeRTOS, …) | Zero connector edits — a new adapter is enough | Needs a second backend, or the connector stays std-only | +| Host tests for the embedded path | Run the same code over the std adapter's transport | Only if a second, injectable backend exists | +| Cost | You write framing or lifecycle logic | The library writes it for you | + +### If you land on tier 3 + +Two legitimate outcomes, both present in this workspace: -**Key patterns:** -- Use `std` types: `std::sync::Arc`, `std::string::String` -- Spawn: `tokio::spawn(async move { ... })` -- Logging: `tracing::{info, warn, error}` -- Async client libraries (e.g., `rumqttc`) +- **std-only connector** — WebSocket and UDS. Honest and simple when there is no + embedded use case. Do not invent an embedded half that nobody wants. +- **Two backends behind one type** — MQTT. `MqttConnector` carries `Native` + (`rumqttc`, std) and `Embedded` (`mountain-mqtt`, any target with a + `StreamDialer`). The seam is the *backend*, not the runtime. -**See:** `aimdb-mqtt-connector/` for complete Tokio implementation +What **not** to do: give the tier-3 backend a `.transport()` method that accepts +a dialer and discards it, to make the two look alike. A signature that lies is +worse than a documented asymmetry. + +**See:** Design 052 (runtime-neutral connectors) for the trait set tiers 1 and 2 +build on. --- -## Embassy Implementation Pattern +## Implementation Pattern + +Write **one** connector, generic over core's I/O traits. The adapter owns +sockets, clocks and channels; the connector owns framing, protocol logic and +sugar. There is no `tokio_*` / `embassy_*` module and no runtime `cfg` on the +code path — a new platform is one adapter crate and zero connector edits. -Embassy's primitives are `!Send` (single-core, cooperative), but AimDB's connector -contract is `Send`-everywhere (so a Tokio app can `tokio::spawn(runner.run())`). **Do not -hand-roll the `unsafe`/force-`Send` bridge** — it lives, audited and once, in -`aimdb_embassy_adapter::connectors` (Design 033). A connector crate contributes only its -transport-specific logic and carries **no `unsafe`**. +**Features name the environment, not the runtime.** The real split is std vs +`no_std`: a `no_std` connector runs under Embassy, FreeRTOS or a host test +alike. Keep runtime names for convenience bundles only. -**Dependencies:** ```toml [features] -# Session transport (serial/TCP): needs the framed-connection spine. -embassy-runtime = ["aimdb-core/connector-session", "aimdb-embassy-adapter/connector-io", …] -# Data-plane transport (MQTT/KNX): needs the sink/source bridges + pumps. -embassy-runtime = ["aimdb-core/connector-session", "aimdb-embassy-adapter/connectors", …] -``` - -**Session transport** (a framed byte stream — serial, TCP): -- Implement `aimdb_embassy_adapter::connectors::Framer` (encode/accumulate/next-frame). -- Client sugar → `EmbassySessionClient::new(OneShotDialer::new(EmbassyConnection::new(rx, tx, MyFramer)), Codec)`. -- Server sugar → `EmbassySessionServer::new(OneShotListener::new(conn), Codec, dispatch_factory, cfg)`, - or a thin `ConnectorBuilder` that stores the moved-in connection in a `OneShotCell` and - drives `serve` (see `aimdb-serial-connector`). - -**Data-plane transport** (a pub/sub channel — MQTT, KNX): -- Implement `EmbassySinkRaw` (outbound publish) and/or `EmbassySourceRaw` (inbound next), - then ride core's pumps: - `pump_sink(db, scheme, Arc::new(EmbassySink(my_sink)))` / - `pump_source(db, scheme, EmbassySource(my_source))`. - (If your channels are already `Send` — e.g. `CriticalSectionRawMutex` — implement core's - `Connector`/`Source` directly and skip the bridges; see `aimdb-knx-connector`.) -- Force-`Send` the long-lived protocol task with `into_box_future(async move { … })`. - -**Other:** `alloc` types (`alloc::sync::Arc`, `alloc::string::String`), `StaticCell` for -channels, `defmt` logging behind `#[cfg(feature = "defmt")]`. Network connectors take the -`embassy_net::Stack` at builder construction, wrapped in -`aimdb_embassy_adapter::connectors::NetStack` (the `EmbassyNetwork` runtime trait is gone -since issue #131 — a `dyn RuntimeOps` cannot surface adapter-specific capabilities). - -**See:** `aimdb-serial-connector` (session), `aimdb-mqtt-connector` / `aimdb-knx-connector` -(data-plane), and `examples/embassy-mqtt-connector-demo/`. +# The std backend, if the protocol library is tier 3 and std-only. +std = ["aimdb-core/std", "protocol-client-crate"] +# The neutral backend: `alloc` only, no executor and no network stack. +embedded = ["aimdb-core/alloc", "aimdb-core/connector-session"] +# Convenience: `embedded` plus one adapter's transports. +embassy-runtime = ["embedded", "aimdb-embassy-adapter/net"] +``` + +**Session transport** (a framed byte stream — serial, TCP): contribute a +`Framer` and let core's `FramedConnection` / `FramingDialer` / `FramingListener` +do the rest over the adapter's `StreamDialer` or `StreamListener`. + +**Data-plane transport** (a pub/sub channel — MQTT, KNX): implement core's +`Connector` (outbound) and `Source` (inbound) over an +`embassy_sync::channel::Channel`, then ride +`pump_sink` / `pump_source`. `CriticalSectionRawMutex` is what makes the +channel `Sync`, and therefore what lets these be plain impls with no +force-`Send` wrapper. It is a link-time obligation on std: enable +`critical-section/std` from your own feature so no std user meets the +undefined-symbol error. + +**Time:** take core's `Delay` rather than a runtime timer. `RuntimeOps::sleep` +is `dyn` and boxes per call, which a poll loop cannot afford; `Delay` is +generic and allocates nothing. The clock for elapsed time stays +`RuntimeOps::now_nanos()`, and wall-clock time is `RuntimeOps::unix_time()`. + +### The `Send` rule, and its one escape hatch + +`ConnectorBuilder::build` returns `Send` futures, so **every trait a generic +connector task calls through needs `+ Send` on its return type** — not just +core's. A bare `async fn` in your own trait will not do it: + +```rust +- async fn send(&mut self, frame: &[u8]) -> bool; ++ fn send(&mut self, frame: &[u8]) -> impl Future + Send; +``` + +That fixes every trait you own. It cannot fix a **foreign** trait: nothing adds +a bound to `embedded_io_async::Read`, and a generic parameter hides whether the +concrete future is `Send`. Expressing it needs return-type notation, which is +not stable on the pinned toolchain. Where that bites, the choices are a +documented `unsafe impl Send` on the task future — sound when the trait bounds +already guarantee every held value is `Send`, as `StreamDialer`'s +`Stream: Send` does — or type-erasing the stream behind `dyn` and paying an +allocation per read. Prefer the first, at exactly one site, with the +justification written down; see `aimdb-mqtt-connector`'s `SendSession`. + +Moved-in resources go in `aimdb_core::session::OneShot`, which is +`Send + Sync` for `T: Send` without `unsafe`. If it refuses your type, fix the +type — a missing `+ Send` on a trait object, usually — rather than forcing the +bound. + +**See:** `aimdb-serial-connector` (session), `aimdb-mqtt-connector` / +`aimdb-knx-connector` (data-plane), and `examples/embassy-mqtt-connector-demo/`. --- @@ -238,24 +316,26 @@ if topic == "sensor/temp" { temp_producer.send(data).await; } router.route(topic, data).await?; ``` -**Embassy lifetime issues:** +**A channel that cannot cross a thread:** ```rust -// ❌ Stack allocation -let channel = Channel::new(); +// ❌ `NoopRawMutex` is !Sync, so the sink and source need a force-`Send` +// wrapper and the whole connector is welded to a single-core executor. +static CH: StaticCell> = StaticCell::new(); -// ✅ Static allocation -static CH: StaticCell> = StaticCell::new(); -let ch = CH.init(Channel::new()); +// ✅ `CriticalSectionRawMutex` is Send + Sync, so `Connector`/`Source` are +// plain impls. `Arc` over `StaticCell` allows several connectors per +// process; `StaticCell` is still right for one-connector firmware. +let actions = Arc::new(Channel::::new()); ``` -**Force-`Send` a protocol task (Embassy):** +**Process-global state where per-connector state belongs:** ```rust -// ❌ Don't hand-roll the unsafe wrapper in your connector crate -Box::pin(SendFutureWrapper(async move { ... })) +// ❌ The second connector silently connects as the first +static CLIENT_ID: OnceLock = OnceLock::new(); +let id: &'static str = CLIENT_ID.get_or_init(|| client_id.to_string()); -// ✅ Use the adapter spine's helper (the unsafe lives there, audited once) -use aimdb_embassy_adapter::connectors::into_box_future; -into_box_future(async move { ... }) +// ✅ One small leak per connector, at build +let id: &'static str = Box::leak(client_id.to_string().into_boxed_str()); ``` --- @@ -445,7 +525,7 @@ Users configure it per link: ## Connector Implementation Checklist -- [ ] Create crate with `tokio-runtime` and `embassy-runtime` features +- [ ] Create crate with `std` and `embedded` features (runtime names are bundles) - [ ] Implement `ConnectorBuilder` trait with `build()` and `scheme()` - [ ] Implement `Connector` trait with `publish()` - [ ] In `build()`: Collect inbound routes via `db.collect_inbound_routes(scheme)` From 98660ff0f8d0a119fdf7345bbf4e2780f2281969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Tue, 8 Sep 2026 04:47:02 +0000 Subject: [PATCH 20/48] docs(mqtt-connector): unlink feature-gated and private items from ungated docs `make doc` runs `cargo doc` per feature leg with `-D warnings`, so an intra-doc link that resolves on one leg and not another fails the build. Five such links had crept in: - `[Embedded]` in the ungated backend table, which only exists with the embedded backend - `[build]` and `[WallClock]`, both private - `[SntpClock]`, deleted when the TLS clock became the runtime's - `[sntp]`, now `embassy-tls`-gated while `tls.rs` is `embedded-tls` The `doc` target only covered `std` and `embassy-runtime`, which is why only the first two reached CI; add the `embedded`, `embedded-tls` and `embassy-tls` legs so the rest cannot recur silently. Co-Authored-By: Claude Opus 5 --- Makefile | 3 +++ aimdb-mqtt-connector/src/connector.rs | 4 ++-- aimdb-mqtt-connector/src/embedded/sntp.rs | 12 +++++------- aimdb-mqtt-connector/src/embedded/tls.rs | 17 +++++++---------- aimdb-mqtt-connector/src/native.rs | 2 +- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/Makefile b/Makefile index 79b962da..81e19fa7 100644 --- a/Makefile +++ b/Makefile @@ -415,7 +415,10 @@ doc: @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,net" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embedded-tls" --no-deps cargo doc --package aimdb-mqtt-connector --no-default-features --features "embassy-runtime" --no-deps + cargo doc --package aimdb-mqtt-connector --no-default-features --features "embassy-tls" --no-deps cargo doc --package aimdb-knx-connector --no-default-features --features "embassy-runtime" --no-deps @cp -r target/doc/* target/doc-final/embedded/ @printf "$(YELLOW) → Building WASM/browser documentation$(NC)\n" diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 32511190..cc44df02 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -11,8 +11,8 @@ //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| -//! | [`Native`] (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | -//! | [`Embedded`] (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | +//! | `Native` (no transport supplied) | `rumqttc` (std) | 0–2 | rustls | +//! | `Embedded` (`.transport(..)`) | `mountain-mqtt` (`no_std`) | 0–1 | `embedded-tls` | use alloc::boxed::Box; use alloc::string::String; diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index 7414d7c5..7a75f655 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -1,11 +1,9 @@ -//! SNTP time source for TLS certificate validation. +//! SNTP time source, for a board whose runtime has no wall clock of its own. //! -//! The reference boards have no battery-backed RTC, but checking a -//! certificate's validity window needs the current Unix time. This module -//! keeps one crate-global clock: Unix seconds at the `embassy_time` epoch -//! (boot), written after each SNTP sync and read through [`unix_now`] / -//! [`SntpClock`]. The TLS manager spawns `run` alongside its broker loop -//! and holds the first handshake until the first sync lands. +//! Checking a certificate's validity window needs the current Unix time, and +//! the reference boards have no battery-backed RTC. Each sync feeds both +//! [`unix_now`] and the TLS handshake clock. Opt in with `TlsOptions::with_sntp`; +//! a runtime that answers `unix_time()` needs none of this. use core::sync::atomic::{AtomicU32, Ordering}; diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 3c9090bb..40e2b3e3 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,18 +1,15 @@ //! The TLS transport for the embedded backend. //! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over an Embassy -//! TCP socket, presented to the MQTT layer as its own `Connection` — not +//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the caller's +//! transport, presented to the MQTT layer as its own `Connection` — not //! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give //! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) -//! against the application-embedded root CA, with time from the [`sntp`] task; -//! entropy comes from the application-injected TRNG ([`TlsOptions::new`]). +//! against the application-embedded root CA, dated by the runtime's wall +//! clock; entropy +//! comes from the application-injected TRNG ([`TlsOptions::new`]). //! -//! The session loop is mountain-mqtt-embassy's own public `handle_messages` -//! (with `State` / `ChannelEventHandler`): -//! it is transport-agnostic (generic over `Client`), so the only thing this -//! module supplies is the transport — resolve → TCP → TLS handshake → session. -//! Upstream `run()` shares that exact loop, keeping the plain and TLS paths in -//! lock-step with no copied code to drift. +//! The dialer resolves the host, so there is no network stack here: the same +//! session runs on a host over the Tokio adapter's transport. use alloc::string::String; use alloc::vec::Vec; diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index 72a19307..f9098e45 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -67,7 +67,7 @@ pub(crate) fn build<'a>( /// Internal MQTT connector build helpers. /// -/// A namespace for the broker-connection setup invoked from [`build`]; the +/// A namespace for the broker-connection setup invoked from `build`; the /// data-plane loops themselves live in the reusable `pump_sink` / /// `pump_source` helpers + the `MqttSink` / `MqttEventLoopSource` adapters /// below. From 698ae36329200b1bdb71314a315a0cec252badde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 12 Sep 2026 17:01:59 +0000 Subject: [PATCH 21/48] feat: Enhance EmbassyTcpDialer to resolve hostnames - Updated `StreamDialer::connect` to accept hostnames and IP literals, enabling DNS resolution in `EmbassyTcpDialer`. - Modified `EmbassyTcpDialer` to include a DNS query mechanism for hostname resolution. - Updated changelog to reflect breaking changes and required adjustments for users. - Added tests to ensure hostname resolution works correctly across both embedded and native backends. - Adjusted resource allocation in examples and other components to accommodate the new DNS feature. --- aimdb-core/src/session/io.rs | 1 + aimdb-embassy-adapter/CHANGELOG.md | 19 + aimdb-embassy-adapter/Cargo.toml | 1 + aimdb-embassy-adapter/src/net.rs | 54 ++- aimdb-embassy-adapter/tests/dns.rs | 334 ++++++++++++++++++ aimdb-mqtt-connector/CHANGELOG.md | 29 ++ aimdb-mqtt-connector/Cargo.toml | 6 +- aimdb-mqtt-connector/src/embedded/manager.rs | 111 ++---- aimdb-mqtt-connector/src/embedded/mod.rs | 60 ++-- aimdb-mqtt-connector/src/embedded/session.rs | 3 +- aimdb-mqtt-connector/src/embedded/tls.rs | 13 +- aimdb-mqtt-connector/tests/backend_parity.rs | 55 +++ docs/design/052-runtime-neutral-connectors.md | 5 +- .../embassy-knx-connector-demo/src/main.rs | 2 +- .../embassy-mqtt-connector-demo/src/main.rs | 4 +- .../weather-station-gamma/src/main.rs | 2 +- 16 files changed, 567 insertions(+), 132 deletions(-) create mode 100644 aimdb-embassy-adapter/tests/dns.rs diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index 531e2dd2..444a6e8e 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -70,6 +70,7 @@ pub trait StreamDialer { type Stream: ByteStream + Send; /// Open a stream to `host:port`. + /// `host` is either a hostname or an unbracketed IP literal fn connect<'a>( &'a self, host: &'a str, diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 69fb4db1..54bfa24a 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -31,6 +31,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`EmbassyTcpDialer` resolves hostnames, and `net` therefore enables + `embassy-net/dns`.** `StreamDialer::connect` takes a host *string* and the + trait puts resolution on the adapter, but the Embassy dialer only parsed IP + literals — so a connector handing through a name dialed fine on + `TokioTcpDialer` and failed with `TransportError::Io` here, forever, on every + reconnect. Connectors papered over it with per-runtime validation gates (the + MQTT connector rejected any plain `mqtt://` host that was not an IPv4 + literal); those are gone now that the contract holds on both adapters. + `EmbassyNet::tcp` takes the `Stack` into the dialer and queries it — `A` + first, `AAAA` only if that answers nothing — while an IP literal is still + parsed locally and never queried, so a stack with no resolver configured + dials literals exactly as before. + **Action required:** `embassy_net::new` adds the resolver socket itself, so + every application using the `net` feature must grow its `StackResources` + by one; too small a `N` panics at stack construction. A name needs a DNS + server in the config (DHCP supplies one; `StaticConfigV4` lists them in + `dns_servers`). `tests/dns.rs` covers the name, literal and + does-not-resolve paths against two crossover-wired stacks. + - **Issue #131 — `EmbassyAdapter` is a stateless unit type; network capability moves to connector construction.** The `EmbassyNetwork` trait and `EmbassyAdapter::new_with_network` are deleted (an `Arc` runtime can't surface adapter-specific capabilities); network connectors take the `embassy_net::Stack` at construction, wrapped in the new force-`Send + Sync` `connectors::NetStack` so the single-core `unsafe` stays in the audited `connectors` module — the adapter itself now carries **zero `unsafe`**. `EmbassyAdapter::new()` returns `Self` (was a never-failing `ExecutorResult` forcing `.unwrap()` at every call site) and `new_db_result()` is deleted. `NetStack::new` is an `unsafe fn`: the force-`Send + Sync` rests on the single-core cooperative-executor invariant, which the constructor cannot check, so each connector constructing one acknowledges it with a `SAFETY` comment (constructing on a multicore / multi-executor setup is UB). `EmbassyRecordRegistrarExt` shrinks to `.buffer(cfg)`; `EmbassyRecordRegistrarExtCustom` (`buffer_sized`, `source_with_context`) re-targets the non-generic `RecordRegistrar<'a, T>` with the concrete `RuntimeContext`, and `source_with_context` drops its needless `Sync` bounds (`Ctx: Send`, `F: Send`, matching core's relaxed `source`). `join_queue.rs` (`EmbassyJoinQueue`) is deleted with the `JoinFanInRuntime` family; the core join queue closes when forwarders exit (the Embassy queue previously never closed) and its capacity is 16 (was 8). ### Added diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index a38a999e..a5b8eb40 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -36,6 +36,7 @@ net = [ "connector-io", "embassy-net-support", "embassy-net/udp", + "embassy-net/dns", "dep:embassy-futures", ] diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 9967697f..c277390b 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -26,9 +26,10 @@ use aimdb_core::session::{ }; use embassy_futures::yield_now; +use embassy_net::dns::DnsQueryType; use embassy_net::tcp::TcpSocket; use embassy_net::udp::{PacketMetadata, UdpSocket}; -use embassy_net::{IpEndpoint, IpListenEndpoint, Stack}; +use embassy_net::{IpAddress, IpEndpoint, IpListenEndpoint, Stack}; use embedded_io_async::Write as _; use crate::SendFutureWrapper; @@ -268,11 +269,52 @@ impl embedded_io_async::ReadReady for EmbassyTcpStream { /// [`TransportError::Busy`]. For a second *concurrent* connection call /// [`EmbassyNet::tcp`] again with its own buffers, which is the only way to get /// a second socket. +/// +/// Holds the stack as well as the socket because [`StreamDialer::connect`] +/// takes a host *string*: resolving it is the dialer's job, and on Embassy that +/// means a DNS query the stack owns. #[derive(Clone)] pub struct EmbassyTcpDialer { + stack: Stack<'static>, slot: Arc, } +// SAFETY: single-core cooperative Embassy executor — see the module invariant. +// The `Arc` is already `Send`/`Sync` on that invariant; `Stack` +// is the `!Send` half, exactly as in `EmbassyUdpBinder` below. +unsafe impl Send for EmbassyTcpDialer {} +// SAFETY: same invariant. +unsafe impl Sync for EmbassyTcpDialer {} + +impl EmbassyTcpDialer { + /// Turn a host into an address to dial. + /// + /// An IP literal is parsed here and never queried, so a stack with no DNS + /// server configured keeps dialing literals. A name goes to the stack's + /// resolver: `A` first and `AAAA` only if that answers nothing, which is + /// the order a dual-stack `getaddrinfo` reports for the same name — the + /// point being that a connector sees one behaviour across adapters. The + /// second query costs a round trip (or, against an unreachable server, a + /// second timeout) but only on a dial that was going to fail anyway. + /// + /// Every failure is [`TransportError::Io`], matching `TokioTcpDialer`, + /// where `TcpStream::connect` folds resolution and connection into one + /// `io::Error` too. + async fn resolve(&self, host: &str) -> TransportResult { + if let Ok(addr) = host.parse::() { + return Ok(addr.into()); + } + for qtype in [DnsQueryType::A, DnsQueryType::Aaaa] { + if let Ok(addrs) = self.stack.dns_query(host, qtype).await { + if let Some(addr) = addrs.first().copied() { + return Ok(addr); + } + } + } + Err(TransportError::Io) + } +} + impl StreamDialer for EmbassyTcpDialer { type Stream = EmbassyTcpStream; @@ -282,10 +324,10 @@ impl StreamDialer for EmbassyTcpDialer { 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); + // Resolution belongs to the adapter, so the socket is only taken + // once there is somewhere to dial — a name that does not resolve + // must not hold the slot against a concurrent literal dial. + let endpoint = IpEndpoint::new(self.resolve(host).await?, port); let Some(socket) = self.slot.take() else { return Err(TransportError::Busy); @@ -571,6 +613,7 @@ impl EmbassyNet { tx_buffer: &'static mut [u8], ) -> EmbassyTcpDialer { EmbassyTcpDialer { + stack, slot: Arc::new(TcpSocketSlot::new(TcpSocket::new( stack, rx_buffer, tx_buffer, ))), @@ -645,6 +688,7 @@ impl aimdb_core::session::Delay for EmbassyDelay { fn _transports_are_send() { fn assert_send() {} assert_send::(); + assert_send::(); assert_send::(); assert_send::>(); assert_send::(); diff --git a/aimdb-embassy-adapter/tests/dns.rs b/aimdb-embassy-adapter/tests/dns.rs new file mode 100644 index 00000000..36ff0b37 --- /dev/null +++ b/aimdb-embassy-adapter/tests/dns.rs @@ -0,0 +1,334 @@ +//! Host smoke for the resolution half of [`StreamDialer`] on Embassy. +//! +//! `connect` takes a host *string* and every adapter must accept both a +//! hostname and an IP literal, or a connector has to grow a per-runtime +//! validation gate — which is exactly what `mqtt://`/`mqtts://` had. Only a +//! real stack can show a name being queried, so two crossover-wired +//! `embassy-net` stacks drive it: B answers DNS on UDP/53 and listens on TCP, +//! A dials it by name. +#![cfg(feature = "net")] + +extern crate alloc; + +use core::future::Future; + +use aimdb_core::session::{ + ByteStream, Datagram, DatagramBinder, StreamDialer, StreamListener, TransportError, +}; +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, and DNS +/// retransmission is on one of them. +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); +const PORT: u16 = 7301; +const DNS_PORT: u16 = 53; + +/// The only name the stub resolver knows, pointing at B. +const BROKER: &str = "broker.test"; + +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, + dns: Option, + 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 mut dns_servers = heapless::Vec::new(); + if let Some(server) = dns { + dns_servers.push(server).expect("one DNS server fits"); + } + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers, + }); + // One slot over the three sockets the test opens: `embassy_net::new` adds + // the resolver socket itself now that `net` enables `embassy-net/dns`. + 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); + } +} + +// =========================================================================== +// Stub resolver. +// =========================================================================== + +/// Build a reply to `query`: one `A` record holding [`B_IP`] when the query +/// names [`BROKER`], `NXDomain` otherwise. +/// +/// Enough of RFC 1035 to satisfy smoltcp's client and no more — it checks the +/// transaction id, the question type, and that the answer's name equals the +/// one it asked about, so the question is echoed verbatim and the answer name +/// repeated uncompressed rather than written as a `0xC00C` pointer. +fn reply(query: &[u8]) -> Option> { + const A: u16 = 0x0001; + const IN: u16 = 0x0001; + + if query.len() < 12 { + return None; + } + // Walk the QNAME's length-prefixed labels to the root label. A query never + // uses compression, so every octet here is a length. + let mut root = 12; + while *query.get(root)? != 0 { + root += 1 + *query.get(root)? as usize; + } + let name = query.get(12..=root)?; + let qtype = u16::from_be_bytes([*query.get(root + 1)?, *query.get(root + 2)?]); + let question_end = root + 5; + let asked = name_to_str(name); + + let mut reply = query.get(..question_end)?.to_vec(); + let known = asked == BROKER && qtype == A; + // QR | recursion desired | recursion available, plus NXDomain (rcode 3) + // for anything but the one name-and-type the stub serves. A name it knows + // and a type it does not gets NOERROR with no answer, as a real resolver + // would for an `AAAA` on a v4-only host. + let flags: u16 = if asked == BROKER { 0x8180 } else { 0x8183 }; + reply[2..4].copy_from_slice(&flags.to_be_bytes()); + reply[6..8].copy_from_slice(&u16::from(known).to_be_bytes()); + if known { + reply.extend_from_slice(name); + reply.extend_from_slice(&A.to_be_bytes()); + reply.extend_from_slice(&IN.to_be_bytes()); + reply.extend_from_slice(&60u32.to_be_bytes()); // TTL + reply.extend_from_slice(&4u16.to_be_bytes()); // RDLENGTH + reply.extend_from_slice(&B_IP.octets()); + } + Some(reply) +} + +/// Render a wire-format QNAME as `label.label`, for comparison against +/// [`BROKER`]. +fn name_to_str(name: &[u8]) -> alloc::string::String { + let mut out = alloc::string::String::new(); + let mut i = 0; + while let Some(&len) = name.get(i) { + if len == 0 { + break; + } + let Some(label) = name.get(i + 1..i + 1 + len as usize) else { + break; + }; + if !out.is_empty() { + out.push('.'); + } + out.push_str(&alloc::string::String::from_utf8_lossy(label)); + i += 1 + len as usize; + } + out +} + +/// Answer queries on `stack`'s UDP/53 forever. +async fn serve_dns(stack: Stack<'static>) -> ! { + let mut socket = EmbassyNet::udp(stack, meta(), buf(), meta(), buf()) + .bind(DNS_PORT) + .await + .expect("bind the stub resolver"); + let mut rx = [0u8; 512]; + loop { + let (len, from) = socket.recv_from(&mut rx).await.expect("read a query"); + if let Some(reply) = reply(&rx[..len]) { + socket.send_to(&reply, from).await.expect("write a reply"); + } + } +} + +// =========================================================================== +// Rig. +// =========================================================================== + +/// Run `foreground` while both stacks poll and B resolves 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::{join, 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, Some(B_IP), 0x1111_2222); + let (b_stack, mut b_net, b_ch) = make_stack(B_IP, None, 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 = join( + join4( + a_net.run(), + b_net.run(), + cable(a_tx, b_rx), + cable(b_tx, a_rx), + ), + serve_dns(b_stack), + ); + 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"), + } + }) +} + +// =========================================================================== +// Tests. +// =========================================================================== + +/// The regression behind `mqtts://broker.example.com`: a hostname reached the +/// dialer, which only parsed IP literals, so the connector reconnect-looped +/// forever. Dial by name and exchange a byte to prove the resolved address is +/// the one that got connected. +#[test] +fn dials_a_hostname() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + let (dialed, accepted) = join(dialer.connect(BROKER, PORT), listener.accept()).await; + let mut client = dialed.expect("a hostname must dial"); + let (mut server, _peer) = accepted.expect("accept"); + + client.write_all(b"ping").await.expect("write"); + client.flush().await.expect("flush"); + let mut got = [0u8; 4]; + server.read(&mut got).await.expect("read"); + assert_eq!(&got, b"ping"); + }); + assert_eq!(outcome, Ok(())); +} + +/// An IP literal still dials without a query, so a deployment with no resolver +/// configured is unaffected by the name path above. +#[test] +fn dials_an_ip_literal() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + let (dialed, accepted) = join(dialer.connect("192.168.0.2", PORT), listener.accept()).await; + dialed.expect("an IP literal must dial"); + accepted.expect("accept"); + }); + assert_eq!(outcome, Ok(())); +} + +/// A name that does not resolve fails as a connect failure would, and — the +/// part that matters for a reconnect loop — hands the socket back, so the next +/// dial is not stuck on [`TransportError::Busy`] forever. +#[test] +fn an_unresolvable_name_fails_and_frees_the_socket() { + let outcome = drive(|a_stack, b_stack| async move { + use futures::future::join; + + let dialer = EmbassyNet::tcp(a_stack, buf(), buf()); + let mut listener = EmbassyNet::listen::<1>(b_stack, PORT, [(buf(), buf())]); + + assert_eq!( + dialer.connect("nowhere.test", PORT).await.err(), + Some(TransportError::Io), + "an unknown name is an I/O failure, not a panic or a hang" + ); + + let (dialed, accepted) = join(dialer.connect(BROKER, PORT), listener.accept()).await; + dialed.expect("the socket must still be dialable"); + accepted.expect("accept"); + }); + assert_eq!(outcome, Ok(())); +} diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index cd9763a8..1d92eb3b 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -51,6 +51,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **A broker hostname works on every backend, `mqtt://` and `mqtts://` alike.** + `setup_manager` vetted plain `mqtt://` hosts with `Ipv4Addr::from_str`, a + rule inherited from the days when this crate built the `embassy_net` + address itself. Since the host string now goes to a `StreamDialer`, that gate + described no dialer in particular: `mqtt://broker.local:1883` connected on + `Native`, and the same URL with `.transport(TokioNet::tcp())` — a dialer that + resolves names perfectly well — was rejected at `build()`. On Embassy the + mirror image bit `mqtts://`, which skips the gate: its hostname reached a + dialer that parsed only IP literals — and a hostname is the configuration + `build()` steers TLS users toward — so it reconnect-looped. The gate is gone + and `EmbassyTcpDialer` resolves (see the adapter's changelog: its `net` + feature now enables `embassy-net/dns` and each stack needs one more + `StackResources` slot). `backend_parity` dials `localhost` on both backends. +- **The embedded session's dead retry path is gone, and a dropped publish now + says so.** `try_action` parked a failed action in `SessionState` for the next + loop iteration to retry, but both call sites propagated the error with `?`, + which ends the session — and `run_sessions` built a *fresh* `SessionState` + per connection, so the parked action was dropped with the old one. + `take_pending_action` could only ever return `None` and `is_retry` was never + `true`. The mechanism is removed rather than repaired: the loss window is + narrow (a dead link is normally found by the 10 ms poll or the 2 s ping, not + by a publish), and where a publish *is* the detector — a response timeout — + the broker has most likely already received the message, so a resend would + duplicate it. An action that fails now logs its topic and the `ClientError` + before the session ends, so the drop is visible instead of silent, and + `handle_messages` documents the at-most-once contract: the action in flight + is lost, everything still queued survives. Dropping the parking slot also + makes `SessionState` non-generic and removes the unused type parameter it + forced onto `ChannelEventHandler`. - **A second connector in one process no longer steals the first's identity.** Client id and credentials were parked in process-global `OnceLock`s, so every connector after the first connected as the first. diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 4b5de4d6..0e226a65 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -93,11 +93,7 @@ critical-section-std-impl = ["dep:critical-section", "critical-section/std"] tracing = ["aimdb-core/tracing"] log = ["aimdb-core/log"] -defmt = [ - "dep:defmt", - "aimdb-core/defmt", - "mountain-mqtt?/defmt", -] +defmt = ["dep:defmt", "aimdb-core/defmt", "mountain-mqtt?/defmt"] # Internal: the embedded backend's host smoke over `TokioNet::tcp()` # (`tests/tokio_broker.rs`) — a real TCP socket and a fake broker, no network diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 7dc4df60..404ab9e9 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -148,24 +148,21 @@ pub enum MqttEvent { /// is not `Sync`, so a session future holding one could not be boxed as the /// runner requires. Every lock is a straight-line read or write, never held /// across an `await`. -pub(crate) struct SessionState { - inner: BlockingMutex>>, +pub(crate) struct SessionState { + inner: BlockingMutex>, } -struct Inner { +struct Inner { /// When the broker last proved it was alive. last_connection_event_ms: u64, - /// An action whose `perform` failed, to retry on the next iteration. - pending_action: Option, } -impl SessionState { +impl SessionState { /// Fresh state for a new connection; the liveness window starts now. pub(crate) fn new(now_ms: u64) -> Self { Self { inner: BlockingMutex::new(RefCell::new(Inner { last_connection_event_ms: now_ms, - pending_action: None, })), } } @@ -179,38 +176,28 @@ impl SessionState { self.inner .lock(|state| state.borrow().last_connection_event_ms) } - - fn take_pending_action(&self) -> Option { - self.inner - .lock(|state| state.borrow_mut().pending_action.take()) - } - - fn set_pending_action(&self, action: A) { - self.inner - .lock(|state| state.borrow_mut().pending_action = Some(action)); - } } /// Forwards received MQTT events onto the event channel and refreshes the /// liveness timestamp on every broker acknowledgement. -pub(crate) struct ChannelEventHandler<'a, A, E, const P: usize, const Q: usize> +pub(crate) struct ChannelEventHandler<'a, E, const P: usize, const Q: usize> where E: FromApplicationMessage

+ Clone, { connection_id: ConnectionId, events: &'a EventChannel, - state: &'a SessionState, + state: &'a SessionState, runtime: &'a dyn RuntimeOps, } -impl<'a, A, E, const P: usize, const Q: usize> ChannelEventHandler<'a, A, E, P, Q> +impl<'a, E, const P: usize, const Q: usize> ChannelEventHandler<'a, E, P, Q> where E: FromApplicationMessage

+ Clone, { pub(crate) fn new( connection_id: ConnectionId, events: &'a EventChannel, - state: &'a SessionState, + state: &'a SessionState, runtime: &'a dyn RuntimeOps, ) -> Self { Self { @@ -222,7 +209,7 @@ where } } -impl EventHandler

for ChannelEventHandler<'_, A, E, P, Q> +impl EventHandler

for ChannelEventHandler<'_, E, P, Q> where E: FromApplicationMessage

+ Clone, { @@ -271,45 +258,33 @@ where } } -/// Perform one action, parking it for retry if the client rejects it. -async fn try_action<'a, A, C>( - connection_id: ConnectionId, - client: &mut C, - state: &SessionState, - connection_settings: &ConnectionSettings<'static>, - mut action: A, - is_retry: bool, -) -> Result<(), ClientError> -where - C: Client<'a>, - A: MqttOperations + Clone, -{ - if let Err(e) = action - .perform( - client, - connection_settings.client_id(), - connection_id, - is_retry, - ) - .await - { - state.set_pending_action(action); - return Err(e); - } - Ok(()) -} - /// Drive one MQTT session until an error ends it: connect, subscribe /// `subscribe_topics`, then keep it alive while dispatching actions and /// forwarding events. /// /// `subscribe_topics` is re-sent on every call, i.e. once per connection, so /// inbound routing survives a reconnect. +/// +/// # Delivery +/// +/// **At most once, at this layer.** An action is taken off `actions` before it +/// is performed, so the one action in flight when the session ends is lost; +/// everything still queued survives, because `actions` outlives the session. +/// The action logs what it dropped before the error propagates. +/// +/// Resending is deliberately not done here. The window is narrow — a dead link +/// is normally found by the 10 ms poll or the 2 s ping, not by a publish — and +/// the case where a publish *is* the detector is a response timeout, where the +/// broker has most likely already received the message and a resend would +/// duplicate it. This layer cannot tell a telemetry sample (resend is +/// pointless, a fresher value is already queued behind it) from a command +/// (resend may be actively wrong). An application that needs at-least-once +/// knows which it has, and can re-produce on [`MqttEvent::Connected`]. #[allow(clippy::too_many_arguments)] pub(crate) async fn handle_messages<'a, A, C, E, D, const P: usize, const Q: usize>( connection_id: ConnectionId, client: &mut C, - state: &SessionState, + state: &SessionState, connection_settings: &ConnectionSettings<'static>, subscribe_topics: &[(&str, QualityOfService)], events: &EventChannel, @@ -365,28 +340,18 @@ where // Poll with no delay while packets are waiting. while client.poll(false).await? {} - if let Some(action) = state.take_pending_action() { - try_action( - connection_id, - client, - state, - connection_settings, - action, - true, - ) - .await?; - } - - while let Ok(action) = actions.try_receive() { - try_action( - connection_id, - client, - state, - connection_settings, - action, - false, - ) - .await?; + // A failed action ends the session, and the action is gone with it — + // see this function's "Delivery" note. `is_retry` is always `false`: + // nothing is ever performed twice. + while let Ok(mut action) = actions.try_receive() { + action + .perform( + client, + connection_settings.client_id(), + connection_id, + false, + ) + .await?; } } } diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 8e080261..21fd0e43 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -41,9 +41,7 @@ use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; use core::future::Future; -use core::net::Ipv4Addr; use core::pin::Pin; -use core::str::FromStr; #[cfg(feature = "embedded-tls")] #[cfg(feature = "embassy-tls")] @@ -102,13 +100,19 @@ pub enum AimdbMqttAction { } /// Implementation of MqttOperations trait for AimDB actions +/// +/// `is_retry` is part of the upstream trait and is always `false` here: the +/// session performs each action exactly once and drops it if it fails (see +/// `handle_messages`' "Delivery" note), so nothing is ever a second attempt. +/// A failure is logged with its topic before it propagates, because it ends +/// the session and takes the message with it. impl MqttOperations for AimdbMqttAction { async fn perform<'a, 'b, C>( &'b mut self, client: &mut C, _client_id: &'a str, _connection_id: ConnectionId, - is_retry: bool, + _is_retry: bool, ) -> Result<(), ClientError> where C: Client<'a>, @@ -121,23 +125,25 @@ impl MqttOperations for AimdbMqttAction { retain, } => { #[cfg(feature = "defmt")] - { - if is_retry { - defmt::debug!("Retrying publish to {}", topic.as_str()); - } else { - defmt::debug!( - "Publishing {} bytes to {} (QoS={:?})", + defmt::debug!( + "Publishing {} bytes to {} (QoS={:?})", + payload.len(), + topic.as_str(), + qos + ); + + client + .publish(topic, payload, *qos, *retain) + .await + .inspect_err(|_e| { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: dropping publish of {} bytes to {}: {}", payload.len(), topic.as_str(), - qos + _e ); - } - } - - #[cfg(not(feature = "defmt"))] - let _ = is_retry; - - client.publish(topic, payload, *qos, *retain).await?; + })?; #[cfg(feature = "defmt")] defmt::info!("Published {} bytes to {}", payload.len(), topic.as_str()); @@ -146,18 +152,12 @@ impl MqttOperations for AimdbMqttAction { } Self::Subscribe { topic, qos } => { #[cfg(feature = "defmt")] - { - if is_retry { - defmt::debug!("Retrying subscribe to {} (QoS={:?})", topic.as_str(), qos); - } else { - defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); - } - } + defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); - #[cfg(not(feature = "defmt"))] - let _ = is_retry; - - client.subscribe(topic, *qos).await?; + client.subscribe(topic, *qos).await.inspect_err(|_e| { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); + })?; #[cfg(feature = "defmt")] defmt::info!("Subscribed to {}", topic.as_str()); @@ -465,10 +465,6 @@ where + 'static, D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { - Ipv4Addr::from_str(&broker.host).map_err(|_| { - build_err("Invalid broker IP address (plain mqtt:// needs an IPv4 literal)") - })?; - let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index dd431881..ff3a1c91 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -170,8 +170,7 @@ where } }; - let state: SessionState = - SessionState::new(now_ms(runtime.as_ref())); + let state = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 40e2b3e3..b7954b7c 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -36,7 +36,7 @@ use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packet_client::Connection; -use crate::embedded::{AimdbMqttAction, AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; +use crate::embedded::{AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. @@ -379,18 +379,13 @@ where }; let timeout_millis = settings.response_timeout.as_millis() as u32; - let state: SessionState = SessionState::new(now_ms(runtime.as_ref())); + let state = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler: ChannelEventHandler< - '_, - AimdbMqttAction, - AimdbMqttEvent, - MAX_PROPERTIES, - CHANNEL_SIZE, - > = ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); + let event_handler: ChannelEventHandler<'_, AimdbMqttEvent, MAX_PROPERTIES, CHANNEL_SIZE> = + ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); let mut client = ClientNoQueue::new( connection, diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index 5d20c437..7100c182 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -233,3 +233,58 @@ async fn with_credentials_reaches_the_wire_on_both_backends() { ); } } + +/// A **hostname** is a broker address on both backends. +/// +/// The embedded backend used to vet plain `mqtt://` hosts with +/// `Ipv4Addr::from_str` and reject everything else, so `.transport(..)` — the +/// call that is supposed to leave behaviour unchanged — was the difference +/// between a URL that works and one that does not. Resolving `host` is the +/// dialer's job on every adapter, so the gate is gone and the two agree. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_hostname_is_a_broker_address_on_both_backends() { + // Bound by name, so the address the broker listens on is whichever one + // `localhost` resolves to first here — the same one the dialers get. + let listener = TcpListener::bind("localhost:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://localhost:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()).with_client_id("host-native"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("host-embedded"); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().connects < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let seen = seen.lock().unwrap(); + panic!( + "watchdog: only {} of 2 backends connected by name ({:?})", + seen.connects, seen.client_ids + ); + } + } + + let seen = seen.lock().unwrap(); + let mut ids = seen.client_ids.clone(); + ids.sort(); + assert_eq!( + ids, + vec![String::from("host-embedded"), String::from("host-native")], + "both backends must reach the broker through a hostname" + ); +} diff --git a/docs/design/052-runtime-neutral-connectors.md b/docs/design/052-runtime-neutral-connectors.md index d8db4cef..f8297986 100644 --- a/docs/design/052-runtime-neutral-connectors.md +++ b/docs/design/052-runtime-neutral-connectors.md @@ -588,8 +588,9 @@ use aimdb_serial_connector::SerialServer; // NEW: the adapter owns the socket. The TCP buffers mountain-mqtt used to // allocate internally are now yours, in statics, like the TCP connector -// already does. `EmbassyNet` also resolves hostnames when embassy-net's -// `dns` feature is on. +// already does. `EmbassyNet::tcp` resolves hostnames through `stack`, as +// `TokioNet::tcp()` does through the OS — so give the stack a DNS server and +// one more `StackResources` slot for the resolver socket. static MQTT_RX: StaticCell<[u8; 4096]> = StaticCell::new(); static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); let mqtt_net = EmbassyNet::tcp(stack, MQTT_RX.init([0; 4096]), MQTT_TX.init([0; 4096])); diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index cbddd5be..7fd463cc 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -217,7 +217,7 @@ async fn main(spawner: Spawner) { let config = embassy_net::Config::dhcpv4(Default::default()); - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); let (stack_obj, runner) = diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 7a5a286e..c38a73f6 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -316,9 +316,9 @@ async fn main(spawner: Spawner) { // gateway: Some(Ipv4Address::new(192, 168, 1, 1)), // }); - // Initialize network stack (TLS builds carry two extra sockets: DNS + SNTP) + // Initialize network stack (TLS builds carry one extra socket: SNTP) #[cfg(not(feature = "tls"))] - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); #[cfg(feature = "tls")] static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); 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 1623c374..e94d88a5 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -213,7 +213,7 @@ async fn main(spawner: Spawner) { let config = embassy_net::Config::dhcpv4(Default::default()); // Initialize network stack - static RESOURCES: StaticCell> = StaticCell::new(); + static RESOURCES: StaticCell> = StaticCell::new(); static STACK_CELL: StaticCell> = StaticCell::new(); let (stack_obj, runner) = From 6221aa1c472a3b77497a858c57aeab18b127f9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sat, 12 Sep 2026 17:39:21 +0000 Subject: [PATCH 22/48] Refactor Makefile and update Clippy commands for MQTT connector - Updated Clippy commands in the Makefile to reflect changes in the MQTT connector's features and targets. - Changed descriptions for Clippy runs to better represent the current configurations. --- Update CHANGELOG.md for aimdb-mqtt-connector - Documented significant changes including protocol backend updates, removal of deprecated features, and improvements in error handling. - Added details about the new `MqttConnectorBuilder` API and adjustments in the session management. --- Modify README.md for aimdb-mqtt-connector - Updated example usage to reflect the new API structure for the MQTT connector. - Adjusted import statements to align with the latest changes in the library. --- Update lib.rs documentation for aimdb-mqtt-connector - Revised documentation to clarify the usage of the embedded and TLS features. - Enhanced descriptions of the API and its components. --- Revise CHANGELOG.md for aimdb-tokio-adapter - Clarified the addition of `Delay` for `TokioTcpDialer` and its implications for the embedded MQTT backend. --- Revise README.md for embassy-mqtt-connector-demo - Updated the demo example to reflect the new connector API and usage patterns. - Clarified hardware requirements and resource links. --- Update main.rs for embassy-mqtt-connector-demo - Adjusted import statements to align with the latest changes in the MQTT connector library. --- Makefile | 4 +- aimdb-mqtt-connector/CHANGELOG.md | 164 +++++++++--------- aimdb-mqtt-connector/README.md | 1 + aimdb-mqtt-connector/src/lib.rs | 18 +- aimdb-tokio-adapter/CHANGELOG.md | 6 +- .../embassy-mqtt-connector-demo/README.md | 54 +++--- .../embassy-mqtt-connector-demo/src/main.rs | 2 +- 7 files changed, 126 insertions(+), 123 deletions(-) diff --git a/Makefile b/Makefile index 45cf1882..d28b25c7 100644 --- a/Makefile +++ b/Makefile @@ -349,11 +349,11 @@ clippy: cargo clippy --package aimdb-mqtt-connector --features "std,tokio-native-tls" --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (tokio + rustls)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --features "std,tokio-rustls" --all-targets -- -D warnings - @printf "$(YELLOW) → Clippy on MQTT connector (embassy + defmt)$(NC)\n" + @printf "$(YELLOW) → Clippy on MQTT connector (neutral, no_std+alloc)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (Embassy bundle + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" -- -D warnings - @printf "$(YELLOW) → Clippy on MQTT connector (embassy + TLS + defmt)$(NC)\n" + @printf "$(YELLOW) → Clippy on MQTT connector (neutral + TLS)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embedded-tls" -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (Embassy + TLS + defmt)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-tls,defmt" -- -D warnings diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 1d92eb3b..d9136fb0 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -48,45 +48,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Time comes from core's `Delay`**, supplied by the dialer, so the session loop names no executor. `Settings` is `core::time::Duration` and lost its dead `address`/`port` fields. +- **Two protocol backends behind one type.** `Native` is `rumqttc` (QoS 0–2, + rustls); `Embedded` is `mountain-mqtt` over a caller-supplied transport. + The Tokio path is unchanged; Embassy callers now write + `MqttConnector::new(url).transport(EmbassyNet::tcp(..))`, or + `.tls(EmbassyNet::tcp(..), opts)` for `mqtts://`, instead of passing the + stack to `new`. The `Tokio*`/`Embassy*` aliases and `MqttConnectorBuilder` + are gone. +- **`run_with_subscriptions` replaced by an owned session loop.** It binds + `embassy_net::Stack` and cannot take a transport, so reconnect-and-resubscribe + is now explicit in `embedded::session::run_sessions` — one loop for both plain + and TLS, extracted from the TLS path already running it. -### Fixed -- **A broker hostname works on every backend, `mqtt://` and `mqtts://` alike.** - `setup_manager` vetted plain `mqtt://` hosts with `Ipv4Addr::from_str`, a - rule inherited from the days when this crate built the `embassy_net` - address itself. Since the host string now goes to a `StreamDialer`, that gate - described no dialer in particular: `mqtt://broker.local:1883` connected on - `Native`, and the same URL with `.transport(TokioNet::tcp())` — a dialer that - resolves names perfectly well — was rejected at `build()`. On Embassy the - mirror image bit `mqtts://`, which skips the gate: its hostname reached a - dialer that parsed only IP literals — and a hostname is the configuration - `build()` steers TLS users toward — so it reconnect-looped. The gate is gone - and `EmbassyTcpDialer` resolves (see the adapter's changelog: its `net` - feature now enables `embassy-net/dns` and each stack needs one more - `StackResources` slot). `backend_parity` dials `localhost` on both backends. -- **The embedded session's dead retry path is gone, and a dropped publish now - says so.** `try_action` parked a failed action in `SessionState` for the next - loop iteration to retry, but both call sites propagated the error with `?`, - which ends the session — and `run_sessions` built a *fresh* `SessionState` - per connection, so the parked action was dropped with the old one. - `take_pending_action` could only ever return `None` and `is_retry` was never - `true`. The mechanism is removed rather than repaired: the loss window is - narrow (a dead link is normally found by the 10 ms poll or the 2 s ping, not - by a publish), and where a publish *is* the detector — a response timeout — - the broker has most likely already received the message, so a resend would - duplicate it. An action that fails now logs its topic and the `ClientError` - before the session ends, so the drop is visible instead of silent, and - `handle_messages` documents the at-most-once contract: the action in flight - is lost, everything still queued survives. Dropping the parking slot also - makes `SessionState` non-generic and removes the unused type parameter it - forced onto `ChannelEventHandler`. -- **A second connector in one process no longer steals the first's identity.** - Client id and credentials were parked in process-global `OnceLock`s, so every - connector after the first connected as the first. -- **One allocation per inbound message instead of two.** The payload is built - as a `Payload` on arrival rather than as a `Vec` that is converted again. -- **`defmt` is no longer forced on `mountain-mqtt`**, and is absent from the - `embedded` graph entirely. +- **`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. + + +- **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. +- `spawn_event_loop()` → `build_event_loop_future()` (Tokio side). `spawn_outbound_publishers()` → `collect_outbound_futures()` on both Tokio and Embassy. +- The `transport::Connector` impl on `MqttConnectorImpl` was removed alongside the discarded `Arc` return path; direct programmatic publish was already unreachable through the `AimDbBuilder` public API. +- **`MqttConnectorImpl` (Embassy) removed entirely (M17).** It was a build-time aggregation holder; its logic collapsed into the private `setup_manager` + the pump composition in `build()`. Register via `MqttConnectorBuilder` as before — the builder's public API is unchanged. ### Added @@ -99,29 +85,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`#[diagnostic::on_unimplemented]` for a missing backend.** A `no_std` build that forgets `.transport(..)` now gets a message naming the fix instead of an unsatisfied `ConnectorBuilder` bound. - -### Changed - -- **One `MqttConnector` over two protocol backends (breaking on Embassy).** - `Native` is `rumqttc` (QoS 0–2, rustls); `Embedded` is `mountain-mqtt` over - a caller-supplied transport. The Tokio path is unchanged; Embassy callers now - write `MqttConnector::new(url).transport(EmbassyNet::tcp(..))` or - `.tls(stack, opts)` instead of passing the stack to `new`. The - `Tokio*`/`Embassy*` aliases and `MqttConnectorBuilder` are gone. -- **`run_with_subscriptions` replaced by an owned session loop.** It binds - `embassy_net::Stack` and cannot take a transport, so reconnect-and-resubscribe - is now explicit in `transport::run_sessions` — one loop for both plain and - TLS, extracted from the TLS path already running it. -- **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 - `#[cfg(feature = "tracing")]` the facade carries itself. The `tracing` feature - no longer pulls `dep:tracing`; a mirrored `log` feature is added alongside it. - No change to what is emitted, or to a consumer that enables `tracing`. - -### Added - -- **`transport` — the broker transport seam.** `BrokerTransport` over +- **`embedded::session` — the broker transport seam.** `BrokerTransport` over `mountain-mqtt`'s own `Connection` (the client needs a non-blocking peek that a byte stream cannot express and TLS cannot provide), plus `SocketTransport` bridging from core's `StreamDialer`. A new runtime supplies MQTT by @@ -142,46 +106,74 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`MqttConnectorBuilder::with_credentials(username, password)` (Embassy, design 044 D8).** Feeds the MQTT CONNECT username/password on both the plain and TLS transports. The `aimdb-dev/mountain-mqtt` fork submodule is bumped to pick up upstream 0.4's `ConnectionSettings::with_auth`/`authenticated` (`aimdb-dev/mountain-mqtt@89a7129`). - `make check` gains an `embassy-runtime,embassy-tls,defmt` clippy leg on `thumbv7em-none-eabihf`. -### Fixed -- **The rustls path no longer builds its configuration through - `TlsConfiguration::default()`**, which `expect`s on `load_native_certs()` and - `unwrap`s each `add()`. Two panics on the connect path, in a crate reachable - through an FFI boundary where a panic is undefined behaviour rather than an - error. The configuration is now built explicitly, and a machine with no usable - trust roots gets a message saying so. +- **`MqttLinkExt` / `MqttOutboundLinkExt` — the MQTT knobs, now where the protocol lives (Issue #134, design 034 §3.6).** New `link_ext` module (compiled on every feature leg, `alloc`-only) with extension traits over core's generic link builders: `MqttLinkExt::with_qos(u8)` on outbound *and* inbound links (publish / subscribe QoS), and `MqttOutboundLinkExt::with_retain(bool)` on outbound links only (retain is a publish-side flag). They push the exact `("qos", …)` / `("retain", …)` option keys both clients have always read from `protocol_options` — wire behavior identical to the deleted core methods; only an extra `use aimdb_mqtt_connector::{MqttLinkExt, MqttOutboundLinkExt};` is needed. The crate now declares `extern crate alloc` unconditionally. ### Changed -- **Embassy connector reuses the upstream session loop instead of copying it.** The TLS path no longer duplicates mountain-mqtt-embassy's `handle_messages`/`State`/`ChannelEventHandler`/`try_action` (~195 lines): the `aimdb-dev/mountain-mqtt` fork now exposes them publicly (plus a `run_with_subscriptions`), so the plain and TLS transports share one keep-alive/action-dispatch/event loop and can no longer drift. The plain `mqtt://` path also switches to `run_with_subscriptions`, which **re-subscribes inbound topics on every connection** — previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. The submodule is bumped to the matching change; no public API change. -- **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. - -### 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. +- **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 + `#[cfg(feature = "tracing")]` the facade carries itself. The `tracing` feature + no longer pulls `dep:tracing`; a mirrored `log` feature is added alongside it. + No change to what is emitted, or to a consumer that enables `tracing`. -### Added -- **`MqttLinkExt` / `MqttOutboundLinkExt` — the MQTT knobs, now where the protocol lives (Issue #134, design 034 §3.6).** New `link_ext` module (compiled on every feature leg, `alloc`-only) with extension traits over core's generic link builders: `MqttLinkExt::with_qos(u8)` on outbound *and* inbound links (publish / subscribe QoS), and `MqttOutboundLinkExt::with_retain(bool)` on outbound links only (retain is a publish-side flag). They push the exact `("qos", …)` / `("retain", …)` option keys both clients have always read from `protocol_options` — wire behavior identical to the deleted core methods; only an extra `use aimdb_mqtt_connector::{MqttLinkExt, MqttOutboundLinkExt};` is needed. The crate now declares `extern crate alloc` unconditionally. +- **Embassy connector reuses the upstream session loop instead of copying it.** The TLS path no longer duplicates mountain-mqtt-embassy's `handle_messages`/`State`/`ChannelEventHandler`/`try_action` (~195 lines): the `aimdb-dev/mountain-mqtt` fork now exposes them publicly (plus a `run_with_subscriptions`), so the plain and TLS transports share one keep-alive/action-dispatch/event loop and can no longer drift. The plain `mqtt://` path also switches to `run_with_subscriptions`, which **re-subscribes inbound topics on every connection** — previously it queued subscribe actions once at startup, so subscriptions were silently lost after a reconnect. The submodule is bumped to the matching change; no public API change. +- **Embassy broker URL parsing now validates the scheme.** `MqttConnectorBuilder::new`'s URL must be `mqtt://` or `mqtts://` (previously any scheme's host/port were used as-is); this is what selects the transport for the `embassy-tls` change above. -### Changed - **Connector-build errors carry their message on `no_std` too (Issue #129).** With `DbError` unified on `alloc::String`, the dual `#[cfg]` error-construction branches in both clients collapse to one `DbError::runtime_error(...)` expression; the Embassy client's "Failed to build MQTT connector" detail is no longer dropped on embedded targets. No API change. - **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers (the connector now writes only its `Connector`/`Source` I/O adapters and composes the pumps in `build()`). Per-route configuration (`qos` / `retain` / `timeout_ms` / …) is threaded from each link URL's query via `ConnectorConfig::from_query`. `std` now enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. - **Outbound publisher survives a consumer lag (Embassy client, Issue #39).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. - **M17 — Embassy client rebuilt on core's pumps via the adapter spine ([Design 033](../docs/design/033-M17-unify-connectors-drop-send.md)).** The hand-rolled outbound publisher and inbound event-router loops are gone: the Embassy half now rides core's `pump_sink` / `pump_source` through the force-`Send` `EmbassySink` / `EmbassySource` bridges in `aimdb-embassy-adapter::connectors`, exactly like the Tokio half rides them — this crate contributes only the broker **manager task** (mountain-mqtt's `run`, force-`Send`ed once via `into_box_future`) and the `MqttSink` / `MqttSource` over its action/event channels. **No `unsafe`, no `SendFutureWrapper`** remain in this crate. Per-route `qos` / `retain` still arrive from each link URL's query (now via `ConnectorConfig::protocol_options`, parsed per publish). Note: per-message inbound routing logs moved from this crate's `defmt` calls into core's `pump_source` (`tracing` feature), so defmt-only MCU builds no longer log per-message routing failures. -### Changed (breaking) +### Fixed -- **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. -- `spawn_event_loop()` → `build_event_loop_future()` (Tokio side). `spawn_outbound_publishers()` → `collect_outbound_futures()` on both Tokio and Embassy. -- The `transport::Connector` impl on `MqttConnectorImpl` was removed alongside the discarded `Arc` return path; direct programmatic publish was already unreachable through the `AimDbBuilder` public API. -- **`MqttConnectorImpl` (Embassy) removed entirely (M17).** It was a build-time aggregation holder; its logic collapsed into the private `setup_manager` + the pump composition in `build()`. Register via `MqttConnectorBuilder` as before — the builder's public API is unchanged. +- **A broker hostname works on every backend, `mqtt://` and `mqtts://` alike.** + `setup_manager` vetted plain `mqtt://` hosts with `Ipv4Addr::from_str`, a + rule inherited from the days when this crate built the `embassy_net` + address itself. Since the host string now goes to a `StreamDialer`, that gate + described no dialer in particular: `mqtt://broker.local:1883` connected on + `Native`, and the same URL with `.transport(TokioNet::tcp())` — a dialer that + resolves names perfectly well — was rejected at `build()`. On Embassy the + mirror image bit `mqtts://`, which skips the gate: its hostname reached a + dialer that parsed only IP literals — and a hostname is the configuration + `build()` steers TLS users toward — so it reconnect-looped. The gate is gone + and `EmbassyTcpDialer` resolves (see the adapter's changelog: its `net` + feature now enables `embassy-net/dns` and each stack needs one more + `StackResources` slot). `backend_parity` dials `localhost` on both backends. +- **The embedded session's dead retry path is gone, and a dropped publish now + says so.** `try_action` parked a failed action in `SessionState` for the next + loop iteration to retry, but both call sites propagated the error with `?`, + which ends the session — and `run_sessions` built a *fresh* `SessionState` + per connection, so the parked action was dropped with the old one. + `take_pending_action` could only ever return `None` and `is_retry` was never + `true`. The mechanism is removed rather than repaired: the loss window is + narrow (a dead link is normally found by the 10 ms poll or the 2 s ping, not + by a publish), and where a publish *is* the detector — a response timeout — + the broker has most likely already received the message, so a resend would + duplicate it. An action that fails now logs its topic and the `ClientError` + before the session ends, so the drop is visible instead of silent, and + `handle_messages` documents the at-most-once contract: the action in flight + is lost, everything still queued survives. Dropping the parking slot also + makes `SessionState` non-generic and removes the unused type parameter it + forced onto `ChannelEventHandler`. +- **A second connector in one process no longer steals the first's identity.** + Client id and credentials were parked in process-global `OnceLock`s, so every + connector after the first connected as the first. +- **One allocation per inbound message instead of two.** The payload is built + as a `Payload` on arrival rather than as a `Vec` that is converted again. +- **`defmt` is no longer forced on `mountain-mqtt`**, and is absent from the + `embedded` graph entirely. + + +- **The rustls path no longer builds its configuration through + `TlsConfiguration::default()`**, which `expect`s on `load_native_certs()` and + `unwrap`s each `add()`. Two panics on the connect path, in a crate reachable + through an FFI boundary where a panic is undefined behaviour rather than an + error. The configuration is now built explicitly, and a machine with no usable + trust roots gets a message saying so. ## [0.6.0] - 2026-05-22 diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index ee2888f7..0def1ef9 100644 --- a/aimdb-mqtt-connector/README.md +++ b/aimdb-mqtt-connector/README.md @@ -95,6 +95,7 @@ Example: #![no_main] use aimdb_core::AimDbBuilder; +use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt}; use aimdb_mqtt_connector::MqttConnector; use alloc::sync::Arc; diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 92ded46f..62ddb10b 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -12,8 +12,10 @@ //! - `std`: the `rumqttc` backend (QoS 0–2, platform trust roots) //! - `embedded`: the `mountain-mqtt` backend over a caller-supplied transport; //! `alloc` only, with no executor, network stack or adapter +//! - `embedded-tls`: `mqtts://` via `embedded-tls`, on the same transport //! - `embassy-runtime`: `embedded` plus the Embassy transport and clock -//! - `embassy-tls`: TLS (`mqtts://`), DNS and the SNTP time source, on Embassy +//! - `embassy-tls`: `embedded-tls` plus the SNTP time source, for a board with +//! no RTC //! - `critical-section-std-impl`: links a `critical-section` impl for std //! binaries, which the session channels need //! - `tokio-runtime`: deprecated alias for `std` @@ -65,22 +67,28 @@ //! # } //! ``` //! -//! ## Embassy Usage (Embedded) +//! ## Embedded Usage //! //! Illustrative (not compiled: requires the `embassy-runtime` feature and a -//! device network stack): +//! device network stack). The transport is what selects the backend — the same +//! call on any other adapter's dialer gets the same connector. //! //! ```rust,ignore //! use aimdb_core::AimDbBuilder; +//! use aimdb_embassy_adapter::net::EmbassyNet; //! use aimdb_embassy_adapter::EmbassyAdapter; -//! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; +//! use aimdb_mqtt_connector::MqttConnector; //! use alloc::sync::Arc; //! //! let runtime = Arc::new(EmbassyAdapter::new()); //! //! let db = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack)) +//! .with_connector( +//! MqttConnector::new("mqtt://192.168.1.100:1883") +//! .transport(EmbassyNet::tcp(stack, rx, tx)) +//! .with_client_id("my-unique-device-id"), +//! ) //! .configure::(|reg| { //! reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) //! .source(sensor_producer) diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index 6d939ea4..e74a16d7 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -25,9 +25,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`Delay` and `Clone` for `TokioTcpDialer`.** The dialer supplies the session - clock and can be handed to several sessions, which is what lets the embedded - MQTT backend run on a host unchanged. +- **`Delay` for `TokioTcpDialer`.** The dialer supplies the session clock, + which — together with the `Clone` it already derived — is what lets the + embedded MQTT backend run on a host unchanged. - **`embedded-io` feature — the `embedded-io-async` trio on the `net` streams.** `TokioByteStream` implements `Read`/`Write` for any `AsyncRead`/`AsyncWrite`, and `ReadReady` on `TokioByteStream` via diff --git a/examples/embassy-mqtt-connector-demo/README.md b/examples/embassy-mqtt-connector-demo/README.md index 40f95000..fee36cbd 100644 --- a/examples/embassy-mqtt-connector-demo/README.md +++ b/examples/embassy-mqtt-connector-demo/README.md @@ -11,9 +11,9 @@ The `aimdb-mqtt-connector` library with Embassy support is fully implemented and ## What's Implemented -The core Embassy MQTT client (`aimdb-mqtt-connector::embassy_client`) provides: +The connector (`aimdb-mqtt-connector`, feature `embassy-runtime`) provides: -- ✅ Async MQTT publishing with mountain-mqtt-embassy +- ✅ Async MQTT publishing with mountain-mqtt - ✅ Channel-based architecture for background task communication - ✅ Automatic reconnection handling - ✅ QoS 0/1/2 support @@ -21,30 +21,32 @@ The core Embassy MQTT client (`aimdb-mqtt-connector::embassy_client`) provides: ## API Usage Pattern +The connector is registered on the builder and the runner drives it; there is +no pool to hold and no task to spawn by hand. Records publish and subscribe +through their links. + ```rust -use aimdb_mqtt_connector::embassy_client::MqttClientPool; -use embassy_net::Stack; - -// Create MQTT client (requires initialized network stack) -let mqtt_result = MqttClientPool::create( - network_stack, // embassy_net::Stack - "192.168.1.100", // Broker IP - 1883, // Broker port - "my-client-id", // Client ID -).await?; - -// Spawn background task (runs forever, maintains connection) -spawner.spawn(async move { - mqtt_result.task.run().await -}).unwrap(); - -// Use the pool to publish messages -mqtt_result.pool.publish_async( - "sensors/temperature", // Topic - b"{\"value\":23.5}", // Payload - 1, // QoS (0, 1, or 2) - false // Retain flag -).await?; +use aimdb_embassy_adapter::net::EmbassyNet; +use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt, MqttOutboundLinkExt}; + +// The adapter owns the socket; the connector takes a transport from it. +let mut builder = AimDbBuilder::new() + .runtime(runtime) + .with_connector( + MqttConnector::new("mqtt://192.168.1.100:1883") + .transport(EmbassyNet::tcp(*stack, rx_buf, tx_buf)) + .with_client_id("my-client-id"), + ); + +builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(sensor_producer) + .link_to("mqtt://sensors/temperature") // outbound + .with_qos(1) + .with_retain(false) + .with_serializer(|_ctx, v: &Temperature| Ok(v.to_bytes())) + .finish(); +}); ``` ## Hardware Requirements (for full example) @@ -160,7 +162,7 @@ aimdb-mqtt-connector = { path = "../../aimdb-mqtt-connector", features = ["embas ## Resources -- [MQTT Client Implementation](../../aimdb-mqtt-connector/src/embassy_client.rs) +- [MQTT Client Implementation](../../aimdb-mqtt-connector/src/embedded/mod.rs) - [Embassy Documentation](https://embassy.dev/) - [mountain-mqtt](https://github.com/mountainlizard/mountain-mqtt) - [AimDB Core Documentation](../../README.md) diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index c38a73f6..739c7d31 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -94,7 +94,7 @@ use {defmt_rtt as _, panic_probe as _}; use aimdb_embassy_adapter::net::EmbassyNet; use aimdb_mqtt_connector::MqttConnector; #[cfg(feature = "tls")] -use aimdb_mqtt_connector::embassy_client::TlsOptions; +use aimdb_mqtt_connector::TlsOptions; // Import shared types, monitors, and compile-time safe keys from the common crate use mqtt_connector_demo_common::{ From 7c4e78c81f46e695c28c398b9004b4aa4772c770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 06:47:47 +0000 Subject: [PATCH 23/48] fix: update documentation and code comments for clarity in MQTT connector --- aimdb-mqtt-connector/src/connector.rs | 4 ++-- aimdb-mqtt-connector/src/embedded/mod.rs | 1 - aimdb-mqtt-connector/src/embedded/session.rs | 2 +- aimdb-mqtt-connector/tests/tls_broker.rs | 3 +-- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index cc44df02..2a764c4d 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -136,13 +136,13 @@ mod sealed { /// A backend with a build path compiled in. /// -/// Implemented for [`Native`] only under `tokio-runtime`, so a `no_std` build +/// Implemented for [`Native`] only under `std`, so a `no_std` build /// that forgets `.transport(..)` fails here with a message naming the fix /// rather than on core's `ConnectorBuilder`. #[diagnostic::on_unimplemented( message = "`MqttConnector<{Self}>` has no MQTT backend compiled in", label = "no backend for this configuration", - note = "supply a transport — `.transport(dialer)` — for the mountain-mqtt backend, or enable this crate's `tokio-runtime` feature for the rumqttc one" + note = "supply a transport — `.transport(dialer)` — for the mountain-mqtt backend, or enable this crate's `std` feature for the rumqttc one" )] pub trait Backend: sealed::Sealed + Send + Sync { /// Connect and collect this backend's data-plane futures. diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 21fd0e43..ebde7cbc 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -43,7 +43,6 @@ use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; -#[cfg(feature = "embedded-tls")] #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index ff3a1c91..5143bcf5 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -5,7 +5,7 @@ //! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs //! `receive_if_ready` — a non-blocking peek — which a byte stream does not //! express and a TLS session cannot provide (its readiness is two-layered; -//! see the `embassy_tls` module). Wrapping core's trait would mean every +//! see the `tls` module). Wrapping core's trait would mean every //! TLS-like transport faking a capability, so the client's own seam is the //! honest one. //! diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs index 541b26e7..22786bd2 100644 --- a/aimdb-mqtt-connector/tests/tls_broker.rs +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -9,6 +9,7 @@ use std::sync::{Arc, Mutex}; use std::time::Duration; +use rand::SeedableRng as _; use tokio::net::TcpListener; use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; use tokio_rustls::rustls::ServerConfig; @@ -172,5 +173,3 @@ async fn the_embedded_backend_completes_an_mqtts_handshake_against_a_pinned_root seen.subscribed_topics() ); } - -use rand::SeedableRng as _; From 994ad2fafb3f73e2a4eca31682c5c0fe078047fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 06:58:04 +0000 Subject: [PATCH 24/48] fix: improve QoS handling and error reporting in MQTT connector --- aimdb-mqtt-connector/src/embedded/mod.rs | 25 +++++++----- aimdb-mqtt-connector/tests/common/mod.rs | 41 +++++++++++++++----- aimdb-mqtt-connector/tests/embassy_broker.rs | 6 ++- aimdb-mqtt-connector/tests/tokio_broker.rs | 7 +++- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index ebde7cbc..2516aa3b 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -223,9 +223,11 @@ impl aimdb_core::transport::Connector for MqttSink { ) -> Pin> + Send + '_>> { // `qos`/`retain` arrive via the URL query (passed through in // `protocol_options`); default to QoS 1 (legacy behaviour), no retain. - let qos = opt_u8(config, "qos") - .map(map_qos) - .unwrap_or(QualityOfService::Qos1); + let qos = match opt_u8(config, "qos").map(map_qos) { + Some(Ok(qos)) => qos, + Some(Err(e)) => return Box::pin(async move { Err(e) }), + None => QualityOfService::Qos1, + }; let retain = opt_bool(config, "retain").unwrap_or(false); let topic = destination.to_string(); let payload = payload.to_vec(); @@ -601,13 +603,18 @@ where Ok((actions, events, tasks)) } -/// Map a QoS level (0/1/2) to mountain-mqtt's `QualityOfService` (2 downgrades to 1). -fn map_qos(qos: u8) -> QualityOfService { +/// Map a QoS level to mountain-mqtt's `QualityOfService`. +/// +/// `2` downgrades to 1: MQTT 5 exactly-once is not implemented by the client. +/// Anything above 2 is not a QoS level at all and is rejected, as `Native` +/// rejects it — a typo in a link URL should not silently publish at a +/// different guarantee than asked for. +fn map_qos(qos: u8) -> Result { match qos { - 0 => QualityOfService::Qos0, - 1 => QualityOfService::Qos1, - 2 => QualityOfService::Qos1, // Downgrade to QoS 1 - _ => QualityOfService::Qos0, // Default to QoS 0 + 0 => Ok(QualityOfService::Qos0), + 1 => Ok(QualityOfService::Qos1), + 2 => Ok(QualityOfService::Qos1), + _ => Err(PublishError::UnsupportedQoS), } } diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index 7bf2b61b..b7f30d9d 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -76,12 +76,34 @@ fn varint(mut n: usize, out: &mut Vec) { } } -/// Step `i` past a varint. -fn skip_varint(body: &[u8], i: &mut usize) { - while *i < body.len() && body[*i] & 0x80 != 0 { +/// Decode an MQTT variable-byte integer at `i`, stepping past it. +/// +/// Returns the value, because every caller here wants it: each varint is a +/// property block's length, and the block itself has to be stepped over too. +fn take_varint(body: &[u8], i: &mut usize) -> Option { + let mut value = 0usize; + let mut shift = 0; + loop { + let byte = *body.get(*i)?; *i += 1; + value |= ((byte & 0x7f) as usize) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + // MQTT caps a variable-byte integer at four bytes. + if shift > 21 { + return None; + } } - *i += 1; +} + +/// Step `i` past an MQTT 5 property block — its length varint, then the +/// properties themselves. +fn skip_properties(body: &[u8], i: &mut usize) -> Option<()> { + let len = take_varint(body, i)?; + *i += len; + Some(()) } /// The protocol level a CONNECT declares: 4 is 3.1.1, 5 is MQTT 5. @@ -104,10 +126,7 @@ fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, St let flags = *body.get(7)?; let mut i = 10; if v5 { - let start = i; - skip_varint(body, &mut i); - // The varint is the property block's length, which follows it. - i += *body.get(start)? as usize; + skip_properties(body, &mut i)?; } let client_id = take_field(body, &mut i)?; @@ -130,7 +149,9 @@ fn suback(body: &[u8], v5: bool, topics: &mut Vec) -> Vec { let packet_id = [body[0], body[1]]; let mut i = 2; if v5 { - skip_varint(body, &mut i); + // Best-effort: this returns a SUBACK either way, and a malformed + // property block shows up as an unparsable topic below. + let _ = skip_properties(body, &mut i); } let mut granted = Vec::new(); @@ -190,7 +211,7 @@ fn parse_publish(first: u8, body: &[u8], v5: bool) -> Option<(String, Vec, O }; if v5 { - skip_varint(body, &mut i); + skip_properties(body, &mut i)?; } Some((topic, body.get(i..)?.to_vec(), packet_id)) } diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs index 25b997e6..8676c9aa 100644 --- a/aimdb-mqtt-connector/tests/embassy_broker.rs +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -29,8 +29,10 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } -// No `defmt::timestamp!` here: this config enables the adapter's `embassy-time`, -// whose `defmt-timestamp-uptime` already defines `_defmt_timestamp`. +// No `defmt::timestamp!` here: `embassy-net` links `embassy-time`, whose +// `defmt-timestamp-uptime` defines `_defmt_timestamp`, so a second definition +// is a duplicate-symbol link error. `tokio_broker` enables the same feature +// but links no `embassy-time`, so it defines its own. /// Real wall-clock time; a frozen `now()` stalls the stack's timers and the /// session loop's reconnection delay. diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index f2a9fd37..b151868b 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -28,8 +28,11 @@ unsafe impl defmt::Logger for HostTestLogger { fn defmt_panic() -> ! { core::panic!("defmt panic in host test") } -// Nothing else defines `_defmt_timestamp` now that the connector pulls no -// crate enabling `embassy-time/defmt-timestamp-uptime`. +// This binary must define `_defmt_timestamp` itself. `embassy-time` would — +// `defmt-timestamp-uptime` is enabled here, as it is for `embassy_broker` — +// but nothing in this test references `embassy-time`, so its object never +// reaches the link and the symbol would be undefined. `embassy_broker` pulls +// it in through `embassy-net` and therefore must *not* define one. defmt::timestamp!("{=u64:us}", 0); /// Real wall-clock time; the session loop's delays are `embassy_time`'s until From df000abf980bc2bbf6a45eec529ac13a23d3a0be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 11:49:15 +0000 Subject: [PATCH 25/48] fix: update aimdb-mountain-mqtt version and improve QoS error handling in tests --- Cargo.lock | 6 +++--- aimdb-mqtt-connector/Cargo.toml | 9 +++++---- aimdb-mqtt-connector/src/embedded/mod.rs | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f158904e..01323a3b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,11 +249,11 @@ dependencies = [ [[package]] name = "aimdb-mountain-mqtt" -version = "0.2.1" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5035a7a126cd374a5e1e83dcbb04d5d5361d28084f32f59710c6f144b5b26349" +checksum = "0069824828d8b3102324245a42618e65bab9c63fd2917ed52fa49e016d89cecf" dependencies = [ - "defmt 0.3.100", + "defmt 1.1.1", "embedded-hal-async", "embedded-io 0.7.1", "embedded-io-async 0.7.0", diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index 0e226a65..b98d515e 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -169,10 +169,11 @@ embassy-net = { version = "0.9.0", optional = true, features = [ "proto-ipv4", ] } -# MQTT for Embassy — aimdb-dev fork: adds `ConnectionSettings::authenticated` -# and embassy 0.9/0.8/0.5.1, so upstream 0.2.0 doesn't build. A patch won't fix -# that downstream. Keyed as before to keep imports and feature references. -mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.2.1", default-features = false, optional = true, features = [ +# MQTT for Embassy — aimdb-dev fork: upstream `main` with a zero-line source +# delta, published because crates.io `mountain-mqtt` is still 0.2.0 and a +# published crate cannot take a git dependency. Keyed as before to keep imports +# and feature references. Retire it when upstream releases 0.5.0 or later. +mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.5.1", default-features = false, optional = true, features = [ "embedded-io-async", "embedded-hal-async", ] } diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 2516aa3b..ec652c3c 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -642,9 +642,9 @@ mod tests { #[test] fn test_qos_mapping() { - assert!(matches!(map_qos(0), QualityOfService::Qos0)); - assert!(matches!(map_qos(1), QualityOfService::Qos1)); - assert!(matches!(map_qos(2), QualityOfService::Qos1)); // Downgrades to QoS 1 - assert!(matches!(map_qos(99), QualityOfService::Qos0)); // Defaults to QoS 0 + assert!(matches!(map_qos(0), Ok(QualityOfService::Qos0))); + assert!(matches!(map_qos(1), Ok(QualityOfService::Qos1))); + assert!(matches!(map_qos(2), Ok(QualityOfService::Qos1))); // Downgrades to QoS 1 + assert!(matches!(map_qos(99), Err(PublishError::UnsupportedQoS))); // Not a QoS level } } From 0fe6c6c1a629d2db0dc4299e91e940f934054dba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 12:05:43 +0000 Subject: [PATCH 26/48] feat: implement ByteRead and ByteWrite traits for split streams in Embassy and Tokio adapters --- aimdb-core/src/session/io.rs | 188 ++++++++++++++++++++++++++++++- aimdb-core/src/session/mod.rs | 4 +- aimdb-embassy-adapter/src/io.rs | 56 ++++++++- aimdb-embassy-adapter/src/net.rs | 66 ++++++++++- aimdb-tokio-adapter/src/net.rs | 48 +++++++- 5 files changed, 350 insertions(+), 12 deletions(-) diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index 444a6e8e..9ff73c16 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -33,17 +33,51 @@ pub type IoError = TransportError; // Byte streams — the one real fork between runtimes. // =========================================================================== +/// The read half of a [`split`](ByteStream::split) stream. +pub trait ByteRead { + /// 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; +} + +/// The write half of a [`split`](ByteStream::split) stream. +pub trait ByteWrite { + /// 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 + '_; +} + /// 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. +/// The stream is **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. A caller needing the two +/// directions to run at once borrows them apart with +/// [`split`](ByteStream::split); one that does not is serialized anyway, as +/// `Connection`'s own `recv`/`send` are. +/// +/// # Cancellation +/// +/// [`read`](ByteStream::read) is cancel-safe on both adapters AimDB ships: +/// dropping the future before it completes consumes nothing. That is a +/// property of those transports rather than a promise of this trait — a +/// reader that cannot resume mid-packet is still free to implement it — so a +/// caller that drops reads has to know which transport it holds. +/// +/// [`write_all`](ByteStream::write_all) is **not** cancel-safe anywhere, and +/// must never sit in a `select` arm: a partial write desynchronises the +/// framing above it with nothing to resync on. pub trait ByteStream { /// Read into `buf`, returning the byte count; `Ok(0)` is EOF. fn read<'a>( @@ -59,6 +93,16 @@ pub trait ByteStream { /// Flush any buffered bytes toward the peer. fn flush(&mut self) -> impl Future> + Send + '_; + + /// Borrow this stream as independent read and write halves. + /// + /// Both may be polled concurrently and neither sees the other's state, so + /// a reader and a writer can share one stack frame without either waiting + /// on the other. The halves borrow the stream rather than owning it — + /// enough for two futures in one `select`, which is what a full-duplex + /// session loop needs; a caller wanting owned or `'static` halves needs a + /// different seam. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_); } /// Produces streams: the client side. @@ -619,6 +663,30 @@ mod tests { self.0.lock().flushes += 1; Ok(()) } + + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (MockHalf(self.clone()), MockHalf(self.clone())) + } + } + + /// Either half of a split [`MockStream`]. The mock is already a shared + /// handle, so a half is just a clone of it. + struct MockHalf(MockStream); + + impl ByteRead for MockHalf { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + ByteStream::read(&mut self.0, buf).await + } + } + + impl ByteWrite for MockHalf { + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + ByteStream::write_all(&mut self.0, buf).await + } + + async fn flush(&mut self) -> TransportResult<()> { + ByteStream::flush(&mut self.0).await + } } /// A stream whose first read fails, to check the error is propagated as-is @@ -635,6 +703,81 @@ mod tests { async fn flush(&mut self) -> TransportResult<()> { Ok(()) } + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (FailingHalf, FailingHalf) + } + } + + /// Either half of a split [`FailingStream`]. + struct FailingHalf; + + impl ByteRead for FailingHalf { + async fn read<'a>(&'a mut self, _buf: &'a mut [u8]) -> TransportResult { + Err(TransportError::Closed) + } + } + + impl ByteWrite for FailingHalf { + async fn write_all<'a>(&'a mut self, _buf: &'a [u8]) -> TransportResult<()> { + Err(TransportError::Closed) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + } + + /// A stream whose read cannot finish until its write half has run: the + /// read waits to be notified, and only `write_all` notifies. Drives the one + /// property [`ByteStream::split`] exists for — a blocked reader must not + /// block the writer — which a single `&mut` stream cannot express at all. + #[derive(Clone, Default)] + struct DuplexMock { + written: Arc>>, + wrote: Arc, + } + + impl ByteStream for DuplexMock { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + self.wrote.notified().await; + let written = self.written.lock(); + let n = written.len().min(buf.len()); + buf[..n].copy_from_slice(&written[..n]); + Ok(n) + } + + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + self.written.lock().extend_from_slice(buf); + self.wrote.notify_one(); + Ok(()) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + (DuplexHalf(self.clone()), DuplexHalf(self.clone())) + } + } + + /// Either half of a split [`DuplexMock`]. + struct DuplexHalf(DuplexMock); + + impl ByteRead for DuplexHalf { + async fn read<'a>(&'a mut self, buf: &'a mut [u8]) -> TransportResult { + ByteStream::read(&mut self.0, buf).await + } + } + + impl ByteWrite for DuplexHalf { + async fn write_all<'a>(&'a mut self, buf: &'a [u8]) -> TransportResult<()> { + ByteStream::write_all(&mut self.0, buf).await + } + + async fn flush(&mut self) -> TransportResult<()> { + ByteStream::flush(&mut self.0).await + } } struct MockDialer(MockStream); @@ -791,6 +934,41 @@ mod tests { let _boxed: Box = Box::new(conn); } + // --- ByteStream::split ------------------------------------------------ + + #[tokio::test] + async fn split_halves_run_concurrently() { + let mut stream = DuplexMock::default(); + let (mut rx, mut tx) = stream.split(); + + // The read cannot complete until the write has run, so this joining at + // all is the assertion: both halves were live at the same time. + let mut buf = [0u8; 4]; + let (read, write) = tokio::join!(rx.read(&mut buf), tx.write_all(b"ping")); + + write.expect("write half"); + assert_eq!(read.expect("read half"), 4); + assert_eq!(&buf, b"ping"); + } + + #[tokio::test] + async fn split_halves_address_the_same_stream() { + let stream = MockStream::with_reads(vec![b"hi".to_vec()]); + let mut split_me = stream.clone(); + let (mut rx, mut tx) = split_me.split(); + + tx.write_all(b"out").await.expect("write half"); + let mut buf = [0u8; 8]; + let n = rx.read(&mut buf).await.expect("read half"); + + assert_eq!(&buf[..n], b"hi", "the read half drains the stream's reads"); + assert_eq!( + stream.0.lock().written, + b"out", + "the write half reaches the stream the halves came from" + ); + } + // --- OneShot / FramerFactory ------------------------------------------ #[test] diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs index 55b839f8..491436ad 100644 --- a/aimdb-core/src/session/mod.rs +++ b/aimdb-core/src/session/mod.rs @@ -49,8 +49,8 @@ pub use connector::{SessionClientConnector, SessionServerConnector}; pub use endpoint::{split_host_port, split_host_port_opt, EndpointError}; #[cfg(feature = "connector-session")] pub use io::{ - ByteStream, Datagram, DatagramBinder, Delay, FrameFault, FramedConnection, Framer, - FramerFactory, FramingDialer, FramingListener, IoError, OneShot, OneShotDialer, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, Delay, FrameFault, FramedConnection, + Framer, FramerFactory, FramingDialer, FramingListener, IoError, OneShot, OneShotDialer, OneShotListener, StreamDialer, StreamListener, }; #[cfg(feature = "connector-session")] diff --git a/aimdb-embassy-adapter/src/io.rs b/aimdb-embassy-adapter/src/io.rs index 90af083e..d762e952 100644 --- a/aimdb-embassy-adapter/src/io.rs +++ b/aimdb-embassy-adapter/src/io.rs @@ -17,7 +17,7 @@ use core::future::Future; -use aimdb_core::session::{ByteStream, TransportError, TransportResult}; +use aimdb_core::session::{ByteRead, ByteStream, ByteWrite, TransportError, TransportResult}; use crate::SendFutureWrapper; @@ -65,6 +65,60 @@ where fn flush(&mut self) -> impl Future> + Send + '_ { SendFutureWrapper(async move { self.tx.flush().await.map_err(|_| TransportError::Closed) }) } + + /// Hand back the halves this type was built from: a UART arrives already + /// split, so there is nothing to divide and nothing to lock. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + ( + EmbassyUartReader(&mut self.rx), + EmbassyUartWriter(&mut self.tx), + ) + } +} + +/// The read half of a split [`EmbassyUart`]. +struct EmbassyUartReader<'a, Rd>(&'a mut Rd); + +/// The write half of a split [`EmbassyUart`]. +struct EmbassyUartWriter<'a, Wr>(&'a mut Wr); + +// SAFETY: single-core cooperative Embassy executor — see the module invariant, +// which is what already makes `EmbassyUart` itself `Send`. +unsafe impl Send for EmbassyUartReader<'_, Rd> {} +// SAFETY: as above. +unsafe impl Send for EmbassyUartWriter<'_, Wr> {} + +impl ByteRead for EmbassyUartReader<'_, Rd> +where + Rd: embedded_io_async::Read, +{ + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { self.0.read(buf).await.map_err(|_| TransportError::Io) }) + } +} + +impl ByteWrite for EmbassyUartWriter<'_, Wr> +where + Wr: embedded_io_async::Write, +{ + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + self.0 + .write_all(buf) + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { self.0.flush().await.map_err(|_| TransportError::Closed) }) + } } #[cfg(test)] diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index c277390b..537c979c 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -21,8 +21,8 @@ use alloc::string::ToString; use alloc::sync::Arc; use aimdb_core::session::{ - ByteStream, Datagram, DatagramBinder, PeerInfo, StreamDialer, StreamListener, TransportError, - TransportResult, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, PeerInfo, StreamDialer, + StreamListener, TransportError, TransportResult, }; use embassy_futures::yield_now; @@ -207,6 +207,68 @@ impl ByteStream for EmbassyTcpStream { socket.flush().await.map_err(|_| TransportError::Closed) }) } + + /// Borrow the socket's own halves, which `embassy-net` hands out lock-free + /// — both are a copy of the socket's `io` handle. + /// + /// A stream whose socket is already gone still has to produce halves, so + /// each carries the `Option` and reports [`TransportError::Closed`] on use, + /// exactly as the unsplit methods do. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + let (rx, tx) = match self.socket.as_mut() { + Some(socket) => { + let (rx, tx) = socket.split(); + (Some(rx), Some(tx)) + } + None => (None, None), + }; + (EmbassyTcpReader(rx), EmbassyTcpWriter(tx)) + } +} + +/// The read half of a split [`EmbassyTcpStream`]; `None` once the socket is +/// gone. +struct EmbassyTcpReader<'s>(Option>); + +/// The write half of a split [`EmbassyTcpStream`]; `None` once the socket is +/// gone. +struct EmbassyTcpWriter<'s>(Option>); + +// SAFETY: single-core cooperative Embassy executor — see the module invariant, +// which is what already makes `EmbassyTcpStream` itself `Send`. +unsafe impl Send for EmbassyTcpReader<'_> {} +// SAFETY: as above. +unsafe impl Send for EmbassyTcpWriter<'_> {} + +impl ByteRead for EmbassyTcpReader<'_> { + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let rx = self.0.as_mut().ok_or(TransportError::Closed)?; + rx.read(buf).await.map_err(|_| TransportError::Io) + }) + } +} + +impl ByteWrite for EmbassyTcpWriter<'_> { + fn write_all<'a>( + &'a mut self, + buf: &'a [u8], + ) -> impl Future> + Send + 'a { + SendFutureWrapper(async move { + let tx = self.0.as_mut().ok_or(TransportError::Closed)?; + tx.write_all(buf).await.map_err(|_| TransportError::Closed) + }) + } + + fn flush(&mut self) -> impl Future> + Send + '_ { + SendFutureWrapper(async move { + let tx = self.0.as_mut().ok_or(TransportError::Closed)?; + tx.flush().await.map_err(|_| TransportError::Closed) + }) + } } // `embedded-io-async` by delegation, so a protocol client that consumes those diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index 8cf1bb47..49ef60fb 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -10,8 +10,8 @@ use std::net::{IpAddr, SocketAddr}; use aimdb_core::session::{ - ByteStream, Datagram, DatagramBinder, Delay, PeerInfo, StreamDialer, StreamListener, - TransportError, TransportResult, + ByteRead, ByteStream, ByteWrite, Datagram, DatagramBinder, Delay, PeerInfo, StreamDialer, + StreamListener, TransportError, TransportResult, }; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream, UdpSocket}; @@ -79,6 +79,50 @@ where async fn flush(&mut self) -> TransportResult<()> { self.0.flush().await.map_err(|_| TransportError::Closed) } + + /// Borrow the stream as halves through `tokio::io::split`. + /// + /// That is the general path, and it costs a lock: the two halves share the + /// stream behind a mutex taken inside each `poll`. It is never held across + /// an await, so it cannot deadlock, but it is a serialisation point the + /// native `TcpStream::split` does not have. The native one is unreachable + /// here — this type is generic over `S`, so an impl specialised to + /// `TcpStream` would overlap this one. + fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { + let (rx, tx) = tokio::io::split(&mut self.0); + (TokioReadHalf(rx), TokioWriteHalf(tx)) + } +} + +/// The read half of a split [`TokioByteStream`]. +struct TokioReadHalf(tokio::io::ReadHalf); + +/// The write half of a split [`TokioByteStream`]. +struct TokioWriteHalf(tokio::io::WriteHalf); + +impl ByteRead for TokioReadHalf +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) + } +} + +impl ByteWrite for TokioWriteHalf +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send, +{ + 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. From 2e47ceb3e610f1a77fa5088b7004261789b8bb19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 12:47:01 +0000 Subject: [PATCH 27/48] Implement event-driven MQTT session loop with enhanced packet handling - Introduced a new session loop in `session_loop.rs` that operates on an event-driven model, replacing the previous polling mechanism. - The new loop efficiently manages reading and writing operations without contention, ensuring that no partially-read packets are discarded. - Added support for handling multiple futures concurrently using `select3`, allowing for improved responsiveness to incoming data and actions. - Implemented a structured approach to manage MQTT packets, including connection, subscription, and publish actions, with appropriate error handling. - Created a comprehensive test suite in `session_loop.rs` to validate the behavior of the new session loop against various scenarios, ensuring it meets the specified design criteria. - The tests cover idle session behavior, handling of partial packets, QoS 1 publish acknowledgments, and the interaction between inbound and outbound traffic under load. --- Cargo.lock | 1 + aimdb-mqtt-connector/Cargo.toml | 8 +- aimdb-mqtt-connector/src/embedded/manager.rs | 41 +- aimdb-mqtt-connector/src/embedded/mod.rs | 30 +- .../src/embedded/packet_reader.rs | 259 ++++++++ aimdb-mqtt-connector/src/embedded/session.rs | 162 ++--- .../src/embedded/session_loop.rs | 577 ++++++++++++++++ aimdb-mqtt-connector/tests/session_loop.rs | 618 ++++++++++++++++++ 8 files changed, 1561 insertions(+), 135 deletions(-) create mode 100644 aimdb-mqtt-connector/src/embedded/packet_reader.rs create mode 100644 aimdb-mqtt-connector/src/embedded/session_loop.rs create mode 100644 aimdb-mqtt-connector/tests/session_loop.rs diff --git a/Cargo.lock b/Cargo.lock index 01323a3b..32fa1800 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -272,6 +272,7 @@ dependencies = [ "async-stream", "critical-section", "defmt 1.1.1", + "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/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index b98d515e..ab6188b7 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -50,8 +50,12 @@ embedded = [ # loop bridges core's `Delay` to the client's `DelayNs`. "dep:embedded-io-async", "dep:embedded-hal-async", - # Executor-independent: channels only. + # Executor-independent: channels and future combinators only. "embassy-sync", + "dep:embassy-futures", + # `mountain-mqtt`'s packet types take `heapless::Vec`, so the session names + # it to build them. Same 0.8 the dependency itself resolves. + "dep:heapless", ] # Convenience bundle: `embedded` plus the Embassy transport and clock. The @@ -162,6 +166,8 @@ futures-core = { version = "0.3", default-features = false } # local checkout — see the workspace `[patch.crates-io]` for why. embassy-time = { version = "0.5.1", optional = true } embassy-sync = { version = "0.8.0", path = "../_external/embassy/embassy-sync", optional = true } +embassy-futures = { workspace = true, optional = true } +heapless = { workspace = true, optional = true } embassy-net = { version = "0.9.0", optional = true, features = [ "tcp", "dhcpv4", diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 404ab9e9..bfc8d55c 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,26 +1,36 @@ -//! Per-session broker state, the event handler that feeds the event channel, -//! and the pump that keeps one connection alive. +//! Session settings, the events a session reports, and — until TLS joins the +//! event-driven loop — the polled pump that keeps one TLS connection alive. //! //! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and //! source are plain `Connector`/`Source` impls with no force-`Send` wrapper. -//! Time comes from core's [`Delay`] and the runtime's monotonic clock, so the -//! pump names no executor. +//! Time comes from core's [`aimdb_core::session::Delay`] and the +//! runtime's monotonic clock, so nothing here names an executor. -use core::cell::RefCell; use core::time::Duration; -use aimdb_core::session::Delay; +#[cfg(feature = "embedded-tls")] +use core::cell::RefCell; + use aimdb_core::RuntimeOps; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::blocking_mutex::Mutex as BlockingMutex; use embassy_sync::channel::Channel; -use mountain_mqtt::client::{ - Client, ClientError, ClientReceivedEvent, ConnectionSettings, EventHandler, EventHandlerError, -}; +use mountain_mqtt::client::{ClientError, EventHandlerError}; use mountain_mqtt::data::quality_of_service::QualityOfService; -use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; +use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packets::publish::ApplicationMessage; +// The TLS path still drives `ClientNoQueue` through the polled loop below; the +// plain path drives `ClientState` directly (design 053 §6.4). Everything gated +// on `embedded-tls` in this module goes when TLS joins the same loop. +#[cfg(feature = "embedded-tls")] +use aimdb_core::session::Delay; +#[cfg(feature = "embedded-tls")] +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; +#[cfg(feature = "embedded-tls")] +use mountain_mqtt::client::{Client, ClientReceivedEvent, ConnectionSettings, EventHandler}; +#[cfg(feature = "embedded-tls")] +use mountain_mqtt::mqtt_manager::MqttOperations; + /// The event channel: broker session to `pump_source`. pub(crate) type EventChannel = Channel, Q>; @@ -141,22 +151,24 @@ pub enum MqttEvent { connection_id: ConnectionId, }, } - /// Per-connection bookkeeping, shared between the pump and its event handler. /// /// The blocking mutex is what makes `&SessionState` `Send`: a bare `RefCell` /// is not `Sync`, so a session future holding one could not be boxed as the /// runner requires. Every lock is a straight-line read or write, never held /// across an `await`. +#[cfg(feature = "embedded-tls")] pub(crate) struct SessionState { inner: BlockingMutex>, } +#[cfg(feature = "embedded-tls")] struct Inner { /// When the broker last proved it was alive. last_connection_event_ms: u64, } +#[cfg(feature = "embedded-tls")] impl SessionState { /// Fresh state for a new connection; the liveness window starts now. pub(crate) fn new(now_ms: u64) -> Self { @@ -177,9 +189,9 @@ impl SessionState { .lock(|state| state.borrow().last_connection_event_ms) } } - /// Forwards received MQTT events onto the event channel and refreshes the /// liveness timestamp on every broker acknowledgement. +#[cfg(feature = "embedded-tls")] pub(crate) struct ChannelEventHandler<'a, E, const P: usize, const Q: usize> where E: FromApplicationMessage

+ Clone, @@ -190,6 +202,7 @@ where runtime: &'a dyn RuntimeOps, } +#[cfg(feature = "embedded-tls")] impl<'a, E, const P: usize, const Q: usize> ChannelEventHandler<'a, E, P, Q> where E: FromApplicationMessage

+ Clone, @@ -209,6 +222,7 @@ where } } +#[cfg(feature = "embedded-tls")] impl EventHandler

for ChannelEventHandler<'_, E, P, Q> where E: FromApplicationMessage

+ Clone, @@ -280,6 +294,7 @@ where /// pointless, a fresher value is already queued behind it) from a command /// (resend may be actively wrong). An application that needs at-least-once /// knows which it has, and can re-produce on [`MqttEvent::Connected`]. +#[cfg(feature = "embedded-tls")] #[allow(clippy::too_many_arguments)] pub(crate) async fn handle_messages<'a, A, C, E, D, const P: usize, const Q: usize>( connection_id: ConnectionId, diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index ec652c3c..33e04134 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -23,6 +23,11 @@ pub mod manager; pub mod session; +// The session's own machinery: incremental framing, and the three futures that +// replace the polled loop (design 053 §5.1). +pub(crate) mod packet_reader; +pub(crate) mod session_loop; + // TLS transport + SNTP time source. #[cfg(feature = "embassy-tls")] pub mod sntp; @@ -46,8 +51,13 @@ use core::pin::Pin; #[cfg(feature = "embassy-tls")] use aimdb_embassy_adapter::connectors::into_box_future; -use mountain_mqtt::client::{Client, ClientError, ConnectionSettings}; +use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; + +// Named only by the TLS path's `MqttOperations` impl, which goes with it. +#[cfg(feature = "embedded-tls")] +use mountain_mqtt::client::{Client, ClientError}; +#[cfg(feature = "embedded-tls")] use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; use crate::embedded::manager::{MqttEvent, Settings}; @@ -100,11 +110,16 @@ pub enum AimdbMqttAction { /// Implementation of MqttOperations trait for AimDB actions /// +/// Only the TLS path still needs this: the plain path drives `ClientState` +/// directly and encodes each action itself (design 053 §6.4). It goes when TLS +/// joins the same loop. +/// /// `is_retry` is part of the upstream trait and is always `false` here: the /// session performs each action exactly once and drops it if it fails (see /// `handle_messages`' "Delivery" note), so nothing is ever a second attempt. /// A failure is logged with its topic before it propagates, because it ends /// the session and takes the message with it. +#[cfg(feature = "embedded-tls")] impl MqttOperations for AimdbMqttAction { async fn perform<'a, 'b, C>( &'b mut self, @@ -469,11 +484,9 @@ where let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); - // The transport the session loop dials each cycle, and the clock it runs - // on — both come from the caller-supplied dialer. - let delay = dialer.clone(); - let transport = - crate::embedded::session::SocketTransport::new(dialer, broker.host.clone(), broker.port); + // The dialer is both the transport and the clock the session runs on. + let host = broker.host.clone(); + let port = broker.port; // SAFETY: every value the session holds is `Send` — `StreamDialer` // guarantees `Stream: Send`, the channels are `CriticalSectionRawMutex` @@ -487,13 +500,14 @@ where defmt::info!("MQTT background task starting"); crate::embedded::session::run_sessions( - transport, + dialer, + host, + port, topics, connection_settings, Settings::default(), events, actions, - delay, runtime, ) .await diff --git a/aimdb-mqtt-connector/src/embedded/packet_reader.rs b/aimdb-mqtt-connector/src/embedded/packet_reader.rs new file mode 100644 index 00000000..f0dca624 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/packet_reader.rs @@ -0,0 +1,259 @@ +//! Incremental MQTT packet framing: push bytes in, take whole packets out. +//! +//! This is what makes a partial packet a non-event. `mountain-mqtt`'s own +//! reader asks the transport for exactly as many bytes as the fixed header +//! promises and waits inside that read until they arrive, so a peer that +//! stalls mid-packet parks the caller — with the polled session loop that +//! meant pings, liveness and every queued publish stopped with it. Here a +//! partial packet is simply "not enough yet": [`feed`](PacketReader::feed) +//! takes whatever arrived, [`framed_len`](PacketReader::framed_len) says +//! whether a whole packet is present, and nothing blocks. +//! +//! # Why framing and parsing are separate +//! +//! `framed_len` borrows nothing and [`parse`](PacketReader::parse) takes +//! `&self`, because `MqttBufReader` holds `&[u8]` rather than `&mut [u8]`. So +//! a parsed packet holds a *shared* borrow of the buffer, it ends when the +//! caller drops the packet, and [`consume`](PacketReader::consume) is then +//! free to take `&mut self` and compact. No `unsafe`, no self-referential +//! struct, no allocation per packet. + +use mountain_mqtt::codec::mqtt_reader::{MqttBufReader, MqttReader}; +use mountain_mqtt::data::packet_type::PacketType; +use mountain_mqtt::error::PacketReadError; +use mountain_mqtt::packets::packet_generic::PacketGeneric; + +/// Reassembles MQTT packets from arbitrary byte chunks. +/// +/// `N` bounds the largest packet that can be received; a longer one is +/// [`PacketReadError::PacketTooLargeForBuffer`] rather than a stall. +pub(crate) struct PacketReader { + buf: [u8; N], + len: usize, +} + +impl PacketReader { + /// An empty reader. + pub(crate) const fn new() -> Self { + Self { + buf: [0u8; N], + len: 0, + } + } + + /// Append freshly read bytes. + /// + /// Fails only if they would not fit, which at this layer means the peer + /// sent a packet larger than `N`. + pub(crate) fn feed(&mut self, bytes: &[u8]) -> Result<(), PacketReadError> { + if self.len + bytes.len() > N { + return Err(PacketReadError::PacketTooLargeForBuffer); + } + self.buf[self.len..self.len + bytes.len()].copy_from_slice(bytes); + self.len += bytes.len(); + Ok(()) + } + + /// Total length of the complete packet at the head of the buffer, or + /// `Ok(None)` if not enough bytes have landed yet. + /// + /// This is upstream's `receive_rest_of_packet` varint scan restated as a + /// pure function over what is already buffered, so it borrows nothing and + /// commits to nothing. + pub(crate) fn framed_len(&self) -> Result, PacketReadError> { + if self.len < 1 { + return Ok(None); + } + if !PacketType::is_valid_first_header_byte(self.buf[0]) { + return Err(PacketReadError::InvalidPacketType); + } + + // The remaining-length field: up to 4 bytes, each continuing while its + // top bit is set. + let mut pos = 1usize; + loop { + if pos > 4 { + return Err(PacketReadError::InvalidVariableByteIntegerEncoding); + } + if self.len < pos + 1 { + return Ok(None); // the length itself is still arriving + } + if self.buf[pos] & 128 == 0 { + pos += 1; + break; + } + pos += 1; + } + + let remaining = { + let mut reader = MqttBufReader::new(&self.buf[1..pos]); + reader.get_variable_u32()? as usize + }; + let total = pos + remaining; + if total > N { + return Err(PacketReadError::PacketTooLargeForBuffer); + } + if self.len < total { + return Ok(None); // header complete, body still arriving + } + Ok(Some(total)) + } + + /// Parse the complete packet at the head of the buffer. + /// + /// `total` must come from [`framed_len`](Self::framed_len). Takes `&self`, + /// so the returned packet holds only a shared borrow — see the module note. + pub(crate) fn parse( + &self, + total: usize, + ) -> Result, PacketReadError> { + let mut reader = MqttBufReader::new(&self.buf[0..total]); + reader.get() + } + + /// Drop a consumed packet from the head, sliding any bytes of the next one + /// down. Needs `&mut self`, so it can only run once the packet is dropped. + pub(crate) fn consume(&mut self, total: usize) { + self.buf.copy_within(total..self.len, 0); + self.len -= total; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + use alloc::vec::Vec; + + /// MQTT 5 CONNACK, as the fake broker in `tests/common` sends it. + const CONNACK: &[u8] = &[0x20, 0x03, 0x00, 0x00, 0x00]; + + /// A QoS-0 MQTT 5 PUBLISH, built the way `tests/common::publish` builds it. + fn publish_bytes(topic: &str, payload: &[u8]) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.push(0x00); // no properties + rest.extend_from_slice(payload); + + let mut packet = vec![0x30]; + let mut n = rest.len(); + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 128; + } + packet.push(byte); + if n == 0 { + break; + } + } + packet.extend_from_slice(&rest); + packet + } + + /// Drain whatever is complete, reporting each packet as a short label. + fn drain(reader: &mut PacketReader) -> Vec<&'static str> { + let mut got = Vec::new(); + while let Some(total) = reader.framed_len().expect("framing must not error") { + { + let packet = reader.parse::<8, 0, 0>(total).expect("parse"); + got.push(match packet { + PacketGeneric::Connack(_) => "connack", + PacketGeneric::Publish(_) => "publish", + _ => "other", + }); + // the packet drops here, releasing the shared borrow... + } + // ...so `consume` can take `&mut self`. + reader.consume(total); + } + got + } + + #[test] + fn one_byte_at_a_time_both_packets_parse() { + let mut wire = Vec::new(); + wire.extend_from_slice(CONNACK); + wire.extend_from_slice(&publish_bytes("sensor/temp", b"21.5")); + + let mut reader = PacketReader::<256>::new(); + let mut got = Vec::new(); + for byte in &wire { + reader.feed(&[*byte]).expect("feed"); + got.extend(drain(&mut reader)); + } + + assert_eq!(got, vec!["connack", "publish"]); + assert_eq!(reader.len, 0, "buffer fully drained"); + } + + #[test] + fn coalesced_chunk_yields_both_packets() { + let mut wire = Vec::new(); + wire.extend_from_slice(CONNACK); + wire.extend_from_slice(&publish_bytes("a/b", b"x")); + + let mut reader = PacketReader::<256>::new(); + reader.feed(&wire).expect("feed"); + + assert_eq!(drain(&mut reader), vec!["connack", "publish"]); + assert_eq!(reader.len, 0); + } + + #[test] + fn every_partial_packet_reads_as_incomplete() { + let bytes = publish_bytes("sensor/temp", b"21.5"); + let mut reader = PacketReader::<256>::new(); + + for cut in 1..bytes.len() { + reader.len = 0; + reader.feed(&bytes[..cut]).expect("feed prefix"); + assert_eq!( + reader.framed_len().expect("a prefix must not error"), + None, + "a strict prefix must read as incomplete, not as a packet" + ); + } + + reader.len = 0; + reader.feed(&bytes).expect("feed whole"); + assert_eq!(reader.framed_len().expect("framing"), Some(bytes.len())); + } + + #[test] + fn two_byte_varint_length_reassembles() { + let bytes = publish_bytes("t", &vec![b'z'; 300]); + assert!(bytes[1] & 128 != 0, "length needs two varint bytes"); + + let mut reader = PacketReader::<512>::new(); + let mut got = Vec::new(); + for byte in &bytes { + reader.feed(&[*byte]).expect("feed"); + got.extend(drain(&mut reader)); + } + + assert_eq!(got, vec!["publish"]); + } + + #[test] + fn a_packet_larger_than_the_buffer_errors_rather_than_stalling() { + let bytes = publish_bytes("t", &vec![b'z'; 300]); + let mut reader = PacketReader::<64>::new(); + + // The header alone is enough to know it will never fit. + reader.feed(&bytes[..4]).expect("feed header"); + assert_eq!( + reader.framed_len(), + Err(PacketReadError::PacketTooLargeForBuffer) + ); + } + + #[test] + fn a_bad_first_header_byte_errors_immediately() { + let mut reader = PacketReader::<64>::new(); + reader.feed(&[0x00]).expect("feed"); + assert_eq!(reader.framed_len(), Err(PacketReadError::InvalidPacketType)); + } +} diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 5143bcf5..735256cc 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -1,79 +1,26 @@ -//! The broker transport seam for the [`Embedded`](crate::connector::Embedded) -//! backend. +//! The broker session loop for the [`Embedded`](crate::connector::Embedded) +//! backend: dial, run one MQTT session over the stream's two halves, wait, +//! repeat. //! -//! Built on `mountain-mqtt`'s own [`Connection`] rather than core's -//! [`ByteStream`](aimdb_core::session::ByteStream): the MQTT client needs -//! `receive_if_ready` — a non-blocking peek — which a byte stream does not -//! express and a TLS session cannot provide (its readiness is two-layered; -//! see the `tls` module). Wrapping core's trait would mean every -//! TLS-like transport faking a capability, so the client's own seam is the -//! honest one. -//! -//! A new runtime supplies MQTT by implementing this once. Anything offering -//! `embedded_io_async::{Read, Write}` plus `ReadReady` — an lwIP socket, say — -//! gets there through `mountain_mqtt::embedded_io_async::ConnectionEmbedded` -//! with no protocol code to touch. +//! Built on core's [`ByteStream`](aimdb_core::session::ByteStream) alone. The +//! MQTT client used to need `receive_if_ready` — a non-blocking peek a byte +//! stream cannot express and a TLS session cannot honestly provide — because +//! the session polled. Nothing polls any more (design 053), so the peek is +//! gone and with it the transport seam that existed to carry it: a runtime that +//! can dial a [`StreamDialer`](aimdb_core::session::StreamDialer) can speak +//! MQTT, with no protocol code and no `embedded-io-async` of its own. -use aimdb_core::session::TransportResult; use core::future::Future; -use mountain_mqtt::packet_client::Connection; - -/// Opens one broker connection per session. -/// -/// The connector calls this once per reconnect cycle, so an implementation -/// must be able to produce a fresh connection each time. -pub trait BrokerTransport { - /// The connection this transport produces. - type Connection: Connection; - - /// Open a connection to the broker. - fn connect(&self) -> impl Future> + Send; -} - -/// Bridges core's [`StreamDialer`](aimdb_core::session::StreamDialer) to -/// [`BrokerTransport`] for any adapter whose stream also offers the -/// `embedded-io-async` trio. -/// -/// This is the path a new runtime takes: implement `StreamDialer` and delegate -/// `Read`/`Write`/`ReadReady` on the stream, and MQTT follows with no code -/// here. TLS does not come this way — its readiness is two-layered, so it -/// implements [`BrokerTransport`] directly. -pub struct SocketTransport { - dialer: D, - host: alloc::string::String, - port: u16, -} - -impl SocketTransport { - /// Dial `host:port` through `dialer` for each broker session. - pub fn new(dialer: D, host: impl Into, port: u16) -> Self { - Self { - dialer, - host: host.into(), - port, - } - } -} - -impl BrokerTransport for SocketTransport -where - D: aimdb_core::session::StreamDialer + Sync, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, -{ - type Connection = mountain_mqtt::embedded_io_async::ConnectionEmbedded; - - async fn connect(&self) -> TransportResult { - let stream = self.dialer.connect(&self.host, self.port).await?; - Ok(mountain_mqtt::embedded_io_async::ConnectionEmbedded::new( - stream, - )) - } -} /// Bridges core's [`Delay`](aimdb_core::session::Delay) to the `DelayNs` the /// MQTT client wants, so the client's timeouts run on the adapter's clock. +/// +/// Only the TLS path still needs it; it goes when TLS joins the same session +/// loop as the plain path. +#[cfg(feature = "embedded-tls")] pub(crate) struct ClientDelay<'a, D>(pub(crate) &'a D); +#[cfg(feature = "embedded-tls")] impl embedded_hal_async::delay::DelayNs for ClientDelay<'_, D> where D: aimdb_core::session::Delay, @@ -122,33 +69,32 @@ impl Future for SendSession { } } -/// The broker session loop: connect, run MQTT until the session ends, wait, -/// repeat. Never returns. +/// Dial, run one session, wait, repeat. Never returns. /// -/// One implementation for every transport. The manager re-subscribes -/// `topics` on each connection, so inbound routing survives a reconnect. +/// One implementation for every transport: the dialer supplies both the stream +/// and the clock. `topics` is re-subscribed on each connection, so inbound +/// routing survives a reconnect. #[allow(clippy::too_many_arguments)] -pub(crate) async fn run_sessions( - transport: T, +pub(crate) async fn run_sessions( + dialer: D, + host: alloc::string::String, + port: u16, topics: alloc::vec::Vec, connection_settings: mountain_mqtt::client::ConnectionSettings<'static>, settings: crate::embedded::manager::Settings, events: alloc::sync::Arc, actions: alloc::sync::Arc, - delay: D, runtime: alloc::sync::Arc, ) -> ! where - T: BrokerTransport, - D: aimdb_core::session::Delay, + D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, { - use mountain_mqtt::client::ClientNoQueue; + use aimdb_core::session::{ByteStream, Delay}; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::embedded::manager::{ - handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, - }; + use crate::embedded::manager::MqttEvent; + use crate::embedded::session_loop::run_session; // Built once and borrowed for the loop; re-sent on every connection. let subscribe_topics: alloc::vec::Vec<(&str, QualityOfService)> = topics @@ -156,58 +102,48 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); - let mut mqtt_buffer = [0u8; crate::embedded::BUFFER_SIZE]; let mut connection_index = 0u32; loop { - let connection = match transport.connect().await { - Ok(connection) => connection, + let mut stream = match dialer.connect(&host, port).await { + Ok(stream) => stream, Err(_e) => { #[cfg(feature = "defmt")] defmt::warn!("MQTT: connect failed, will retry"); - delay.sleep(settings.reconnection_delay).await; + Delay::sleep(&dialer, settings.reconnection_delay).await; continue; } }; - let state = SessionState::new(now_ms(runtime.as_ref())); let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler = - ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); - let mut client = ClientNoQueue::new( - connection, - &mut mqtt_buffer, - mountain_mqtt::embedded_hal_async::DelayEmbedded::new(ClientDelay(&delay)), - settings.response_timeout.as_millis() as u32, - event_handler, - ); - - if let Err(error) = handle_messages( + // The halves live exactly as long as the session that reads and writes + // them, which is why borrowed halves are enough (design 053 §6.1). + let (rx, tx) = stream.split(); + let error = run_session( connection_id, - &mut client, - &state, + rx, + tx, &connection_settings, &subscribe_topics, &events, &actions, &settings, - &delay, + &dialer, runtime.as_ref(), ) - .await - { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT: session errored: {:?}", error); - events - .send(MqttEvent::Disconnected { - connection_id, - error, - }) - .await; - } - - delay.sleep(settings.reconnection_delay).await; + .await; + + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: session errored: {:?}", error); + events + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; + + Delay::sleep(&dialer, settings.reconnection_delay).await; } } diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs new file mode 100644 index 00000000..6473dea9 --- /dev/null +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -0,0 +1,577 @@ +//! The event-driven broker session: three futures in one `select3`. +//! +//! Design 053 §5.1. The stream is split, so reading and writing never contend, +//! and **the only thing ever cancelled is a channel receive**: +//! +//! - [`read_into`] frames nothing and knows no MQTT — it lifts bytes off the +//! socket and hands them on. Never cancelled. +//! - [`write_out`] drains encoded packets to the socket. Never cancelled, which +//! is what keeps `write_all` — which is *not* cancel-safe — out of a `select` +//! arm by construction. +//! - [`client_loop`] selects on two channels and one timer. It owns every piece +//! of client state: the [`PacketReader`], the `ClientState`, the liveness +//! window and the deadlines. Nothing is shared across the three futures, so +//! there is no `RefCell` and no cross-future wakeup. +//! +//! Because no socket read is ever dropped, no partially-read packet is ever +//! discarded — not ours, and not a TLS record reader's. That is what lets the +//! TLS path share this loop. +//! +//! # What replaces the poll +//! +//! Nothing wakes this loop but data, an action, or a deadline. A connected idle +//! session wakes at the ping cadence (2 s) rather than the 100 Hz the previous +//! loop paid, and a QoS 1 publish no longer spins at 1 kHz waiting inline for +//! its PUBACK: the acknowledgement arrives through the read half like any other +//! packet, and the action arm simply stays parked until it does. +//! +//! # Framing division +//! +//! §5.1 sketches `rx_fut` framing whole packets into the channel. This does the +//! division one notch lower — raw chunks cross the channel and `client_loop` +//! frames them — because a packet-granular channel needs a second packet-sized +//! buffer for its slot, and criterion 7 requires the framing buffer, the +//! channel slots and the encode buffer to come out of the existing +//! `BUFFER_SIZE` rather than add to it. Chunks cost two 256-byte buffers +//! instead of two 2 KB ones, which is what pays for the reassembly buffer being +//! as large as it is. `rx_fut` ends up with even less knowledge than §5.1 gives +//! it, which is the direction that section argues for. + +use core::convert::Infallible; +use core::time::Duration; + +use aimdb_core::session::{ByteRead, ByteWrite, Delay}; +use aimdb_core::RuntimeOps; +use alloc::vec::Vec; +use embassy_futures::select::{select3, Either3}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::channel::Channel; + +use mountain_mqtt::client::{ClientError, ConnectionSettings}; +use mountain_mqtt::client_state::{ClientState, ClientStateNoQueue, ClientStateReceiveEvent}; +use mountain_mqtt::codec::mqtt_writer::{MqttBufWriter, MqttLenWriter, MqttWriter}; +use mountain_mqtt::codec::write::Write; +use mountain_mqtt::data::property::ConnectProperty; +use mountain_mqtt::data::quality_of_service::QualityOfService; +use mountain_mqtt::error::{PacketReadError, PacketWriteError}; +use mountain_mqtt::mqtt_manager::ConnectionId; +use mountain_mqtt::packets::connect::Connect; +use mountain_mqtt::packets::packet_generic::PacketGeneric; + +use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, MqttEvent, Settings}; +use crate::embedded::packet_reader::PacketReader; +use crate::embedded::{ + ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, +}; + +/// Bytes lifted off the socket at a time, and the size of one `inbound` slot. +const RX_CHUNK: usize = 256; + +/// The largest MQTT packet the session can receive. +/// +/// The memory budget is criterion 7: the reassembly buffer, the inbound slots +/// and the encode buffer together must not exceed what the old loop's single +/// `mqtt_buffer` cost. `rx`'s scratch plus one inbound slot take `2 * RX_CHUNK` +/// of it; outbound packets are encoded to exactly-sized `Vec`s, the same +/// heap the action channel already uses, so they hold no fixed buffer at all. +const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; + +/// One chunk of freshly read bytes, in flight from the read half to the loop. +type Chunk = heapless::Vec; + +/// Drive one MQTT session over a split stream until an error ends it. +/// +/// Connects, subscribes `subscribe_topics`, then keeps the session alive while +/// dispatching actions and forwarding events. Returns only on failure — the +/// caller reconnects. +/// +/// # Delivery +/// +/// **At most once, at this layer**, unchanged from the polled loop it replaces: +/// an action is taken off `actions` before it is performed, so the one action +/// in flight when a session ends is lost. Everything still queued survives, +/// because `actions` outlives the session. What has changed is *when* a publish +/// is considered failed: a QoS 1 publish no longer blocks the loop waiting for +/// its PUBACK, so a slow broker no longer stops pings. +#[allow(clippy::too_many_arguments)] +pub(crate) async fn run_session( + connection_id: ConnectionId, + rx: R, + tx: W, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Error +where + R: ByteRead, + W: ByteWrite, + D: Delay, +{ + let inbound: Channel = Channel::new(); + // Four slots of a pointer each: enough that a burst of small packets does + // not park the loop, and cheap because the bytes live on the heap. + let outbound: Channel, 4> = Channel::new(); + + let session = client_loop( + connection_id, + &inbound, + &outbound, + connection_settings, + subscribe_topics, + events, + actions, + settings, + delay, + runtime, + ); + + match select3(read_into(rx, &inbound), write_out(tx, &outbound), session).await { + Either3::First(error) => error, + Either3::Second(error) => error, + Either3::Third(Err(error)) => error, + // `client_loop` only ever returns an error. + Either3::Third(Ok(never)) => match never {}, + } +} + +/// Lift bytes off the socket and hand them to the loop. Never cancelled, so a +/// read is never dropped mid-packet. +async fn read_into( + mut rx: R, + inbound: &Channel, +) -> Error { + let mut scratch = [0u8; RX_CHUNK]; + loop { + let n = match rx.read(&mut scratch).await { + // A closed peer is an ended session, not an error condition to sit in. + Ok(0) | Err(_) => return receive_failed(), + Ok(n) => n, + }; + let mut chunk = Chunk::new(); + // `n <= RX_CHUNK` by construction, so this cannot overflow the chunk. + if chunk.extend_from_slice(&scratch[..n]).is_err() { + return receive_failed(); + } + inbound.send(chunk).await; + } +} + +/// Drain encoded packets to the socket. Never cancelled, which is what keeps +/// the non-cancel-safe `write_all` out of a `select` arm. +async fn write_out( + mut tx: W, + outbound: &Channel, 4>, +) -> Error { + loop { + let packet = outbound.receive().await; + if tx.write_all(&packet).await.is_err() || tx.flush().await.is_err() { + return Error::Client(ClientError::PacketWrite(PacketWriteError::ConnectionSend)); + } + } +} + +fn receive_failed() -> Error { + Error::Client(ClientError::PacketRead(PacketReadError::ConnectionReceive)) +} + +/// Everything the session knows, in one future: state, framing, deadlines. +#[allow(clippy::too_many_arguments)] +async fn client_loop( + connection_id: ConnectionId, + inbound: &Channel, + outbound: &Channel, 4>, + connection_settings: &ConnectionSettings<'static>, + subscribe_topics: &[(&str, QualityOfService)], + events: &EventChannel, + actions: &ActionChannel, + settings: &Settings, + delay: &D, + runtime: &dyn RuntimeOps, +) -> Result { + let ping_interval = settings.ping_interval.as_millis() as u64; + let max_silence = settings.connection_event_max_interval.as_millis() as u64; + let stabilisation = settings.stabilisation_interval.as_millis() as u64; + let response_timeout = settings.response_timeout.as_millis() as u64; + + let mut state = ClientStateNoQueue::new(); + let mut reader = PacketReader::::new(); + + let start = now_ms(runtime); + let mut last_ack_ms = start; + let mut last_ping_ms = start; + // Set while an acknowledgement is outstanding, so a broker that never + // answers a CONNECT, SUBSCRIBE or QoS 1 PUBLISH is caught by + // `response_timeout` rather than only by the liveness window. + let mut waiting_since: Option = Some(start); + let mut stable_at: Option = None; + let mut connected = false; + let mut next_topic = 0usize; + + // CONNECT goes out first; its CONNACK is what flips `connected`. + { + let mut properties = heapless::Vec::new(); + // Topic aliases are declined: honouring them would mean storing the + // server's topic names for the life of the connection. + let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); + let connect: Connect<'_, 1, 0> = Connect::new( + connection_settings.keep_alive(), + *connection_settings.username(), + *connection_settings.password(), + connection_settings.client_id(), + true, + None, + properties, + ); + state.connect(&connect).map_err(client_error)?; + queue(outbound, encode(&connect)?); + } + + loop { + let now = now_ms(runtime); + + // --- deadlines, checked before anything parks ---------------------- + + if now.saturating_sub(last_ack_ms) > max_silence { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: broker unresponsive"); + return Err(Error::MqttServerUnresponsive); + } + + if let Some(since) = waiting_since { + if now.saturating_sub(since) > response_timeout { + return Err(Error::Client(ClientError::TimeoutOnResponsePacket)); + } + } + + if let Some(at) = stable_at { + if now >= at { + stable_at = None; + events + .send(MqttEvent::ConnectionStable { connection_id }) + .await; + } + } + + if connected && now.saturating_sub(last_ping_ms) >= ping_interval { + last_ping_ms = now; + let ping = state.send_ping().map_err(client_error)?; + // A ping dropped because the write half is backed up is not worth + // ending the session over: the next deadline tries again, and if + // the link really is gone the liveness window closes it. + queue(outbound, encode(&ping)?); + } + + // Subscriptions go out one at a time: `ClientStateNoQueue` tracks a + // single outstanding request, so the next one waits for this SUBACK. + if connected && !state.waiting_for_responses() && next_topic < subscribe_topics.len() { + let (topic, qos) = subscribe_topics[next_topic]; + let packet = state.subscribe_packet(topic, qos).map_err(client_error)?; + queue(outbound, encode(&packet)?); + state.subscribe_update(&packet).map_err(client_error)?; + next_topic += 1; + // `continue` skips the bottom-of-loop bookkeeping, so arm the + // response deadline here: a broker that never SUBACKs should be + // caught by `response_timeout`, not only by the liveness window. + waiting_since = Some(now); + continue; + } + + // --- park until something happens ---------------------------------- + + // The action arm is armed only when a publish can actually be sent: + // connected, nothing awaiting acknowledgement (§6.4's single in-flight + // slot), every subscription placed, and room to queue the bytes. This + // is what replaces the old inline wait for a PUBACK — the ping and + // liveness deadlines keep running while it is parked. + let action_ready = connected + && !state.waiting_for_responses() + && next_topic >= subscribe_topics.len() + && !outbound.is_full(); + let action_arm = async { + if !action_ready { + core::future::pending::<()>().await; + } + actions.receive().await + }; + + let sleep_for = Duration::from_millis(next_deadline( + now, + connected, + last_ping_ms + ping_interval, + last_ack_ms + max_silence, + stable_at, + waiting_since.map(|since| since + response_timeout), + )); + + match select3(inbound.receive(), action_arm, delay.sleep(sleep_for)).await { + Either3::First(chunk) => { + reader.feed(&chunk).map_err(client_error)?; + drain_packets( + &mut reader, + &mut state, + connection_id, + outbound, + events, + runtime, + &mut last_ack_ms, + &mut connected, + &mut stable_at, + stabilisation, + ) + .await?; + } + Either3::Second(action) => { + perform(action, &mut state, outbound)?; + } + // The timer fired: the top of the loop re-evaluates every deadline. + Either3::Third(()) => {} + } + + waiting_since = match (state.waiting_for_responses(), waiting_since) { + (true, Some(since)) => Some(since), + (true, None) => Some(now_ms(runtime)), + (false, _) => None, + }; + } +} + +/// Parse and dispatch every whole packet the reader now holds. +#[allow(clippy::too_many_arguments)] +async fn drain_packets( + reader: &mut PacketReader, + state: &mut ClientStateNoQueue, + connection_id: ConnectionId, + outbound: &Channel, 4>, + events: &EventChannel, + runtime: &dyn RuntimeOps, + last_ack_ms: &mut u64, + connected: &mut bool, + stable_at: &mut Option, + stabilisation: u64, +) -> Result<(), Error> { + while let Some(total) = reader.framed_len().map_err(client_error)? { + // The packet borrows the reader's buffer, so everything that outlives + // it — the response bytes, the application event — is made owned inside + // this scope. `consume` can then take `&mut`. + let (response, received) = { + let packet: PacketGeneric<'_, MAX_PROPERTIES, 0, 0> = + reader.parse(total).map_err(client_error)?; + + // Produce the PUBACK before the state update, as upstream does, so + // the two cannot disagree about what was acknowledged. + let response = match state + .receive_produce_response(&packet) + .map_err(client_error)? + { + Some(puback) => Some(encode(&puback)?), + None => None, + }; + + let event = state.receive(packet).map_err(client_error)?; + (response, Received::of(event, connection_id)?) + }; + reader.consume(total); + + if let Some(bytes) = response { + queue(outbound, bytes); + } + + // Every packet the state accepted proves the broker is alive. + *last_ack_ms = now_ms(runtime); + + // The CONNACK is whatever moved the state to `Connected`; nothing else + // does, so there is no need to inspect packet types for it. + if !*connected && matches!(state, ClientStateNoQueue::Connected(_)) { + *connected = true; + *stable_at = Some(now_ms(runtime) + stabilisation); + events.send(MqttEvent::Connected { connection_id }).await; + } + + if let Received::Event(event) = received { + events.send(event).await; + } + } + Ok(()) +} + +/// What a received packet leaves for the loop to do, owned so the reader's +/// buffer can be compacted first. +enum Received { + /// An acknowledgement: liveness only, nothing to forward. + Ack, + /// Something the application asked to hear about. + Event(MqttEvent), +} + +impl Received { + fn of( + event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>, + connection_id: ConnectionId, + ) -> Result { + Ok(match event { + ClientStateReceiveEvent::Ack => Self::Ack, + + ClientStateReceiveEvent::Publish { publish } + | ClientStateReceiveEvent::PublishAndPuback { publish, .. } => { + if publish.topic_name().is_empty() { + return Err(Error::Client( + ClientError::EmptyTopicNameWithAliasesDisabled, + )); + } + let message = publish.into(); + let event = AimdbMqttEvent::from_application_message(&message) + .map_err(|e| Error::Client(ClientError::EventHandler(e)))?; + Self::Event(MqttEvent::ApplicationEvent { + connection_id, + event, + }) + } + + ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { + granted_qos, + maximum_qos, + } => Self::Event(MqttEvent::SubscriptionGrantedBelowMaximumQos { + connection_id, + granted_qos, + maximum_qos, + }), + + ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers => { + Self::Event(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) + } + + ClientStateReceiveEvent::NoSubscriptionExisted => { + Self::Event(MqttEvent::NoSubscriptionExisted { connection_id }) + } + + ClientStateReceiveEvent::Disconnect { disconnect } => { + return Err(Error::Client(ClientError::Disconnected( + *disconnect.reason_code(), + ))) + } + }) + } +} + +/// Turn one queued action into a packet on the wire. +/// +/// Sent before the state update, as upstream does: a state that believes a +/// publish is in flight when it is not would park the action arm forever. +fn perform( + action: AimdbMqttAction, + state: &mut ClientStateNoQueue, + outbound: &Channel, 4>, +) -> Result<(), Error> { + match action { + AimdbMqttAction::Publish { + topic, + payload, + qos, + retain, + } => { + let packet = state + .publish_packet(&topic, &payload, qos, retain) + .map_err(client_error)?; + queue(outbound, encode(&packet)?); + state.publish_update(&packet).map_err(client_error)?; + } + AimdbMqttAction::Subscribe { topic, qos } => { + let packet = state.subscribe_packet(&topic, qos).map_err(client_error)?; + queue(outbound, encode(&packet)?); + state.subscribe_update(&packet).map_err(client_error)?; + } + } + Ok(()) +} + +/// Encode a packet to exactly its own length. +/// +/// Two passes over a counting writer and then a real one, which is how the +/// codec measures a packet anyway — the alternative is a fixed buffer sized for +/// the largest packet anyone might send, which is what criterion 7 is trying to +/// avoid. The bytes are the same heap the action channel already carries. +fn encode(packet: &P) -> Result, Error> { + let mut len_writer = MqttLenWriter::new(); + len_writer.put(packet).map_err(write_error)?; + + let mut bytes = alloc::vec![0u8; len_writer.position()]; + let mut writer = MqttBufWriter::new(&mut bytes); + writer.put(packet).map_err(write_error)?; + Ok(bytes) +} + +/// Queue encoded bytes for the write half. +/// +/// Never blocks: the action arm is gated on there being room, and a ping or +/// PUBACK dropped because the write half is backed up is recovered by the next +/// deadline or by the broker redelivering. Blocking here instead would park the +/// loop that has to notice the link is gone. +fn queue(outbound: &Channel, 4>, bytes: Vec) { + if outbound.try_send(bytes).is_err() { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: write queue full, packet dropped"); + } +} + +/// Milliseconds to sleep before the earliest armed deadline. +fn next_deadline( + now: u64, + connected: bool, + ping_at: u64, + liveness_at: u64, + stable_at: Option, + response_at: Option, +) -> u64 { + let mut earliest = liveness_at; + if connected { + earliest = earliest.min(ping_at); + } + if let Some(at) = stable_at { + earliest = earliest.min(at); + } + if let Some(at) = response_at { + earliest = earliest.min(at); + } + earliest.saturating_sub(now).max(1) +} + +fn client_error(error: impl Into) -> Error { + Error::Client(error.into()) +} + +fn write_error(error: PacketWriteError) -> Error { + Error::Client(ClientError::PacketWrite(error)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_buffer_budget_is_what_the_old_loop_cost() { + // Criterion 7: the reassembly buffer plus the read scratch plus one + // inbound slot come out of `BUFFER_SIZE`, not in addition to it. + assert_eq!(PACKET_BUFFER_SIZE + 2 * RX_CHUNK, BUFFER_SIZE); + } + + #[test] + fn the_earliest_armed_deadline_wins() { + // Liveness only, before the connection is up. + assert_eq!(next_deadline(0, false, 100, 500, None, None), 500); + // Once connected the ping is usually nearest. + assert_eq!(next_deadline(0, true, 100, 500, None, None), 100); + // Stabilisation and the response timeout arm independently. + assert_eq!(next_deadline(0, true, 100, 500, Some(50), None), 50); + assert_eq!(next_deadline(0, true, 100, 500, None, Some(20)), 20); + } + + #[test] + fn a_deadline_in_the_past_still_sleeps_a_tick() { + // Never zero: a zero-length sleep would spin the loop. + assert_eq!(next_deadline(1_000, true, 100, 500, None, None), 1); + } +} diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs new file mode 100644 index 00000000..a070ace6 --- /dev/null +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -0,0 +1,618 @@ +//! What the event-driven session promises that the polled one could not +//! (design 053 §10, criteria 1, 2, 4 and 11). +//! +//! These are behavioural, not smoke: every one of them passes trivially on a +//! loop that polls at 100 Hz and blocks inline for acknowledgements, or fails +//! outright on it. The broker here is scripted rather than the shared +//! `common::fake_broker`, because each test needs to control *when* it answers +//! — mid-packet, late, or not at all. +#![cfg(feature = "_test-tokio-broker")] + +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aimdb_core::session::{Delay, StreamDialer, TransportResult}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; + +// Each test binary defines 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:us}", 0); + +/// Real wall-clock time for `embassy-time`, which the test's dependency graph +/// links even though the session loop itself runs on core's `Delay`. +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); + +// --------------------------------------------------------------------------- +// A dialer that counts what the session sleeps on. +// --------------------------------------------------------------------------- + +/// `TokioNet::tcp()` with a tally of every `Delay::sleep` the connector asks +/// for. The connector takes its clock from the dialer, so this is the seam +/// where "how often does the session wake?" is observable at all. +#[derive(Clone)] +struct CountingDialer { + inner: aimdb_tokio_adapter::net::TokioTcpDialer, + sleeps: Arc, +} + +impl CountingDialer { + fn new() -> Self { + Self { + inner: aimdb_tokio_adapter::net::TokioNet::tcp(), + sleeps: Arc::new(AtomicUsize::new(0)), + } + } +} + +impl StreamDialer for CountingDialer { + type Stream = ::Stream; + + fn connect<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future> + Send + 'a { + self.inner.connect(host, port) + } +} + +impl Delay for CountingDialer { + fn sleep(&self, d: Duration) -> impl Future + Send { + self.sleeps.fetch_add(1, Ordering::Relaxed); + Delay::sleep(&self.inner, d) + } +} + +// --------------------------------------------------------------------------- +// A scripted broker: the same wire format as `common`, but the test decides +// when each answer goes out. +// --------------------------------------------------------------------------- + +/// What the scripted broker saw, and when. +#[derive(Default)] +struct Log { + pings: usize, + /// Client publishes, as (topic, payload). + publishes: Vec<(String, Vec)>, + /// Pings that arrived while the broker was deliberately stalling. + pings_during_stall: usize, + /// Client publishes that arrived while the broker was stalling. + publishes_during_stall: usize, +} + +fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 128; + } + out.push(byte); + if n == 0 { + return; + } + } +} + +/// An MQTT 5 PUBLISH at QoS 0. +fn publish_packet(topic: &str, payload: &[u8]) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.push(0x00); // no properties + rest.extend_from_slice(payload); + + let mut packet = vec![0x30]; + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet +} + +/// Read one packet: header byte, varint remaining length, body. +async fn read_one(socket: &mut S) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + let mut body = vec![0u8; remaining]; + socket.read_exact(&mut body).await.ok()?; + Some((first, body)) +} + +/// Pull the topic and payload out of a client PUBLISH, and its packet id when +/// it carries one (QoS > 0). +fn parse_publish(first: u8, body: &[u8]) -> Option<(String, Vec, Option<[u8; 2]>)> { + let topic_len = u16::from_be_bytes([*body.first()?, *body.get(1)?]) as usize; + let topic = String::from_utf8_lossy(body.get(2..2 + topic_len)?).into_owned(); + let mut i = 2 + topic_len; + + let packet_id = if (first >> 1) & 0x03 > 0 { + let id = [*body.get(i)?, *body.get(i + 1)?]; + i += 2; + Some(id) + } else { + None + }; + + // MQTT 5 property length (always short here). + let property_len = *body.get(i)? as usize; + i += 1 + property_len; + + Some((topic, body.get(i..)?.to_vec(), packet_id)) +} + +/// How the scripted broker should misbehave after it has SUBACKed. +#[derive(Clone, Copy)] +enum Script { + /// Answer nothing but pings: an idle, healthy session. + Idle, + /// Push a PUBLISH split in two with `gap` between the halves. + SplitPublish { gap: Duration }, + /// Hold every PUBACK back by `delay`. + SlowPuback { delay: Duration }, + /// Push inbound PUBLISHes as fast as they will go, for `duration`. + Flood { duration: Duration }, +} + +/// The broker's write side. +/// +/// While `hold` is `Some`, the broker has a packet half-written and must not +/// put anything else on the wire: a byte stream carries packets in order, so +/// injecting a PUBACK between the halves of a PUBLISH would corrupt the +/// framing rather than test it. Held bytes go out behind the packet's tail — +/// which is exactly what a sender whose peer is slow ends up doing. +struct Wire { + writer: tokio::net::tcp::OwnedWriteHalf, + hold: Option>, +} + +type Writer = Arc>; + +async fn send(writer: &Writer, bytes: &[u8]) -> bool { + let mut wire = writer.lock().await; + match wire.hold.as_mut() { + Some(held) => { + held.extend_from_slice(bytes); + true + } + None => wire.writer.write_all(bytes).await.is_ok(), + } +} + +/// Serve exactly one connection, following `script`. +/// +/// The socket is split and every scripted delay runs in its own task, so the +/// broker **never stops reading**. That is what makes the stall counters mean +/// anything: a ping that arrives while the broker is stalling has to be read +/// and counted while the stall is still open, not afterwards. +async fn scripted_broker(listener: TcpListener, log: Arc>, script: Script) { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let (mut reader, writer) = socket.into_split(); + let writer: Writer = Arc::new(tokio::sync::Mutex::new(Wire { writer, hold: None })); + + // Open while the broker is deliberately withholding something. + let stalling = Arc::new(AtomicUsize::new(0)); + + loop { + let Some((first, body)) = read_one(&mut reader).await else { + return; + }; + let in_stall = stalling.load(Ordering::Relaxed) == 1; + + match first >> 4 { + // CONNECT -> CONNACK + 1 => { + if !send(&writer, &[0x20, 0x03, 0x00, 0x00, 0x00]).await { + return; + } + } + // SUBSCRIBE -> SUBACK, then run the script. + 8 => { + let packet_id = [body[0], body[1]]; + if !send( + &writer, + &[0x90, 0x04, packet_id[0], packet_id[1], 0x00, 0x01], + ) + .await + { + return; + } + + match script { + Script::Idle | Script::SlowPuback { .. } => {} + Script::SplitPublish { gap } => { + // Half a packet, a long silence, then the rest. The + // polled loop's `receive_if_ready` commits to reading + // the whole packet and parks here. + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + let packet = publish_packet("sensors/temperature", b"21"); + let cut = packet.len() / 2; + { + let mut wire = writer.lock().await; + if wire.writer.write_all(&packet[..cut]).await.is_err() { + return; + } + // Nothing else may reach the wire until the + // tail does. + wire.hold = Some(Vec::new()); + } + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(gap).await; + stalling.store(0, Ordering::Relaxed); + + let mut wire = writer.lock().await; + let held = wire.hold.take().unwrap_or_default(); + if wire.writer.write_all(&packet[cut..]).await.is_err() { + return; + } + let _ = wire.writer.write_all(&held).await; + }); + } + Script::Flood { duration } => { + let writer = writer.clone(); + tokio::spawn(async move { + let deadline = tokio::time::Instant::now() + duration; + let packet = publish_packet("sensors/temperature", b"7"); + while tokio::time::Instant::now() < deadline { + // The lock is taken and released per packet, so + // PUBACKs and PINGRESPs interleave with the + // flood rather than queueing behind all of it. + if !send(&writer, &packet).await { + return; + } + tokio::task::yield_now().await; + } + }); + } + } + } + // PUBLISH from the client. + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body) else { + return; + }; + { + let mut log = log.lock().unwrap(); + log.publishes.push((topic, payload)); + if in_stall { + log.publishes_during_stall += 1; + } + } + if let Some(id) = packet_id { + match script { + // Acknowledge late, in its own task, with the stall + // window open: a ping arriving meanwhile is the + // assertion, and the read loop has to stay live to see + // it. + Script::SlowPuback { delay } => { + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(delay).await; + stalling.store(0, Ordering::Relaxed); + send(&writer, &[0x40, 0x02, id[0], id[1]]).await; + }); + } + _ => { + if !send(&writer, &[0x40, 0x02, id[0], id[1]]).await { + return; + } + } + } + } + } + // PINGREQ -> PINGRESP + 12 => { + { + let mut log = log.lock().unwrap(); + log.pings += 1; + if in_stall { + log.pings_during_stall += 1; + } + } + if !send(&writer, &[0xD0, 0x00]).await { + return; + } + } + 14 => return, + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// The database under test. +// --------------------------------------------------------------------------- + +/// Build an AimDb with one inbound record, and optionally an outbound record +/// that publishes every `publish_every`. +async fn build_db( + port: u16, + dialer: CountingDialer, + publish: Option<(Duration, u8)>, +) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) + .transport(dialer) + .with_client_id("session-loop"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + if let Some((every, qos)) = publish { + let destination = format!("mqtt://sensors/uptime?qos={qos}"); + builder.configure::("uptime", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + let mut n = 0u64; + loop { + producer.produce(n); + n += 1; + tokio::time::sleep(every).await; + } + }) + .link_to(&destination) + .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) + .finish(); + }); + } + + builder.build().await.expect("build db") +} + +// --------------------------------------------------------------------------- +// Criterion 1 — an idle session wakes at the ping cadence, not at 100 Hz. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_idle_session_wakes_at_the_ping_cadence() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + let dialer = CountingDialer::new(); + let sleeps = dialer.sleeps.clone(); + let (_db, runner) = build_db(port, dialer, None).await; + + // Long enough to span several of the old loop's 10 ms polls, and to cover + // the 2 s ping cadence at least once. + const WINDOW: Duration = Duration::from_secs(3); + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = scripted_broker(listener, log.clone(), Script::Idle) => panic!("the broker returned"), + _ = tokio::time::sleep(WINDOW) => {} + } + + let woke = sleeps.load(Ordering::Relaxed); + let pings = log.lock().unwrap().pings; + + assert!(pings >= 1, "the session must still ping; saw {pings}"); + // The old loop slept `poll_interval` (10 ms) every turn: ~300 wakes in this + // window, plus a 1 kHz burst per acknowledgement. The new one arms a timer + // per deadline — ping, liveness, stabilisation — so a generous ceiling is + // still two orders of magnitude below the poll. + assert!( + woke < 30, + "an idle session woke {woke} times in {WINDOW:?}; the polled loop it \ + replaces would have woken ~{}", + WINDOW.as_millis() / 10 + ); +} + +// --------------------------------------------------------------------------- +// Criterion 2 — a partial packet stops neither pings nor publishes. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_partial_packet_stops_neither_pings_nor_publishes() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Longer than the 2 s ping interval, so a ping falls due while the packet + // is half-delivered — the case the polled loop wedges on. + const GAP: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 0))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = scripted_broker(listener, log.clone(), Script::SplitPublish { gap: GAP }) => { + panic!("the broker returned") + } + received = async { inbound.recv().await.expect("inbound record") } => received, + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings ({} mid-stall), {} publishes ({} mid-stall)", + log.pings, log.pings_during_stall, log.publishes.len(), log.publishes_during_stall + ); + } + }; + + let log = log.lock().unwrap(); + assert!( + log.pings_during_stall >= 1, + "a ping must go out while a packet is half-delivered; saw {} of {} total", + log.pings_during_stall, + log.pings + ); + assert!( + log.publishes_during_stall >= 1, + "publishes must keep flowing while a packet is half-delivered; saw {} of {} total", + log.publishes_during_stall, + log.publishes.len() + ); + assert_eq!( + received, 21, + "the packet must still be delivered once its tail arrives" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 4 — a QoS 1 publish survives a slow broker without blocking pings. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_puback_does_not_block_the_ping() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Again longer than the ping interval: the old loop waited for this PUBACK + // inline, at 1 kHz, with the ping behind it. + const ACK_DELAY: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (_db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 1))).await; + + let until_ping_during_stall = async { + loop { + if log.lock().unwrap().pings_during_stall >= 1 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = scripted_broker(listener, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + panic!("the broker returned") + } + _ = until_ping_during_stall => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + let log = log.lock().unwrap(); + panic!("watchdog: {} pings, {} publishes", log.pings, log.publishes.len()); + } + } + + let log = log.lock().unwrap(); + assert!( + !log.publishes.is_empty(), + "the publish under acknowledgement must have reached the broker" + ); + assert!( + log.pings_during_stall >= 1, + "the ping must go out while a QoS 1 publish waits for its PUBACK" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 11 — neither direction starves the other. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn outbound_keeps_moving_under_an_inbound_flood() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + const FLOOD: Duration = Duration::from_secs(2); + + let dialer = CountingDialer::new(); + // Produce far faster than the flood's own cadence, so the outbound path is + // saturated too and the two are genuinely competing. + let (db, runner) = build_db(port, dialer, Some((Duration::from_millis(2), 1))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let delivered = Arc::new(AtomicUsize::new(0)); + let counting = { + let delivered = delivered.clone(); + async move { + while inbound.recv().await.is_ok() { + delivered.fetch_add(1, Ordering::Relaxed); + } + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = scripted_broker(listener, log.clone(), Script::Flood { duration: FLOOD }) => { + panic!("the broker returned") + } + _ = counting => panic!("the inbound record closed"), + _ = tokio::time::sleep(FLOOD + Duration::from_secs(1)) => {} + } + + let log = log.lock().unwrap(); + assert!( + log.publishes.len() >= 50, + "outbound starved under an inbound flood: only {} publishes got through", + log.publishes.len() + ); + assert!( + delivered.load(Ordering::Relaxed) >= 10, + "inbound starved: only {} messages were delivered", + delivered.load(Ordering::Relaxed) + ); +} From 734e38dc29c6f5719ba45464acf00983d9cf01eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 13:12:19 +0000 Subject: [PATCH 28/48] Implement scripted broker and TLS session tests for MQTT connector - Added a scripted broker implementation in `common/mod.rs` to simulate various broker behaviors during tests. - Introduced `CountingDialer` to track sleep calls made by the session. - Refactored session loop tests in `session_loop.rs` to utilize the new scripted broker. - Created a new test file `tls_session.rs` to validate MQTT over TLS, ensuring session behavior aligns with design criteria. - Implemented tests for idle session wake-up, handling partial packets, and ensuring pings are sent during slow PUBACKs over TLS. --- Makefile | 11 + aimdb-mqtt-connector/src/embedded/manager.rs | 245 +---------- aimdb-mqtt-connector/src/embedded/mod.rs | 87 +--- aimdb-mqtt-connector/src/embedded/session.rs | 20 - .../src/embedded/session_loop.rs | 28 +- aimdb-mqtt-connector/src/embedded/tls.rs | 385 +++++++++++------- aimdb-mqtt-connector/tests/common/mod.rs | 280 +++++++++++++ aimdb-mqtt-connector/tests/session_loop.rs | 325 +-------------- aimdb-mqtt-connector/tests/tls_session.rs | 331 +++++++++++++++ 9 files changed, 916 insertions(+), 796 deletions(-) create mode 100644 aimdb-mqtt-connector/tests/tls_session.rs diff --git a/Makefile b/Makefile index d28b25c7..4629a9c4 100644 --- a/Makefile +++ b/Makefile @@ -244,6 +244,12 @@ test: cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity @printf "$(YELLOW) → Testing MQTT connector (mqtts:// against a pinned self-signed root)$(NC)\n" cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker + @printf "$(YELLOW) → Testing MQTT connector (event-driven session: wake cadence, partial packets, QoS 1)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop + @printf "$(YELLOW) → Testing MQTT connector (the same criteria over mqtts://)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session + @printf "$(YELLOW) → Testing MQTT connector (no_std unit tests: framing, deadlines, TLS duplex)$(NC)\n" + cargo test --package aimdb-mqtt-connector --no-default-features --features "embedded-tls,critical-section-std-impl" --lib fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -387,6 +393,11 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-backend-parity" --test backend_parity -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (mqtts:// host smoke)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (event-driven session criteria)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop -- -D warnings + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (no_std unit tests)$(NC)\n" + cargo clippy --package aimdb-mqtt-connector --no-default-features --features "embedded-tls,critical-section-std-impl" --lib --tests -- -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-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index bfc8d55c..9c77f091 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,16 +1,13 @@ -//! Session settings, the events a session reports, and — until TLS joins the -//! event-driven loop — the polled pump that keeps one TLS connection alive. +//! Session cadence, the events a session reports, and the channels it reports +//! them over. //! //! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and //! source are plain `Connector`/`Source` impls with no force-`Send` wrapper. -//! Time comes from core's [`aimdb_core::session::Delay`] and the -//! runtime's monotonic clock, so nothing here names an executor. +//! Time comes from core's [`aimdb_core::session::Delay`] and the runtime's +//! monotonic clock, so nothing here names an executor. use core::time::Duration; -#[cfg(feature = "embedded-tls")] -use core::cell::RefCell; - use aimdb_core::RuntimeOps; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; @@ -19,18 +16,6 @@ use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packets::publish::ApplicationMessage; -// The TLS path still drives `ClientNoQueue` through the polled loop below; the -// plain path drives `ClientState` directly (design 053 §6.4). Everything gated -// on `embedded-tls` in this module goes when TLS joins the same loop. -#[cfg(feature = "embedded-tls")] -use aimdb_core::session::Delay; -#[cfg(feature = "embedded-tls")] -use embassy_sync::blocking_mutex::Mutex as BlockingMutex; -#[cfg(feature = "embedded-tls")] -use mountain_mqtt::client::{Client, ClientReceivedEvent, ConnectionSettings, EventHandler}; -#[cfg(feature = "embedded-tls")] -use mountain_mqtt::mqtt_manager::MqttOperations; - /// The event channel: broker session to `pump_source`. pub(crate) type EventChannel = Channel, Q>; @@ -83,8 +68,6 @@ pub struct Settings { pub connection_event_max_interval: Duration, /// Wait between a failed session and the next dial. pub reconnection_delay: Duration, - /// Delay applied to each pump iteration. - pub poll_interval: Duration, /// Maximum round-trip wait for a packet that expects a response. pub response_timeout: Duration, /// How long a connection must hold before it counts as stable. @@ -97,7 +80,6 @@ impl Default for Settings { ping_interval: Duration::from_millis(2_000), connection_event_max_interval: Duration::from_millis(10_000), reconnection_delay: Duration::from_millis(2_000), - poll_interval: Duration::from_millis(10), response_timeout: Duration::from_millis(5_000), stabilisation_interval: Duration::from_millis(5_000), } @@ -151,222 +133,3 @@ pub enum MqttEvent { connection_id: ConnectionId, }, } -/// Per-connection bookkeeping, shared between the pump and its event handler. -/// -/// The blocking mutex is what makes `&SessionState` `Send`: a bare `RefCell` -/// is not `Sync`, so a session future holding one could not be boxed as the -/// runner requires. Every lock is a straight-line read or write, never held -/// across an `await`. -#[cfg(feature = "embedded-tls")] -pub(crate) struct SessionState { - inner: BlockingMutex>, -} - -#[cfg(feature = "embedded-tls")] -struct Inner { - /// When the broker last proved it was alive. - last_connection_event_ms: u64, -} - -#[cfg(feature = "embedded-tls")] -impl SessionState { - /// Fresh state for a new connection; the liveness window starts now. - pub(crate) fn new(now_ms: u64) -> Self { - Self { - inner: BlockingMutex::new(RefCell::new(Inner { - last_connection_event_ms: now_ms, - })), - } - } - - fn record_connection_event(&self, now_ms: u64) { - self.inner - .lock(|state| state.borrow_mut().last_connection_event_ms = now_ms); - } - - fn last_connection_event_ms(&self) -> u64 { - self.inner - .lock(|state| state.borrow().last_connection_event_ms) - } -} -/// Forwards received MQTT events onto the event channel and refreshes the -/// liveness timestamp on every broker acknowledgement. -#[cfg(feature = "embedded-tls")] -pub(crate) struct ChannelEventHandler<'a, E, const P: usize, const Q: usize> -where - E: FromApplicationMessage

+ Clone, -{ - connection_id: ConnectionId, - events: &'a EventChannel, - state: &'a SessionState, - runtime: &'a dyn RuntimeOps, -} - -#[cfg(feature = "embedded-tls")] -impl<'a, E, const P: usize, const Q: usize> ChannelEventHandler<'a, E, P, Q> -where - E: FromApplicationMessage

+ Clone, -{ - pub(crate) fn new( - connection_id: ConnectionId, - events: &'a EventChannel, - state: &'a SessionState, - runtime: &'a dyn RuntimeOps, - ) -> Self { - Self { - connection_id, - events, - state, - runtime, - } - } -} - -#[cfg(feature = "embedded-tls")] -impl EventHandler

for ChannelEventHandler<'_, E, P, Q> -where - E: FromApplicationMessage

+ Clone, -{ - async fn handle_event( - &mut self, - event: ClientReceivedEvent<'_, P>, - ) -> Result<(), EventHandlerError> { - let connection_id = self.connection_id; - match event { - ClientReceivedEvent::ApplicationMessage(message) => { - let event = E::from_application_message(&message)?; - self.events - .send(MqttEvent::ApplicationEvent { - connection_id, - event, - }) - .await; - } - ClientReceivedEvent::Ack => { - self.state.record_connection_event(now_ms(self.runtime)); - } - ClientReceivedEvent::SubscriptionGrantedBelowMaximumQos { - granted_qos, - maximum_qos, - } => { - self.events - .send(MqttEvent::SubscriptionGrantedBelowMaximumQos { - connection_id, - granted_qos, - maximum_qos, - }) - .await - } - ClientReceivedEvent::PublishedMessageHadNoMatchingSubscribers => { - self.events - .send(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) - .await - } - ClientReceivedEvent::NoSubscriptionExisted => { - self.events - .send(MqttEvent::NoSubscriptionExisted { connection_id }) - .await - } - } - Ok(()) - } -} - -/// Drive one MQTT session until an error ends it: connect, subscribe -/// `subscribe_topics`, then keep it alive while dispatching actions and -/// forwarding events. -/// -/// `subscribe_topics` is re-sent on every call, i.e. once per connection, so -/// inbound routing survives a reconnect. -/// -/// # Delivery -/// -/// **At most once, at this layer.** An action is taken off `actions` before it -/// is performed, so the one action in flight when the session ends is lost; -/// everything still queued survives, because `actions` outlives the session. -/// The action logs what it dropped before the error propagates. -/// -/// Resending is deliberately not done here. The window is narrow — a dead link -/// is normally found by the 10 ms poll or the 2 s ping, not by a publish — and -/// the case where a publish *is* the detector is a response timeout, where the -/// broker has most likely already received the message and a resend would -/// duplicate it. This layer cannot tell a telemetry sample (resend is -/// pointless, a fresher value is already queued behind it) from a command -/// (resend may be actively wrong). An application that needs at-least-once -/// knows which it has, and can re-produce on [`MqttEvent::Connected`]. -#[cfg(feature = "embedded-tls")] -#[allow(clippy::too_many_arguments)] -pub(crate) async fn handle_messages<'a, A, C, E, D, const P: usize, const Q: usize>( - connection_id: ConnectionId, - client: &mut C, - state: &SessionState, - connection_settings: &ConnectionSettings<'static>, - subscribe_topics: &[(&str, QualityOfService)], - events: &EventChannel, - actions: &ActionChannel, - settings: &Settings, - delay: &D, - runtime: &dyn RuntimeOps, -) -> Result<(), Error> -where - C: Client<'a>, - A: MqttOperations + Clone, - E: FromApplicationMessage

+ Clone, - D: Delay, -{ - client.connect(connection_settings).await?; - events.send(MqttEvent::Connected { connection_id }).await; - - for (topic, qos) in subscribe_topics { - client.subscribe(topic, *qos).await?; - } - - let ping_interval = settings.ping_interval.as_millis() as u64; - let stabilisation_interval = settings.stabilisation_interval.as_millis() as u64; - let max_silence = settings.connection_event_max_interval.as_millis() as u64; - - let mut connected_at = Some(now_ms(runtime)); - let mut last_ping_ms = now_ms(runtime); - - loop { - delay.sleep(settings.poll_interval).await; - let now = now_ms(runtime); - - if now.saturating_sub(last_ping_ms) > ping_interval { - last_ping_ms = now; - client.send_ping().await?; - } - - if let Some(since) = connected_at { - if now.saturating_sub(since) > stabilisation_interval { - connected_at = None; - events - .send(MqttEvent::ConnectionStable { connection_id }) - .await; - } - } - - if now.saturating_sub(state.last_connection_event_ms()) > max_silence { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT: broker unresponsive"); - return Err(Error::MqttServerUnresponsive); - } - - // Poll with no delay while packets are waiting. - while client.poll(false).await? {} - - // A failed action ends the session, and the action is gone with it — - // see this function's "Delivery" note. `is_retry` is always `false`: - // nothing is ever performed twice. - while let Ok(mut action) = actions.try_receive() { - action - .perform( - client, - connection_settings.client_id(), - connection_id, - false, - ) - .await?; - } - } -} diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 33e04134..98509fd4 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -4,7 +4,7 @@ //! [`pump_source`] directly — the session channels are `Sync`, so nothing //! force-`Send` stands between them and the runner. This module contributes //! the connector builder, the `MqttSink`/`MqttSource` over those channels, and -//! the `MqttOperations`/`FromApplicationMessage` glue. +//! the actions and events that cross them. //! //! # Usage //! @@ -54,12 +54,6 @@ use aimdb_embassy_adapter::connectors::into_box_future; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; -// Named only by the TLS path's `MqttOperations` impl, which goes with it. -#[cfg(feature = "embedded-tls")] -use mountain_mqtt::client::{Client, ClientError}; -#[cfg(feature = "embedded-tls")] -use mountain_mqtt::mqtt_manager::{ConnectionId, MqttOperations}; - use crate::embedded::manager::{MqttEvent, Settings}; #[cfg(feature = "embedded-tls")] @@ -89,9 +83,10 @@ pub(crate) type ActionChannel = /// Inbound messages: broker session to pumps. pub(crate) type EventChannel = crate::embedded::manager::EventChannel; -/// MQTT actions that can be performed +/// What the pumps ask the session to put on the wire. /// -/// Implements the `MqttOperations` trait required by mountain-mqtt-embassy. +/// The session encodes each of these itself against the MQTT client state, so +/// an action is data rather than a call: see `session_loop::perform`. #[derive(Clone)] pub enum AimdbMqttAction { /// Publish a message to a topic @@ -108,80 +103,6 @@ pub enum AimdbMqttAction { }, } -/// Implementation of MqttOperations trait for AimDB actions -/// -/// Only the TLS path still needs this: the plain path drives `ClientState` -/// directly and encodes each action itself (design 053 §6.4). It goes when TLS -/// joins the same loop. -/// -/// `is_retry` is part of the upstream trait and is always `false` here: the -/// session performs each action exactly once and drops it if it fails (see -/// `handle_messages`' "Delivery" note), so nothing is ever a second attempt. -/// A failure is logged with its topic before it propagates, because it ends -/// the session and takes the message with it. -#[cfg(feature = "embedded-tls")] -impl MqttOperations for AimdbMqttAction { - async fn perform<'a, 'b, C>( - &'b mut self, - client: &mut C, - _client_id: &'a str, - _connection_id: ConnectionId, - _is_retry: bool, - ) -> Result<(), ClientError> - where - C: Client<'a>, - { - match self { - Self::Publish { - topic, - payload, - qos, - retain, - } => { - #[cfg(feature = "defmt")] - defmt::debug!( - "Publishing {} bytes to {} (QoS={:?})", - payload.len(), - topic.as_str(), - qos - ); - - client - .publish(topic, payload, *qos, *retain) - .await - .inspect_err(|_e| { - #[cfg(feature = "defmt")] - defmt::warn!( - "MQTT: dropping publish of {} bytes to {}: {}", - payload.len(), - topic.as_str(), - _e - ); - })?; - - #[cfg(feature = "defmt")] - defmt::info!("Published {} bytes to {}", payload.len(), topic.as_str()); - - Ok(()) - } - Self::Subscribe { topic, qos } => { - #[cfg(feature = "defmt")] - defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); - - client.subscribe(topic, *qos).await.inspect_err(|_e| { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); - })?; - - #[cfg(feature = "defmt")] - defmt::info!("Subscribed to {}", topic.as_str()); - - Ok(()) - } - } - } -} - /// MQTT events for received messages /// /// Handles incoming MQTT messages that will be routed to the appropriate diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 735256cc..0d1df200 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -12,26 +12,6 @@ use core::future::Future; -/// Bridges core's [`Delay`](aimdb_core::session::Delay) to the `DelayNs` the -/// MQTT client wants, so the client's timeouts run on the adapter's clock. -/// -/// Only the TLS path still needs it; it goes when TLS joins the same session -/// loop as the plain path. -#[cfg(feature = "embedded-tls")] -pub(crate) struct ClientDelay<'a, D>(pub(crate) &'a D); - -#[cfg(feature = "embedded-tls")] -impl embedded_hal_async::delay::DelayNs for ClientDelay<'_, D> -where - D: aimdb_core::session::Delay, -{ - async fn delay_ns(&mut self, ns: u32) { - self.0 - .sleep(core::time::Duration::from_nanos(u64::from(ns))) - .await - } -} - /// Asserts that a broker session future is `Send`. /// /// Everything the session holds is `Send`: [`StreamDialer`] guarantees diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 6473dea9..d473aa38 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -473,14 +473,40 @@ fn perform( qos, retain, } => { + #[cfg(feature = "defmt")] + defmt::debug!( + "Publishing {} bytes to {} (QoS={:?})", + payload.len(), + topic.as_str(), + qos + ); let packet = state .publish_packet(&topic, &payload, qos, retain) + .inspect_err(|_e| { + // The action is already off the channel, so a failure here + // loses this message and ends the session — say which. + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: dropping publish of {} bytes to {}: {}", + payload.len(), + topic.as_str(), + _e + ); + }) .map_err(client_error)?; queue(outbound, encode(&packet)?); state.publish_update(&packet).map_err(client_error)?; } AimdbMqttAction::Subscribe { topic, qos } => { - let packet = state.subscribe_packet(&topic, qos).map_err(client_error)?; + #[cfg(feature = "defmt")] + defmt::info!("Subscribing to {} (QoS={:?})", topic.as_str(), qos); + let packet = state + .subscribe_packet(&topic, qos) + .inspect_err(|_e| { + #[cfg(feature = "defmt")] + defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); + }) + .map_err(client_error)?; queue(outbound, encode(&packet)?); state.subscribe_update(&packet).map_err(client_error)?; } diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index b7954b7c..b48c3895 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,42 +1,46 @@ //! The TLS transport for the embedded backend. //! //! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the caller's -//! transport, presented to the MQTT layer as its own `Connection` — not -//! `ConnectionEmbedded`, which needs a `ReadReady` a TLS session cannot give -//! (see `TlsSession` below). Certificate verification is `rustpki` (pure Rust) -//! against the application-embedded root CA, dated by the runtime's wall -//! clock; entropy -//! comes from the application-injected TRNG ([`TlsOptions::new`]). +//! transport, split into halves the session loop reads and writes exactly as +//! it does a plaintext socket. Certificate verification is `rustpki` (pure +//! Rust) against the application-embedded root CA, dated by the runtime's wall +//! clock; entropy comes from the application-injected TRNG +//! ([`TlsOptions::new`]). //! //! The dialer resolves the host, so there is no network stack here: the same //! session runs on a host over the Tokio adapter's transport. +//! +//! # Why this path used to be different +//! +//! The MQTT client needed a non-blocking peek (`receive_if_ready`), which a +//! TLS session cannot answer honestly: its readiness is two-layered, since +//! bytes on the wire may decrypt to no application data at all. That forced a +//! bespoke `Connection` here, a readiness probe onto the raw socket underneath +//! the TLS session, and one `RefCell` wrapping the whole socket so both could +//! reach it. Nothing polls any more, so the peek is gone and with it all +//! three: TLS now differs from the plain path by two adapter types and a +//! handshake. use alloc::string::String; +use alloc::sync::Arc; use alloc::vec::Vec; -use core::cell::RefCell; +use core::future::Future; use core::net::IpAddr; -use alloc::sync::Arc; - +use aimdb_core::session::{ByteRead, ByteStream, ByteWrite, TransportError, TransportResult}; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; +use embassy_sync::mutex::Mutex; use embedded_tls::pki::CertVerifier; use embedded_tls::{ Aes128GcmSha256, Certificate, CryptoProvider, CryptoRngCore, TlsConfig, TlsConnection, - TlsContext, TlsError, TlsVerifier, + TlsContext, TlsError, TlsReader, TlsVerifier, TlsWriter, }; -use embedded_io_async::Write as _; - -use crate::embedded::manager::{ - handle_messages, now_ms, ChannelEventHandler, MqttEvent, SessionState, Settings, -}; -use mountain_mqtt::client::{ClientNoQueue, ConnectionSettings}; +use crate::embedded::manager::{MqttEvent, Settings}; +use crate::embedded::session_loop::run_session; +use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; -use mountain_mqtt::embedded_hal_async::DelayEmbedded; -use mountain_mqtt::error::{PacketReadError, PacketWriteError}; use mountain_mqtt::mqtt_manager::ConnectionId; -use mountain_mqtt::packet_client::Connection; - -use crate::embedded::{AimdbMqttEvent, BUFFER_SIZE, CHANNEL_SIZE, MAX_PROPERTIES}; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. @@ -113,111 +117,167 @@ impl TlsOptions { } } -/// The stream shared between the TLS session (its transport) and the -/// MQTT-level readiness probe ([`TlsSession::receive_if_ready`]), which needs -/// to ask the wire after the stream has been handed to `embedded-tls`. +/// The socket's two halves behind one handle, so `embedded-tls` can clone a +/// "socket" into its reader and its writer. /// -/// Borrow discipline: the session task drives exactly one client operation at -/// a time, so a `borrow_mut` held across an I/O `.await` can never overlap -/// the probe's short `borrow` — both are called sequentially from the same -/// loop. -struct SharedStream<'r, S>(&'r RefCell); - -impl Clone for SharedStream<'_, S> { - fn clone(&self) -> Self { - Self(self.0) - } +/// [`TlsConnection::split`] requires `Socket: Clone` and hands a clone to each +/// half, so the handle must tolerate one clone being read while another is +/// written — which is exactly what the session does. **Separate locks per +/// direction** make that safe by type: `TlsReader`'s impls require only +/// `AsyncRead` and `TlsWriter`'s only `AsyncWrite`, so the reader only ever +/// touches `rx` and the writer only `tx`. They never contend, and neither ever +/// waits on the other. +/// +/// The locks are async rather than `RefCell`s because a guard is held across +/// the inner `.await`. A `RefCell` there would be either a panic waiting for +/// the first genuinely concurrent read and write — which is what the session +/// now does on every connection — or an `await_holding_refcell_ref` allow +/// papering over it. Uncontended by construction, so the cost is an atomic +/// apiece. +/// +/// **The disjointness is an argument about a dependency**, and the one real +/// risk here: an `embedded-tls` that let its reader write — to answer a +/// KeyUpdate inline, say — would make the two halves contend at runtime. +/// `tests/tls_duplex.rs` drives a concurrent read and write to completion so +/// that shows up at a version bump rather than in the field. +struct DuplexHandle<'a, Rx, Tx> { + rx: &'a Mutex, + tx: &'a Mutex, } -impl SharedStream<'_, S> { - fn can_recv(&self) -> bool { - self.0.borrow_mut().read_ready().unwrap_or(false) +impl Clone for DuplexHandle<'_, Rx, Tx> { + fn clone(&self) -> Self { + Self { + rx: self.rx, + tx: self.tx, + } } } -impl embedded_io_async::ErrorType for SharedStream<'_, S> { - type Error = S::Error; +impl embedded_io_async::ErrorType for DuplexHandle<'_, Rx, Tx> { + type Error = embedded_io_async::ErrorKind; } -// The held-across-await borrows below are safe by the struct-level borrow -// discipline (sequential single-task use); a panic would mean a second client -// operation ran concurrently, which the session loop cannot do. -#[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Read for SharedStream<'_, S> { +impl embedded_io_async::Read for DuplexHandle<'_, Rx, Tx> +where + Rx: ByteRead, +{ async fn read(&mut self, buf: &mut [u8]) -> Result { - self.0.borrow_mut().read(buf).await + self.rx + .lock() + .await + .read(buf) + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) } } -#[allow(clippy::await_holding_refcell_ref)] -impl embedded_io_async::Write for SharedStream<'_, S> { +impl embedded_io_async::Write for DuplexHandle<'_, Rx, Tx> +where + Tx: ByteWrite, +{ async fn write(&mut self, buf: &[u8]) -> Result { - self.0.borrow_mut().write(buf).await + self.tx + .lock() + .await + .write_all(buf) + .await + .map(|()| buf.len()) + .map_err(|_| embedded_io_async::ErrorKind::Other) } async fn flush(&mut self) -> Result<(), Self::Error> { - self.0.borrow_mut().flush().await + self.tx + .lock() + .await + .flush() + .await + .map_err(|_| embedded_io_async::ErrorKind::Other) } } -/// mountain-mqtt [`Connection`] over an open TLS session. +/// Asserts that a TLS half's I/O future is `Send`. /// -/// Not `ConnectionEmbedded`: that adapter needs `ReadReady`, which -/// [`TlsConnection`] cannot offer — and TLS readiness is two-layered anyway. -/// Data can be ready as already-decrypted plaintext left over from a record -/// that carried more than one MQTT packet (`plaintext_remaining`), or as -/// undecrypted bytes on the wire (`can_recv` on the shared socket). Checking -/// both keeps coalesced packets flowing promptly. +/// `embedded-tls` holds a `Range<*const u8>` over its own record buffer across +/// an await, so its futures are `!Send` by type even though nothing in them is +/// shared: the pointers address the very buffer the future owns exclusively. /// -/// Known limitation: wire bytes that decrypt to *no* application data -/// (unsolicited session tickets, KeyUpdate) make `receive` wait for the next -/// real record; if the broker stays silent, the keep-alive lapse tears the -/// session down and the manager reconnects. -struct TlsSession<'r, 'b, S> -where - S: embedded_io_async::Read + embedded_io_async::Write, -{ - tls: TlsConnection<'b, SharedStream<'r, S>, Aes128GcmSha256>, - socket: SharedStream<'r, S>, - /// Decrypted-but-unread plaintext left in the TLS record buffer. - plaintext_remaining: usize, +/// The session's three futures are polled as one task, and the TLS halves are +/// reachable from nowhere else, so no value here is ever touched from two +/// threads at once. A task that migrates between threads moves the whole of +/// itself, which is exactly what `Send` on the composed future asserts — and +/// the connector already asserts it, one level up, for the session as a whole +/// ([`SendSession`](crate::embedded::session::SendSession)). This states the +/// same thing at the point where the type system actually needs it. +struct AssertSend(F); + +// SAFETY: upheld by the single-task argument above. +unsafe impl Send for AssertSend {} + +impl Future for AssertSend { + type Output = F::Output; + + fn poll( + self: core::pin::Pin<&mut Self>, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll { + // SAFETY: a transparent projection; `AssertSend` is never moved out of. + unsafe { self.map_unchecked_mut(|s| &mut s.0) }.poll(cx) + } } -impl Connection for TlsSession<'_, '_, S> +/// The TLS session's read half, as the session loop's [`ByteRead`]. +/// +/// This pair is the whole of what TLS costs the loop: below them the session +/// cannot tell a plaintext socket from a record stream, so both paths run the +/// same three futures. +struct TlsRead<'a, 'b, Rx, Tx>(TlsReader<'a, DuplexHandle<'b, Rx, Tx>, Aes128GcmSha256>); + +/// The TLS session's write half, as the session loop's [`ByteWrite`]. +struct TlsWrite<'a, 'b, Rx, Tx>(TlsWriter<'a, DuplexHandle<'b, Rx, Tx>, Aes128GcmSha256>); + +impl<'a, 'b, Rx, Tx> ByteRead for TlsRead<'a, 'b, Rx, Tx> where - S: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, + // The socket handle outlives the TLS session borrowed from it. + 'b: 'a, + Rx: ByteRead + Send + 'b, + Tx: ByteWrite + Send + 'b, { - async fn send(&mut self, buf: &[u8]) -> Result<(), PacketWriteError> { - self.tls - .write_all(buf) - .await - .map_err(|_| PacketWriteError::ConnectionSend)?; - self.tls - .flush() - .await - .map_err(|_| PacketWriteError::ConnectionSend) + fn read<'r>( + &'r mut self, + buf: &'r mut [u8], + ) -> impl Future> + Send + 'r { + AssertSend(async move { + use embedded_io_async::Read as _; + self.0.read(buf).await.map_err(|_| TransportError::Io) + }) } +} - async fn receive(&mut self, buf: &mut [u8]) -> Result<(), PacketReadError> { - let mut filled = 0; - while filled < buf.len() { - let mut read_buffer = self - .tls - .read_buffered() +impl<'a, 'b, Rx, Tx> ByteWrite for TlsWrite<'a, 'b, Rx, Tx> +where + 'b: 'a, + Rx: ByteRead + Send + 'b, + Tx: ByteWrite + Send + 'b, +{ + fn write_all<'w>( + &'w mut self, + buf: &'w [u8], + ) -> impl Future> + Send + 'w { + AssertSend(async move { + use embedded_io_async::Write as _; + self.0 + .write_all(buf) .await - .map_err(|_| PacketReadError::ConnectionReceive)?; - filled += read_buffer.pop_into(&mut buf[filled..]); - self.plaintext_remaining = read_buffer.len(); - } - Ok(()) + .map_err(|_| TransportError::Closed) + }) } - async fn receive_if_ready(&mut self, buf: &mut [u8]) -> Result { - if self.plaintext_remaining == 0 && !self.socket.can_recv() { - return Ok(false); - } - self.receive(buf).await?; - Ok(true) + fn flush(&mut self) -> impl Future> + Send + '_ { + AssertSend(async move { + use embedded_io_async::Write as _; + self.0.flush().await.map_err(|_| TransportError::Closed) + }) } } @@ -299,7 +359,6 @@ pub(crate) async fn run_tls( ) -> ! where D: aimdb_core::session::StreamDialer + aimdb_core::session::Delay, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { let TlsOptions { rng, @@ -309,9 +368,7 @@ where .. } = options; - let mut mqtt_buffer = [0u8; BUFFER_SIZE]; - - // Re-subscribed by `handle_messages` on every (re)connection, so inbound + // Re-subscribed by the session on every (re)connection, so inbound // routing survives reconnects. Built once — borrows `topics` for the loop. let subscribe_topics: Vec<(&str, QualityOfService)> = topics .iter() @@ -339,7 +396,7 @@ where } } - let stream = match dialer.connect(&host, port).await { + let mut stream = match dialer.connect(&host, port).await { Ok(stream) => stream, Err(_e) => { #[cfg(feature = "defmt")] @@ -349,15 +406,20 @@ where } }; - let stream = RefCell::new(stream); - let shared = SharedStream(&stream); + // One lock per direction, so the TLS reader and writer never contend. + let (rx, tx) = stream.split(); + let rx = Mutex::new(rx); + let tx = Mutex::new(tx); + let handle = DuplexHandle { rx: &rx, tx: &tx }; let tls_config = TlsConfig::new().with_server_name(&host); - let mut tls = TlsConnection::new(shared.clone(), &mut *read_buf, &mut *write_buf); + let mut tls = TlsConnection::new(handle.clone(), &mut *read_buf, &mut *write_buf); let provider = TrngProvider { rng: &mut *rng, verifier: CertVerifier::new(Certificate::X509(ca_der)), }; + // The handshake reads and writes sequentially through one connection, + // so it needs no split and takes neither lock twice. if let Err(e) = tls.open(TlsContext::new(&tls_config, provider)).await { #[cfg(feature = "defmt")] defmt::warn!( @@ -372,33 +434,16 @@ where #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS: session established"); - let connection = TlsSession { - tls, - socket: shared, - plaintext_remaining: 0, - }; - let timeout_millis = settings.response_timeout.as_millis() as u32; - - let state = SessionState::new(now_ms(runtime.as_ref())); - let connection_id = ConnectionId::new(connection_index); connection_index += 1; - let event_handler: ChannelEventHandler<'_, AimdbMqttEvent, MAX_PROPERTIES, CHANNEL_SIZE> = - ChannelEventHandler::new(connection_id, &events, &state, runtime.as_ref()); - - let mut client = ClientNoQueue::new( - connection, - &mut mqtt_buffer, - DelayEmbedded::new(crate::embedded::session::ClientDelay(&delay)), - timeout_millis, - event_handler, - ); - - if let Err(error) = handle_messages( + // From here the session is the plain path's, byte for byte: the record + // layer is just another pair of halves. + let (tls_rx, tls_tx) = tls.split(); + let error = run_session( connection_id, - &mut client, - &state, + TlsRead(tls_rx), + TlsWrite(tls_tx), &connection_settings, &subscribe_topics, &events, @@ -407,17 +452,16 @@ where &delay, runtime.as_ref(), ) - .await - { - #[cfg(feature = "defmt")] - defmt::warn!("MQTT-TLS: session errored: {:?}", error); - events - .send(MqttEvent::Disconnected { - connection_id, - error, - }) - .await; - } + .await; + + #[cfg(feature = "defmt")] + defmt::warn!("MQTT-TLS: session errored: {:?}", error); + events + .send(MqttEvent::Disconnected { + connection_id, + error, + }) + .await; aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; } @@ -437,3 +481,68 @@ pub(crate) fn host_ip_literal(host: &str) -> Option { .unwrap_or(host); host.parse::().ok() } + +#[cfg(test)] +mod tests { + use super::*; + use core::pin::pin; + use core::task::{Context, Poll}; + + /// A read half that never completes, so the reader's lock stays held. + struct PendingRead; + + impl ByteRead for PendingRead { + async fn read(&mut self, _buf: &mut [u8]) -> TransportResult { + core::future::pending().await + } + } + + /// A write half that completes immediately, recording what it was given. + struct RecordingWrite(Vec); + + impl ByteWrite for RecordingWrite { + async fn write_all(&mut self, buf: &[u8]) -> TransportResult<()> { + self.0.extend_from_slice(buf); + Ok(()) + } + + async fn flush(&mut self) -> TransportResult<()> { + Ok(()) + } + } + + /// §6.6's disjointness, as an assertion rather than an argument: a read + /// parked inside one clone of the handle must not hold up a write through + /// another. Over a single shared cell — what `SharedStream` was — this is + /// precisely the shape that panics; over two locks it simply works. + #[test] + fn a_parked_reader_does_not_hold_up_the_writer() { + let rx = Mutex::new(PendingRead); + let tx = Mutex::new(RecordingWrite(Vec::new())); + let handle = DuplexHandle { rx: &rx, tx: &tx }; + + // What `TlsConnection::split` does: a clone apiece. + let mut reader = handle.clone(); + let mut writer = handle; + + let mut cx = Context::from_waker(core::task::Waker::noop()); + + let mut buf = [0u8; 4]; + let mut read = pin!(embedded_io_async::Read::read(&mut reader, &mut buf)); + assert!( + matches!(read.as_mut().poll(&mut cx), Poll::Pending), + "the read must park — otherwise this test proves nothing" + ); + + let mut write = pin!(embedded_io_async::Write::write(&mut writer, b"ping")); + assert!( + matches!(write.as_mut().poll(&mut cx), Poll::Ready(Ok(4))), + "the write must complete while the read is parked" + ); + + assert!( + matches!(read.as_mut().poll(&mut cx), Poll::Pending), + "and the reader must be undisturbed by it" + ); + } +} diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index b7f30d9d..1a6f73dd 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -7,7 +7,12 @@ //! Compiled into each test binary, so not every item is used by all of them. #![allow(dead_code)] +use std::future::Future; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use aimdb_core::session::{Delay, StreamDialer, TransportResult}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -349,3 +354,278 @@ pub async fn fake_broker_concurrent( }); } } + +// =========================================================================== +// The scripted broker: the same wire format as above, but the test decides +// when each answer goes out (design 053's criteria 1, 2, 4 and 11). +// =========================================================================== + +/// The topic the scripted broker pushes on. +pub const SCRIPT_TOPIC: &str = "sensors/temperature"; + +/// What the scripted broker saw, and when. +#[derive(Default)] +pub struct Log { + pub pings: usize, + /// Client publishes, as (topic, payload). + pub publishes: Vec<(String, Vec)>, + /// Pings that arrived while the broker was deliberately stalling. + pub pings_during_stall: usize, + /// Client publishes that arrived while the broker was stalling. + pub publishes_during_stall: usize, +} + +/// Read one packet: header byte, varint remaining length, body. +async fn read_one(socket: &mut S) -> Option<(u8, Vec)> { + let mut byte = [0u8; 1]; + socket.read_exact(&mut byte).await.ok()?; + let first = byte[0]; + + let mut remaining = 0usize; + let mut shift = 0; + loop { + socket.read_exact(&mut byte).await.ok()?; + remaining |= ((byte[0] & 0x7F) as usize) << shift; + if byte[0] & 0x80 == 0 { + break; + } + shift += 7; + } + + let mut body = vec![0u8; remaining]; + socket.read_exact(&mut body).await.ok()?; + Some((first, body)) +} + +/// How the scripted broker should misbehave after it has SUBACKed. +#[derive(Clone, Copy)] +pub enum Script { + /// Answer nothing but pings: an idle, healthy session. + Idle, + /// Push a PUBLISH split in two with `gap` between the halves. + SplitPublish { gap: Duration }, + /// Hold every PUBACK back by `delay`. + SlowPuback { delay: Duration }, + /// Push inbound PUBLISHes as fast as they will go, for `duration`. + Flood { duration: Duration }, +} + +/// The broker's write side. +/// +/// While `hold` is `Some`, the broker has a packet half-written and must not +/// put anything else on the wire: a byte stream carries packets in order, so +/// injecting a PUBACK between the halves of a PUBLISH would corrupt the +/// framing rather than test it. Held bytes go out behind the packet's tail — +/// which is exactly what a sender whose peer is slow ends up doing. +struct Wire { + writer: tokio::io::WriteHalf, + hold: Option>, +} + +type Writer = Arc>>; + +async fn send(writer: &Writer, bytes: &[u8]) -> bool { + let mut wire = writer.lock().await; + match wire.hold.as_mut() { + Some(held) => { + held.extend_from_slice(bytes); + true + } + None => wire.writer.write_all(bytes).await.is_ok(), + } +} + +/// Serve one already-accepted connection, following `script`. +/// +/// Generic over the stream, so the same script runs over plain TCP and over a +/// TLS session — which is what lets the TLS path be held to the same criteria. +/// +/// The stream is split and every scripted delay runs in its own task, so the +/// broker **never stops reading**. That is what makes the stall counters mean +/// anything: a ping that arrives while the broker is stalling has to be read +/// and counted while the stall is still open, not afterwards. +pub async fn scripted_broker(stream: S, log: Arc>, script: Script) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, +{ + let (mut reader, writer) = tokio::io::split(stream); + let writer: Writer = Arc::new(tokio::sync::Mutex::new(Wire { writer, hold: None })); + + // Open while the broker is deliberately withholding something. + let stalling = Arc::new(AtomicUsize::new(0)); + + loop { + let Some((first, body)) = read_one(&mut reader).await else { + return; + }; + let in_stall = stalling.load(Ordering::Relaxed) == 1; + + match first >> 4 { + // CONNECT -> CONNACK + 1 => { + if !send(&writer, &[0x20, 0x03, 0x00, 0x00, 0x00]).await { + return; + } + } + // SUBSCRIBE -> SUBACK, then run the script. + 8 => { + let packet_id = [body[0], body[1]]; + if !send( + &writer, + &[0x90, 0x04, packet_id[0], packet_id[1], 0x00, 0x01], + ) + .await + { + return; + } + + match script { + Script::Idle | Script::SlowPuback { .. } => {} + Script::SplitPublish { gap } => { + // Half a packet, a long silence, then the rest. The + // polled loop's `receive_if_ready` commits to reading + // the whole packet and parks here. + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + let packet = publish(SCRIPT_TOPIC, b"21", true); + let cut = packet.len() / 2; + { + let mut wire = writer.lock().await; + if wire.writer.write_all(&packet[..cut]).await.is_err() { + return; + } + // Nothing else may reach the wire until the + // tail does. + wire.hold = Some(Vec::new()); + } + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(gap).await; + stalling.store(0, Ordering::Relaxed); + + let mut wire = writer.lock().await; + let held = wire.hold.take().unwrap_or_default(); + if wire.writer.write_all(&packet[cut..]).await.is_err() { + return; + } + let _ = wire.writer.write_all(&held).await; + }); + } + Script::Flood { duration } => { + let writer = writer.clone(); + tokio::spawn(async move { + let deadline = tokio::time::Instant::now() + duration; + let packet = publish(SCRIPT_TOPIC, b"7", true); + while tokio::time::Instant::now() < deadline { + // The lock is taken and released per packet, so + // PUBACKs and PINGRESPs interleave with the + // flood rather than queueing behind all of it. + if !send(&writer, &packet).await { + return; + } + tokio::task::yield_now().await; + } + }); + } + } + } + // PUBLISH from the client. + 3 => { + let Some((topic, payload, packet_id)) = parse_publish(first, &body, true) else { + return; + }; + { + let mut log = log.lock().unwrap(); + log.publishes.push((topic, payload)); + if in_stall { + log.publishes_during_stall += 1; + } + } + if let Some(id) = packet_id { + match script { + // Acknowledge late, in its own task, with the stall + // window open: a ping arriving meanwhile is the + // assertion, and the read loop has to stay live to see + // it. + Script::SlowPuback { delay } => { + let writer = writer.clone(); + let stalling = stalling.clone(); + tokio::spawn(async move { + stalling.store(1, Ordering::Relaxed); + tokio::time::sleep(delay).await; + stalling.store(0, Ordering::Relaxed); + send(&writer, &[0x40, 0x02, id[0], id[1]]).await; + }); + } + _ => { + if !send(&writer, &[0x40, 0x02, id[0], id[1]]).await { + return; + } + } + } + } + } + // PINGREQ -> PINGRESP + 12 => { + { + let mut log = log.lock().unwrap(); + log.pings += 1; + if in_stall { + log.pings_during_stall += 1; + } + } + if !send(&writer, &[0xD0, 0x00]).await { + return; + } + } + 14 => return, + _ => {} + } + } +} + +// =========================================================================== +// A dialer that counts what the session sleeps on. +// =========================================================================== + +/// `TokioNet::tcp()` with a tally of every `Delay::sleep` the connector asks +/// for. The connector takes its clock from the dialer, so this is the seam +/// where "how often does the session wake?" is observable at all. +#[derive(Clone)] +pub struct CountingDialer { + inner: aimdb_tokio_adapter::net::TokioTcpDialer, + sleeps: Arc, +} + +impl CountingDialer { + pub fn new() -> Self { + Self { + inner: aimdb_tokio_adapter::net::TokioNet::tcp(), + sleeps: Arc::new(AtomicUsize::new(0)), + } + } + + /// The running tally, shared with the dialer the connector holds. + pub fn sleeps(&self) -> Arc { + self.sleeps.clone() + } +} + +impl StreamDialer for CountingDialer { + type Stream = ::Stream; + + fn connect<'a>( + &'a self, + host: &'a str, + port: u16, + ) -> impl Future> + Send + 'a { + self.inner.connect(host, port) + } +} + +impl Delay for CountingDialer { + fn sleep(&self, d: Duration) -> impl Future + Send { + self.sleeps.fetch_add(1, Ordering::Relaxed); + Delay::sleep(&self.inner, d) + } +} diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index a070ace6..be99f4de 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -8,15 +8,15 @@ //! — mid-packet, late, or not at all. #![cfg(feature = "_test-tokio-broker")] -use std::future::Future; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use std::time::Duration; -use aimdb_core::session::{Delay, StreamDialer, TransportResult}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; +mod common; +use common::{scripted_broker, CountingDialer, Log, Script, SCRIPT_TOPIC}; + // Each test binary defines these exactly once. #[defmt::global_logger] struct HostTestLogger; @@ -49,318 +49,17 @@ impl embassy_time_driver::Driver for HostClock { } embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); -// --------------------------------------------------------------------------- -// A dialer that counts what the session sleeps on. -// --------------------------------------------------------------------------- - -/// `TokioNet::tcp()` with a tally of every `Delay::sleep` the connector asks -/// for. The connector takes its clock from the dialer, so this is the seam -/// where "how often does the session wake?" is observable at all. -#[derive(Clone)] -struct CountingDialer { - inner: aimdb_tokio_adapter::net::TokioTcpDialer, - sleeps: Arc, -} - -impl CountingDialer { - fn new() -> Self { - Self { - inner: aimdb_tokio_adapter::net::TokioNet::tcp(), - sleeps: Arc::new(AtomicUsize::new(0)), - } - } -} - -impl StreamDialer for CountingDialer { - type Stream = ::Stream; - - fn connect<'a>( - &'a self, - host: &'a str, - port: u16, - ) -> impl Future> + Send + 'a { - self.inner.connect(host, port) - } -} - -impl Delay for CountingDialer { - fn sleep(&self, d: Duration) -> impl Future + Send { - self.sleeps.fetch_add(1, Ordering::Relaxed); - Delay::sleep(&self.inner, d) - } -} - // --------------------------------------------------------------------------- // A scripted broker: the same wire format as `common`, but the test decides // when each answer goes out. // --------------------------------------------------------------------------- -/// What the scripted broker saw, and when. -#[derive(Default)] -struct Log { - pings: usize, - /// Client publishes, as (topic, payload). - publishes: Vec<(String, Vec)>, - /// Pings that arrived while the broker was deliberately stalling. - pings_during_stall: usize, - /// Client publishes that arrived while the broker was stalling. - publishes_during_stall: usize, -} - -fn varint(mut n: usize, out: &mut Vec) { - loop { - let mut byte = (n % 128) as u8; - n /= 128; - if n > 0 { - byte |= 128; - } - out.push(byte); - if n == 0 { - return; - } - } -} - -/// An MQTT 5 PUBLISH at QoS 0. -fn publish_packet(topic: &str, payload: &[u8]) -> Vec { - let mut rest = Vec::new(); - rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); - rest.extend_from_slice(topic.as_bytes()); - rest.push(0x00); // no properties - rest.extend_from_slice(payload); - - let mut packet = vec![0x30]; - varint(rest.len(), &mut packet); - packet.extend_from_slice(&rest); - packet -} - -/// Read one packet: header byte, varint remaining length, body. -async fn read_one(socket: &mut S) -> Option<(u8, Vec)> { - let mut byte = [0u8; 1]; - socket.read_exact(&mut byte).await.ok()?; - let first = byte[0]; - - let mut remaining = 0usize; - let mut shift = 0; - loop { - socket.read_exact(&mut byte).await.ok()?; - remaining |= ((byte[0] & 0x7F) as usize) << shift; - if byte[0] & 0x80 == 0 { - break; - } - shift += 7; - } - - let mut body = vec![0u8; remaining]; - socket.read_exact(&mut body).await.ok()?; - Some((first, body)) -} - -/// Pull the topic and payload out of a client PUBLISH, and its packet id when -/// it carries one (QoS > 0). -fn parse_publish(first: u8, body: &[u8]) -> Option<(String, Vec, Option<[u8; 2]>)> { - let topic_len = u16::from_be_bytes([*body.first()?, *body.get(1)?]) as usize; - let topic = String::from_utf8_lossy(body.get(2..2 + topic_len)?).into_owned(); - let mut i = 2 + topic_len; - - let packet_id = if (first >> 1) & 0x03 > 0 { - let id = [*body.get(i)?, *body.get(i + 1)?]; - i += 2; - Some(id) - } else { - None - }; - - // MQTT 5 property length (always short here). - let property_len = *body.get(i)? as usize; - i += 1 + property_len; - - Some((topic, body.get(i..)?.to_vec(), packet_id)) -} - -/// How the scripted broker should misbehave after it has SUBACKed. -#[derive(Clone, Copy)] -enum Script { - /// Answer nothing but pings: an idle, healthy session. - Idle, - /// Push a PUBLISH split in two with `gap` between the halves. - SplitPublish { gap: Duration }, - /// Hold every PUBACK back by `delay`. - SlowPuback { delay: Duration }, - /// Push inbound PUBLISHes as fast as they will go, for `duration`. - Flood { duration: Duration }, -} - -/// The broker's write side. -/// -/// While `hold` is `Some`, the broker has a packet half-written and must not -/// put anything else on the wire: a byte stream carries packets in order, so -/// injecting a PUBACK between the halves of a PUBLISH would corrupt the -/// framing rather than test it. Held bytes go out behind the packet's tail — -/// which is exactly what a sender whose peer is slow ends up doing. -struct Wire { - writer: tokio::net::tcp::OwnedWriteHalf, - hold: Option>, -} - -type Writer = Arc>; - -async fn send(writer: &Writer, bytes: &[u8]) -> bool { - let mut wire = writer.lock().await; - match wire.hold.as_mut() { - Some(held) => { - held.extend_from_slice(bytes); - true - } - None => wire.writer.write_all(bytes).await.is_ok(), - } -} - -/// Serve exactly one connection, following `script`. -/// -/// The socket is split and every scripted delay runs in its own task, so the -/// broker **never stops reading**. That is what makes the stall counters mean -/// anything: a ping that arrives while the broker is stalling has to be read -/// and counted while the stall is still open, not afterwards. -async fn scripted_broker(listener: TcpListener, log: Arc>, script: Script) { +/// Accept one plain-TCP connection and serve it under `script`. +async fn serve_one(listener: TcpListener, log: Arc>, script: Script) { let Ok((socket, _)) = listener.accept().await else { return; }; - let (mut reader, writer) = socket.into_split(); - let writer: Writer = Arc::new(tokio::sync::Mutex::new(Wire { writer, hold: None })); - - // Open while the broker is deliberately withholding something. - let stalling = Arc::new(AtomicUsize::new(0)); - - loop { - let Some((first, body)) = read_one(&mut reader).await else { - return; - }; - let in_stall = stalling.load(Ordering::Relaxed) == 1; - - match first >> 4 { - // CONNECT -> CONNACK - 1 => { - if !send(&writer, &[0x20, 0x03, 0x00, 0x00, 0x00]).await { - return; - } - } - // SUBSCRIBE -> SUBACK, then run the script. - 8 => { - let packet_id = [body[0], body[1]]; - if !send( - &writer, - &[0x90, 0x04, packet_id[0], packet_id[1], 0x00, 0x01], - ) - .await - { - return; - } - - match script { - Script::Idle | Script::SlowPuback { .. } => {} - Script::SplitPublish { gap } => { - // Half a packet, a long silence, then the rest. The - // polled loop's `receive_if_ready` commits to reading - // the whole packet and parks here. - let writer = writer.clone(); - let stalling = stalling.clone(); - tokio::spawn(async move { - let packet = publish_packet("sensors/temperature", b"21"); - let cut = packet.len() / 2; - { - let mut wire = writer.lock().await; - if wire.writer.write_all(&packet[..cut]).await.is_err() { - return; - } - // Nothing else may reach the wire until the - // tail does. - wire.hold = Some(Vec::new()); - } - stalling.store(1, Ordering::Relaxed); - tokio::time::sleep(gap).await; - stalling.store(0, Ordering::Relaxed); - - let mut wire = writer.lock().await; - let held = wire.hold.take().unwrap_or_default(); - if wire.writer.write_all(&packet[cut..]).await.is_err() { - return; - } - let _ = wire.writer.write_all(&held).await; - }); - } - Script::Flood { duration } => { - let writer = writer.clone(); - tokio::spawn(async move { - let deadline = tokio::time::Instant::now() + duration; - let packet = publish_packet("sensors/temperature", b"7"); - while tokio::time::Instant::now() < deadline { - // The lock is taken and released per packet, so - // PUBACKs and PINGRESPs interleave with the - // flood rather than queueing behind all of it. - if !send(&writer, &packet).await { - return; - } - tokio::task::yield_now().await; - } - }); - } - } - } - // PUBLISH from the client. - 3 => { - let Some((topic, payload, packet_id)) = parse_publish(first, &body) else { - return; - }; - { - let mut log = log.lock().unwrap(); - log.publishes.push((topic, payload)); - if in_stall { - log.publishes_during_stall += 1; - } - } - if let Some(id) = packet_id { - match script { - // Acknowledge late, in its own task, with the stall - // window open: a ping arriving meanwhile is the - // assertion, and the read loop has to stay live to see - // it. - Script::SlowPuback { delay } => { - let writer = writer.clone(); - let stalling = stalling.clone(); - tokio::spawn(async move { - stalling.store(1, Ordering::Relaxed); - tokio::time::sleep(delay).await; - stalling.store(0, Ordering::Relaxed); - send(&writer, &[0x40, 0x02, id[0], id[1]]).await; - }); - } - _ => { - if !send(&writer, &[0x40, 0x02, id[0], id[1]]).await { - return; - } - } - } - } - } - // PINGREQ -> PINGRESP - 12 => { - { - let mut log = log.lock().unwrap(); - log.pings += 1; - if in_stall { - log.pings_during_stall += 1; - } - } - if !send(&writer, &[0xD0, 0x00]).await { - return; - } - } - 14 => return, - _ => {} - } - } + scripted_broker(socket, log, script).await; } // --------------------------------------------------------------------------- @@ -389,7 +88,7 @@ async fn build_db( builder.configure::("temperature", |reg| { reg.buffer(BufferCfg::SingleLatest) - .link_from("mqtt://sensors/temperature") + .link_from(&format!("mqtt://{SCRIPT_TOPIC}")) .with_deserializer(|_ctx, data: &[u8]| { core::str::from_utf8(data) .ok() @@ -431,7 +130,7 @@ async fn an_idle_session_wakes_at_the_ping_cadence() { let log = Arc::new(Mutex::new(Log::default())); let dialer = CountingDialer::new(); - let sleeps = dialer.sleeps.clone(); + let sleeps = dialer.sleeps(); let (_db, runner) = build_db(port, dialer, None).await; // Long enough to span several of the old loop's 10 ms polls, and to cover @@ -440,7 +139,7 @@ async fn an_idle_session_wakes_at_the_ping_cadence() { tokio::select! { _ = runner.run() => panic!("the session loop returned"), - _ = scripted_broker(listener, log.clone(), Script::Idle) => panic!("the broker returned"), + _ = serve_one(listener, log.clone(), Script::Idle) => panic!("the broker returned"), _ = tokio::time::sleep(WINDOW) => {} } @@ -483,7 +182,7 @@ async fn a_partial_packet_stops_neither_pings_nor_publishes() { let received = tokio::select! { _ = runner.run() => panic!("the session loop returned"), - _ = scripted_broker(listener, log.clone(), Script::SplitPublish { gap: GAP }) => { + _ = serve_one(listener, log.clone(), Script::SplitPublish { gap: GAP }) => { panic!("the broker returned") } received = async { inbound.recv().await.expect("inbound record") } => received, @@ -543,7 +242,7 @@ async fn a_slow_puback_does_not_block_the_ping() { tokio::select! { _ = runner.run() => panic!("the session loop returned"), - _ = scripted_broker(listener, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + _ = serve_one(listener, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { panic!("the broker returned") } _ = until_ping_during_stall => {} @@ -597,7 +296,7 @@ async fn outbound_keeps_moving_under_an_inbound_flood() { tokio::select! { _ = runner.run() => panic!("the session loop returned"), - _ = scripted_broker(listener, log.clone(), Script::Flood { duration: FLOOD }) => { + _ = serve_one(listener, log.clone(), Script::Flood { duration: FLOOD }) => { panic!("the broker returned") } _ = counting => panic!("the inbound record closed"), diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs new file mode 100644 index 00000000..8d60222f --- /dev/null +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -0,0 +1,331 @@ +//! The event-driven session's promises, held over `mqtts://` +//! (design 053 criterion 8, and the guard on risk 1). +//! +//! `tests/session_loop.rs` drives criteria 1, 2 and 4 over a plaintext socket; +//! this drives the same three over a real TLS 1.3 session against a pinned +//! self-signed root. The point is that the session below the record layer is +//! the *same* session — after design 053 the TLS path is two adapter types and +//! a handshake, not a loop of its own. +//! +//! It is also where `DuplexHandle`'s disjointness is exercised for real +//! (criterion 9): while the session's read half is parked inside `TlsReader`, +//! its write half has to push pings through `TlsWriter`. If a future +//! `embedded-tls` ever made its reader take the write lock, these tests would +//! stop completing rather than fail quietly in the field. +#![cfg(feature = "_test-tls-broker")] + +use std::sync::atomic::Ordering; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use rand::SeedableRng as _; +use tokio::net::TcpListener; +use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer}; +use tokio_rustls::rustls::ServerConfig; +use tokio_rustls::TlsAcceptor; + +mod common; +use common::{scripted_broker, CountingDialer, Log, Script, SCRIPT_TOPIC}; + +// Each test binary defines 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:us}", 0); + +/// Real wall-clock time for `embassy-time`, which this binary links. +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); + +/// The name the certificate is issued for, and the name the client verifies. +const BROKER_HOST: &str = "localhost"; + +/// A self-signed certificate for `localhost`, as (server chain, key, root CA). +fn self_signed() -> ( + CertificateDer<'static>, + PrivateKeyDer<'static>, + &'static [u8], +) { + let cert = rcgen::generate_simple_self_signed(vec![BROKER_HOST.to_string()]) + .expect("generate self-signed certificate"); + let der = cert.cert.der().to_vec(); + let key = PrivateKeyDer::try_from(cert.key_pair.serialize_der()).expect("server key"); + let ca: &'static [u8] = Box::leak(der.clone().into_boxed_slice()); + (CertificateDer::from(der), key, ca) +} + +/// Accept one TLS connection and run the scripted broker over it. +async fn serve_one_tls( + listener: TcpListener, + acceptor: TlsAcceptor, + log: Arc>, + script: Script, +) { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let Ok(stream) = acceptor.accept(socket).await else { + return; + }; + scripted_broker(stream, log, script).await; +} + +/// Everything a `mqtts://` test needs: a listener, its acceptor, and the +/// connector's TLS materials. +fn tls_setup() -> ( + TcpListener, + TlsAcceptor, + aimdb_mqtt_connector::TlsOptions, + u16, +) { + let (chain, key, ca_der) = self_signed(); + let server_config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![chain], key) + .expect("server config"); + let acceptor = TlsAcceptor::from(Arc::new(server_config)); + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + listener.set_nonblocking(true).expect("nonblocking"); + let port = listener.local_addr().unwrap().port(); + let listener = TcpListener::from_std(listener).expect("adopt listener"); + + // On a board these are `StaticCell`s; here one leak apiece. + let rng: &'static mut (dyn embedded_tls::CryptoRngCore + Send) = + Box::leak(Box::new(rand::rngs::StdRng::from_entropy())); + let read_buf: &'static mut [u8] = Box::leak(vec![0u8; 16_640].into_boxed_slice()); + let write_buf: &'static mut [u8] = Box::leak(vec![0u8; 4_096].into_boxed_slice()); + + ( + listener, + acceptor, + aimdb_mqtt_connector::TlsOptions::new(rng, ca_der, read_buf, write_buf), + port, + ) +} + +/// An AimDb whose MQTT connector speaks `mqtts://` through `dialer`, with one +/// inbound record and optionally an outbound one publishing every `every` at +/// `qos`. +async fn build_tls_db( + port: u16, + dialer: CountingDialer, + options: aimdb_mqtt_connector::TlsOptions, + publish: Option<(Duration, u8)>, +) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::MqttConnector; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) + .tls(dialer, options) + .with_client_id("tls-session"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from(&format!("mqtt://{SCRIPT_TOPIC}")) + .with_deserializer(|_ctx, data: &[u8]| { + core::str::from_utf8(data) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .ok_or_else(|| String::from("bad payload")) + }) + .finish(); + }); + + if let Some((every, qos)) = publish { + let destination = format!("mqtt://sensors/uptime?qos={qos}"); + builder.configure::("uptime", move |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(move |_ctx, producer| async move { + let mut n = 0u64; + loop { + producer.produce(n); + n += 1; + tokio::time::sleep(every).await; + } + }) + .link_to(&destination) + .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) + .finish(); + }); + } + + builder.build().await.expect("build db") +} + +// --------------------------------------------------------------------------- +// Criterion 1, over TLS. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_idle_tls_session_wakes_at_the_ping_cadence() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + let dialer = CountingDialer::new(); + let sleeps = dialer.sleeps(); + let (_db, runner) = build_tls_db(port, dialer, options, None).await; + + const WINDOW: Duration = Duration::from_secs(3); + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::Idle) => { + panic!("the broker returned") + } + _ = tokio::time::sleep(WINDOW) => {} + } + + let woke = sleeps.load(Ordering::Relaxed); + let pings = log.lock().unwrap().pings; + + assert!(pings >= 1, "the TLS session must still ping; saw {pings}"); + assert!( + woke < 30, + "an idle TLS session woke {woke} times in {WINDOW:?}; the polled loop \ + it replaces would have woken ~{}", + WINDOW.as_millis() / 10 + ); +} + +// --------------------------------------------------------------------------- +// Criterion 2, over TLS. +// --------------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_partial_packet_over_tls_stops_neither_pings_nor_publishes() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + // Longer than the 2 s ping interval, so a ping falls due while the MQTT + // packet is half-delivered — here, half of it inside a complete TLS record + // and the rest in a later one. + const GAP: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (db, runner) = + build_tls_db(port, dialer, options, Some((Duration::from_millis(100), 0))).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let received = tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::SplitPublish { gap: GAP }) => { + panic!("the broker returned") + } + received = async { inbound.recv().await.expect("inbound record") } => received, + _ = tokio::time::sleep(Duration::from_secs(60)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings ({} mid-stall), {} publishes ({} mid-stall)", + log.pings, log.pings_during_stall, log.publishes.len(), log.publishes_during_stall + ); + } + }; + + let log = log.lock().unwrap(); + assert!( + log.pings_during_stall >= 1, + "a ping must go out over TLS while a packet is half-delivered; saw {} of {}", + log.pings_during_stall, + log.pings + ); + assert!( + log.publishes_during_stall >= 1, + "publishes must keep flowing over TLS meanwhile; saw {} of {}", + log.publishes_during_stall, + log.publishes.len() + ); + assert_eq!( + received, 21, + "the packet must still be delivered once its tail arrives" + ); +} + +// --------------------------------------------------------------------------- +// Criterion 4 over TLS — and criterion 9's concurrent read and write. +// --------------------------------------------------------------------------- + +/// A QoS 1 publish waiting on a slow broker must not stop the ping. +/// +/// Over TLS this is also the live test of `DuplexHandle`: for a ping to reach +/// the broker while the PUBACK is outstanding, `TlsWriter` has to take the +/// write lock while `TlsReader` is parked holding the read one. The two are +/// disjoint by type today; if that ever stopped being true, this test would +/// hang rather than pass. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_slow_puback_over_tls_does_not_block_the_ping() { + let (listener, acceptor, options, port) = tls_setup(); + let log = Arc::new(Mutex::new(Log::default())); + + const ACK_DELAY: Duration = Duration::from_millis(2_600); + + let dialer = CountingDialer::new(); + let (_db, runner) = + build_tls_db(port, dialer, options, Some((Duration::from_millis(100), 1))).await; + + let until_ping_during_stall = async { + loop { + if log.lock().unwrap().pings_during_stall >= 1 { + return; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one_tls(listener, acceptor, log.clone(), Script::SlowPuback { delay: ACK_DELAY }) => { + panic!("the broker returned") + } + _ = until_ping_during_stall => {} + _ = tokio::time::sleep(Duration::from_secs(60)) => { + let log = log.lock().unwrap(); + panic!( + "watchdog: {} pings, {} publishes — a concurrent TLS read and \ + write did not complete", + log.pings, + log.publishes.len() + ); + } + } + + let log = log.lock().unwrap(); + assert!( + !log.publishes.is_empty(), + "the publish under acknowledgement must have reached the broker" + ); + assert!( + log.pings_during_stall >= 1, + "the ping must go out over TLS while a QoS 1 publish waits for its PUBACK" + ); +} From c91f71fe6ea97b822c3c59a5783088d810c99320 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 16:59:25 +0000 Subject: [PATCH 29/48] feat: refactor MQTT connector to remove embedded-io-async and embedded-hal-async dependencies; enhance session handling and add ByteStream::split support --- Cargo.lock | 3 - Makefile | 13 +++- aimdb-core/CHANGELOG.md | 18 +++++ aimdb-embassy-adapter/CHANGELOG.md | 9 +++ aimdb-mqtt-connector/CHANGELOG.md | 67 +++++++++++++++++ aimdb-mqtt-connector/Cargo.toml | 19 +++-- aimdb-mqtt-connector/src/connector.rs | 2 - aimdb-mqtt-connector/src/embedded/mod.rs | 4 - .../src/embedded/session_loop.rs | 74 +++++++++++++++++++ aimdb-tokio-adapter/CHANGELOG.md | 6 ++ 10 files changed, 195 insertions(+), 20 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32fa1800..67866073 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -254,9 +254,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0069824828d8b3102324245a42618e65bab9c63fd2917ed52fa49e016d89cecf" dependencies = [ "defmt 1.1.1", - "embedded-hal-async", - "embedded-io 0.7.1", - "embedded-io-async 0.7.0", "heapless 0.8.0", ] diff --git a/Makefile b/Makefile index 4629a9c4..8c8381e5 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,8 @@ RED := \033[0;31m SYNC_NO_STD_FORBIDDEN := tokio|libc # The embedded MQTT backend runs on any target with a `StreamDialer`, so no # executor, network stack, adapter or logger may reach its graph. -MQTT_EMBEDDED_FORBIDDEN := embassy-net|embassy-executor|embassy-time|static_cell|aimdb-embassy-adapter|defmt +MQTT_EMBEDDED_FORBIDDEN := embassy-net|embassy-executor|embassy-time|static_cell|aimdb-embassy-adapter|defmt|embedded-hal-async +MQTT_DEPENDENCY_FORBIDDEN := embedded-io|embedded-hal|tokio NC := \033[0m # No Color ## Show available commands @@ -515,6 +516,16 @@ test-embedded: printf '%s\n' "$$out" | grep -iE '$(MQTT_EMBEDDED_FORBIDDEN)'; exit 1; \ fi @printf "$(BLUE)✓ embedded MQTT graph is free of $(MQTT_EMBEDDED_FORBIDDEN)$(NC)\n" + @printf "$(YELLOW) → Asserting the MQTT dependency is codec-only$(NC)\n" + @out=$$(cargo tree -p aimdb-mountain-mqtt --target thumbv7em-none-eabihf -e normal 2>&1) || { \ + printf "$(RED)✗ cargo tree failed — refusing to pass vacuously:$(NC)\n"; \ + printf '%s\n' "$$out"; exit 1; \ + }; \ + if printf '%s\n' "$$out" | grep -qiE '$(MQTT_DEPENDENCY_FORBIDDEN)'; then \ + printf "$(RED)✗ the MQTT dependency pulled a driver crate$(NC)\n"; \ + printf '%s\n' "$$out" | grep -iE '$(MQTT_DEPENDENCY_FORBIDDEN)'; exit 1; \ + fi + @printf "$(BLUE)✓ mountain-mqtt is the codec alone$(NC)\n" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy bundle) 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-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 1589e0c6..31542865 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,6 +9,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ByteStream::split`, with `ByteRead` / `ByteWrite`** (design 053 §6.1). + Borrows a stream into independently usable read and write halves, so a + session can run a reader and a writer concurrently in one `select` — which a + single `&mut` stream cannot express at all. Borrowed halves are enough: both + live in the same stack frame, which is why this needs nothing owned or + `'static` and why the objection design 052 recorded against `connector-io` + does not apply. `read`/`write_all`/`flush` stay for the handshake and for + callers that never split. The MQTT connector's event-driven session is the + first consumer; the Embassy and Tokio adapters implement it. +- **The cancellation contract is written down** (design 053 §6.2). `read` is + cancel-safe on both adapters AimDB ships — dropping the future consumes + nothing, verified per layer and end to end over a drip transport — but that + is documented as a property of those transports rather than a promise of the + trait, so a reader that cannot resume mid-packet is still free to implement + it. `write_all` is cancel-safe **nowhere** and must never sit in a `select` + arm: a partial write desynchronises the framing above it with nothing to + resync on. + - **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 diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 54bfa24a..1c88e63f 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ByteStream::split` for `EmbassyTcpStream` and `EmbassyUart`.** The TCP + stream delegates to `embassy-net`'s own lock-free `TcpSocket::split` — both + halves are a copy of the socket's `io` handle — and a stream whose socket has + already gone still yields halves, which report `TransportError::Closed` on + use exactly as the unsplit methods do. `EmbassyUart` simply hands back the two + halves it was built from. Like the rest of this module the halves are + force-`Send` under the single-core cooperative-executor invariant, since + `TcpReader`/`TcpWriter` are `!Send`. + - **`Delay` for `EmbassyTcpDialer`** (feature `embassy-time`). The dialer supplies the session clock, so a connector generic over it needs no separate handle — which is what keeps the MQTT call sites unchanged. diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index d9136fb0..23d211d7 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -9,6 +9,73 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **The embedded session is event-driven: nothing polls** (design 053). The + loop used to wake every 10 ms to ask three sources whether they had work, + which on a battery node is the only state that normally runs — and the + "non-blocking peek" it polled with could block indefinitely, parking the loop + and with it the pings, the liveness check and every queued publish. Both were + one problem. The stream is now split into halves driven by three futures in + one `select`: a reader that lifts bytes off the socket, a writer that drains + encoded packets, and the session itself selecting on two channels and one + timer. Measured against the loop it replaces: **5 wakes in 3 seconds where + the poll cost ~300**, and a QoS 1 publish no longer spins at 1 kHz waiting + inline for its PUBACK — the acknowledgement arrives through the read half + like any other packet while the ping deadline keeps running. + + Two consequences worth knowing about. A partial packet is now "not enough + yet" rather than a parked loop, because packets are reassembled incrementally + instead of being read to a length the peer promised. And **the largest MQTT + packet the session can receive is 3584 bytes** (previously 4096): the + reassembly buffer, the read scratch and the inbound slot are carved out of + the same total the old single buffer cost, rather than added to it. Outbound + packets are encoded to exactly their own size on the heap the action channel + already uses, so they gain no fixed cap. + +- **TLS runs that same session** (design 053 §6.6). `mqtts://` was a loop of + its own because the MQTT client wanted a readiness peek that a TLS session + cannot answer honestly — its readiness is two-layered, since bytes on the + wire may decrypt to no application data at all. Nothing peeks any more, so + the bespoke `Connection`, the readiness probe onto the raw socket underneath + the TLS session, and the single `RefCell` that wrapped the whole socket so + both could reach it are all gone. What replaces them is one lock per + direction behind a cloneable handle, which is what lets `embedded-tls`'s + reader and writer run at once. TLS is now two adapter types and a handshake. + +- **`Settings::poll_interval` is removed.** There is no poll to pace. The other + fields are unchanged, and `ping_interval`, `connection_event_max_interval` + and `stabilisation_interval` now arm real deadlines rather than being + compared against a 10 ms tick. + +- **`BrokerTransport` and `SocketTransport` are removed** from + `embedded::session`. They existed to carry the readiness peek that a + `ByteStream` could not express; with the peek gone, a runtime that can dial a + `StreamDialer` can speak MQTT with no protocol code and no + `embedded-io-async` of its own. `MqttConnector::new(..).transport(..)` and + `.tls(..)` are untouched — this only affects code naming those two items + directly. + +- **The `D::Stream: embedded_io_async::{Read, Write, ReadReady}` bounds are + gone** from the connector's builders. A relaxation, so no caller breaks: the + connector now reaches a stream only through core's byte-stream traits. + +- **The `mountain-mqtt` dependency is the codec alone** (design 053 §6.7). It + moves to `aimdb-mountain-mqtt` 0.5.1 — upstream `main` with a zero-line + source delta — with `default-features = false` and **no features**, `defmt` + added back on the defmt leg alone. What this crate takes from it is the + sans-io half: the packet types, the readers and writers, the client state + machine. The driver half — the incremental reader, the loop, the in-flight + tracking — lives here now, so `embedded-hal-async` leaves the crate entirely + and `embedded-io-async` moves to the `embedded-tls` feature, the only place + that still names those traits. A Makefile guard asserts the dependency's + subtree stays codec-only. + +- **At-most-once delivery is unchanged, but a publish fails later.** An action + is still taken off the queue before it is performed and still dropped if the + session ends, logged with its topic. What changed is *when* a publish counts + as failed: it no longer blocks the loop waiting for its acknowledgement, so a + slow broker no longer stops pings, and only one QoS 1 publish is in flight at + a time — the action arm simply parks until the PUBACK lands. + - **The backend split is std vs `no_std`, not Tokio vs Embassy.** The embedded backend runs on any target whose adapter supplies a `StreamDialer`, so a new platform costs one adapter crate and no change here. Features rename diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index ab6188b7..f30b9215 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -46,10 +46,6 @@ embedded = [ "aimdb-core/alloc", "aimdb-core/connector-session", "mountain-mqtt", - # The transport bridge names these traits in its bounds, and the session - # loop bridges core's `Delay` to the client's `DelayNs`. - "dep:embedded-io-async", - "dep:embedded-hal-async", # Executor-independent: channels and future combinators only. "embassy-sync", "dep:embassy-futures", @@ -77,7 +73,12 @@ embassy-runtime = [ # verification (`rustpki`; `rsa`/`p384` so public CA chains verify out of the # box). Runtime-neutral: the dialer resolves the host and the runtime's wall # clock dates the certificate. -embedded-tls = ["embedded", "dep:embedded-tls", "dep:rand_core"] +embedded-tls = [ + "embedded", + "dep:embedded-tls", + "dep:rand_core", + "dep:embedded-io-async", +] # `embedded-tls` plus the SNTP time source, for a board with no RTC. Needs a # network stack of its own, which is why it is the Embassy half. @@ -179,10 +180,7 @@ embassy-net = { version = "0.9.0", optional = true, features = [ # delta, published because crates.io `mountain-mqtt` is still 0.2.0 and a # published crate cannot take a git dependency. Keyed as before to keep imports # and feature references. Retire it when upstream releases 0.5.0 or later. -mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.5.1", default-features = false, optional = true, features = [ - "embedded-io-async", - "embedded-hal-async", -] } +mountain-mqtt = { package = "aimdb-mountain-mqtt", version = "0.5.1", default-features = false, optional = true } # TLS for the Embassy client (no_std TLS 1.3; design 044) embedded-tls = { version = "0.19", default-features = false, optional = true, features = [ @@ -202,6 +200,7 @@ embassy-net-driver-channel = { version = "0.4.0", optional = true } critical-section = { version = "1.1", optional = true } [dev-dependencies] +embedded-io-async = { workspace = true } # The `mqtts://` host smoke: a self-signed certificate and a real TLS server. rand = "0.8" rcgen = "0.13" @@ -219,7 +218,7 @@ aimdb-data-contracts = { path = "../aimdb-data-contracts", default-features = fa ] } aimdb-tokio-adapter = { path = "../aimdb-tokio-adapter", features = [ "tokio-runtime", - "embedded-io", + "net", ] } [package.metadata.docs.rs] diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 2a764c4d..d60d4217 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -177,7 +177,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( &'a self, @@ -199,7 +198,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { fn build<'a>( &'a self, diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 98509fd4..90e6caf7 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -231,7 +231,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { Box::pin(async move { let topics = inbound_topics(db); @@ -268,7 +267,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { Box::pin(async move { let topics = inbound_topics(db); @@ -400,7 +398,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); @@ -458,7 +455,6 @@ where + Send + Sync + 'static, - D::Stream: embedded_io_async::Read + embedded_io_async::Write + embedded_io_async::ReadReady, { match host_ip_literal(&broker.host) { Some(core::net::IpAddr::V6(_)) => { diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index d473aa38..177ebf04 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -577,6 +577,35 @@ fn write_error(error: PacketWriteError) -> Error { mod tests { use super::*; + /// Halves that never do anything: enough to build the session future and + /// measure it without polling it. + struct NullRead; + struct NullWrite; + + impl ByteRead for NullRead { + async fn read(&mut self, _buf: &mut [u8]) -> aimdb_core::session::TransportResult { + core::future::pending().await + } + } + + impl ByteWrite for NullWrite { + async fn write_all(&mut self, _buf: &[u8]) -> aimdb_core::session::TransportResult<()> { + Ok(()) + } + + async fn flush(&mut self) -> aimdb_core::session::TransportResult<()> { + Ok(()) + } + } + + struct NullDelay; + + impl Delay for NullDelay { + fn sleep(&self, _d: Duration) -> impl core::future::Future + Send { + core::future::pending() + } + } + #[test] fn the_buffer_budget_is_what_the_old_loop_cost() { // Criterion 7: the reassembly buffer plus the read scratch plus one @@ -595,6 +624,51 @@ mod tests { assert_eq!(next_deadline(0, true, 100, 500, None, Some(20)), 20); } + /// Criterion 7, as an enforced bound rather than an argument: the session + /// task's footprint must not grow past what the polled loop cost. + /// + /// That loop held one `BUFFER_SIZE` packet buffer plus its client; this one + /// holds the reassembly buffer, the read scratch and one inbound slot, + /// which `the_buffer_budget_is_what_the_old_loop_cost` pins at exactly + /// `BUFFER_SIZE` between them. What this adds is a ceiling on everything + /// else — client state, channel overhead, the three futures' frames — + /// measured at 6184 bytes when written. + /// + /// The bound is `BUFFER_SIZE * 2` rather than that measurement: a few dozen + /// bytes either way is codegen, and a test that fails on a toolchain bump + /// teaches people to raise it rather than to look. Another buffer of any + /// consequence does not fit underneath it. + #[test] + fn the_session_future_has_not_outgrown_the_loop_it_replaced() { + let events = EventChannel::new(); + let actions = ActionChannel::new(); + let settings = Settings::default(); + let connection_settings = ConnectionSettings::unauthenticated("size-probe"); + let runtime = aimdb_core::executor::test_support::NoopRuntimeOps; + + // Built, never polled: `size_of_val` on the future is the whole point. + let session = run_session( + ConnectionId::new(0), + NullRead, + NullWrite, + &connection_settings, + &[], + &events, + &actions, + &settings, + &NullDelay, + &runtime, + ); + + let size = core::mem::size_of_val(&session); + assert!( + size <= BUFFER_SIZE * 2, + "the session future is {size} bytes, over the {} allowed — has a \ + buffer been added rather than carved out of BUFFER_SIZE?", + BUFFER_SIZE * 2 + ); + } + #[test] fn a_deadline_in_the_past_still_sleeps_a_tick() { // Never zero: a zero-length sleep would spin the loop. diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index e74a16d7..839d8b25 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -25,6 +25,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`ByteStream::split` for `TokioByteStream`.** Through `tokio::io::split`, + which costs a lock: the halves share the stream behind a mutex taken inside + each `poll`. It is never held across an await, so it cannot deadlock, but it + is a serialisation point the native `TcpStream::split` does not have. The + native one is unreachable here — the type is generic over `S`, so an impl + specialised to `TcpStream` would overlap the blanket one. - **`Delay` for `TokioTcpDialer`.** The dialer supplies the session clock, which — together with the `Clone` it already derived — is what lets the embedded MQTT backend run on a host unchanged. From a3cda51490acaae1d8ae4d4ef95045c531333b85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 17:28:44 +0000 Subject: [PATCH 30/48] Refactor and improve documentation across MQTT connector modules - Updated comments in `sntp.rs` to clarify the purpose of SNTP for Unix time synchronization. - Simplified and clarified documentation in `tls.rs`, focusing on the TLS transport and its interaction with the MQTT client. - Enhanced clarity in `native.rs` regarding the broker connection setup and the role of the `MqttConnectorImpl`. - Improved comments in `backend_parity.rs` and `common/mod.rs` to better explain the purpose of tests and the behavior of the fake broker. - Streamlined documentation in `embassy_broker.rs` to focus on the interaction between `embassy-net` stacks and the fake broker. - Clarified the behavior of the session loop in `session_loop.rs` and `tokio_broker.rs`, emphasizing reconnect and subscription logic. - Updated `net.rs` to improve the explanation of stream splitting and its implications for locking and deadlock prevention. --- aimdb-core/src/session/io.rs | 43 +++---- aimdb-embassy-adapter/src/net.rs | 22 ++-- aimdb-embassy-adapter/tests/dns.rs | 33 +++-- aimdb-mqtt-connector/src/connector.rs | 28 ++--- aimdb-mqtt-connector/src/embedded/manager.rs | 5 +- aimdb-mqtt-connector/src/embedded/mod.rs | 61 +++------ .../src/embedded/packet_reader.rs | 40 ++---- aimdb-mqtt-connector/src/embedded/session.rs | 29 ++--- .../src/embedded/session_loop.rs | 100 ++++----------- aimdb-mqtt-connector/src/embedded/sntp.rs | 25 ++-- aimdb-mqtt-connector/src/embedded/tls.rs | 119 +++++------------- aimdb-mqtt-connector/src/native.rs | 48 +++---- aimdb-mqtt-connector/tests/backend_parity.rs | 18 ++- aimdb-mqtt-connector/tests/common/mod.rs | 35 ++---- aimdb-mqtt-connector/tests/embassy_broker.rs | 23 ++-- aimdb-mqtt-connector/tests/session_loop.rs | 11 +- aimdb-mqtt-connector/tests/tls_broker.rs | 19 ++- aimdb-mqtt-connector/tests/tls_session.rs | 28 ++--- aimdb-mqtt-connector/tests/tokio_broker.rs | 23 ++-- aimdb-tokio-adapter/src/net.rs | 10 +- 20 files changed, 233 insertions(+), 487 deletions(-) diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index 9ff73c16..40d84a31 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -58,26 +58,18 @@ pub trait ByteWrite { /// 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 **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. A caller needing the two -/// directions to run at once borrows them apart with -/// [`split`](ByteStream::split); one that does not is serialized anyway, as -/// `Connection`'s own `recv`/`send` are. +/// `embedded_io_async::Read` and `tokio::io::AsyncRead`. The stream is **one +/// value** — `&mut self` on both directions — so it can wrap a socket that +/// lends out only borrowed halves; a caller needing both directions at once +/// borrows them apart with [`split`](ByteStream::split). /// /// # Cancellation /// -/// [`read`](ByteStream::read) is cancel-safe on both adapters AimDB ships: -/// dropping the future before it completes consumes nothing. That is a -/// property of those transports rather than a promise of this trait — a -/// reader that cannot resume mid-packet is still free to implement it — so a -/// caller that drops reads has to know which transport it holds. -/// -/// [`write_all`](ByteStream::write_all) is **not** cancel-safe anywhere, and -/// must never sit in a `select` arm: a partial write desynchronises the -/// framing above it with nothing to resync on. +/// [`read`](ByteStream::read) is cancel-safe on both adapters AimDB ships, but +/// that is a property of those transports, not a promise of this trait. +/// [`write_all`](ByteStream::write_all) is **not** cancel-safe anywhere and +/// must never sit in a `select` arm: a partial write desynchronises the framing +/// above it with nothing to resync on. pub trait ByteStream { /// Read into `buf`, returning the byte count; `Ok(0)` is EOF. fn read<'a>( @@ -94,14 +86,11 @@ pub trait ByteStream { /// Flush any buffered bytes toward the peer. fn flush(&mut self) -> impl Future> + Send + '_; - /// Borrow this stream as independent read and write halves. + /// Borrow this stream as independent read and write halves, pollable + /// concurrently without either waiting on the other. /// - /// Both may be polled concurrently and neither sees the other's state, so - /// a reader and a writer can share one stack frame without either waiting - /// on the other. The halves borrow the stream rather than owning it — - /// enough for two futures in one `select`, which is what a full-duplex - /// session loop needs; a caller wanting owned or `'static` halves needs a - /// different seam. + /// The halves borrow rather than own, so a caller wanting owned or + /// `'static` halves needs a different seam. fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_); } @@ -727,10 +716,8 @@ mod tests { } } - /// A stream whose read cannot finish until its write half has run: the - /// read waits to be notified, and only `write_all` notifies. Drives the one - /// property [`ByteStream::split`] exists for — a blocked reader must not - /// block the writer — which a single `&mut` stream cannot express at all. + /// A stream whose read cannot finish until its write half has run, so a + /// blocked reader that blocked the writer would deadlock the test. #[derive(Clone, Default)] struct DuplexMock { written: Arc>>, diff --git a/aimdb-embassy-adapter/src/net.rs b/aimdb-embassy-adapter/src/net.rs index 537c979c..6afb5e1a 100644 --- a/aimdb-embassy-adapter/src/net.rs +++ b/aimdb-embassy-adapter/src/net.rs @@ -208,12 +208,10 @@ impl ByteStream for EmbassyTcpStream { }) } - /// Borrow the socket's own halves, which `embassy-net` hands out lock-free - /// — both are a copy of the socket's `io` handle. + /// Borrow the socket's own halves, which `embassy-net` hands out lock-free. /// /// A stream whose socket is already gone still has to produce halves, so - /// each carries the `Option` and reports [`TransportError::Closed`] on use, - /// exactly as the unsplit methods do. + /// each carries the `Option` and reports [`TransportError::Closed`] on use. fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { let (rx, tx) = match self.socket.as_mut() { Some(socket) => { @@ -351,17 +349,11 @@ unsafe impl Sync for EmbassyTcpDialer {} impl EmbassyTcpDialer { /// Turn a host into an address to dial. /// - /// An IP literal is parsed here and never queried, so a stack with no DNS - /// server configured keeps dialing literals. A name goes to the stack's - /// resolver: `A` first and `AAAA` only if that answers nothing, which is - /// the order a dual-stack `getaddrinfo` reports for the same name — the - /// point being that a connector sees one behaviour across adapters. The - /// second query costs a round trip (or, against an unreachable server, a - /// second timeout) but only on a dial that was going to fail anyway. - /// - /// Every failure is [`TransportError::Io`], matching `TokioTcpDialer`, - /// where `TcpStream::connect` folds resolution and connection into one - /// `io::Error` too. + /// An IP literal is never queried, so a stack with no DNS server configured + /// still dials literals. A name goes to the resolver, `A` first and `AAAA` + /// only if that answers nothing — the order `getaddrinfo` reports, so a + /// connector sees one behaviour across adapters. Every failure is + /// [`TransportError::Io`], matching `TokioTcpDialer`. async fn resolve(&self, host: &str) -> TransportResult { if let Ok(addr) = host.parse::() { return Ok(addr.into()); diff --git a/aimdb-embassy-adapter/tests/dns.rs b/aimdb-embassy-adapter/tests/dns.rs index 36ff0b37..9a780309 100644 --- a/aimdb-embassy-adapter/tests/dns.rs +++ b/aimdb-embassy-adapter/tests/dns.rs @@ -1,11 +1,9 @@ //! Host smoke for the resolution half of [`StreamDialer`] on Embassy. //! -//! `connect` takes a host *string* and every adapter must accept both a -//! hostname and an IP literal, or a connector has to grow a per-runtime -//! validation gate — which is exactly what `mqtt://`/`mqtts://` had. Only a -//! real stack can show a name being queried, so two crossover-wired -//! `embassy-net` stacks drive it: B answers DNS on UDP/53 and listens on TCP, -//! A dials it by name. +//! `connect` takes a host *string*, and every adapter must accept both a +//! hostname and an IP literal so no connector needs a per-runtime validation +//! gate. Only a real stack shows a name being queried, so two crossover-wired +//! `embassy-net` stacks drive it: B answers DNS on UDP/53, A dials it by name. #![cfg(feature = "net")] extern crate alloc; @@ -121,10 +119,9 @@ async fn cable(mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, /// Build a reply to `query`: one `A` record holding [`B_IP`] when the query /// names [`BROKER`], `NXDomain` otherwise. /// -/// Enough of RFC 1035 to satisfy smoltcp's client and no more — it checks the -/// transaction id, the question type, and that the answer's name equals the -/// one it asked about, so the question is echoed verbatim and the answer name -/// repeated uncompressed rather than written as a `0xC00C` pointer. +/// Enough of RFC 1035 for smoltcp's client, which checks the transaction id, +/// the question type and the answer's name — so the question is echoed verbatim +/// and the answer name repeated uncompressed rather than as a `0xC00C` pointer. fn reply(query: &[u8]) -> Option> { const A: u16 = 0x0001; const IN: u16 = 0x0001; @@ -267,10 +264,8 @@ where // Tests. // =========================================================================== -/// The regression behind `mqtts://broker.example.com`: a hostname reached the -/// dialer, which only parsed IP literals, so the connector reconnect-looped -/// forever. Dial by name and exchange a byte to prove the resolved address is -/// the one that got connected. +/// A hostname is resolved rather than rejected: dial by name and exchange a +/// byte, proving the resolved address is the one that got connected. #[test] fn dials_a_hostname() { let outcome = drive(|a_stack, b_stack| async move { @@ -292,8 +287,8 @@ fn dials_a_hostname() { assert_eq!(outcome, Ok(())); } -/// An IP literal still dials without a query, so a deployment with no resolver -/// configured is unaffected by the name path above. +/// An IP literal dials without a query, so a deployment with no resolver +/// configured is unaffected by the name path. #[test] fn dials_an_ip_literal() { let outcome = drive(|a_stack, b_stack| async move { @@ -309,9 +304,9 @@ fn dials_an_ip_literal() { assert_eq!(outcome, Ok(())); } -/// A name that does not resolve fails as a connect failure would, and — the -/// part that matters for a reconnect loop — hands the socket back, so the next -/// dial is not stuck on [`TransportError::Busy`] forever. +/// A name that does not resolve fails as a connect failure would *and* hands +/// the socket back, so a reconnect loop is not stuck on +/// [`TransportError::Busy`] forever. #[test] fn an_unresolvable_name_fails_and_frees_the_socket() { let outcome = drive(|a_stack, b_stack| async move { diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index d60d4217..21075236 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -1,13 +1,9 @@ //! One `MqttConnector` over two protocol backends. //! -//! Unlike the other connectors, MQTT does not converge on a single protocol -//! implementation. `rumqttc` owns its socket, TLS and reconnect — its -//! `Transport` is a closed enum, so no stream can be injected — while -//! `mountain-mqtt` is generic over `embedded-io-async`. The two stay separate, -//! and this type is the seam between them. -//! -//! Broker URL, client id and credentials live here rather than in a backend, so -//! there is one constructor and one set of setters whichever backend runs. +//! The two backends cannot converge: `rumqttc`'s `Transport` is a closed enum, +//! so no stream can be injected, while `mountain-mqtt` is generic over +//! `embedded-io-async`. Broker URL, client id and credentials live here rather +//! than in either backend, so there is one set of setters whichever runs. //! //! | Backend | Client | QoS | TLS | //! |---|---|---|---| @@ -58,9 +54,9 @@ pub struct MqttConnector { impl MqttConnector { /// Connect to `broker_url` (`mqtt://host:port` or `mqtts://host:port`). /// - /// Without a transport this is the `rumqttc` backend, and without - /// [`with_client_id`](Self::with_client_id) it generates a UUID-based - /// client id at build. + /// Without a transport this is the `rumqttc` backend; without + /// [`with_client_id`](Self::with_client_id) the client id is a generated + /// UUID. pub fn new(broker_url: impl Into) -> Self { Self { broker_url: broker_url.into(), @@ -70,8 +66,7 @@ impl MqttConnector { } } - /// Dial plain sessions through an adapter's stream dialer — the same call - /// on any runtime's adapter, with no change in this crate. + /// Dial plain sessions through an adapter's stream dialer. #[cfg(feature = "embedded")] pub fn transport(self, dialer: D) -> MqttConnector> { MqttConnector { @@ -84,8 +79,6 @@ impl MqttConnector { /// Dial `mqtts://` sessions through an adapter's stream dialer, with /// `options` supplying the trust root, buffers and entropy. - /// - /// The dialer resolves the host, so TLS needs no network stack of its own. #[cfg(feature = "embedded-tls")] pub fn tls( self, @@ -136,9 +129,8 @@ mod sealed { /// A backend with a build path compiled in. /// -/// Implemented for [`Native`] only under `std`, so a `no_std` build -/// that forgets `.transport(..)` fails here with a message naming the fix -/// rather than on core's `ConnectorBuilder`. +/// Implemented for [`Native`] only under `std`, so a `no_std` build that +/// forgets `.transport(..)` fails here rather than deep in core. #[diagnostic::on_unimplemented( message = "`MqttConnector<{Self}>` has no MQTT backend compiled in", label = "no backend for this configuration", diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 9c77f091..adaf318f 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -2,9 +2,8 @@ //! them over. //! //! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and -//! source are plain `Connector`/`Source` impls with no force-`Send` wrapper. -//! Time comes from core's [`aimdb_core::session::Delay`] and the runtime's -//! monotonic clock, so nothing here names an executor. +//! source need no force-`Send` wrapper. Time comes from core's +//! [`aimdb_core::session::Delay`], so nothing here names an executor. use core::time::Duration; diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 90e6caf7..4c271d7a 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -2,23 +2,9 @@ //! //! Outbound publishes and inbound routing ride core's [`pump_sink`] / //! [`pump_source`] directly — the session channels are `Sync`, so nothing -//! force-`Send` stands between them and the runner. This module contributes -//! the connector builder, the `MqttSink`/`MqttSource` over those channels, and -//! the actions and events that cross them. +//! force-`Send` stands between them and the runner. //! -//! # Usage -//! -//! ```rust,ignore -//! let db = AimDbBuilder::new() -//! .runtime(embassy_adapter) -//! .with_connector( -//! MqttConnector::new("mqtt://192.168.1.100:1883") -//! .transport(EmbassyNet::tcp(stack, rx, tx)) -//! .with_client_id("my-unique-device-id"), -//! ) -//! .build() -//! .await?; -//! ``` +//! See the crate docs for a usage example. pub mod manager; pub mod session; @@ -86,7 +72,7 @@ pub(crate) type EventChannel = crate::embedded::manager::EventChannel for AimdbM // wrapper stands between them and the runner. // =========================================================================== -/// Outbound sink: turns a `pump_sink` publish into an -/// `AimdbMqttAction::Publish` enqueued onto the session's action channel. +/// Turns a `pump_sink` publish into an `AimdbMqttAction::Publish` on the +/// session's action channel. struct MqttSink { actions: Arc, } @@ -182,8 +165,7 @@ impl aimdb_core::transport::Connector for MqttSink { } } -/// Inbound source: drains the session's event channel, yielding each received -/// message as `(topic, payload)` for `pump_source` to fan out. +/// Drains the session's event channel as `(topic, payload)` for `pump_source`. struct MqttSource { events: Arc, } @@ -207,12 +189,8 @@ impl aimdb_core::session::Source for MqttSource { } /// Force-`Send + Sync` slot for the TLS materials: [`TlsOptions`] holds -/// `&'static mut` exclusive resources (TRNG, record buffers), so it is -/// neither `Sync` nor takeable through the `&self` that -/// [`ConnectorBuilder::build`] receives without interior mutability. -/// -/// 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. +/// `&'static mut` exclusive resources, so it is neither `Sync` nor takeable +/// through the `&self` that [`ConnectorBuilder::build`] receives. #[cfg(feature = "embedded-tls")] pub(crate) type TlsSlot = aimdb_core::session::OneShot; @@ -360,8 +338,8 @@ fn parse_broker_url(broker_url: &str) -> Result /// Build the `ConnectionSettings<'static>` for MQTT CONNECT. /// /// The identity strings are leaked to reach `'static`: one small, bounded leak -/// per connector at build. A shared cell would be smaller but would hand every -/// connector after the first the identity of the first. +/// per connector at build, so that a second connector cannot inherit the +/// first's identity. fn static_connection_settings( client_id: Option<&str>, credentials: Option<&(String, String)>, @@ -380,10 +358,8 @@ fn static_connection_settings( } /// Set up the plain-TCP broker session loop, returning the action channel -/// (outbound), the event channel (inbound), and the task future. The loop -/// re-subscribes the inbound topics on every connection, so routing survives -/// reconnects. Synchronous — no `.await` — so the caller's `build` future -/// stays `Send`. +/// (outbound), the event channel (inbound), and the task future. Synchronous — +/// no `.await` — so the caller's `build` future stays `Send`. fn setup_manager( broker: &BrokerUrl, connection_settings: ConnectionSettings<'static>, @@ -436,9 +412,8 @@ where Ok((actions, events, alloc::vec![manager_task])) } -/// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source -/// task. Synchronous — no `.await` — so the caller's `build` future stays -/// `Send`. +/// Set up the TLS broker manager ([`run_tls`]) plus the SNTP time-source task. +/// Synchronous — no `.await` — so the caller's `build` future stays `Send`. #[cfg(feature = "embedded-tls")] fn setup_tls_manager( broker: &BrokerUrl, @@ -536,10 +511,8 @@ where /// Map a QoS level to mountain-mqtt's `QualityOfService`. /// -/// `2` downgrades to 1: MQTT 5 exactly-once is not implemented by the client. -/// Anything above 2 is not a QoS level at all and is rejected, as `Native` -/// rejects it — a typo in a link URL should not silently publish at a -/// different guarantee than asked for. +/// `2` downgrades to 1 (the client implements no exactly-once); anything above +/// 2 is rejected, as `Native` rejects it. fn map_qos(qos: u8) -> Result { match qos { 0 => Ok(QualityOfService::Qos0), diff --git a/aimdb-mqtt-connector/src/embedded/packet_reader.rs b/aimdb-mqtt-connector/src/embedded/packet_reader.rs index f0dca624..47864322 100644 --- a/aimdb-mqtt-connector/src/embedded/packet_reader.rs +++ b/aimdb-mqtt-connector/src/embedded/packet_reader.rs @@ -1,22 +1,13 @@ //! Incremental MQTT packet framing: push bytes in, take whole packets out. //! -//! This is what makes a partial packet a non-event. `mountain-mqtt`'s own -//! reader asks the transport for exactly as many bytes as the fixed header -//! promises and waits inside that read until they arrive, so a peer that -//! stalls mid-packet parks the caller — with the polled session loop that -//! meant pings, liveness and every queued publish stopped with it. Here a -//! partial packet is simply "not enough yet": [`feed`](PacketReader::feed) -//! takes whatever arrived, [`framed_len`](PacketReader::framed_len) says -//! whether a whole packet is present, and nothing blocks. +//! A partial packet is a non-event: nothing blocks, where `mountain-mqtt`'s own +//! reader waits inside one read for as many bytes as the fixed header promises +//! and so parks the caller on a peer that stalls mid-packet. //! -//! # Why framing and parsing are separate -//! -//! `framed_len` borrows nothing and [`parse`](PacketReader::parse) takes -//! `&self`, because `MqttBufReader` holds `&[u8]` rather than `&mut [u8]`. So -//! a parsed packet holds a *shared* borrow of the buffer, it ends when the -//! caller drops the packet, and [`consume`](PacketReader::consume) is then -//! free to take `&mut self` and compact. No `unsafe`, no self-referential -//! struct, no allocation per packet. +//! Framing and parsing are separate because [`parse`](PacketReader::parse) +//! takes `&self`: a parsed packet holds only a shared borrow, so dropping it +//! leaves [`consume`](PacketReader::consume) free to take `&mut self` and +//! compact. No `unsafe`, no self-referential struct, no allocation per packet. use mountain_mqtt::codec::mqtt_reader::{MqttBufReader, MqttReader}; use mountain_mqtt::data::packet_type::PacketType; @@ -41,10 +32,8 @@ impl PacketReader { } } - /// Append freshly read bytes. - /// - /// Fails only if they would not fit, which at this layer means the peer - /// sent a packet larger than `N`. + /// Append freshly read bytes. Fails only if the peer sent a packet larger + /// than `N`. pub(crate) fn feed(&mut self, bytes: &[u8]) -> Result<(), PacketReadError> { if self.len + bytes.len() > N { return Err(PacketReadError::PacketTooLargeForBuffer); @@ -55,10 +44,7 @@ impl PacketReader { } /// Total length of the complete packet at the head of the buffer, or - /// `Ok(None)` if not enough bytes have landed yet. - /// - /// This is upstream's `receive_rest_of_packet` varint scan restated as a - /// pure function over what is already buffered, so it borrows nothing and + /// `Ok(None)` if not enough bytes have landed yet. Borrows nothing and /// commits to nothing. pub(crate) fn framed_len(&self) -> Result, PacketReadError> { if self.len < 1 { @@ -102,7 +88,7 @@ impl PacketReader { /// Parse the complete packet at the head of the buffer. /// /// `total` must come from [`framed_len`](Self::framed_len). Takes `&self`, - /// so the returned packet holds only a shared borrow — see the module note. + /// so the returned packet holds only a shared borrow. pub(crate) fn parse( &self, total: usize, @@ -111,8 +97,8 @@ impl PacketReader { reader.get() } - /// Drop a consumed packet from the head, sliding any bytes of the next one - /// down. Needs `&mut self`, so it can only run once the packet is dropped. + /// Drop a consumed packet from the head, sliding the next one down. Takes + /// `&mut self`, so it can only run once the parsed packet is dropped. pub(crate) fn consume(&mut self, total: usize) { self.buf.copy_within(total..self.len, 0); self.len -= total; diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index 0d1df200..c92794c8 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -2,27 +2,19 @@ //! backend: dial, run one MQTT session over the stream's two halves, wait, //! repeat. //! -//! Built on core's [`ByteStream`](aimdb_core::session::ByteStream) alone. The -//! MQTT client used to need `receive_if_ready` — a non-blocking peek a byte -//! stream cannot express and a TLS session cannot honestly provide — because -//! the session polled. Nothing polls any more (design 053), so the peek is -//! gone and with it the transport seam that existed to carry it: a runtime that -//! can dial a [`StreamDialer`](aimdb_core::session::StreamDialer) can speak -//! MQTT, with no protocol code and no `embedded-io-async` of its own. +//! Built on core's [`ByteStream`](aimdb_core::session::ByteStream) alone: any +//! runtime that can dial a +//! [`StreamDialer`](aimdb_core::session::StreamDialer) can speak MQTT. use core::future::Future; /// Asserts that a broker session future is `Send`. /// -/// Everything the session holds is `Send`: [`StreamDialer`] guarantees -/// `Stream: Send`, the channels use `CriticalSectionRawMutex`, and the state -/// cell is a blocking mutex. What the compiler cannot see through is -/// `embedded-io-async` — its traits put no `Send` bound on their futures, and -/// the loop reaches them through a generic transport, so naming the bound needs -/// return-type notation, still unstable on the pinned toolchain. -/// -/// This is weaker than an executor assumption, not stronger: it rests on a -/// trait guarantee, so it holds under a preemptive scheduler too. +/// Everything the session holds is `Send` — [`StreamDialer`] guarantees +/// `Stream: Send`, the channels use `CriticalSectionRawMutex` — but +/// `embedded-io-async` puts no `Send` bound on its futures, and naming that +/// bound through a generic transport needs return-type notation, unstable on +/// the pinned toolchain. pub(crate) struct SendSession(F); // SAFETY: upheld by the caller of `SendSession::new`. @@ -51,9 +43,8 @@ impl Future for SendSession { /// Dial, run one session, wait, repeat. Never returns. /// -/// One implementation for every transport: the dialer supplies both the stream -/// and the clock. `topics` is re-subscribed on each connection, so inbound -/// routing survives a reconnect. +/// The dialer supplies both the stream and the clock. `topics` is re-subscribed +/// on each connection, so inbound routing survives a reconnect. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_sessions( dialer: D, diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 177ebf04..a901301d 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -1,41 +1,14 @@ //! The event-driven broker session: three futures in one `select3`. //! -//! Design 053 §5.1. The stream is split, so reading and writing never contend, -//! and **the only thing ever cancelled is a channel receive**: +//! The stream is split and the only thing ever cancelled is a channel receive, +//! so the non-cancel-safe `write_all` never sits in a `select` arm and no +//! partially-read packet is ever discarded — which is what lets the TLS path +//! share this loop. //! -//! - [`read_into`] frames nothing and knows no MQTT — it lifts bytes off the -//! socket and hands them on. Never cancelled. -//! - [`write_out`] drains encoded packets to the socket. Never cancelled, which -//! is what keeps `write_all` — which is *not* cancel-safe — out of a `select` -//! arm by construction. -//! - [`client_loop`] selects on two channels and one timer. It owns every piece -//! of client state: the [`PacketReader`], the `ClientState`, the liveness -//! window and the deadlines. Nothing is shared across the three futures, so -//! there is no `RefCell` and no cross-future wakeup. -//! -//! Because no socket read is ever dropped, no partially-read packet is ever -//! discarded — not ours, and not a TLS record reader's. That is what lets the -//! TLS path share this loop. -//! -//! # What replaces the poll -//! -//! Nothing wakes this loop but data, an action, or a deadline. A connected idle -//! session wakes at the ping cadence (2 s) rather than the 100 Hz the previous -//! loop paid, and a QoS 1 publish no longer spins at 1 kHz waiting inline for -//! its PUBACK: the acknowledgement arrives through the read half like any other -//! packet, and the action arm simply stays parked until it does. -//! -//! # Framing division -//! -//! §5.1 sketches `rx_fut` framing whole packets into the channel. This does the -//! division one notch lower — raw chunks cross the channel and `client_loop` -//! frames them — because a packet-granular channel needs a second packet-sized -//! buffer for its slot, and criterion 7 requires the framing buffer, the -//! channel slots and the encode buffer to come out of the existing -//! `BUFFER_SIZE` rather than add to it. Chunks cost two 256-byte buffers -//! instead of two 2 KB ones, which is what pays for the reassembly buffer being -//! as large as it is. `rx_fut` ends up with even less knowledge than §5.1 gives -//! it, which is the direction that section argues for. +//! [`read_into`] and [`write_out`] know no MQTT; [`client_loop`] owns all +//! client state and wakes only on data, an action or a deadline. Raw chunks +//! cross the inbound channel rather than whole packets, so framing needs no +//! second packet-sized buffer. use core::convert::Infallible; use core::time::Duration; @@ -69,11 +42,9 @@ const RX_CHUNK: usize = 256; /// The largest MQTT packet the session can receive. /// -/// The memory budget is criterion 7: the reassembly buffer, the inbound slots -/// and the encode buffer together must not exceed what the old loop's single -/// `mqtt_buffer` cost. `rx`'s scratch plus one inbound slot take `2 * RX_CHUNK` -/// of it; outbound packets are encoded to exactly-sized `Vec`s, the same -/// heap the action channel already uses, so they hold no fixed buffer at all. +/// Reassembly, the inbound slots and the encode buffer all come out of one +/// `BUFFER_SIZE`; outbound packets are encoded to exactly-sized `Vec`s +/// rather than a fixed buffer. const PACKET_BUFFER_SIZE: usize = BUFFER_SIZE - 2 * RX_CHUNK; /// One chunk of freshly read bytes, in flight from the read half to the loop. @@ -81,18 +52,12 @@ type Chunk = heapless::Vec; /// Drive one MQTT session over a split stream until an error ends it. /// -/// Connects, subscribes `subscribe_topics`, then keeps the session alive while -/// dispatching actions and forwarding events. Returns only on failure — the -/// caller reconnects. -/// -/// # Delivery +/// Connects, subscribes `subscribe_topics`, then dispatches actions and +/// forwards events. Returns only on failure — the caller reconnects. /// -/// **At most once, at this layer**, unchanged from the polled loop it replaces: -/// an action is taken off `actions` before it is performed, so the one action -/// in flight when a session ends is lost. Everything still queued survives, -/// because `actions` outlives the session. What has changed is *when* a publish -/// is considered failed: a QoS 1 publish no longer blocks the loop waiting for -/// its PUBACK, so a slow broker no longer stops pings. +/// **At most once**: an action is taken off `actions` before it is performed, +/// so the one in flight when a session ends is lost. Everything still queued +/// survives. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_session( connection_id: ConnectionId, @@ -460,7 +425,7 @@ impl Received { /// Turn one queued action into a packet on the wire. /// /// Sent before the state update, as upstream does: a state that believes a -/// publish is in flight when it is not would park the action arm forever. +/// publish is in flight when it is not parks the action arm forever. fn perform( action: AimdbMqttAction, state: &mut ClientStateNoQueue, @@ -514,12 +479,8 @@ fn perform( Ok(()) } -/// Encode a packet to exactly its own length. -/// -/// Two passes over a counting writer and then a real one, which is how the -/// codec measures a packet anyway — the alternative is a fixed buffer sized for -/// the largest packet anyone might send, which is what criterion 7 is trying to -/// avoid. The bytes are the same heap the action channel already carries. +/// Encode a packet to exactly its own length: a counting pass, then a real +/// one, so no fixed buffer is sized for the largest packet anyone might send. fn encode(packet: &P) -> Result, Error> { let mut len_writer = MqttLenWriter::new(); len_writer.put(packet).map_err(write_error)?; @@ -532,10 +493,9 @@ fn encode(packet: &P) -> Result, Error> { /// Queue encoded bytes for the write half. /// -/// Never blocks: the action arm is gated on there being room, and a ping or -/// PUBACK dropped because the write half is backed up is recovered by the next -/// deadline or by the broker redelivering. Blocking here instead would park the -/// loop that has to notice the link is gone. +/// Never blocks — blocking would park the loop that has to notice the link is +/// gone. A ping or PUBACK dropped because the write half is backed up is +/// recovered by the next deadline or by redelivery. fn queue(outbound: &Channel, 4>, bytes: Vec) { if outbound.try_send(bytes).is_err() { #[cfg(feature = "defmt")] @@ -624,20 +584,8 @@ mod tests { assert_eq!(next_deadline(0, true, 100, 500, None, Some(20)), 20); } - /// Criterion 7, as an enforced bound rather than an argument: the session - /// task's footprint must not grow past what the polled loop cost. - /// - /// That loop held one `BUFFER_SIZE` packet buffer plus its client; this one - /// holds the reassembly buffer, the read scratch and one inbound slot, - /// which `the_buffer_budget_is_what_the_old_loop_cost` pins at exactly - /// `BUFFER_SIZE` between them. What this adds is a ceiling on everything - /// else — client state, channel overhead, the three futures' frames — - /// measured at 6184 bytes when written. - /// - /// The bound is `BUFFER_SIZE * 2` rather than that measurement: a few dozen - /// bytes either way is codegen, and a test that fails on a toolchain bump - /// teaches people to raise it rather than to look. Another buffer of any - /// consequence does not fit underneath it. + /// A ceiling on the session task's footprint. The bound is loose enough to + /// absorb codegen drift, but not loose enough to fit another buffer. #[test] fn the_session_future_has_not_outgrown_the_loop_it_replaced() { let events = EventChannel::new(); diff --git a/aimdb-mqtt-connector/src/embedded/sntp.rs b/aimdb-mqtt-connector/src/embedded/sntp.rs index 7a75f655..650337a1 100644 --- a/aimdb-mqtt-connector/src/embedded/sntp.rs +++ b/aimdb-mqtt-connector/src/embedded/sntp.rs @@ -1,9 +1,8 @@ //! SNTP time source, for a board whose runtime has no wall clock of its own. //! -//! Checking a certificate's validity window needs the current Unix time, and -//! the reference boards have no battery-backed RTC. Each sync feeds both -//! [`unix_now`] and the TLS handshake clock. Opt in with `TlsOptions::with_sntp`; -//! a runtime that answers `unix_time()` needs none of this. +//! Certificate validity needs the current Unix time, and the reference boards +//! have no battery-backed RTC. Each sync feeds both [`unix_now`] and the TLS +//! handshake clock. Opt in with `TlsOptions::with_sntp`. use core::sync::atomic::{AtomicU32, Ordering}; @@ -14,16 +13,14 @@ use embassy_time::{with_timeout, Duration, Instant, Timer}; use crate::sntp_codec; -/// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` is -/// unambiguous until 2106 and stays a single atomic on Cortex-M (no 64-bit -/// atomics there). +/// Unix seconds at the `embassy_time` epoch; 0 = not yet synced. `u32` stays a +/// single atomic on Cortex-M, which has no 64-bit atomics. static BOOT_UNIX_SECS: AtomicU32 = AtomicU32::new(0); /// NTP server port. const SNTP_PORT: u16 = 123; -/// Local ephemeral-port range for the client socket. smoltcp cannot bind -/// port 0, so "random source port" is randomized here per attempt — a reply -/// must land on the right port *and* echo the request nonce to be accepted. +/// Local ephemeral-port range for the client socket, randomized per attempt +/// because smoltcp cannot bind port 0. const LOCAL_PORT_BASE: u16 = 49152; const LOCAL_PORT_SPAN: u16 = 16384; /// How long to wait for a server reply before treating the sync as failed. @@ -98,11 +95,9 @@ pub(crate) enum SntpError { InvalidReply, } -/// Best-effort request nonce: the hardware TRNG belongs to the TLS session -/// (injected via `TlsOptions`), so unpredictability comes from the tick -/// counter through a splitmix64 finalizer. Enough to defeat *blind* reply -/// spoofing — an off-path attacker cannot observe when the request fired — -/// while an on-path attacker defeats unauthenticated NTP regardless. +/// Best-effort request nonce from the tick counter — the hardware TRNG belongs +/// to the TLS session. Enough to defeat blind reply spoofing; an on-path +/// attacker defeats unauthenticated NTP regardless. fn request_nonce() -> u64 { let mut z = Instant::now() .as_ticks() diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index b48c3895..18670c5f 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -1,25 +1,11 @@ //! The TLS transport for the embedded backend. //! -//! `mqtts://` broker sessions: an `embedded-tls` 1.3 session over the caller's -//! transport, split into halves the session loop reads and writes exactly as -//! it does a plaintext socket. Certificate verification is `rustpki` (pure -//! Rust) against the application-embedded root CA, dated by the runtime's wall -//! clock; entropy comes from the application-injected TRNG -//! ([`TlsOptions::new`]). -//! -//! The dialer resolves the host, so there is no network stack here: the same -//! session runs on a host over the Tokio adapter's transport. -//! -//! # Why this path used to be different -//! -//! The MQTT client needed a non-blocking peek (`receive_if_ready`), which a -//! TLS session cannot answer honestly: its readiness is two-layered, since -//! bytes on the wire may decrypt to no application data at all. That forced a -//! bespoke `Connection` here, a readiness probe onto the raw socket underneath -//! the TLS session, and one `RefCell` wrapping the whole socket so both could -//! reach it. Nothing polls any more, so the peek is gone and with it all -//! three: TLS now differs from the plain path by two adapter types and a -//! handshake. +//! An `embedded-tls` 1.3 session over the caller's transport, split into halves +//! the session loop reads and writes exactly as it does a plaintext socket. +//! Certificate verification is `rustpki` against the application-embedded root +//! CA, dated by the runtime's wall clock; entropy comes from the injected TRNG +//! ([`TlsOptions::new`]). The dialer resolves the host, so there is no network +//! stack here. use alloc::string::String; use alloc::sync::Arc; @@ -46,18 +32,15 @@ use mountain_mqtt::mqtt_manager::ConnectionId; /// covers RSA-4096 leaves with headroom. const CERT_BUFFER_SIZE: usize = 4096; -/// Minimum TLS record read buffer: a TLS 1.3 peer may send full-size records -/// (2^14 payload + record overhead) regardless of our `max_fragment_length` -/// offer, and `embedded-tls` fails any record larger than the buffer — a -/// smaller buffer works until the first big record, then reconnect-loops. -/// Enforced at `build()` so undersizing fails loudly instead. +/// Minimum TLS record read buffer. A peer may send full-size records (2^14 + +/// overhead) whatever `max_fragment_length` we offer, and `embedded-tls` fails +/// any record larger than the buffer, so `build()` rejects a smaller one. pub(crate) const READ_BUF_MIN: usize = 16_640; /// TLS materials for a `mqtts://` broker connection. /// -/// All references are `'static`: the session outlives `build()`, so buffers -/// and the RNG live in `StaticCell`s (or equivalents) owned by the -/// application — the one party that knows the board's memory budget. +/// All references are `'static`: the session outlives `build()`, so the buffers +/// and the RNG are owned by the application, which knows the memory budget. pub struct TlsOptions { pub(crate) rng: &'static mut (dyn CryptoRngCore + Send), pub(crate) ca_der: &'static [u8], @@ -120,26 +103,12 @@ impl TlsOptions { /// The socket's two halves behind one handle, so `embedded-tls` can clone a /// "socket" into its reader and its writer. /// -/// [`TlsConnection::split`] requires `Socket: Clone` and hands a clone to each -/// half, so the handle must tolerate one clone being read while another is -/// written — which is exactly what the session does. **Separate locks per -/// direction** make that safe by type: `TlsReader`'s impls require only -/// `AsyncRead` and `TlsWriter`'s only `AsyncWrite`, so the reader only ever -/// touches `rx` and the writer only `tx`. They never contend, and neither ever -/// waits on the other. -/// -/// The locks are async rather than `RefCell`s because a guard is held across -/// the inner `.await`. A `RefCell` there would be either a panic waiting for -/// the first genuinely concurrent read and write — which is what the session -/// now does on every connection — or an `await_holding_refcell_ref` allow -/// papering over it. Uncontended by construction, so the cost is an atomic -/// apiece. -/// -/// **The disjointness is an argument about a dependency**, and the one real -/// risk here: an `embedded-tls` that let its reader write — to answer a -/// KeyUpdate inline, say — would make the two halves contend at runtime. -/// `tests/tls_duplex.rs` drives a concurrent read and write to completion so -/// that shows up at a version bump rather than in the field. +/// One clone is read while another is written, so the directions take +/// **separate async locks** — `TlsReader` only ever touches `rx`, `TlsWriter` +/// only `tx`, and a guard may be held across the inner `.await`. That holds +/// only while `embedded-tls` never writes from its reader; +/// `tests/tls_duplex.rs` drives a concurrent read and write so a version bump +/// that changed it shows up there. struct DuplexHandle<'a, Rx, Tx> { rx: &'a Mutex, tx: &'a Mutex, @@ -198,17 +167,10 @@ where /// Asserts that a TLS half's I/O future is `Send`. /// -/// `embedded-tls` holds a `Range<*const u8>` over its own record buffer across -/// an await, so its futures are `!Send` by type even though nothing in them is -/// shared: the pointers address the very buffer the future owns exclusively. -/// -/// The session's three futures are polled as one task, and the TLS halves are -/// reachable from nowhere else, so no value here is ever touched from two -/// threads at once. A task that migrates between threads moves the whole of -/// itself, which is exactly what `Send` on the composed future asserts — and -/// the connector already asserts it, one level up, for the session as a whole -/// ([`SendSession`](crate::embedded::session::SendSession)). This states the -/// same thing at the point where the type system actually needs it. +/// `embedded-tls` holds a `Range<*const u8>` into the record buffer its own +/// future owns exclusively, which makes that future `!Send` by type. The +/// session's three futures are polled as one task, so nothing here is ever +/// touched from two threads at once. struct AssertSend(F); // SAFETY: upheld by the single-task argument above. @@ -226,11 +188,8 @@ impl Future for AssertSend { } } -/// The TLS session's read half, as the session loop's [`ByteRead`]. -/// -/// This pair is the whole of what TLS costs the loop: below them the session -/// cannot tell a plaintext socket from a record stream, so both paths run the -/// same three futures. +/// The TLS session's read half, as the session loop's [`ByteRead`]. Below this +/// pair the session cannot tell a plaintext socket from a record stream. struct TlsRead<'a, 'b, Rx, Tx>(TlsReader<'a, DuplexHandle<'b, Rx, Tx>, Aes128GcmSha256>); /// The TLS session's write half, as the session loop's [`ByteWrite`]. @@ -283,10 +242,8 @@ where /// Unix seconds for certificate validity, refreshed before each handshake. /// -/// `embedded_tls::TlsClock::now` is a static method, so the reading has to -/// reach it through a global. The source is whatever the runtime's wall clock -/// reports; a runtime with no clock of its own (an MCU without an RTC) gets -/// one from the SNTP task instead. +/// A global because `embedded_tls::TlsClock::now` is a static method. Fed by +/// the runtime's wall clock, or by the SNTP task on an MCU with no RTC. static UNIX_SECS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); /// The certificate-validity clock. `u32` is unambiguous until 2106 and stays a @@ -316,9 +273,8 @@ impl embedded_tls::TlsClock for WallClock { } /// [`CryptoProvider`] pairing the injected TRNG with `rustpki` certificate -/// verification (time from [`WallClock`]). Client-certificate signing is -/// deliberately absent — the mesh authenticates with MQTT credentials -/// instead. +/// verification (time from [`WallClock`]). No client-certificate signing: the +/// mesh authenticates with MQTT credentials. struct TrngProvider<'a> { rng: &'a mut (dyn CryptoRngCore + Send), verifier: CertVerifier<'static, Aes128GcmSha256, WallClock, CERT_BUFFER_SIZE>, @@ -340,9 +296,6 @@ impl CryptoProvider for TrngProvider<'_> { /// The TLS broker manager: dial → TLS handshake → MQTT session, reconnecting /// forever with the same [`Settings`] cadence as the plain path. -/// -/// The dialer resolves the host, so there is no DNS here and no network stack: -/// any runtime whose streams offer the `embedded-io-async` trio can run this. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_tls( dialer: D, @@ -467,13 +420,11 @@ where } } -/// Parse the broker host as an IP literal (with or without URL-style -/// brackets, `[::1]`). `build()` uses this to vet `mqtts://` hosts: -/// certificate verification prefers a DNS name, but an IPv4 literal can -/// still pass through `rustpki`'s CN fallback when a private CA pins the -/// dotted quad there (the dev bench does) — allowed with a warning. An IPv6 -/// literal can never match (the verifier's hostname charset has no `:`) and -/// is rejected. +/// Parse the broker host as an IP literal, brackets optional (`[::1]`). +/// +/// `build()` vets `mqtts://` hosts with this: an IPv4 literal can still match +/// through `rustpki`'s CN fallback, so it is allowed with a warning, while an +/// IPv6 literal never can (the hostname charset has no `:`) and is rejected. pub(crate) fn host_ip_literal(host: &str) -> Option { let host = host .strip_prefix('[') @@ -511,10 +462,8 @@ mod tests { } } - /// §6.6's disjointness, as an assertion rather than an argument: a read - /// parked inside one clone of the handle must not hold up a write through - /// another. Over a single shared cell — what `SharedStream` was — this is - /// precisely the shape that panics; over two locks it simply works. + /// A read parked inside one clone of the handle must not hold up a write + /// through another. #[test] fn a_parked_reader_does_not_hold_up_the_writer() { let rx = Mutex::new(PendingRead); diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index f9098e45..f47f7d8f 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -65,26 +65,17 @@ pub(crate) fn build<'a>( }) } -/// Internal MQTT connector build helpers. -/// -/// A namespace for the broker-connection setup invoked from `build`; the -/// data-plane loops themselves live in the reusable `pump_sink` / -/// `pump_source` helpers + the `MqttSink` / `MqttEventLoopSource` adapters -/// below. +/// The broker-connection setup invoked from `build`; the data-plane loops +/// themselves are core's `pump_sink` / `pump_source`. pub struct MqttConnectorImpl; impl MqttConnectorImpl { - /// Connect to the broker and subscribe to all configured topics (internal). - /// - /// Creates the MQTT client, sizes the send-channel from the route count, and - /// subscribes to every topic in `router`. Returns the shared client (for the - /// outbound `pump_sink`) plus the raw event loop (handed to a - /// [`MqttEventLoopSource`] for the inbound `pump_source`). + /// Connect to the broker and subscribe to every topic in `router`, sizing + /// the send channel from the route count. /// - /// # Arguments - /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) - /// * `client_id` - Optional client ID (if None, generates UUID-based ID) - /// * `router` - Routes used only for the subscription list + capacity sizing + /// Returns the shared client (for the outbound `pump_sink`) plus the raw + /// event loop (for [`MqttEventLoopSource`] and the inbound `pump_source`). + /// A `None` `client_id` generates a UUID-based one. async fn build_internal( broker_url: &str, client_id: Option<&str>, @@ -200,10 +191,8 @@ impl MqttConnectorImpl { /// Pure outbound publish adapter driven by `pump_sink`. /// -/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's protocol -/// options (threaded through by `pump_sink` via [`ConnectorConfig::from_query`]), -/// interpreted with MQTT's legacy defaults — **QoS 1 (`AtLeastOnce`)** when -/// unspecified, no retain — so the wire stays byte-identical to the old loop. +/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's +/// protocol options, defaulting to **QoS 1 (`AtLeastOnce`)** and no retain. struct MqttSink { client: Arc, } @@ -265,16 +254,13 @@ impl Connector for MqttSink { /// Inbound frame source driven by `pump_source`. /// -/// Yields `(topic, payload)` for each incoming MQTT publish. The inner poll loop -/// discards non-publish packets — keeping QoS handshakes and keepalive flowing — -/// and backs off 5s on a connection error before retrying, reproducing the old -/// hand-rolled event-loop future exactly. It never yields `None`: the reader runs -/// for the lifetime of the connector. +/// Yields `(topic, payload)` for each incoming MQTT publish, discarding other +/// packets and backing off 5s on a connection error. Never yields `None`: the +/// reader runs for the lifetime of the connector. struct MqttEventLoopSource { event_loop: EventLoop, - /// Only ever used to name the broker in an error line. One `String` per - /// connection, held for its lifetime — no longer feature-gated, because the - /// facade decides its own gating and a `#[cfg]` here could not follow it. + /// Only ever used to name the broker in an error line. Ungated, because the + /// logging facade decides its own gating. broker_key: String, } @@ -316,9 +302,9 @@ fn tls_configuration() -> Result { Ok(rumqttc::TlsConfiguration::Native) } -/// Built by hand rather than via `TlsConfiguration::default()`, which does the -/// same work and then `expect`s on failure. A panic on the connect path is -/// undefined behaviour across an FFI boundary; a returned error is a status. +/// Built by hand rather than via `TlsConfiguration::default()`, which `expect`s +/// on failure: a panic on the connect path is undefined behaviour across an FFI +/// boundary. #[cfg(all(feature = "tokio-rustls", not(feature = "tokio-native-tls")))] fn tls_configuration() -> Result { use rumqttc::tokio_rustls::rustls::{ClientConfig, RootCertStore}; diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index 7100c182..83e386ba 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -1,9 +1,9 @@ //! Both backends against the same broker, in one process //! (`_test-backend-parity`). //! -//! `Native` is `rumqttc` over MQTT 3.1.1; `Embedded` is `mountain-mqtt` over -//! MQTT 5 and `TokioNet::tcp()`. The point is that the two are interchangeable -//! from a record's point of view: same link URLs, same payloads on the wire. +//! `Native` is `rumqttc` over MQTT 3.1.1, `Embedded` is `mountain-mqtt` over +//! MQTT 5 — interchangeable from a record's point of view: same link URLs, same +//! payloads on the wire. #![cfg(feature = "_test-backend-parity")] use std::sync::{Arc, Mutex}; @@ -185,9 +185,8 @@ async fn both_backends_round_trip_against_one_broker() { /// `with_credentials` reaches the wire on both backends. /// -/// It is new plumbing on `Native` — `rumqttc` previously took credentials only -/// from the URL authority — so a setter that was accepted and dropped would -/// look exactly like success. +/// A setter that was accepted and then dropped would look exactly like success, +/// so the assertion is on the CONNECT packet the broker saw. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn with_credentials_reaches_the_wire_on_both_backends() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); @@ -236,11 +235,8 @@ async fn with_credentials_reaches_the_wire_on_both_backends() { /// A **hostname** is a broker address on both backends. /// -/// The embedded backend used to vet plain `mqtt://` hosts with -/// `Ipv4Addr::from_str` and reject everything else, so `.transport(..)` — the -/// call that is supposed to leave behaviour unchanged — was the difference -/// between a URL that works and one that does not. Resolving `host` is the -/// dialer's job on every adapter, so the gate is gone and the two agree. +/// Resolving `host` is the dialer's job on every adapter, so `.transport(..)` +/// makes no difference to which broker URLs are accepted. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_hostname_is_a_broker_address_on_both_backends() { // Bound by name, so the address the broker listens on is whichever one diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index 1a6f73dd..e10eecd1 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -1,8 +1,6 @@ //! A fake MQTT broker over a real TCP socket, speaking just enough of both //! dialects to complete a session: 3.1.1 for `rumqttc`, 5 for `mountain-mqtt`. -//! -//! The version is read off the CONNECT packet, so one broker serves both -//! backends and a parity test needs only one listener. +//! The version is read off the CONNECT packet, so one listener serves both. //! //! Compiled into each test binary, so not every item is used by all of them. #![allow(dead_code)] @@ -82,9 +80,6 @@ fn varint(mut n: usize, out: &mut Vec) { } /// Decode an MQTT variable-byte integer at `i`, stepping past it. -/// -/// Returns the value, because every caller here wants it: each varint is a -/// property block's length, and the block itself has to be stepped over too. fn take_varint(body: &[u8], i: &mut usize) -> Option { let mut value = 0usize; let mut shift = 0; @@ -125,8 +120,7 @@ fn take_field(body: &[u8], i: &mut usize) -> Option { } /// The identity a CONNECT carries: client id, then the credentials its flags -/// advertise. The payload follows the 10-byte variable header plus, on MQTT 5, -/// a property block. Nothing here sets a will, so the fields are contiguous. +/// advertise. Nothing here sets a will, so the payload fields are contiguous. fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, String)>)> { let flags = *body.get(7)?; let mut i = 10; @@ -333,8 +327,7 @@ pub async fn fake_broker( } } -/// Serve several clients at once, which a parity test needs: both backends -/// hold a connection simultaneously. +/// Serve several clients at once, as a parity test needs. pub async fn fake_broker_concurrent( listener: TcpListener, seen: Arc>, @@ -412,11 +405,9 @@ pub enum Script { /// The broker's write side. /// -/// While `hold` is `Some`, the broker has a packet half-written and must not -/// put anything else on the wire: a byte stream carries packets in order, so -/// injecting a PUBACK between the halves of a PUBLISH would corrupt the -/// framing rather than test it. Held bytes go out behind the packet's tail — -/// which is exactly what a sender whose peer is slow ends up doing. +/// While `hold` is `Some`, a packet is half-written and nothing else may go on +/// the wire: injecting a PUBACK between the halves of a PUBLISH would corrupt +/// the framing rather than test it. Held bytes go out behind the packet's tail. struct Wire { writer: tokio::io::WriteHalf, hold: Option>, @@ -437,13 +428,11 @@ async fn send(writer: &Writer, bytes: &[u8]) -> boo /// Serve one already-accepted connection, following `script`. /// -/// Generic over the stream, so the same script runs over plain TCP and over a -/// TLS session — which is what lets the TLS path be held to the same criteria. +/// Generic over the stream, so the same script runs over plain TCP and TLS. /// -/// The stream is split and every scripted delay runs in its own task, so the -/// broker **never stops reading**. That is what makes the stall counters mean -/// anything: a ping that arrives while the broker is stalling has to be read -/// and counted while the stall is still open, not afterwards. +/// Every scripted delay runs in its own task, so the broker **never stops +/// reading** — without which a ping arriving mid-stall would be counted after +/// the stall rather than during it. pub async fn scripted_broker(stream: S, log: Arc>, script: Script) where S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + 'static, @@ -589,8 +578,8 @@ where // =========================================================================== /// `TokioNet::tcp()` with a tally of every `Delay::sleep` the connector asks -/// for. The connector takes its clock from the dialer, so this is the seam -/// where "how often does the session wake?" is observable at all. +/// for — the connector takes its clock from the dialer, so this is where the +/// wake cadence is observable. #[derive(Clone)] pub struct CountingDialer { inner: aimdb_tokio_adapter::net::TokioTcpDialer, diff --git a/aimdb-mqtt-connector/tests/embassy_broker.rs b/aimdb-mqtt-connector/tests/embassy_broker.rs index 8676c9aa..0540dcc6 100644 --- a/aimdb-mqtt-connector/tests/embassy_broker.rs +++ b/aimdb-mqtt-connector/tests/embassy_broker.rs @@ -1,11 +1,8 @@ //! Host smoke for the Embassy broker session loop (`_test-embassy-broker`). //! -//! The loop is what replaced mountain-mqtt-embassy's `run_with_subscriptions` -//! when the transport became injectable, so reconnect-and-resubscribe is this -//! crate's behaviour now rather than the helper's. Two `embassy-net` stacks -//! wired by an in-memory driver-channel crossover drive it against a fake -//! broker that speaks just enough MQTT: CONNECT/CONNACK, SUBSCRIBE/SUBACK, and -//! a server-initiated PUBLISH. +//! Two `embassy-net` stacks wired by an in-memory driver-channel crossover, +//! against a fake broker speaking CONNECT/CONNACK, SUBSCRIBE/SUBACK and a +//! server-initiated PUBLISH. #![cfg(feature = "_test-embassy-broker")] extern crate alloc; @@ -158,9 +155,8 @@ where // A fake broker: just enough MQTT 5 to complete a session. // --------------------------------------------------------------------------- -/// Accept one TCP connection and answer CONNECT and SUBSCRIBE, then push a -/// PUBLISH. Records what it saw so the test can assert on the wire, not on -/// side effects. +/// Accept one connection, answer CONNECT and SUBSCRIBE, then push a PUBLISH, +/// recording what it saw so the test asserts on the wire. #[derive(Default)] struct Seen { connect: bool, @@ -279,12 +275,9 @@ async fn fake_broker(stack: Stack<'static>, seen: &core::cell::RefCell) { // The test. // --------------------------------------------------------------------------- -/// The session loop completes a broker session over the injected transport: -/// CONNECT is answered, and the inbound topics are **subscribed on the wire**. -/// -/// That subscribe is the property `run_with_subscriptions` used to provide and -/// this crate now owns — without it, inbound routing dies silently on the first -/// reconnect. +/// A broker session completes over the injected transport, with the inbound +/// topics **subscribed on the wire** — losing that kills inbound routing +/// silently. #[test] fn the_session_loop_connects_and_subscribes() { use aimdb_core::buffer::BufferCfg; diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index be99f4de..8037c02c 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -1,11 +1,8 @@ -//! What the event-driven session promises that the polled one could not -//! (design 053 §10, criteria 1, 2, 4 and 11). +//! Liveness under a stalled peer, pings that keep flowing while a QoS 1 publish +//! is outstanding, and an idle session that wakes at the ping cadence. //! -//! These are behavioural, not smoke: every one of them passes trivially on a -//! loop that polls at 100 Hz and blocks inline for acknowledgements, or fails -//! outright on it. The broker here is scripted rather than the shared -//! `common::fake_broker`, because each test needs to control *when* it answers -//! — mid-packet, late, or not at all. +//! The broker is scripted rather than `common::fake_broker`, because each test +//! controls *when* it answers — mid-packet, late, or not at all. #![cfg(feature = "_test-tokio-broker")] use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs index 22786bd2..b3437e55 100644 --- a/aimdb-mqtt-connector/tests/tls_broker.rs +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -1,9 +1,8 @@ //! `mqtts://` on the host: the embedded backend against a local broker whose //! self-signed certificate is pinned as the root CA (`_test-tls-broker`). //! -//! The first host coverage the TLS path has had. It runs the same -//! `embedded-tls` session an MCU runs, over `TokioNet::tcp()`, with the clock -//! from the runtime's wall clock and no SNTP task. +//! The same `embedded-tls` session an MCU runs, over `TokioNet::tcp()`, clocked +//! by the runtime's wall clock with no SNTP task. #![cfg(feature = "_test-tls-broker")] use std::sync::{Arc, Mutex}; @@ -34,13 +33,12 @@ fn defmt_panic() -> ! { defmt::timestamp!("{=u64:us}", 0); /// The name the certificate is issued for, and the name the client verifies. -/// A hostname rather than an IP literal: `rustpki` matches an IP only through -/// the CN fallback, which is a narrower path than this test should depend on. +/// A hostname rather than an IP literal, which `rustpki` matches only through +/// the narrower CN fallback. const BROKER_HOST: &str = "localhost"; -/// A self-signed certificate for `localhost`, returned as (server chain, -/// server key, root CA in DER) — the same bytes on both sides, which is what -/// "pinned root" means. +/// A self-signed certificate for `localhost`, as (server chain, key, root CA in +/// DER) — the same bytes on both sides, which is what "pinned" means. fn self_signed() -> ( CertificateDer<'static>, PrivateKeyDer<'static>, @@ -82,9 +80,8 @@ async fn tls_broker( } } -/// A `mqtts://` session completes and round-trips a record, with the -/// certificate verified against the pinned root and the clock from -/// `SystemTime` — no SNTP anywhere. +/// A `mqtts://` session completes and round-trips a record, verified against +/// the pinned root and clocked by `SystemTime` — no SNTP anywhere. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn the_embedded_backend_completes_an_mqtts_handshake_against_a_pinned_root() { use aimdb_core::buffer::BufferCfg; diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs index 8d60222f..4234f709 100644 --- a/aimdb-mqtt-connector/tests/tls_session.rs +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -1,17 +1,10 @@ -//! The event-driven session's promises, held over `mqtts://` -//! (design 053 criterion 8, and the guard on risk 1). +//! `tests/session_loop.rs`'s promises, re-driven over a real TLS 1.3 session +//! against a pinned self-signed root. //! -//! `tests/session_loop.rs` drives criteria 1, 2 and 4 over a plaintext socket; -//! this drives the same three over a real TLS 1.3 session against a pinned -//! self-signed root. The point is that the session below the record layer is -//! the *same* session — after design 053 the TLS path is two adapter types and -//! a handshake, not a loop of its own. -//! -//! It is also where `DuplexHandle`'s disjointness is exercised for real -//! (criterion 9): while the session's read half is parked inside `TlsReader`, -//! its write half has to push pings through `TlsWriter`. If a future -//! `embedded-tls` ever made its reader take the write lock, these tests would -//! stop completing rather than fail quietly in the field. +//! Also where `DuplexHandle`'s disjointness is exercised for real: while the +//! read half is parked inside `TlsReader`, the write half has to push pings +//! through `TlsWriter`. An `embedded-tls` whose reader took the write lock would +//! hang these tests rather than fail quietly in the field. #![cfg(feature = "_test-tls-broker")] use std::sync::atomic::Ordering; @@ -275,13 +268,8 @@ async fn a_partial_packet_over_tls_stops_neither_pings_nor_publishes() { // Criterion 4 over TLS — and criterion 9's concurrent read and write. // --------------------------------------------------------------------------- -/// A QoS 1 publish waiting on a slow broker must not stop the ping. -/// -/// Over TLS this is also the live test of `DuplexHandle`: for a ping to reach -/// the broker while the PUBACK is outstanding, `TlsWriter` has to take the -/// write lock while `TlsReader` is parked holding the read one. The two are -/// disjoint by type today; if that ever stopped being true, this test would -/// hang rather than pass. +/// A QoS 1 publish waiting on a slow broker must not stop the ping — which over +/// TLS means `TlsWriter` taking the write lock while `TlsReader` is parked. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn a_slow_puback_over_tls_does_not_block_the_ping() { let (listener, acceptor, options, port) = tls_setup(); diff --git a/aimdb-mqtt-connector/tests/tokio_broker.rs b/aimdb-mqtt-connector/tests/tokio_broker.rs index b151868b..0a452a7d 100644 --- a/aimdb-mqtt-connector/tests/tokio_broker.rs +++ b/aimdb-mqtt-connector/tests/tokio_broker.rs @@ -1,10 +1,9 @@ //! Host smoke for the embedded MQTT backend over `TokioNet::tcp()` //! (`_test-tokio-broker`). //! -//! The same session loop the Embassy smoke drives, but over a real TCP socket -//! and a fake broker on the same host — no network stack to stand up. What it -//! adds over that smoke is the reconnect: the broker hangs up after the first -//! SUBACK, and the loop must dial again and re-subscribe. +//! The same loop as the Embassy smoke, over a real TCP socket with no network +//! stack to stand up, plus the reconnect: the broker hangs up after the first +//! SUBACK and the loop must dial again and re-subscribe. #![cfg(feature = "_test-tokio-broker")] use std::sync::{Arc, Mutex}; @@ -56,10 +55,8 @@ embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock) // The test. // --------------------------------------------------------------------------- -/// The session loop re-subscribes after the broker hangs up. -/// -/// Losing that is silent: publishes keep working and inbound routing simply -/// stops, so this is the assertion the reconnect loop exists for. +/// The session loop re-subscribes after the broker hangs up. Losing that is +/// silent — publishes keep working and only inbound routing stops. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn the_session_loop_reconnects_and_resubscribes() { use aimdb_core::buffer::BufferCfg; @@ -128,8 +125,7 @@ async fn the_session_loop_reconnects_and_resubscribes() { } /// The embedded backend carries records both ways over `TokioNet::tcp()`, on a -/// multi-thread runtime: an inbound PUBLISH reaches a record, and a record's -/// outbound link reaches the broker. +/// multi-thread runtime. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn the_embedded_backend_round_trips_records_on_a_multi_thread_runtime() { use aimdb_core::buffer::BufferCfg; @@ -223,10 +219,9 @@ async fn the_embedded_backend_round_trips_records_on_a_multi_thread_runtime() { assert_eq!(payload, b"42", "the serializer's bytes must arrive intact"); } -/// Two connectors in one process keep their own identities. -/// -/// They shared a process-global cell before the channels moved to `Arc`, so the -/// second silently connected under the first's client id. +/// Two connectors in one process keep their own identities: nothing about a +/// connector's client id is process-global, so the second does not connect +/// under the first's. #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn two_connectors_in_one_process_keep_their_own_client_ids() { use aimdb_core::buffer::BufferCfg; diff --git a/aimdb-tokio-adapter/src/net.rs b/aimdb-tokio-adapter/src/net.rs index 49ef60fb..db833ba5 100644 --- a/aimdb-tokio-adapter/src/net.rs +++ b/aimdb-tokio-adapter/src/net.rs @@ -82,12 +82,10 @@ where /// Borrow the stream as halves through `tokio::io::split`. /// - /// That is the general path, and it costs a lock: the two halves share the - /// stream behind a mutex taken inside each `poll`. It is never held across - /// an await, so it cannot deadlock, but it is a serialisation point the - /// native `TcpStream::split` does not have. The native one is unreachable - /// here — this type is generic over `S`, so an impl specialised to - /// `TcpStream` would overlap this one. + /// This costs a lock taken inside each `poll` — never held across an await, + /// so it cannot deadlock, but a serialisation point the native + /// `TcpStream::split` does not have. That one is unreachable here: this type + /// is generic over `S`, so a `TcpStream`-specialised impl would overlap it. fn split(&mut self) -> (impl ByteRead + Send + '_, impl ByteWrite + Send + '_) { let (rx, tx) = tokio::io::split(&mut self.0); (TokioReadHalf(rx), TokioWriteHalf(tx)) From 3994f8c4a1dc76a1bae5a784d53d01d66a023edb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 18:19:27 +0000 Subject: [PATCH 31/48] chore: update changelog and improve packet reader documentation for clarity on packet handling and limits --- aimdb-mqtt-connector/CHANGELOG.md | 17 +++++++++++++---- .../src/embedded/packet_reader.rs | 12 ++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 23d211d7..a52aab77 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -25,11 +25,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Two consequences worth knowing about. A partial packet is now "not enough yet" rather than a parked loop, because packets are reassembled incrementally instead of being read to a length the peer promised. And **the largest MQTT - packet the session can receive is 3584 bytes** (previously 4096): the + packet the session can receive is 3328 bytes** (previously 4096): the reassembly buffer, the read scratch and the inbound slot are carved out of - the same total the old single buffer cost, rather than added to it. Outbound - packets are encoded to exactly their own size on the heap the action channel - already uses, so they gain no fixed cap. + the same total the old single buffer cost, rather than added to it, and the + reassembly buffer keeps one read chunk of that in reserve so a chunk + completing one packet can still carry the head of the next. Outbound packets + are encoded to exactly their own size on the heap the action channel already + uses, so they gain no fixed cap. + + Size the inbound topics accordingly: an over-limit packet ends the session + rather than being skipped. An ordinary publish then costs one dropped message + and a reconnect, because the session is clean-start and the broker requeues + nothing — but a **retained** message lives with the topic, so it is replayed + on every resubscribe and reconnect-loops the connector until it is cleared. + Either way the cause is named in the session's error log. - **TLS runs that same session** (design 053 §6.6). `mqtts://` was a loop of its own because the MQTT client wanted a readiness peek that a TLS session diff --git a/aimdb-mqtt-connector/src/embedded/packet_reader.rs b/aimdb-mqtt-connector/src/embedded/packet_reader.rs index 47864322..ce376f3d 100644 --- a/aimdb-mqtt-connector/src/embedded/packet_reader.rs +++ b/aimdb-mqtt-connector/src/embedded/packet_reader.rs @@ -16,8 +16,10 @@ use mountain_mqtt::packets::packet_generic::PacketGeneric; /// Reassembles MQTT packets from arbitrary byte chunks. /// -/// `N` bounds the largest packet that can be received; a longer one is -/// [`PacketReadError::PacketTooLargeForBuffer`] rather than a stall. +/// A packet of up to `N` minus one feed chunk is always received; a longer one +/// is [`PacketReadError::PacketTooLargeForBuffer`] rather than a stall. The +/// chunk of slack is what [`feed`](Self::feed) needs to take a whole read at +/// once — do not reclaim it without changing `feed` to accept partial chunks. pub(crate) struct PacketReader { buf: [u8; N], len: usize, @@ -32,8 +34,10 @@ impl PacketReader { } } - /// Append freshly read bytes. Fails only if the peer sent a packet larger - /// than `N`. + /// Append freshly read bytes, all or nothing: the whole chunk has to fit + /// beside what is already buffered. So a packet within a chunk of `N` can + /// still be refused, when the chunk completing it also carries the head of + /// the next one — see the type's stated limit. pub(crate) fn feed(&mut self, bytes: &[u8]) -> Result<(), PacketReadError> { if self.len + bytes.len() > N { return Err(PacketReadError::PacketTooLargeForBuffer); From c524c173e0745bcda695ebf51b95e7dcd544dd56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 18:39:38 +0000 Subject: [PATCH 32/48] feat: enhance QoS 1 message handling in MQTT session loop; ensure all pushes are acknowledged --- aimdb-mqtt-connector/CHANGELOG.md | 8 ++ .../src/embedded/session_loop.rs | 51 ++++++++---- aimdb-mqtt-connector/tests/common/mod.rs | 49 ++++++++++++ aimdb-mqtt-connector/tests/session_loop.rs | 77 +++++++++++++++++++ 4 files changed, 168 insertions(+), 17 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index a52aab77..1cea8907 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -85,6 +85,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 slow broker no longer stops pings, and only one QoS 1 publish is in flight at a time — the action arm simply parks until the PUBACK lands. + Everything the protocol obliges the session to send — CONNECT, SUBSCRIBE, + PUBLISH, and the PUBACKs answering QoS 1 delivery — waits for a slot in the + write queue rather than being discarded when it is full, which is also where + the session takes backpressure from a peer that has stopped reading. Only + pings are still dropped on a full queue: a ping arms no response deadline, so + skipping one costs nothing and the next deadline reissues it, whereas parking + on one would stall the loop that has to notice the link is gone. + - **The backend split is std vs `no_std`, not Tokio vs Embassy.** The embedded backend runs on any target whose adapter supplies a `StreamDialer`, so a new platform costs one adapter crate and no change here. Features rename diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index a901301d..4247f627 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -192,7 +192,7 @@ async fn client_loop( properties, ); state.connect(&connect).map_err(client_error)?; - queue(outbound, encode(&connect)?); + queue(outbound, encode(&connect)?).await; } loop { @@ -224,10 +224,9 @@ async fn client_loop( if connected && now.saturating_sub(last_ping_ms) >= ping_interval { last_ping_ms = now; let ping = state.send_ping().map_err(client_error)?; - // A ping dropped because the write half is backed up is not worth - // ending the session over: the next deadline tries again, and if - // the link really is gone the liveness window closes it. - queue(outbound, encode(&ping)?); + // The one packet worth dropping rather than waiting for — see + // `queue_lossy`. + queue_lossy(outbound, encode(&ping)?); } // Subscriptions go out one at a time: `ClientStateNoQueue` tracks a @@ -235,7 +234,7 @@ async fn client_loop( if connected && !state.waiting_for_responses() && next_topic < subscribe_topics.len() { let (topic, qos) = subscribe_topics[next_topic]; let packet = state.subscribe_packet(topic, qos).map_err(client_error)?; - queue(outbound, encode(&packet)?); + queue(outbound, encode(&packet)?).await; state.subscribe_update(&packet).map_err(client_error)?; next_topic += 1; // `continue` skips the bottom-of-loop bookkeeping, so arm the @@ -290,7 +289,7 @@ async fn client_loop( .await?; } Either3::Second(action) => { - perform(action, &mut state, outbound)?; + perform(action, &mut state, outbound).await?; } // The timer fired: the top of the loop re-evaluates every deadline. Either3::Third(()) => {} @@ -342,7 +341,7 @@ async fn drain_packets( reader.consume(total); if let Some(bytes) = response { - queue(outbound, bytes); + queue(outbound, bytes).await; } // Every packet the state accepted proves the broker is alive. @@ -426,7 +425,7 @@ impl Received { /// /// Sent before the state update, as upstream does: a state that believes a /// publish is in flight when it is not parks the action arm forever. -fn perform( +async fn perform( action: AimdbMqttAction, state: &mut ClientStateNoQueue, outbound: &Channel, 4>, @@ -459,7 +458,7 @@ fn perform( ); }) .map_err(client_error)?; - queue(outbound, encode(&packet)?); + queue(outbound, encode(&packet)?).await; state.publish_update(&packet).map_err(client_error)?; } AimdbMqttAction::Subscribe { topic, qos } => { @@ -472,7 +471,7 @@ fn perform( defmt::warn!("MQTT: dropping subscribe to {}: {}", topic.as_str(), _e); }) .map_err(client_error)?; - queue(outbound, encode(&packet)?); + queue(outbound, encode(&packet)?).await; state.subscribe_update(&packet).map_err(client_error)?; } } @@ -491,15 +490,33 @@ fn encode(packet: &P) -> Result, Error> { Ok(bytes) } -/// Queue encoded bytes for the write half. +/// Queue encoded bytes for the write half, waiting for a slot. /// -/// Never blocks — blocking would park the loop that has to notice the link is -/// gone. A ping or PUBACK dropped because the write half is backed up is -/// recovered by the next deadline or by redelivery. -fn queue(outbound: &Channel, 4>, bytes: Vec) { +/// Everything the protocol obliges us to send goes through here: CONNECT, +/// SUBSCRIBE, PUBLISH and the PUBACKs answering QoS 1 delivery. None of those +/// can be dropped — the state machine has already committed to them, so a +/// discarded packet leaves our state and the wire disagreeing, with nothing to +/// resync on. +/// +/// Waiting cannot deadlock: [`write_out`] is this channel's only consumer and +/// is a sibling arm of the same `select`, so parking here is what lets it run. +/// It is also the backpressure — a peer that stops reading stops us encoding. +async fn queue(outbound: &Channel, 4>, bytes: Vec) { + outbound.send(bytes).await; +} + +/// Queue encoded bytes only if the write half is keeping up, dropping them if +/// it is not. +/// +/// For pings alone. A ping carries no state — `send_ping` bumps a counter but +/// arms no response deadline — so a dropped one costs nothing and the next +/// ping deadline tries again; if the link really is gone, the liveness window +/// closes the session. Parking on a ping would be worse than skipping it: the +/// loop that has to notice the link is gone would be the thing stuck. +fn queue_lossy(outbound: &Channel, 4>, bytes: Vec) { if outbound.try_send(bytes).is_err() { #[cfg(feature = "defmt")] - defmt::warn!("MQTT: write queue full, packet dropped"); + defmt::warn!("MQTT: write queue full, ping dropped"); } } diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index e10eecd1..541e9438 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -194,6 +194,23 @@ fn publish(topic: &str, payload: &[u8], v5: bool) -> Vec { packet } +/// An MQTT 5 QoS 1 PUBLISH, which obliges the receiver to answer with a PUBACK +/// carrying `packet_id`. [`publish`] builds the QoS 0 form, which obliges +/// nothing. +fn publish_qos1(topic: &str, payload: &[u8], packet_id: u16) -> Vec { + let mut rest = Vec::new(); + rest.extend_from_slice(&(topic.len() as u16).to_be_bytes()); + rest.extend_from_slice(topic.as_bytes()); + rest.extend_from_slice(&packet_id.to_be_bytes()); + rest.push(0x00); // no properties + rest.extend_from_slice(payload); + + let mut packet = vec![0x32]; // PUBLISH, QoS 1 + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet +} + /// Decode a PUBLISH the client sent: topic, payload, and the packet id that is /// present only above QoS 0. fn parse_publish(first: u8, body: &[u8], v5: bool) -> Option<(String, Vec, Option<[u8; 2]>)> { @@ -366,6 +383,11 @@ pub struct Log { pub pings_during_stall: usize, /// Client publishes that arrived while the broker was stalling. pub publishes_during_stall: usize, + /// QoS 1 PUBLISHes the broker pushed, by packet id. + pub pushed_qos1: Vec, + /// PUBACKs the client sent back, by packet id. Short of `pushed_qos1` + /// means the client received a message and never acknowledged it. + pub pubacks: Vec, } /// Read one packet: header byte, varint remaining length, body. @@ -401,6 +423,9 @@ pub enum Script { SlowPuback { delay: Duration }, /// Push inbound PUBLISHes as fast as they will go, for `duration`. Flood { duration: Duration }, + /// Push `count` QoS 1 PUBLISHes in a single write, so they reach the client + /// coalesced and it has to answer every one with a PUBACK. + FloodQos1 { count: u16 }, } /// The broker's write side. @@ -516,6 +541,21 @@ where } }); } + Script::FloodQos1 { count } => { + let writer = writer.clone(); + let log = log.clone(); + tokio::spawn(async move { + // One write, so the burst reaches the client as + // few large reads rather than one packet per read + // — which is the case the outbox has to survive. + let mut burst = Vec::new(); + for id in 1..=count { + burst.extend_from_slice(&publish_qos1(SCRIPT_TOPIC, b"7", id)); + log.lock().unwrap().pushed_qos1.push(id); + } + send(&writer, &burst).await; + }); + } } } // PUBLISH from the client. @@ -554,6 +594,15 @@ where } } } + // PUBACK from the client, answering a QoS 1 push. + 4 => { + if let (Some(hi), Some(lo)) = (body.first(), body.get(1)) { + log.lock() + .unwrap() + .pubacks + .push(u16::from_be_bytes([*hi, *lo])); + } + } // PINGREQ -> PINGRESP 12 => { { diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index 8037c02c..daf91be2 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -312,3 +312,80 @@ async fn outbound_keeps_moving_under_an_inbound_flood() { delivered.load(Ordering::Relaxed) ); } + +// --------------------------------------------------------------------------- +// Every QoS 1 message the broker pushes must be acknowledged. +// --------------------------------------------------------------------------- + +/// A burst of QoS 1 pushes arriving coalesced must be PUBACKed in full. +/// +/// The session encodes one PUBACK per message into `outbound`, a 4-slot channel +/// drained by the write half — and the write half only runs when the session +/// loop parks. `drain_packets` does not park on its own while the event channel +/// has room, so a burst spanning more packets than the outbox holds is exactly +/// the case where responses have nowhere to go. Queueing them with `.await` is +/// what makes the loop park there, letting the writer drain. +/// +/// This matters because a lost PUBACK is unrecoverable: by the time it would be +/// dropped the client state has already retired the message, so nothing +/// retries, and the session is clean-start so no reconnect replays it. The +/// broker would hold each one against its in-flight window while the connection +/// still looked healthy — pings keep the liveness watchdog satisfied — so the +/// node would go deaf on inbound with no error and no reconnect. +/// +/// Failed at 19 of 40 while every packet went out through `try_send`-and-forget. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn every_qos1_push_is_acknowledged() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let log = Arc::new(Mutex::new(Log::default())); + + // Enough to span several 256-byte reads: ~9 of these packets fit per read, + // and the outbox holds 4. + const PUSHED: u16 = 40; + + let dialer = CountingDialer::new(); + let (db, runner) = build_db(port, dialer, None).await; + let mut inbound = db + .consumer::("temperature") + .expect("temperature consumer") + .subscribe(); + + let delivered = Arc::new(AtomicUsize::new(0)); + let counting = { + let delivered = delivered.clone(); + async move { + while inbound.recv().await.is_ok() { + delivered.fetch_add(1, Ordering::Relaxed); + } + } + }; + + tokio::select! { + _ = runner.run() => panic!("the session loop returned"), + _ = serve_one(listener, log.clone(), Script::FloodQos1 { count: PUSHED }) => { + panic!("the broker returned") + } + _ = counting => panic!("the inbound record closed"), + // Long enough that anything merely slow has finished. + _ = tokio::time::sleep(Duration::from_secs(3)) => {} + } + + let log = log.lock().unwrap(); + assert_eq!( + log.pushed_qos1.len(), + PUSHED as usize, + "the broker must have pushed the whole burst" + ); + assert_eq!( + log.pubacks.len(), + log.pushed_qos1.len(), + "the client acknowledged {} of {} QoS 1 messages — {} went unacknowledged, \ + and the broker holds each against its in-flight window for the life of \ + the connection (delivered to the app: {})", + log.pubacks.len(), + log.pushed_qos1.len(), + log.pushed_qos1.len() - log.pubacks.len(), + delivered.load(Ordering::Relaxed), + ); +} From 1d0fb03bdfdb62bcf5daeaa0a81dffda633182f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 18:43:39 +0000 Subject: [PATCH 33/48] feat: remove embedded-hal-async dependency from Cargo.toml and Cargo.lock --- Cargo.lock | 1 - aimdb-mqtt-connector/Cargo.toml | 2 -- 2 files changed, 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 67866073..a92cb494 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,6 @@ dependencies = [ "embassy-sync", "embassy-time", "embassy-time-driver", - "embedded-hal-async", "embedded-io-async 0.7.0", "embedded-tls", "futures", diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index f30b9215..18a6221d 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -189,10 +189,8 @@ embedded-tls = { version = "0.19", default-features = false, optional = true, fe "p384", ] } embedded-io-async = { workspace = true, optional = true } -embedded-hal-async = { workspace = true, optional = true } rand_core = { version = "0.6", default-features = false, optional = true } - # Optional observability defmt = { workspace = true, optional = true } From 2d740718acaaaac42a0a8148caaa622744e1d6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 18:50:47 +0000 Subject: [PATCH 34/48] feat: rename deprecated module names for clarity and remove compatibility re-exports --- aimdb-mqtt-connector/CHANGELOG.md | 7 ++++++- aimdb-mqtt-connector/src/lib.rs | 9 --------- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 1cea8907..7f57d3b0 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -102,7 +102,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 becomes a convenience bundle over it. TLS splits the same way: `embedded-tls` is runtime-neutral, `embassy-tls` adds the SNTP time source a board with no RTC needs. Modules follow: `tokio_client` → `native`, `embassy_client` → - `embedded` (both kept as deprecated re-exports for one release). + `embedded`, renamed outright with no compatibility re-export. A shim would + have been theatre: the builders those modules held are gone too, so the old + import fails either way. Failing at the module boundary — `unresolved import + ... could not find 'tokio_client'` — at least points at the line to change, + where a module alias would have resolved and then failed on a type the caller + never named. - **One constructor.** `MqttConnector::new(url)` is unconditional, and the transport — or its absence — picks the backend, so both compile into one binary. Previously the two inherent `new`s collided with `E0034` whenever diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 62ddb10b..c0984ad9 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -128,15 +128,6 @@ pub mod embedded; #[cfg_attr(not(feature = "embassy-tls"), allow(dead_code))] pub(crate) mod sntp_codec; -// Deprecated module names, kept for one release so existing imports keep -// working. The modules no longer name a runtime. -#[cfg(feature = "embedded")] -#[deprecated(since = "0.7.0", note = "renamed to `embedded`")] -pub use crate::embedded as embassy_client; -#[cfg(feature = "std")] -#[deprecated(since = "0.7.0", note = "renamed to `native`")] -pub use crate::native as tokio_client; - #[cfg(feature = "embedded")] pub use connector::Embedded; #[cfg(feature = "embedded-tls")] From 53cec271d3bc6dffaf8572abbb2b73efc974907e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 19:05:04 +0000 Subject: [PATCH 35/48] feat: implement QoS 2 downgrade warning for embedded backend and update documentation --- aimdb-mqtt-connector/CHANGELOG.md | 4 +- aimdb-mqtt-connector/README.md | 8 ++++ aimdb-mqtt-connector/src/embedded/mod.rs | 43 +++++++++++++++++++- aimdb-mqtt-connector/tests/session_loop.rs | 47 ++++++++++++++++++++-- aimdb-mqtt-connector/tests/tls_session.rs | 6 +-- 5 files changed, 99 insertions(+), 9 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 7f57d3b0..5c2c5bc5 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -138,7 +138,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 loop names no executor. `Settings` is `core::time::Duration` and lost its dead `address`/`port` fields. - **Two protocol backends behind one type.** `Native` is `rumqttc` (QoS 0–2, - rustls); `Embedded` is `mountain-mqtt` over a caller-supplied transport. + rustls); `Embedded` is `mountain-mqtt` over a caller-supplied transport + (QoS 0–1 — a `qos=2` route publishes at QoS 1, and the build now names each + such route in a warning, since the same route gets exactly-once on `Native`). The Tokio path is unchanged; Embassy callers now write `MqttConnector::new(url).transport(EmbassyNet::tcp(..))`, or `.tls(EmbassyNet::tcp(..), opts)` for `mqtts://`, instead of passing the diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index 0def1ef9..7e9c440a 100644 --- a/aimdb-mqtt-connector/README.md +++ b/aimdb-mqtt-connector/README.md @@ -285,6 +285,14 @@ MQTT Quality of Service levels are configured using integers: - **QoS 1**: Commands, important events (default) - **QoS 2**: Critical state changes, financial transactions +> **Backend note:** only the `std` (`rumqttc`) backend implements QoS 2. On the +> `embedded` backend a `qos=2` route publishes at QoS 1 (at-least-once) and logs +> a warning naming that route at startup, so the same route gives a weaker +> guarantee there. Design for at-least-once if the route has to run on both. +> +> Set QoS with `.with_qos(n)` on the link — a `?qos=` query in the link URL is +> stripped during parsing and has no effect. + ## Error Handling ```rust diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 4c271d7a..46fdc702 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -212,6 +212,7 @@ where { Box::pin(async move { let topics = inbound_topics(db); + warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if broker.tls { return Err(build_err("mqtts:// broker URLs require .tls(...)")); @@ -248,6 +249,7 @@ where { Box::pin(async move { let topics = inbound_topics(db); + warn_unsupported_qos(db); let broker = parse_broker_url(broker_url)?; if !broker.tls { return Err(build_err(".tls(...) requires an mqtts:// broker URL")); @@ -511,8 +513,10 @@ where /// Map a QoS level to mountain-mqtt's `QualityOfService`. /// -/// `2` downgrades to 1 (the client implements no exactly-once); anything above -/// 2 is rejected, as `Native` rejects it. +/// `2` downgrades to 1 — this client implements no exactly-once handshake, +/// where [`Native`](crate::connector::Native) honours the same route URL +/// exactly. [`warn_unsupported_qos`] is what says so, once per route at build. +/// Anything above 2 is rejected, as `Native` rejects it. fn map_qos(qos: u8) -> Result { match qos { 0 => Ok(QualityOfService::Qos0), @@ -522,6 +526,41 @@ fn map_qos(qos: u8) -> Result { } } +/// Name, at build, every outbound route asking for a QoS this backend cannot +/// give. +/// +/// Checked here rather than in [`map_qos`] because `map_qos` runs per publish: +/// warning there would repeat at the route's own rate for the life of the +/// process, and latching it to fire once would hide the message whenever the +/// first publish beats the logger into place. The route set is fixed at build, +/// so once per offending route — naming the route, while the caller is still +/// reading startup output — is both quieter and more use than either. +/// +/// Both facades fire: they are independent, and neither covers the other. +/// `log_warn!` reaches `tracing`/`log` when this backend runs on a host, +/// `defmt` reaches an MCU. +fn warn_unsupported_qos(db: &aimdb_core::builder::AimDb) { + for route in db.collect_outbound_routes("mqtt") { + let asked = route + .config + .iter() + .find(|(k, _)| k == "qos") + .and_then(|(_, v)| v.parse::().ok()); + + if asked == Some(2) { + aimdb_core::log_warn!( + "MQTT: route '{}' asks for qos=2; this backend publishes it at QoS 1 (at-least-once). The std backend honours qos=2 on the same URL.", + route.topic + ); + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT: route '{}' asks qos=2; publishing at QoS 1 (at-least-once)", + route.topic.as_str() + ); + } + } +} + /// Read a `u8` option from the per-route `protocol_options` (URL query). fn opt_u8(config: &ConnectorConfig, key: &str) -> Option { config diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index daf91be2..5b157568 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -72,7 +72,7 @@ async fn build_db( ) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; - use aimdb_mqtt_connector::MqttConnector; + use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) @@ -96,7 +96,6 @@ async fn build_db( }); if let Some((every, qos)) = publish { - let destination = format!("mqtt://sensors/uptime?qos={qos}"); builder.configure::("uptime", move |reg| { reg.buffer(BufferCfg::SingleLatest) .source(move |_ctx, producer| async move { @@ -107,7 +106,8 @@ async fn build_db( tokio::time::sleep(every).await; } }) - .link_to(&destination) + .link_to("mqtt://sensors/uptime") + .with_qos(qos) .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) .finish(); }); @@ -389,3 +389,44 @@ async fn every_qos1_push_is_acknowledged() { delivered.load(Ordering::Relaxed), ); } + +// --------------------------------------------------------------------------- +// The build-time QoS warning can actually see what it warns about. +// --------------------------------------------------------------------------- + +/// `warn_unsupported_qos` scans `collect_outbound_routes("mqtt")` for a `qos` +/// entry in each route's query config. That scan is the part that can silently +/// find nothing — a scheme filter that does not match, or a config key that +/// never lands — leaving a warning that compiles and never fires. This asserts +/// the shape it depends on, mirroring the private function exactly. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_qos2_route_is_visible_to_the_build_time_scan() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + let dialer = CountingDialer::new(); + let (db, _runner) = build_db(port, dialer, Some((Duration::from_secs(60), 2))).await; + + let routes = db.collect_outbound_routes("mqtt"); + assert!( + !routes.is_empty(), + "the mqtt scheme must match, or the scan sees no routes at all" + ); + + let flagged: Vec<(&str, &str)> = routes + .iter() + .filter_map(|route| { + route + .config + .iter() + .find(|(k, _)| k == "qos") + .map(|(_, v)| (route.topic.as_str(), v.as_str())) + }) + .collect(); + + assert_eq!( + flagged, + vec![("sensors/uptime", "2")], + "the scan must see the route's topic and its qos option; got {flagged:?}" + ); +} diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs index 4234f709..8642ca63 100644 --- a/aimdb-mqtt-connector/tests/tls_session.rs +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -129,7 +129,7 @@ async fn build_tls_db( ) -> (aimdb_core::AimDb, aimdb_core::builder::AimDbRunner) { use aimdb_core::buffer::BufferCfg; use aimdb_core::AimDbBuilder; - use aimdb_mqtt_connector::MqttConnector; + use aimdb_mqtt_connector::{MqttConnector, MqttLinkExt}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) @@ -153,7 +153,6 @@ async fn build_tls_db( }); if let Some((every, qos)) = publish { - let destination = format!("mqtt://sensors/uptime?qos={qos}"); builder.configure::("uptime", move |reg| { reg.buffer(BufferCfg::SingleLatest) .source(move |_ctx, producer| async move { @@ -164,7 +163,8 @@ async fn build_tls_db( tokio::time::sleep(every).await; } }) - .link_to(&destination) + .link_to("mqtt://sensors/uptime") + .with_qos(qos) .with_serializer(|_ctx, value: &u64| Ok(value.to_string().into_bytes())) .finish(); }); From 8097f102b0ce24858d1da32a2707c7746c89ab8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 19:11:59 +0000 Subject: [PATCH 36/48] feat: enhance documentation for DuplexHandle locking behavior and related tests --- aimdb-mqtt-connector/src/embedded/tls.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 18670c5f..a2473cc5 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -106,9 +106,18 @@ impl TlsOptions { /// One clone is read while another is written, so the directions take /// **separate async locks** — `TlsReader` only ever touches `rx`, `TlsWriter` /// only `tx`, and a guard may be held across the inner `.await`. That holds -/// only while `embedded-tls` never writes from its reader; -/// `tests/tls_duplex.rs` drives a concurrent read and write so a version bump -/// that changed it shows up there. +/// only while `embedded-tls` never writes from its reader. +/// +/// Two tests cover that, and only one of them can see the bet go bad: +/// +/// * `a_parked_reader_does_not_hold_up_the_writer` below proves the locks +/// really are separate — but over mock halves, with no `embedded-tls` in the +/// picture, so it would keep passing if a version bump started writing from +/// the read path. +/// * `tls_session::a_slow_puback_over_tls_does_not_block_the_ping` is the one +/// that would catch it: a real `TlsConnection`, split, writing a ping while +/// the reader is parked on a PUBACK the broker is withholding. Check that +/// test still passes after bumping `embedded-tls`. struct DuplexHandle<'a, Rx, Tx> { rx: &'a Mutex, tx: &'a Mutex, @@ -464,6 +473,11 @@ mod tests { /// A read parked inside one clone of the handle must not hold up a write /// through another. + /// + /// Mock halves, so this is about [`DuplexHandle`]'s own locking and nothing + /// else: it cannot tell you whether `embedded-tls` still reads and writes + /// from the halves it was given. `tls_session::a_slow_puback_over_tls_does_not_block_the_ping` + /// is the test that does. #[test] fn a_parked_reader_does_not_hold_up_the_writer() { let rx = Mutex::new(PendingRead); From 5170f164ac222ba920f600c98a6b3955b978915b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 19:18:25 +0000 Subject: [PATCH 37/48] feat: improve Clippy output messages and enhance documentation in MQTT connector --- Makefile | 1 + aimdb-core/src/session/io.rs | 2 +- aimdb-mqtt-connector/src/embedded/mod.rs | 15 +++++++++++---- aimdb-mqtt-connector/src/embedded/tls.rs | 5 ++++- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index 8c8381e5..d5cd9056 100644 --- a/Makefile +++ b/Makefile @@ -396,6 +396,7 @@ clippy: cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_broker -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (event-driven session criteria)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tokio-broker" --test session_loop -- -D warnings + @printf "$(YELLOW) → Clippy on MQTT connector (the same criteria over mqtts://)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "_test-tls-broker" --test tls_session -- -D warnings @printf "$(YELLOW) → Clippy on MQTT connector (no_std unit tests)$(NC)\n" cargo clippy --package aimdb-mqtt-connector --no-default-features --features "embedded-tls,critical-section-std-impl" --lib --tests -- -D warnings diff --git a/aimdb-core/src/session/io.rs b/aimdb-core/src/session/io.rs index 40d84a31..c7344c80 100644 --- a/aimdb-core/src/session/io.rs +++ b/aimdb-core/src/session/io.rs @@ -103,7 +103,7 @@ pub trait StreamDialer { type Stream: ByteStream + Send; /// Open a stream to `host:port`. - /// `host` is either a hostname or an unbracketed IP literal + /// `host` is either a hostname or an unbracketed IP literal. fn connect<'a>( &'a self, host: &'a str, diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 46fdc702..75419ff2 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -320,7 +320,7 @@ fn build_err(msg: &str) -> aimdb_core::DbError { fn parse_broker_url(broker_url: &str) -> Result { // Add a dummy topic if none, so parsing succeeds. let mut url = broker_url.to_string(); - if !url.contains('/') || url.matches('/').count() < 3 { + if url.matches('/').count() < 3 { url = format!("{}/dummy", url.trim_end_matches('/')); } let connector_url = ConnectorUrl::parse(&url).map_err(|_| build_err("Invalid MQTT URL"))?; @@ -339,9 +339,16 @@ fn parse_broker_url(broker_url: &str) -> Result /// Build the `ConnectionSettings<'static>` for MQTT CONNECT. /// -/// The identity strings are leaked to reach `'static`: one small, bounded leak -/// per connector at build, so that a second connector cannot inherit the -/// first's identity. +/// The identity strings are leaked to reach `'static` — the session task is +/// `'static`, so what it borrows must be too — giving each connector its own +/// identity rather than a shared one. +/// +/// The leak is per `build()` call, not per connector: three short strings, once +/// at startup, which is the normal case and indistinguishable from a static. +/// Only a process that rebuilds the database repeatedly accumulates them. The +/// alternative is owning the strings in the session task and rebuilding +/// `ConnectionSettings` per connection, which costs four signatures for memory +/// nobody misses. fn static_connection_settings( client_id: Option<&str>, credentials: Option<&(String, String)>, diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index a2473cc5..86ea9c25 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -252,7 +252,10 @@ where /// Unix seconds for certificate validity, refreshed before each handshake. /// /// A global because `embedded_tls::TlsClock::now` is a static method. Fed by -/// the runtime's wall clock, or by the SNTP task on an MCU with no RTC. +/// the runtime's wall clock, or by the SNTP task on an MCU with no RTC — and +/// process-wide, so two TLS connectors share one reading rather than keeping a +/// clock each. Harmless while they agree on what time it is, which any two +/// sources of wall-clock time had better. static UNIX_SECS: core::sync::atomic::AtomicU32 = core::sync::atomic::AtomicU32::new(0); /// The certificate-validity clock. `u32` is unambiguous until 2106 and stays a From 5c35aa0f0a6f70558e35c2e7d3edff7f707a1208 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Sun, 13 Sep 2026 19:29:10 +0000 Subject: [PATCH 38/48] feat: refine changelog entries and improve comments for clarity --- aimdb-core/CHANGELOG.md | 4 ++-- aimdb-mqtt-connector/CHANGELOG.md | 6 +++--- aimdb-mqtt-connector/src/embedded/mod.rs | 2 +- aimdb-mqtt-connector/src/embedded/session.rs | 2 +- aimdb-mqtt-connector/src/embedded/session_loop.rs | 7 ++++--- aimdb-mqtt-connector/tests/common/mod.rs | 2 +- aimdb-mqtt-connector/tests/session_loop.rs | 8 ++++---- aimdb-mqtt-connector/tests/tls_session.rs | 6 +++--- 8 files changed, 19 insertions(+), 18 deletions(-) diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 31542865..eeb4387a 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **`ByteStream::split`, with `ByteRead` / `ByteWrite`** (design 053 §6.1). +- **`ByteStream::split`, with `ByteRead` / `ByteWrite`.** Borrows a stream into independently usable read and write halves, so a session can run a reader and a writer concurrently in one `select` — which a single `&mut` stream cannot express at all. Borrowed halves are enough: both @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 does not apply. `read`/`write_all`/`flush` stay for the handshake and for callers that never split. The MQTT connector's event-driven session is the first consumer; the Embassy and Tokio adapters implement it. -- **The cancellation contract is written down** (design 053 §6.2). `read` is +- **The cancellation contract is written down.** `read` is cancel-safe on both adapters AimDB ships — dropping the future consumes nothing, verified per layer and end to end over a drip transport — but that is documented as a property of those transports rather than a promise of the diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 5c2c5bc5..011c628c 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) -- **The embedded session is event-driven: nothing polls** (design 053). The +- **The embedded session is event-driven: nothing polls.** The loop used to wake every 10 ms to ask three sources whether they had work, which on a battery node is the only state that normally runs — and the "non-blocking peek" it polled with could block indefinitely, parking the loop @@ -40,7 +40,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 on every resubscribe and reconnect-loops the connector until it is cleared. Either way the cause is named in the session's error log. -- **TLS runs that same session** (design 053 §6.6). `mqtts://` was a loop of +- **TLS runs that same session.** `mqtts://` was a loop of its own because the MQTT client wanted a readiness peek that a TLS session cannot answer honestly — its readiness is two-layered, since bytes on the wire may decrypt to no application data at all. Nothing peeks any more, so @@ -67,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 gone** from the connector's builders. A relaxation, so no caller breaks: the connector now reaches a stream only through core's byte-stream traits. -- **The `mountain-mqtt` dependency is the codec alone** (design 053 §6.7). It +- **The `mountain-mqtt` dependency is the codec alone.** It moves to `aimdb-mountain-mqtt` 0.5.1 — upstream `main` with a zero-line source delta — with `default-features = false` and **no features**, `defmt` added back on the defmt leg alone. What this crate takes from it is the diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 75419ff2..35cb8496 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -10,7 +10,7 @@ pub mod manager; pub mod session; // The session's own machinery: incremental framing, and the three futures that -// replace the polled loop (design 053 §5.1). +// replace the polled loop. pub(crate) mod packet_reader; pub(crate) mod session_loop; diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index c92794c8..cf4dfeb1 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -90,7 +90,7 @@ where connection_index += 1; // The halves live exactly as long as the session that reads and writes - // them, which is why borrowed halves are enough (design 053 §6.1). + // them, which is why borrowed halves are enough. let (rx, tx) = stream.split(); let error = run_session( connection_id, diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 4247f627..141d548c 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -247,8 +247,9 @@ async fn client_loop( // --- park until something happens ---------------------------------- // The action arm is armed only when a publish can actually be sent: - // connected, nothing awaiting acknowledgement (§6.4's single in-flight - // slot), every subscription placed, and room to queue the bytes. This + // connected, nothing awaiting acknowledgement (the client state holds + // one in-flight slot), every subscription placed, and room to queue the + // bytes. This // is what replaces the old inline wait for a PUBACK — the ping and // liveness deadlines keep running while it is parked. let action_ready = connected @@ -585,7 +586,7 @@ mod tests { #[test] fn the_buffer_budget_is_what_the_old_loop_cost() { - // Criterion 7: the reassembly buffer plus the read scratch plus one + // The reassembly buffer plus the read scratch plus one // inbound slot come out of `BUFFER_SIZE`, not in addition to it. assert_eq!(PACKET_BUFFER_SIZE + 2 * RX_CHUNK, BUFFER_SIZE); } diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index 541e9438..13d419f9 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -367,7 +367,7 @@ pub async fn fake_broker_concurrent( // =========================================================================== // The scripted broker: the same wire format as above, but the test decides -// when each answer goes out (design 053's criteria 1, 2, 4 and 11). +// when each answer goes out. // =========================================================================== /// The topic the scripted broker pushes on. diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index 5b157568..1ed7b4c0 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -117,7 +117,7 @@ async fn build_db( } // --------------------------------------------------------------------------- -// Criterion 1 — an idle session wakes at the ping cadence, not at 100 Hz. +// An idle session wakes at the ping cadence, not at 100 Hz. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -157,7 +157,7 @@ async fn an_idle_session_wakes_at_the_ping_cadence() { } // --------------------------------------------------------------------------- -// Criterion 2 — a partial packet stops neither pings nor publishes. +// A partial packet stops neither pings nor publishes. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -212,7 +212,7 @@ async fn a_partial_packet_stops_neither_pings_nor_publishes() { } // --------------------------------------------------------------------------- -// Criterion 4 — a QoS 1 publish survives a slow broker without blocking pings. +// A QoS 1 publish survives a slow broker without blocking pings. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -261,7 +261,7 @@ async fn a_slow_puback_does_not_block_the_ping() { } // --------------------------------------------------------------------------- -// Criterion 11 — neither direction starves the other. +// Neither direction starves the other. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs index 8642ca63..78c3d872 100644 --- a/aimdb-mqtt-connector/tests/tls_session.rs +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -174,7 +174,7 @@ async fn build_tls_db( } // --------------------------------------------------------------------------- -// Criterion 1, over TLS. +// An idle TLS session wakes at the ping cadence. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -209,7 +209,7 @@ async fn an_idle_tls_session_wakes_at_the_ping_cadence() { } // --------------------------------------------------------------------------- -// Criterion 2, over TLS. +// A partial packet over TLS stops neither pings nor publishes. // --------------------------------------------------------------------------- #[tokio::test(flavor = "multi_thread", worker_threads = 4)] @@ -265,7 +265,7 @@ async fn a_partial_packet_over_tls_stops_neither_pings_nor_publishes() { } // --------------------------------------------------------------------------- -// Criterion 4 over TLS — and criterion 9's concurrent read and write. +// A slow PUBACK over TLS does not block the ping — a concurrent read and write. // --------------------------------------------------------------------------- /// A QoS 1 publish waiting on a slow broker must not stop the ping — which over From d686f756bf15b6b5f27880336c276185216a29c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 17:53:40 +0000 Subject: [PATCH 39/48] feat: enhance credential handling in MQTT connection settings and tests --- aimdb-mqtt-connector/CHANGELOG.md | 6 +- aimdb-mqtt-connector/src/embedded/mod.rs | 27 +++++- aimdb-mqtt-connector/tests/backend_parity.rs | 99 ++++++++++++++++++++ 3 files changed, 126 insertions(+), 6 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 011c628c..7c3467d3 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -113,8 +113,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 binary. Previously the two inherent `new`s collided with `E0034` whenever both features were on. Broker URL, client id and credentials moved onto `MqttConnector` itself, so `with_client_id` / `with_credentials` work on - either backend; `with_credentials` now reaches `rumqttc` too, taking - precedence over the URL authority. + either backend; `with_credentials` now reaches `rumqttc` too. Both backends + honour credentials in the URL authority (`mqtt://user:pass@host`), and on + both the setter takes precedence over them — it is the only way to name a + password that is not URL-safe. - **`.tls(dialer, options)` replaces `.tls(stack, options)`.** The dialer resolves the host, so TLS needs no network stack: DNS, the socket buffers and the SNTP task all leave the TLS path. The certificate-validity clock comes diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 35cb8496..e6cf2362 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -217,7 +217,8 @@ where if broker.tls { return Err(build_err("mqtts:// broker URLs require .tls(...)")); } - let connection_settings = static_connection_settings(client_id, credentials); + let connection_settings = + static_connection_settings(client_id, credentials, broker.credentials.as_ref()); let (actions, events, manager_tasks) = setup_manager( &broker, @@ -258,7 +259,8 @@ where .options .take() .ok_or_else(|| build_err("TLS materials already taken; build() ran twice"))?; - let connection_settings = static_connection_settings(client_id, credentials); + let connection_settings = + static_connection_settings(client_id, credentials, broker.credentials.as_ref()); let (actions, events, manager_tasks) = setup_tls_manager( &broker, @@ -307,6 +309,9 @@ struct BrokerUrl { tls: bool, host: String, port: u16, + /// Credentials from the URL authority (`mqtt://user:pass@host`), which + /// `MqttConnector::with_credentials` overrides. + credentials: Option<(String, String)>, } fn build_err(msg: &str) -> aimdb_core::DbError { @@ -316,7 +321,10 @@ fn build_err(msg: &str) -> aimdb_core::DbError { } /// Parse the broker URL into transport + host + port (`mqtt://` 1883, -/// `mqtts://` 8883). +/// `mqtts://` 8883), plus any credentials in the authority. +/// +/// A username without a password is ignored rather than sent half-formed, +/// which is what the `rumqttc` backend does with the same URL. fn parse_broker_url(broker_url: &str) -> Result { // Add a dummy topic if none, so parsing succeeds. let mut url = broker_url.to_string(); @@ -330,15 +338,25 @@ fn parse_broker_url(broker_url: &str) -> Result _ => return Err(build_err("Broker URL scheme must be mqtt:// or mqtts://")), }; let port = connector_url.port.unwrap_or(if tls { 8883 } else { 1883 }); + let credentials = match (connector_url.username, connector_url.password) { + (Some(username), Some(password)) => Some((username, password)), + _ => None, + }; Ok(BrokerUrl { tls, host: connector_url.host, port, + credentials, }) } /// Build the `ConnectionSettings<'static>` for MQTT CONNECT. /// +/// `credentials` is what the connector was given; `url_credentials` is what the +/// broker URL's authority carried. The explicit setter wins, as it does on the +/// `rumqttc` backend — it is the only way to name a password that is not +/// URL-safe. +/// /// The identity strings are leaked to reach `'static` — the session task is /// `'static`, so what it borrows must be too — giving each connector its own /// identity rather than a shared one. @@ -352,13 +370,14 @@ fn parse_broker_url(broker_url: &str) -> Result fn static_connection_settings( client_id: Option<&str>, credentials: Option<&(String, String)>, + url_credentials: Option<&(String, String)>, ) -> ConnectionSettings<'static> { fn leak(s: &str) -> &'static str { Box::leak(s.to_string().into_boxed_str()) } let client_id = leak(client_id.unwrap_or("aimdb-client")); - match credentials { + match credentials.or(url_credentials) { Some((username, password)) => { ConnectionSettings::authenticated(client_id, leak(username), leak(password).as_bytes()) } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index 83e386ba..602f46e4 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -233,6 +233,105 @@ async fn with_credentials_reaches_the_wire_on_both_backends() { } } +/// Credentials in the broker URL's authority reach the wire on both backends. +/// +/// The sibling above covers the explicit setter. This one covers +/// `mqtt://user:pass@host`, which the embedded backend used to parse for its +/// host and port and then drop — producing an unauthenticated CONNECT, a +/// rejecting broker, and a silent reconnect loop. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn url_credentials_reach_the_wire_on_both_backends() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://hub:s3cret@127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()).with_client_id("url-creds-native"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("url-creds-embedded"); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().credentials.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().credentials); + } + } + + let seen = seen.lock().unwrap(); + let expected = Some((String::from("hub"), String::from("s3cret"))); + for (n, credentials) in seen.credentials.iter().enumerate() { + assert_eq!( + *credentials, expected, + "connection {n} ({}) dropped the URL's credentials", + seen.client_ids[n] + ); + } +} + +/// The explicit setter overrides what the URL carries, on both backends. +/// +/// `with_credentials` is the only way to name a password that is not URL-safe, +/// so it has to win rather than merely fill a gap. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn the_setter_overrides_url_credentials_on_both_backends() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://urluser:urlpass@127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let native = MqttConnector::new(url.clone()) + .with_client_id("override-native") + .with_credentials("hub", "s3cret"); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("override-embedded") + .with_credentials("hub", "s3cret"); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().credentials.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().credentials); + } + } + + let seen = seen.lock().unwrap(); + let expected = Some((String::from("hub"), String::from("s3cret"))); + for (n, credentials) in seen.credentials.iter().enumerate() { + assert_eq!( + *credentials, expected, + "connection {n} ({}) let the URL win over the setter", + seen.client_ids[n] + ); + } +} + /// A **hostname** is a broker address on both backends. /// /// Resolving `host` is the dialer's job on every adapter, so `.transport(..)` From e046e4934b54264612af88954db010cfea370d98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 18:36:54 +0000 Subject: [PATCH 40/48] feat: update README and main.rs for improved MQTT broker setup and authentication details --- .../embassy-mqtt-connector-demo/README.md | 21 +-- .../embassy-mqtt-connector-demo/src/main.rs | 129 +++++++++--------- 2 files changed, 74 insertions(+), 76 deletions(-) diff --git a/examples/embassy-mqtt-connector-demo/README.md b/examples/embassy-mqtt-connector-demo/README.md index fee36cbd..e60e96cc 100644 --- a/examples/embassy-mqtt-connector-demo/README.md +++ b/examples/embassy-mqtt-connector-demo/README.md @@ -16,7 +16,8 @@ The connector (`aimdb-mqtt-connector`, feature `embassy-runtime`) provides: - ✅ Async MQTT publishing with mountain-mqtt - ✅ Channel-based architecture for background task communication - ✅ Automatic reconnection handling -- ✅ QoS 0/1/2 support +- ✅ QoS 0 and 1 (a `qos=2` route publishes at QoS 1 and is warned about at + startup — only the `std`/rumqttc backend implements exactly-once) - ✅ `no_std` compatible (works in embedded environments) ## API Usage Pattern @@ -88,16 +89,16 @@ DNS, optional MQTT username/password, and an automatic SNTP time sync that gates the first handshake (certificate validity needs real time — the board has no RTC battery). -1. In `src/main.rs`, set `MQTT_BROKER_HOST` and, if the broker requires it, - `MQTT_CREDENTIALS`. Prefer a DNS name: an IPv4 literal verifies only when - the certificate pins that IP in its CN (the repo's `dev/mosquitto` bench - CA does; public CAs won't issue such certs). IPv6 literals are rejected - at build. -2. Drop the broker's root CA next to `Cargo.toml`, DER-encoded — for the - `dev/mosquitto` bench broker: +1. Mint the bench CA and start the broker. The script writes `ca.der` into + this directory and prints the constants to copy: ```bash - openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der + cd ../../dev/mosquitto && ./gen-certs.sh && docker compose up -d ``` +2. In `src/main.rs`, set `MQTT_BROKER_HOST`, `MQTT_USERNAME` and + `MQTT_PASSWORD` to what the script printed. The host must match the string + the script was given: it is what the certificate is verified against, and + `embedded-tls` reads only `DNS:` SANs, which is why the script puts even an + IPv4 literal in as one. IPv6 literals are rejected at build. 3. Build (and flash) from this directory, so its `.cargo/config.toml` selects the thumbv8m target and probe-rs runner: ```bash @@ -115,7 +116,7 @@ You can test the MQTT connector implementation using the Tokio runtime version: ```bash # In aimdb-mqtt-connector directory -cargo test --features tokio-runtime +cargo test --features std # Check Embassy features compile cargo check --features embassy-runtime diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 739c7d31..1e856663 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -28,45 +28,40 @@ //! //! ## Running //! -//! 1. Start an MQTT broker on your network: +//! 1. Start the bench broker on a machine the board can reach over the LAN. +//! It enforces authentication on both listeners, so a CONNECT that lost its +//! credentials is refused rather than quietly accepted: //! ```bash -//! docker run -d -p 1883:1883 eclipse-mosquitto:2 mosquitto -c /mosquitto-no-auth.conf +//! cd ../../dev/mosquitto && ./gen-certs.sh && docker compose up -d //! ``` //! -//! 2. Subscribe to sensor data: -//! ```bash -//! mosquitto_sub -h -t 'sensors/#' -v -//! ``` +//! 2. Put the address and credentials it prints into the constants below. //! -//! 3. Send commands to device: +//! 3. Build and flash from this directory — its `.cargo/config.toml` selects +//! the thumbv8m target and the probe-rs runner: //! ```bash -//! mosquitto_pub -h -t 'commands/temp/indoor' -m '{"action":"read","sensor_id":"indoor-001"}' +//! cargo run --release //! ``` //! -//! 4. Update MQTT_BROKER_IP constant below to match your broker -//! -//! 5. Flash to target: +//! 4. Watch the traffic, and send the board a command: //! ```bash -//! cargo run --example embassy-mqtt-connector-demo --features embassy-runtime,tracing +//! mosquitto_sub -h -p 1883 -u aimdb -P aimdb-bench -t 'sensors/#' -v +//! mosquitto_pub -h -p 1883 -u aimdb -P aimdb-bench \ +//! -t commands/temp/indoor -m '{"action":"read","sensor_id":"indoor-001"}' //! ``` //! //! ## TLS (`mqtts://`) //! -//! Build with `--features tls` to connect to a TLS broker instead: the URL -//! becomes `mqtts://` (hostname, resolved via DNS), the CONNECT authenticates -//! with `MQTT_CREDENTIALS`, and certificate time comes from SNTP -//! automatically. Before building: +//! `--features tls` switches the same demo to port 8883. The dialer resolves +//! the host, `embedded-tls` verifies the broker against the CA compiled in at +//! `ca.der`, and — this board having no RTC — certificate validity is dated by +//! the connector's own SNTP task, so the first handshake waits for a time sync. +//! +//! `gen-certs.sh` writes `ca.der` into this directory. `MQTT_BROKER_HOST` must +//! then be the same string the script was given: it is what the certificate is +//! verified against, and `embedded-tls` reads only `DNS:` SANs (an `IP:` SAN is +//! skipped), which is why the script puts even an IPv4 literal in as one. //! -//! 1. Set `MQTT_BROKER_HOST` (prefer a DNS name; an IPv4 literal needs the -//! certificate to pin that IP in its CN — the `dev/mosquitto` bench CA -//! does) and `MQTT_CREDENTIALS` below. -//! 2. Drop the broker's root CA in DER form at the crate root; for the dev -//! bench: -//! ```bash -//! openssl x509 -in ../../dev/mosquitto/config/certs/ca.crt -outform der -out ca.der -//! ``` -//! 3. Build and flash from this directory (its `.cargo/config.toml` selects -//! the thumbv8m target and the probe-rs runner): //! ```bash //! cargo run --release --features tls //! ``` @@ -123,6 +118,10 @@ async fn net_task(mut runner: embassy_net::Runner<'static, Device>) -> ! { // ============================================================================ // TEMPERATURE PRODUCERS (platform-specific due to embassy-time) +// +// Each cycles its readings endlessly rather than stopping after a fixed +// count: reconnect, re-subscribe and ping cadence only become observable +// while something is still publishing. // ============================================================================ /// Indoor temperature sensor producer @@ -130,7 +129,7 @@ async fn indoor_temp_producer(ctx: RuntimeContext, temperature: Producer = None; // Some(("user", "password")) +/// MQTT CONNECT credentials, which travel in the broker URL below. +const MQTT_USERNAME: &str = "aimdb"; +const MQTT_PASSWORD: &str = "aimdb-bench"; -/// The broker's root CA, DER-encoded (see the TLS section in the module doc). +/// The broker's root CA, DER-encoded. `gen-certs.sh` writes it here. #[cfg(feature = "tls")] static MQTT_CA_DER: &[u8] = include_bytes!("../ca.der"); @@ -354,12 +341,21 @@ async fn main(spawner: Spawner) { // Create AimDB database with Embassy adapter let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); - // Build MQTT broker URL (the scheme selects the transport) + // Build the broker URL. The scheme selects the transport; the authority + // carries the credentials, which both backends read. + // + // Nothing un-escapes this string on the way to the CONNECT, so a password + // needing percent-encoding (`@`, `:`, `/`) belongs in + // `.with_credentials(..)` on the builder below instead. use alloc::format; #[cfg(not(feature = "tls"))] - let broker_url = format!("mqtt://{}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); + let scheme = "mqtt"; #[cfg(feature = "tls")] - let broker_url = format!("mqtts://{}:{}", MQTT_BROKER_HOST, MQTT_BROKER_TLS_PORT); + let scheme = "mqtts"; + let broker_url = format!( + "{}://{}:{}@{}:{}", + scheme, MQTT_USERNAME, MQTT_PASSWORD, MQTT_BROKER_HOST, MQTT_BROKER_PORT + ); // ── AimX-over-serial: serve this db over USART3 (ST-LINK VCP, PD8=TX/PD9=RX) ── // A *second* connector alongside MQTT. With no extra cabling on a Nucleo-H563ZI @@ -414,7 +410,7 @@ async fn main(spawner: Spawner) { static MQTT_TX: StaticCell<[u8; 4096]> = StaticCell::new(); static TLS_READ_BUF: StaticCell<[u8; 16_640]> = StaticCell::new(); static TLS_WRITE_BUF: StaticCell<[u8; 4_096]> = StaticCell::new(); - let mqtt = MqttConnector::new(&broker_url) + MqttConnector::new(&broker_url) .tls( EmbassyNet::tcp(*stack, MQTT_RX.init([0; 4096]), MQTT_TX.init([0; 4096])), TlsOptions::new( @@ -425,11 +421,7 @@ async fn main(spawner: Spawner) { ) .with_sntp(stack, "pool.ntp.org"), ) - .with_client_id("embassy-demo-001"); - match MQTT_CREDENTIALS { - Some((username, password)) => mqtt.with_credentials(username, password), - None => mqtt, - } + .with_client_id("embassy-demo-001") }; let mut builder = AimDbBuilder::new() @@ -501,7 +493,12 @@ async fn main(spawner: Spawner) { info!("✅ Database configured with multi-sensor MQTT:"); info!(" OUTBOUND: sensors/temp/indoor, outdoor, server_room"); info!(" INBOUND: commands/temp/indoor, outdoor"); - info!(" Broker: {}", broker_url.as_str()); + // Without the authority: the URL carries the password, and this line goes + // to the RTT log. + info!( + " Broker: {}://{}:{}", + scheme, MQTT_BROKER_HOST, MQTT_BROKER_PORT + ); info!(" SERIAL (read-only AimX over USART3 / ST-LINK VCP):"); info!( " aimdb --features transport-serial --connect serial:///dev/ttyACM0?baud=115200 record list" @@ -510,12 +507,12 @@ async fn main(spawner: Spawner) { #[cfg(not(feature = "tls"))] { info!( - "Subscribe: mosquitto_sub -h {} -t 'sensors/#' -v", - MQTT_BROKER_IP + "Subscribe: mosquitto_sub -h {} -u {} -P -t 'sensors/#' -v", + MQTT_BROKER_HOST, MQTT_USERNAME ); info!( - "Command: mosquitto_pub -h {} -t 'commands/temp/indoor' \\", - MQTT_BROKER_IP + "Command: mosquitto_pub -h {} -u {} -P -t 'commands/temp/indoor' \\", + MQTT_BROKER_HOST, MQTT_USERNAME ); info!(" -m '{{\"action\":\"read\",\"sensor_id\":\"test\"}}'"); } From 6aa76b604a901c7fbbd0b033c8b3442b4b66b6b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 18:40:10 +0000 Subject: [PATCH 41/48] feat: update dependencies for chacha20, event-listener, rustls, rustls-webpki, and spin --- Cargo.lock | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a92cb494..4c85e06e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -790,9 +790,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1889,11 +1889,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "pin-project-lite", ] @@ -3671,15 +3670,15 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", "once_cell", "rustls-pki-types", - "rustls-webpki 0.103.13", + "rustls-webpki 0.103.15", "subtle", "zeroize", ] @@ -3727,9 +3726,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -4075,9 +4074,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] From 0532b961a7d292d75adf4553809fb0a4018d3669 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 18:42:48 +0000 Subject: [PATCH 42/48] feat: add rustls-pemfile advisory to ignore list in cargo-audit configuration --- .cargo/audit.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/.cargo/audit.toml b/.cargo/audit.toml index 00a5c931..08346a0e 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -28,4 +28,5 @@ ignore = [ "RUSTSEC-2026-0098", # rustls-webpki: URI name constraints "RUSTSEC-2026-0099", # rustls-webpki: name constraints vs wildcard names "RUSTSEC-2026-0104", # rustls-webpki: panic in CRL parsing + "RUSTSEC-2025-0134", # rustls-pemfile unmaintained (transitive via rumqttc) ] From e30e9efa5f68d14d1c97c2fa13aafd056ff255fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 19:03:00 +0000 Subject: [PATCH 43/48] feat: refactor MQTT event handling and remove unused connection state events --- aimdb-mqtt-connector/CHANGELOG.md | 16 ++++ aimdb-mqtt-connector/src/embedded/manager.rs | 58 +------------ aimdb-mqtt-connector/src/embedded/mod.rs | 19 ++--- aimdb-mqtt-connector/src/embedded/session.rs | 16 +--- .../src/embedded/session_loop.rs | 83 +++++-------------- aimdb-mqtt-connector/src/embedded/tls.rs | 17 +--- 6 files changed, 48 insertions(+), 161 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 7c3467d3..2f50f31d 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -125,6 +125,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The `mountain-mqtt-embassy` fork is absorbed and dropped.** Its state, event handler and message pump live in `embedded::manager`, with the mutex and the clock as this crate's choices rather than the fork's. +- **The fork's `MqttEvent` goes with it, and `embedded::manager` is now + private.** The fork reported `Connected`, `ConnectionStable`, `Disconnected`, + `SubscriptionGrantedBelowMaximumQos`, + `PublishedMessageHadNoMatchingSubscribers` and `NoSubscriptionExisted` over a + channel the *application* held. In AimDB `pump_source` owns that end and a + record has no connection-state callback, so all six were constructed and then + dropped on the floor — along with `Settings::stabilisation_interval` and the + `stable_at` deadline in the session loop, whose sole output was + `ConnectionStable`. `ConnectionId` went too: it threaded through four + signatures only to populate those events. The event channel now carries the + application message itself. `embedded::manager` and `embedded::session` are + `pub(crate)`, which also withdraws `Settings` — public, documented, and never + reachable, since both build paths hard-code `Settings::default()`. Session + cadence stays a crate-internal constant; making it a knob is a separate + change. Disconnects are unaffected: `defmt` already reported them next to the + event, and still does. - **Session channels use `CriticalSectionRawMutex` in an `Arc`.** They are therefore `Sync`, so `MqttSink` and `MqttSource` are plain `Connector` / `Source` impls and the `EmbassySink`/`EmbassySource` force-`Send` spine is diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index adaf318f..a5738735 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -1,5 +1,4 @@ -//! Session cadence, the events a session reports, and the channels it reports -//! them over. +//! Session cadence and the channels a session talks over. //! //! Channels use `CriticalSectionRawMutex`, so they are `Sync` and the sink and //! source need no force-`Send` wrapper. Time comes from core's @@ -11,12 +10,10 @@ use aimdb_core::RuntimeOps; use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::Channel; use mountain_mqtt::client::{ClientError, EventHandlerError}; -use mountain_mqtt::data::quality_of_service::QualityOfService; -use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packets::publish::ApplicationMessage; /// The event channel: broker session to `pump_source`. -pub(crate) type EventChannel = Channel, Q>; +pub(crate) type EventChannel = Channel; /// The action channel: `pump_sink` to broker session. pub(crate) type ActionChannel = Channel; @@ -69,8 +66,6 @@ pub struct Settings { pub reconnection_delay: Duration, /// Maximum round-trip wait for a packet that expects a response. pub response_timeout: Duration, - /// How long a connection must hold before it counts as stable. - pub stabilisation_interval: Duration, } impl Default for Settings { @@ -80,55 +75,6 @@ impl Default for Settings { connection_event_max_interval: Duration::from_millis(10_000), reconnection_delay: Duration::from_millis(2_000), response_timeout: Duration::from_millis(5_000), - stabilisation_interval: Duration::from_millis(5_000), } } } - -/// What the session reports to the event channel. -#[derive(Debug, Clone)] -pub enum MqttEvent { - /// An application message arrived and converted to `E`. - ApplicationEvent { - /// The connection it arrived on. - connection_id: ConnectionId, - /// The converted message. - event: E, - }, - /// A new connection was established. - Connected { - /// The new connection. - connection_id: ConnectionId, - }, - /// A connection held for `stabilisation_interval`. - ConnectionStable { - /// The connection that stabilised. - connection_id: ConnectionId, - }, - /// A connection ended; the next one is dialled automatically. - Disconnected { - /// The connection that ended. - connection_id: ConnectionId, - /// Why it ended. - error: Error, - }, - /// A subscription was granted below the QoS requested. - SubscriptionGrantedBelowMaximumQos { - /// The connection it was granted on. - connection_id: ConnectionId, - /// What the broker granted. - granted_qos: QualityOfService, - /// What was asked for. - maximum_qos: QualityOfService, - }, - /// A published message reached no subscriber. - PublishedMessageHadNoMatchingSubscribers { - /// The connection it was published on. - connection_id: ConnectionId, - }, - /// An unsubscribe named a subscription the broker did not hold. - NoSubscriptionExisted { - /// The connection it was sent on. - connection_id: ConnectionId, - }, -} diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index e6cf2362..69f503af 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -6,8 +6,8 @@ //! //! See the crate docs for a usage example. -pub mod manager; -pub mod session; +pub(crate) mod manager; +pub(crate) mod session; // The session's own machinery: incremental framing, and the three futures that // replace the polled loop. @@ -40,7 +40,7 @@ use aimdb_embassy_adapter::connectors::into_box_future; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; -use crate::embedded::manager::{MqttEvent, Settings}; +use crate::embedded::manager::Settings; #[cfg(feature = "embedded-tls")] pub use crate::embedded::tls::TlsOptions; @@ -173,17 +173,8 @@ struct MqttSource { impl aimdb_core::session::Source for MqttSource { fn next(&mut self) -> aimdb_core::BoxFut<'_, Option<(String, Payload)>> { Box::pin(async move { - loop { - match self.events.receive().await { - MqttEvent::ApplicationEvent { - event: AimdbMqttEvent::MessageReceived { topic, payload }, - .. - } => return Some((topic, payload)), - // Connection lifecycle events carry no record data; skip - // and keep draining. - _ => continue, - } - } + let AimdbMqttEvent::MessageReceived { topic, payload } = self.events.receive().await; + Some((topic, payload)) }) } } diff --git a/aimdb-mqtt-connector/src/embedded/session.rs b/aimdb-mqtt-connector/src/embedded/session.rs index cf4dfeb1..2adc9f63 100644 --- a/aimdb-mqtt-connector/src/embedded/session.rs +++ b/aimdb-mqtt-connector/src/embedded/session.rs @@ -62,9 +62,7 @@ where { use aimdb_core::session::{ByteStream, Delay}; use mountain_mqtt::data::quality_of_service::QualityOfService; - use mountain_mqtt::mqtt_manager::ConnectionId; - use crate::embedded::manager::MqttEvent; use crate::embedded::session_loop::run_session; // Built once and borrowed for the loop; re-sent on every connection. @@ -73,8 +71,6 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); - let mut connection_index = 0u32; - loop { let mut stream = match dialer.connect(&host, port).await { Ok(stream) => stream, @@ -86,14 +82,10 @@ where } }; - let connection_id = ConnectionId::new(connection_index); - connection_index += 1; - // The halves live exactly as long as the session that reads and writes // them, which is why borrowed halves are enough. let (rx, tx) = stream.split(); let error = run_session( - connection_id, rx, tx, &connection_settings, @@ -108,12 +100,8 @@ where #[cfg(feature = "defmt")] defmt::warn!("MQTT: session errored: {:?}", error); - events - .send(MqttEvent::Disconnected { - connection_id, - error, - }) - .await; + #[cfg(not(feature = "defmt"))] + let _ = error; Delay::sleep(&dialer, settings.reconnection_delay).await; } diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 141d548c..b543ce9f 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -27,11 +27,10 @@ use mountain_mqtt::codec::write::Write; use mountain_mqtt::data::property::ConnectProperty; use mountain_mqtt::data::quality_of_service::QualityOfService; use mountain_mqtt::error::{PacketReadError, PacketWriteError}; -use mountain_mqtt::mqtt_manager::ConnectionId; use mountain_mqtt::packets::connect::Connect; use mountain_mqtt::packets::packet_generic::PacketGeneric; -use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, MqttEvent, Settings}; +use crate::embedded::manager::{now_ms, Error, FromApplicationMessage, Settings}; use crate::embedded::packet_reader::PacketReader; use crate::embedded::{ ActionChannel, AimdbMqttAction, AimdbMqttEvent, EventChannel, BUFFER_SIZE, MAX_PROPERTIES, @@ -60,7 +59,6 @@ type Chunk = heapless::Vec; /// survives. #[allow(clippy::too_many_arguments)] pub(crate) async fn run_session( - connection_id: ConnectionId, rx: R, tx: W, connection_settings: &ConnectionSettings<'static>, @@ -82,7 +80,6 @@ where let outbound: Channel, 4> = Channel::new(); let session = client_loop( - connection_id, &inbound, &outbound, connection_settings, @@ -146,7 +143,6 @@ fn receive_failed() -> Error { /// Everything the session knows, in one future: state, framing, deadlines. #[allow(clippy::too_many_arguments)] async fn client_loop( - connection_id: ConnectionId, inbound: &Channel, outbound: &Channel, 4>, connection_settings: &ConnectionSettings<'static>, @@ -159,7 +155,6 @@ async fn client_loop( ) -> Result { let ping_interval = settings.ping_interval.as_millis() as u64; let max_silence = settings.connection_event_max_interval.as_millis() as u64; - let stabilisation = settings.stabilisation_interval.as_millis() as u64; let response_timeout = settings.response_timeout.as_millis() as u64; let mut state = ClientStateNoQueue::new(); @@ -172,7 +167,6 @@ async fn client_loop( // answers a CONNECT, SUBSCRIBE or QoS 1 PUBLISH is caught by // `response_timeout` rather than only by the liveness window. let mut waiting_since: Option = Some(start); - let mut stable_at: Option = None; let mut connected = false; let mut next_topic = 0usize; @@ -212,15 +206,6 @@ async fn client_loop( } } - if let Some(at) = stable_at { - if now >= at { - stable_at = None; - events - .send(MqttEvent::ConnectionStable { connection_id }) - .await; - } - } - if connected && now.saturating_sub(last_ping_ms) >= ping_interval { last_ping_ms = now; let ping = state.send_ping().map_err(client_error)?; @@ -268,7 +253,6 @@ async fn client_loop( connected, last_ping_ms + ping_interval, last_ack_ms + max_silence, - stable_at, waiting_since.map(|since| since + response_timeout), )); @@ -278,14 +262,11 @@ async fn client_loop( drain_packets( &mut reader, &mut state, - connection_id, outbound, events, runtime, &mut last_ack_ms, &mut connected, - &mut stable_at, - stabilisation, ) .await?; } @@ -309,14 +290,11 @@ async fn client_loop( async fn drain_packets( reader: &mut PacketReader, state: &mut ClientStateNoQueue, - connection_id: ConnectionId, outbound: &Channel, 4>, events: &EventChannel, runtime: &dyn RuntimeOps, last_ack_ms: &mut u64, connected: &mut bool, - stable_at: &mut Option, - stabilisation: u64, ) -> Result<(), Error> { while let Some(total) = reader.framed_len().map_err(client_error)? { // The packet borrows the reader's buffer, so everything that outlives @@ -337,7 +315,7 @@ async fn drain_packets( }; let event = state.receive(packet).map_err(client_error)?; - (response, Received::of(event, connection_id)?) + (response, Received::of(event)?) }; reader.consume(total); @@ -352,8 +330,6 @@ async fn drain_packets( // does, so there is no need to inspect packet types for it. if !*connected && matches!(state, ClientStateNoQueue::Connected(_)) { *connected = true; - *stable_at = Some(now_ms(runtime) + stabilisation); - events.send(MqttEvent::Connected { connection_id }).await; } if let Received::Event(event) = received { @@ -368,15 +344,12 @@ async fn drain_packets( enum Received { /// An acknowledgement: liveness only, nothing to forward. Ack, - /// Something the application asked to hear about. - Event(MqttEvent), + /// A message for `pump_source` to route. + Event(AimdbMqttEvent), } impl Received { - fn of( - event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>, - connection_id: ConnectionId, - ) -> Result { + fn of(event: ClientStateReceiveEvent<'_, '_, MAX_PROPERTIES>) -> Result { Ok(match event { ClientStateReceiveEvent::Ack => Self::Ack, @@ -390,28 +363,18 @@ impl Received { let message = publish.into(); let event = AimdbMqttEvent::from_application_message(&message) .map_err(|e| Error::Client(ClientError::EventHandler(e)))?; - Self::Event(MqttEvent::ApplicationEvent { - connection_id, - event, - }) + Self::Event(event) } - ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { - granted_qos, - maximum_qos, - } => Self::Event(MqttEvent::SubscriptionGrantedBelowMaximumQos { - connection_id, - granted_qos, - maximum_qos, - }), - - ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers => { - Self::Event(MqttEvent::PublishedMessageHadNoMatchingSubscribers { connection_id }) - } - - ClientStateReceiveEvent::NoSubscriptionExisted => { - Self::Event(MqttEvent::NoSubscriptionExisted { connection_id }) - } + // Liveness, and nothing else. The broker is telling us a + // subscription was granted below the QoS asked for, that a publish + // matched no subscriber, or that an unsubscribe named a + // subscription it did not hold. AimDB has nowhere to deliver any of + // that: `pump_source` owns the channel an application would have + // read it from, and a record has no connection-state callback. + ClientStateReceiveEvent::SubscriptionGrantedBelowMaximumQos { .. } + | ClientStateReceiveEvent::PublishedMessageHadNoMatchingSubscribers + | ClientStateReceiveEvent::NoSubscriptionExisted => Self::Ack, ClientStateReceiveEvent::Disconnect { disconnect } => { return Err(Error::Client(ClientError::Disconnected( @@ -527,16 +490,12 @@ fn next_deadline( connected: bool, ping_at: u64, liveness_at: u64, - stable_at: Option, response_at: Option, ) -> u64 { let mut earliest = liveness_at; if connected { earliest = earliest.min(ping_at); } - if let Some(at) = stable_at { - earliest = earliest.min(at); - } if let Some(at) = response_at { earliest = earliest.min(at); } @@ -594,12 +553,11 @@ mod tests { #[test] fn the_earliest_armed_deadline_wins() { // Liveness only, before the connection is up. - assert_eq!(next_deadline(0, false, 100, 500, None, None), 500); + assert_eq!(next_deadline(0, false, 100, 500, None), 500); // Once connected the ping is usually nearest. - assert_eq!(next_deadline(0, true, 100, 500, None, None), 100); - // Stabilisation and the response timeout arm independently. - assert_eq!(next_deadline(0, true, 100, 500, Some(50), None), 50); - assert_eq!(next_deadline(0, true, 100, 500, None, Some(20)), 20); + assert_eq!(next_deadline(0, true, 100, 500, None), 100); + // The response timeout arms independently. + assert_eq!(next_deadline(0, true, 100, 500, Some(20)), 20); } /// A ceiling on the session task's footprint. The bound is loose enough to @@ -614,7 +572,6 @@ mod tests { // Built, never polled: `size_of_val` on the future is the whole point. let session = run_session( - ConnectionId::new(0), NullRead, NullWrite, &connection_settings, @@ -638,6 +595,6 @@ mod tests { #[test] fn a_deadline_in_the_past_still_sleeps_a_tick() { // Never zero: a zero-length sleep would spin the loop. - assert_eq!(next_deadline(1_000, true, 100, 500, None, None), 1); + assert_eq!(next_deadline(1_000, true, 100, 500, None), 1); } } diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index 86ea9c25..b77ddaf5 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -22,11 +22,10 @@ use embedded_tls::{ TlsContext, TlsError, TlsReader, TlsVerifier, TlsWriter, }; -use crate::embedded::manager::{MqttEvent, Settings}; +use crate::embedded::manager::Settings; use crate::embedded::session_loop::run_session; use mountain_mqtt::client::ConnectionSettings; use mountain_mqtt::data::quality_of_service::QualityOfService; -use mountain_mqtt::mqtt_manager::ConnectionId; /// Room for the server's leaf certificate (DER) inside the verifier — 4 KB /// covers RSA-4096 leaves with headroom. @@ -340,8 +339,6 @@ where .map(|topic| (topic.as_str(), QualityOfService::Qos1)) .collect(); - let mut connection_index = 0u32; - loop { // Certificate validity needs real time. Take it from the runtime when // it has a wall clock; otherwise wait for whatever feeds `WallClock` @@ -399,14 +396,10 @@ where #[cfg(feature = "defmt")] defmt::info!("MQTT-TLS: session established"); - let connection_id = ConnectionId::new(connection_index); - connection_index += 1; - // From here the session is the plain path's, byte for byte: the record // layer is just another pair of halves. let (tls_rx, tls_tx) = tls.split(); let error = run_session( - connection_id, TlsRead(tls_rx), TlsWrite(tls_tx), &connection_settings, @@ -421,12 +414,8 @@ where #[cfg(feature = "defmt")] defmt::warn!("MQTT-TLS: session errored: {:?}", error); - events - .send(MqttEvent::Disconnected { - connection_id, - error, - }) - .await; + #[cfg(not(feature = "defmt"))] + let _ = error; aimdb_core::session::Delay::sleep(&delay, settings.reconnection_delay).await; } From 34b273e37f673fd3e3fc0f0de581a9bcbc21a293 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 19:18:16 +0000 Subject: [PATCH 44/48] feat: implement keep-alive configuration for MQTT connectors and ensure consistency across backends --- aimdb-mqtt-connector/src/connector.rs | 99 ++++++++++++++++++- aimdb-mqtt-connector/src/embedded/manager.rs | 71 +++++++++++-- aimdb-mqtt-connector/src/embedded/mod.rs | 10 +- .../src/embedded/session_loop.rs | 5 +- aimdb-mqtt-connector/src/native.rs | 47 ++++++--- aimdb-mqtt-connector/tests/backend_parity.rs | 83 ++++++++++++++++ aimdb-mqtt-connector/tests/common/mod.rs | 11 +++ 7 files changed, 297 insertions(+), 29 deletions(-) diff --git a/aimdb-mqtt-connector/src/connector.rs b/aimdb-mqtt-connector/src/connector.rs index 21075236..ccf9ac59 100644 --- a/aimdb-mqtt-connector/src/connector.rs +++ b/aimdb-mqtt-connector/src/connector.rs @@ -15,10 +15,19 @@ use alloc::string::String; use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; +use core::time::Duration; use aimdb_core::connector::ConnectorBuilder; use aimdb_core::{AimDb, DbResult}; +/// Keep-alive used when the caller names none. +pub(crate) const KEEP_ALIVE_DEFAULT_SECS: u16 = 60; + +/// The shortest keep-alive accepted. Below this the derived ping interval stops +/// being a meaningful fraction, and the round-trip timeout would outlive the +/// window it is supposed to fit inside. +const KEEP_ALIVE_MIN_SECS: u16 = 10; + /// The runner's collected future type. type BoxFuture = Pin + Send + 'static>>; /// What [`ConnectorBuilder::build`] returns. @@ -48,6 +57,7 @@ pub struct MqttConnector { pub(crate) broker_url: String, pub(crate) client_id: Option, pub(crate) credentials: Option<(String, String)>, + pub(crate) keep_alive: Duration, pub(crate) backend: B, } @@ -62,6 +72,7 @@ impl MqttConnector { broker_url: broker_url.into(), client_id: None, credentials: None, + keep_alive: Duration::from_secs(KEEP_ALIVE_DEFAULT_SECS as u64), backend: Native, } } @@ -73,6 +84,7 @@ impl MqttConnector { broker_url: self.broker_url, client_id: self.client_id, credentials: self.credentials, + keep_alive: self.keep_alive, backend: Embedded { dialer }, } } @@ -89,6 +101,7 @@ impl MqttConnector { broker_url: self.broker_url, client_id: self.client_id, credentials: self.credentials, + keep_alive: self.keep_alive, backend: EmbeddedTls { dialer, options: crate::embedded::TlsSlot::new(options), @@ -116,6 +129,29 @@ impl MqttConnector { self.credentials = Some((username.into(), password.into())); self } + + /// Promise the broker it will hear from this client at least this often + /// (MQTT CONNECT keep-alive). Defaults to 60 s. + pub fn with_keep_alive(mut self, keep_alive: Duration) -> Self { + self.keep_alive = keep_alive; + self + } +} + +/// Whole seconds for the wire, or the reason this keep-alive cannot be used. +fn keep_alive_secs(keep_alive: Duration) -> DbResult { + let secs = keep_alive.as_secs(); + if secs < u64::from(KEEP_ALIVE_MIN_SECS) { + return Err(aimdb_core::DbError::runtime_error(alloc::format!( + "MQTT keep-alive must be at least {KEEP_ALIVE_MIN_SECS}s, got {secs}s" + ))); + } + u16::try_from(secs).map_err(|_| { + aimdb_core::DbError::runtime_error(alloc::format!( + "MQTT keep-alive must fit in u16 seconds (max {}), got {secs}s", + u16::MAX + )) + }) } mod sealed { @@ -144,6 +180,7 @@ pub trait Backend: sealed::Sealed + Send + Sync { broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, ) -> BuildFuture<'a>; } @@ -155,8 +192,9 @@ impl Backend for Native { broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, ) -> BuildFuture<'a> { - crate::native::build(db, broker_url, client_id, credentials) + crate::native::build(db, broker_url, client_id, credentials, keep_alive_secs) } } @@ -176,8 +214,16 @@ where broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, ) -> BuildFuture<'a> { - crate::embedded::build_plain(db, broker_url, client_id, credentials, &self.dialer) + crate::embedded::build_plain( + db, + broker_url, + client_id, + credentials, + keep_alive_secs, + &self.dialer, + ) } } @@ -197,18 +243,33 @@ where broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, ) -> BuildFuture<'a> { - crate::embedded::build_tls(db, broker_url, client_id, credentials, self) + crate::embedded::build_tls( + db, + broker_url, + client_id, + credentials, + keep_alive_secs, + self, + ) } } impl ConnectorBuilder for MqttConnector { fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + // Checked here rather than in either backend: one keep-alive, one + // rejection, whichever one runs. + let keep_alive_secs = match keep_alive_secs(self.keep_alive) { + Ok(secs) => secs, + Err(e) => return Box::pin(async move { Err(e) }), + }; self.backend.build( db, &self.broker_url, self.client_id.as_deref(), self.credentials.as_ref(), + keep_alive_secs, ) } @@ -216,3 +277,35 @@ impl ConnectorBuilder for MqttConnector { "mqtt" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_keep_alive_is_whole_seconds_on_the_wire() { + assert_eq!(keep_alive_secs(Duration::from_secs(60)).unwrap(), 60); + // Truncated, not rounded: the promise must never overstate the gap. + assert_eq!( + keep_alive_secs(Duration::from_millis(60_900)).unwrap(), + 60, + "a partial second must round down, so the broker is never told to \ + wait longer than we actually allow" + ); + } + + #[test] + fn a_keep_alive_that_cannot_be_honoured_is_refused() { + // Zero means "no keep-alive" in MQTT; the derived cadence has no + // meaning there, so it is refused rather than reinterpreted. + assert!(keep_alive_secs(Duration::ZERO).is_err()); + assert!(keep_alive_secs(Duration::from_secs(9)).is_err()); + assert!(keep_alive_secs(Duration::from_secs(u64::from(KEEP_ALIVE_MIN_SECS) - 1)).is_err()); + // The wire field is u16 seconds. + assert!(keep_alive_secs(Duration::from_secs(u64::from(u16::MAX) + 1)).is_err()); + assert_eq!( + keep_alive_secs(Duration::from_secs(u64::from(u16::MAX))).unwrap(), + u16::MAX + ); + } +} diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index a5738735..26830870 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -55,26 +55,83 @@ impl defmt::Format for Error { } } -/// Session cadence: how often to ping, how long to wait, when to give up. +/// Session cadence, derived from the keep-alive the CONNECT promises. #[derive(Debug, Clone, Copy)] pub struct Settings { - /// Minimum interval between pings. + /// What the CONNECT promises the broker, in seconds — its own unit. + pub keep_alive_secs: u16, + /// Minimum interval between pings. Half the keep-alive, so a lost ping + /// still leaves a whole interval before anyone gives up. pub ping_interval: Duration, /// Maximum silence from the broker before the session is declared dead. + /// One and a half keep-alives, mirroring the rule MQTT gives the *broker* + /// for disconnecting a silent client, so the two sides give up together. pub connection_event_max_interval: Duration, /// Wait between a failed session and the next dial. pub reconnection_delay: Duration, - /// Maximum round-trip wait for a packet that expects a response. + /// Maximum round-trip wait for a packet that expects a response. A bound on + /// broker latency, unrelated to the keep-alive. pub response_timeout: Duration, } -impl Default for Settings { - fn default() -> Self { +impl Settings { + /// Derive the cadence from a keep-alive in seconds. + /// + /// The caller has already rejected values too small to halve — see + /// `MqttConnector::with_keep_alive`. + pub(crate) fn from_keep_alive_secs(keep_alive_secs: u16) -> Self { + let keep_alive = Duration::from_secs(u64::from(keep_alive_secs)); Self { - ping_interval: Duration::from_millis(2_000), - connection_event_max_interval: Duration::from_millis(10_000), + keep_alive_secs, + ping_interval: keep_alive / 2, + connection_event_max_interval: keep_alive * 3 / 2, reconnection_delay: Duration::from_millis(2_000), response_timeout: Duration::from_millis(5_000), } } } + +impl Default for Settings { + fn default() -> Self { + Self::from_keep_alive_secs(crate::connector::KEEP_ALIVE_DEFAULT_SECS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The three numbers move together, and in the order the protocol needs. + #[test] + fn the_cadence_is_derived_from_the_promise() { + for secs in [10u16, 45, 60, 300, u16::MAX] { + let s = Settings::from_keep_alive_secs(secs); + let keep_alive = Duration::from_secs(u64::from(secs)); + + assert_eq!(s.keep_alive_secs, secs, "the promise is sent verbatim"); + assert_eq!(s.ping_interval, keep_alive / 2); + assert_eq!(s.connection_event_max_interval, keep_alive * 3 / 2); + + // What the derivation exists to guarantee: we always speak well + // before the broker may hang up, and we never declare a session + // dead before a ping has had a full interval to be answered. + assert!( + s.ping_interval < keep_alive, + "a ping must land inside the keep-alive it promised" + ); + assert!( + s.connection_event_max_interval > s.ping_interval * 2, + "one lost ping must not be enough to abandon the session" + ); + } + } + + /// The default is the same 60 s the CONNECT used to carry by accident. + #[test] + fn the_default_promises_sixty_seconds() { + let s = Settings::default(); + assert_eq!(s.keep_alive_secs, 60); + assert_eq!(s.ping_interval, Duration::from_secs(30)); + assert_eq!(s.connection_event_max_interval, Duration::from_secs(90)); + } +} diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 69f503af..96189a4c 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -191,6 +191,7 @@ pub(crate) fn build_plain<'a, D>( broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, dialer: &'a D, ) -> Pin>> + Send + 'a>> where @@ -216,6 +217,7 @@ where connection_settings, dialer.clone(), topics, + Settings::from_keep_alive_secs(keep_alive_secs), db.runtime_ops(), )?; Ok(collect_pumps(db, actions, events, manager_tasks)) @@ -229,6 +231,7 @@ pub(crate) fn build_tls<'a, D>( broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, backend: &'a crate::connector::EmbeddedTls, ) -> Pin>> + Send + 'a>> where @@ -259,6 +262,7 @@ where connection_settings, backend.dialer.clone(), topics, + Settings::from_keep_alive_secs(keep_alive_secs), db.runtime_ops(), )?; Ok(collect_pumps(db, actions, events, manager_tasks)) @@ -384,6 +388,7 @@ fn setup_manager( connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, + settings: Settings, runtime: Arc, ) -> Result where @@ -418,7 +423,7 @@ where port, topics, connection_settings, - Settings::default(), + settings, events, actions, runtime, @@ -440,6 +445,7 @@ fn setup_tls_manager( connection_settings: ConnectionSettings<'static>, dialer: D, topics: Vec, + settings: Settings, runtime: Arc, ) -> Result where @@ -502,7 +508,7 @@ where port, topics, connection_settings, - Settings::default(), + settings, events, actions, delay, diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index b543ce9f..310fac37 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -176,8 +176,11 @@ async fn client_loop( // Topic aliases are declined: honouring them would mean storing the // server's topic names for the life of the connection. let _ = properties.push(ConnectProperty::TopicAliasMaximum(0.into())); + // Ours, not `connection_settings.keep_alive()`: that field has no + // setter, so it is always mountain-mqtt's own 60 s constant. The + // cadence below is derived from the value we actually send. let connect: Connect<'_, 1, 0> = Connect::new( - connection_settings.keep_alive(), + settings.keep_alive_secs, *connection_settings.username(), *connection_settings.password(), connection_settings.client_id(), diff --git a/aimdb-mqtt-connector/src/native.rs b/aimdb-mqtt-connector/src/native.rs index f47f7d8f..10725781 100644 --- a/aimdb-mqtt-connector/src/native.rs +++ b/aimdb-mqtt-connector/src/native.rs @@ -24,6 +24,7 @@ pub(crate) fn build<'a>( broker_url: &'a str, client_id: Option<&'a str>, credentials: Option<&'a (String, String)>, + keep_alive_secs: u16, ) -> Pin>> + Send + 'a>> { Box::pin(async move { // Build a router from the inbound routes purely to drive the MQTT @@ -36,15 +37,17 @@ pub(crate) fn build<'a>( log_info!("MQTT subscribing to {} topics", router.resource_ids().len()); // Connect, subscribe, and hand back the raw event loop. - let (client, event_loop) = - MqttConnectorImpl::build_internal(broker_url, client_id, credentials, router) - .await - .map_err(|e| { - aimdb_core::DbError::runtime_error(format!( - "Failed to build MQTT connector: {}", - e - )) - })?; + let (client, event_loop) = MqttConnectorImpl::build_internal( + broker_url, + client_id, + credentials, + keep_alive_secs, + router, + ) + .await + .map_err(|e| { + aimdb_core::DbError::runtime_error(format!("Failed to build MQTT connector: {}", e)) + })?; let mut futures: Vec = Vec::new(); @@ -80,6 +83,7 @@ impl MqttConnectorImpl { broker_url: &str, client_id: Option<&str>, credentials: Option<&(String, String)>, + keep_alive_secs: u16, router: Router, ) -> Result<(Arc, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic @@ -111,7 +115,10 @@ impl MqttConnectorImpl { let mut mqtt_opts = MqttOptions::new(client_id, host, port); - mqtt_opts.set_keep_alive(Duration::from_secs(30)); + // The same promise the embedded backend makes, from the same setter: + // the two backends used to disagree here (30 s against 60 s) for one + // route URL. + mqtt_opts.set_keep_alive(Duration::from_secs(keep_alive_secs.into())); // `with_credentials` wins over anything in the URL's authority, which // is the only way to name a password that is not URL-safe. @@ -345,7 +352,8 @@ mod tests { async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, None, router).await; + MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, None, 60, router) + .await; assert!(connector.is_ok()); } @@ -353,7 +361,8 @@ mod tests { async fn test_connector_with_port() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, None, router).await; + MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, None, 60, router) + .await; assert!(connector.is_ok()); } @@ -361,7 +370,7 @@ mod tests { async fn test_invalid_url() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("not-a-valid-url", None, None, router).await; + MqttConnectorImpl::build_internal("not-a-valid-url", None, None, 60, router).await; assert!(connector.is_err()); } @@ -374,6 +383,7 @@ mod tests { "mqtts://hub-sub:secret@broker.example.com:8883", None, None, + 60, router, ) .await; @@ -401,9 +411,14 @@ mod tests { #[tokio::test] async fn test_connector_mqtt_url_needs_no_tls_backend() { let router = RouterBuilder::new().build(); - let connector = - MqttConnectorImpl::build_internal("mqtt://broker.example.com:1883", None, None, router) - .await; + let connector = MqttConnectorImpl::build_internal( + "mqtt://broker.example.com:1883", + None, + None, + 60, + router, + ) + .await; assert!(connector.is_ok()); } } diff --git a/aimdb-mqtt-connector/tests/backend_parity.rs b/aimdb-mqtt-connector/tests/backend_parity.rs index 602f46e4..6e7061c5 100644 --- a/aimdb-mqtt-connector/tests/backend_parity.rs +++ b/aimdb-mqtt-connector/tests/backend_parity.rs @@ -332,6 +332,89 @@ async fn the_setter_overrides_url_credentials_on_both_backends() { } } +/// Both backends promise the broker the same keep-alive, and `with_keep_alive` +/// is what sets it. +/// +/// They used to disagree on an unset default — `rumqttc` hard-coded 30 s while +/// the embedded path sent mountain-mqtt's 60 s — for one and the same route +/// URL. The embedded session additionally pinged every 2 s regardless, an +/// inherited constant unrelated to what it had promised. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn both_backends_promise_the_keep_alive_they_were_given() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let url = format!("mqtt://127.0.0.1:{port}"); + let seen = Arc::new(Mutex::new(Seen::default())); + + let keep_alive = Duration::from_secs(45); + let native = MqttConnector::new(url.clone()) + .with_client_id("ka-native") + .with_keep_alive(keep_alive); + let embedded = MqttConnector::new(url) + .transport(TokioNet::tcp()) + .with_client_id("ka-embedded") + .with_keep_alive(keep_alive); + + let (_native_db, native_runner) = build_db(native, 1).await; + let (_embedded_db, embedded_runner) = build_db(embedded, 2).await; + + let broker = fake_broker_concurrent(listener, seen.clone(), None); + let seen_for_wait = seen.clone(); + + tokio::select! { + _ = native_runner.run() => panic!("the native runner returned"), + _ = embedded_runner.run() => panic!("the embedded runner returned"), + _ = broker => panic!("the broker returned"), + _ = async { + while seen_for_wait.lock().unwrap().keep_alives.len() < 2 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + } => {} + _ = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("watchdog: saw {:?}", seen.lock().unwrap().keep_alives); + } + } + + let seen = seen.lock().unwrap(); + for (n, promised) in seen.keep_alives.iter().enumerate() { + assert_eq!( + *promised, 45, + "connection {n} ({}) promised a keep-alive it was not given", + seen.client_ids[n] + ); + } +} + +/// A keep-alive too short to halve is refused at build, not quietly adjusted. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_keep_alive_below_the_floor_fails_the_build() { + // Port 1 is never listened on here: the build must fail on the keep-alive + // before anything is dialled. + let connector = MqttConnector::new("mqtt://127.0.0.1:1") + .transport(TokioNet::tcp()) + .with_keep_alive(Duration::from_secs(1)); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("outbound", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .source(|_ctx, producer| async move { producer.produce(1) }) + .link_to(OUTBOUND) + .with_serializer(|_ctx, v: &u64| Ok(v.to_string().into_bytes())) + .finish(); + }); + + let Err(err) = builder.build().await else { + panic!("a 1s keep-alive must be refused"); + }; + let message = err.to_string(); + assert!( + message.contains("keep-alive") && message.contains("10"), + "the error should name the floor, got: {message}" + ); +} + /// A **hostname** is a broker address on both backends. /// /// Resolving `host` is the dialer's job on every adapter, so `.transport(..)` diff --git a/aimdb-mqtt-connector/tests/common/mod.rs b/aimdb-mqtt-connector/tests/common/mod.rs index 13d419f9..e2b35ce3 100644 --- a/aimdb-mqtt-connector/tests/common/mod.rs +++ b/aimdb-mqtt-connector/tests/common/mod.rs @@ -22,6 +22,8 @@ pub struct Seen { pub client_ids: Vec, /// The username/password each CONNECT carried, when it carried any. pub credentials: Vec>, + /// The keep-alive each CONNECT promised, in seconds. + pub keep_alives: Vec, pub subscribes: Vec>, pub published: Vec<(String, Vec)>, } @@ -119,6 +121,12 @@ fn take_field(body: &[u8], i: &mut usize) -> Option { Some(field) } +/// The keep-alive a CONNECT promises: bytes 8-9 of the variable header, after +/// the protocol name, level and flags. +fn connect_keep_alive(body: &[u8]) -> Option { + Some(u16::from_be_bytes([*body.get(8)?, *body.get(9)?])) +} + /// The identity a CONNECT carries: client id, then the credentials its flags /// advertise. Nothing here sets a will, so the payload fields are contiguous. fn connect_identity(body: &[u8], v5: bool) -> Option<(String, Option<(String, String)>)> { @@ -269,6 +277,9 @@ where seen.client_ids.push(id); seen.credentials.push(credentials); } + if let Some(keep_alive) = connect_keep_alive(&body) { + seen.keep_alives.push(keep_alive); + } } let ack: &[u8] = if v5 { &[0x20, 0x03, 0x00, 0x00, 0x00] From ee488da12cd6dc49859c312384ac804a34989b26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 19:35:19 +0000 Subject: [PATCH 45/48] feat: enhance keep-alive functionality and adjust session timing parameters for MQTT connections --- aimdb-mqtt-connector/CHANGELOG.md | 24 +++++++++++++++---- aimdb-mqtt-connector/src/embedded/manager.rs | 20 +++++++++++++--- aimdb-mqtt-connector/tests/session_loop.rs | 23 ++++++++++++------ aimdb-mqtt-connector/tests/tls_session.rs | 19 +++++++++++---- .../embassy-mqtt-connector-demo/src/main.rs | 4 ++++ 5 files changed, 71 insertions(+), 19 deletions(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 2f50f31d..5a7b1be0 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -50,10 +50,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 direction behind a cloneable handle, which is what lets `embedded-tls`'s reader and writer run at once. TLS is now two adapter types and a handshake. -- **`Settings::poll_interval` is removed.** There is no poll to pace. The other - fields are unchanged, and `ping_interval`, `connection_event_max_interval` - and `stabilisation_interval` now arm real deadlines rather than being - compared against a 10 ms tick. +- **`Settings::poll_interval` is removed.** There is no poll to pace. What + remains arms real deadlines rather than being compared against a 10 ms tick — + and is derived from the keep-alive rather than set field by field; see + `with_keep_alive` below. - **`BrokerTransport` and `SocketTransport` are removed** from `embedded::session`. They existed to carry the readiness peek that a @@ -117,6 +117,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 honour credentials in the URL authority (`mqtt://user:pass@host`), and on both the setter takes precedence over them — it is the only way to name a password that is not URL-safe. +- **`with_keep_alive(Duration)`, and a session cadence derived from it.** The + CONNECT used to promise whatever nobody had chosen — `rumqttc` hard-coded + 30 s, the embedded path sent mountain-mqtt's 60 s because + `ConnectionSettings::keep_alive` has no setter — while the embedded session + pinged every 2 s regardless, an interval inherited from the absorbed fork's + polled loop. One route URL, two different promises, and a client talking 30× + more often than it had said it would. Both backends now send the keep-alive + they were given (60 s by default), and the embedded session derives the rest + from it: ping at half, give up on an unanswered CONNACK/SUBACK/PUBACK at one, + abandon the session after one and a half — the same multiple MQTT gives the + broker for dropping a silent client, so both sides give up together. Strictly + ordered, so no two deadlines ever come due at once. Keep-alive is the only + cadence knob because the others are not independent of it: noticing a link + that died silently means sending something and waiting, so the detection + window *is* the ping interval. Values under 10 s, and the 0 that means "no + keep-alive" in MQTT, are refused at `build()` rather than silently adjusted. - **`.tls(dialer, options)` replaces `.tls(stack, options)`.** The dialer resolves the host, so TLS needs no network stack: DNS, the socket buffers and the SNTP task all leave the TLS path. The certificate-validity clock comes diff --git a/aimdb-mqtt-connector/src/embedded/manager.rs b/aimdb-mqtt-connector/src/embedded/manager.rs index 26830870..206b7425 100644 --- a/aimdb-mqtt-connector/src/embedded/manager.rs +++ b/aimdb-mqtt-connector/src/embedded/manager.rs @@ -69,8 +69,11 @@ pub struct Settings { pub connection_event_max_interval: Duration, /// Wait between a failed session and the next dial. pub reconnection_delay: Duration, - /// Maximum round-trip wait for a packet that expects a response. A bound on - /// broker latency, unrelated to the keep-alive. + /// Maximum round-trip wait for a packet that expects one — CONNACK, SUBACK, + /// or the PUBACK of a QoS 1 publish. A whole keep-alive, which puts it + /// between the ping interval and the liveness window: a ping is never + /// racing an outstanding acknowledgement, and the acknowledgement always + /// gives up before the session does. pub response_timeout: Duration, } @@ -85,8 +88,10 @@ impl Settings { keep_alive_secs, ping_interval: keep_alive / 2, connection_event_max_interval: keep_alive * 3 / 2, + // Backoff between dials, not a cadence: nothing about the + // keep-alive says how long to wait before trying again. reconnection_delay: Duration::from_millis(2_000), - response_timeout: Duration::from_millis(5_000), + response_timeout: keep_alive, } } } @@ -123,6 +128,14 @@ mod tests { s.connection_event_max_interval > s.ping_interval * 2, "one lost ping must not be enough to abandon the session" ); + // Strictly ordered, so no two deadlines can come due together: a + // ping never races an outstanding acknowledgement, and that + // acknowledgement gives up before the whole session does. + assert!( + s.ping_interval < s.response_timeout + && s.response_timeout < s.connection_event_max_interval, + "ping < response < liveness must hold at every keep-alive" + ); } } @@ -132,6 +145,7 @@ mod tests { let s = Settings::default(); assert_eq!(s.keep_alive_secs, 60); assert_eq!(s.ping_interval, Duration::from_secs(30)); + assert_eq!(s.response_timeout, Duration::from_secs(60)); assert_eq!(s.connection_event_max_interval, Duration::from_secs(90)); } } diff --git a/aimdb-mqtt-connector/tests/session_loop.rs b/aimdb-mqtt-connector/tests/session_loop.rs index 1ed7b4c0..e0d256e5 100644 --- a/aimdb-mqtt-connector/tests/session_loop.rs +++ b/aimdb-mqtt-connector/tests/session_loop.rs @@ -59,6 +59,14 @@ async fn serve_one(listener: TcpListener, log: Arc>, script: Script) scripted_broker(socket, log, script).await; } +/// The shortest keep-alive `with_keep_alive` accepts, so the ping interval the +/// session derives from it — half, see `Settings::from_keep_alive_secs` — is as +/// short as a test can ask for. Every window below is written against it. +const TEST_KEEP_ALIVE: Duration = Duration::from_secs(10); + +/// What the session derives from [`TEST_KEEP_ALIVE`]. +const TEST_PING_INTERVAL: Duration = Duration::from_secs(5); + // --------------------------------------------------------------------------- // The database under test. // --------------------------------------------------------------------------- @@ -77,7 +85,8 @@ async fn build_db( let connector = MqttConnector::new(format!("mqtt://127.0.0.1:{port}")) .transport(dialer) - .with_client_id("session-loop"); + .with_client_id("session-loop") + .with_keep_alive(TEST_KEEP_ALIVE); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) @@ -131,8 +140,8 @@ async fn an_idle_session_wakes_at_the_ping_cadence() { let (_db, runner) = build_db(port, dialer, None).await; // Long enough to span several of the old loop's 10 ms polls, and to cover - // the 2 s ping cadence at least once. - const WINDOW: Duration = Duration::from_secs(3); + // the derived ping cadence at least once. + const WINDOW: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_secs(2)); tokio::select! { _ = runner.run() => panic!("the session loop returned"), @@ -166,9 +175,9 @@ async fn a_partial_packet_stops_neither_pings_nor_publishes() { let port = listener.local_addr().unwrap().port(); let log = Arc::new(Mutex::new(Log::default())); - // Longer than the 2 s ping interval, so a ping falls due while the packet - // is half-delivered — the case the polled loop wedges on. - const GAP: Duration = Duration::from_millis(2_600); + // Longer than the derived ping interval, so a ping falls due while the + // packet is half-delivered — the case the polled loop wedges on. + const GAP: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_millis(600)); let dialer = CountingDialer::new(); let (db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 0))).await; @@ -223,7 +232,7 @@ async fn a_slow_puback_does_not_block_the_ping() { // Again longer than the ping interval: the old loop waited for this PUBACK // inline, at 1 kHz, with the ping behind it. - const ACK_DELAY: Duration = Duration::from_millis(2_600); + const ACK_DELAY: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_millis(600)); let dialer = CountingDialer::new(); let (_db, runner) = build_db(port, dialer, Some((Duration::from_millis(100), 1))).await; diff --git a/aimdb-mqtt-connector/tests/tls_session.rs b/aimdb-mqtt-connector/tests/tls_session.rs index 78c3d872..c7fc7965 100644 --- a/aimdb-mqtt-connector/tests/tls_session.rs +++ b/aimdb-mqtt-connector/tests/tls_session.rs @@ -54,6 +54,14 @@ embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock) /// The name the certificate is issued for, and the name the client verifies. const BROKER_HOST: &str = "localhost"; +/// The shortest keep-alive `with_keep_alive` accepts, so the ping interval the +/// session derives from it — half, see `Settings::from_keep_alive_secs` — is as +/// short as a test can ask for. Every window below is written against it. +const TEST_KEEP_ALIVE: Duration = Duration::from_secs(10); + +/// What the session derives from [`TEST_KEEP_ALIVE`]. +const TEST_PING_INTERVAL: Duration = Duration::from_secs(5); + /// A self-signed certificate for `localhost`, as (server chain, key, root CA). fn self_signed() -> ( CertificateDer<'static>, @@ -134,7 +142,8 @@ async fn build_tls_db( let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:{port}")) .tls(dialer, options) - .with_client_id("tls-session"); + .with_client_id("tls-session") + .with_keep_alive(TEST_KEEP_ALIVE); let mut builder = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) @@ -186,7 +195,7 @@ async fn an_idle_tls_session_wakes_at_the_ping_cadence() { let sleeps = dialer.sleeps(); let (_db, runner) = build_tls_db(port, dialer, options, None).await; - const WINDOW: Duration = Duration::from_secs(3); + const WINDOW: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_secs(2)); tokio::select! { _ = runner.run() => panic!("the session loop returned"), @@ -217,10 +226,10 @@ async fn a_partial_packet_over_tls_stops_neither_pings_nor_publishes() { let (listener, acceptor, options, port) = tls_setup(); let log = Arc::new(Mutex::new(Log::default())); - // Longer than the 2 s ping interval, so a ping falls due while the MQTT + // Longer than the derived ping interval, so a ping falls due while the MQTT // packet is half-delivered — here, half of it inside a complete TLS record // and the rest in a later one. - const GAP: Duration = Duration::from_millis(2_600); + const GAP: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_millis(600)); let dialer = CountingDialer::new(); let (db, runner) = @@ -275,7 +284,7 @@ async fn a_slow_puback_over_tls_does_not_block_the_ping() { let (listener, acceptor, options, port) = tls_setup(); let log = Arc::new(Mutex::new(Log::default())); - const ACK_DELAY: Duration = Duration::from_millis(2_600); + const ACK_DELAY: Duration = TEST_PING_INTERVAL.saturating_add(Duration::from_millis(600)); let dialer = CountingDialer::new(); let (_db, runner) = diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 1e856663..38fa7e5c 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -201,6 +201,8 @@ const MQTT_BROKER_PORT: u16 = 8883; const MQTT_USERNAME: &str = "aimdb"; const MQTT_PASSWORD: &str = "aimdb-bench"; +const MQTT_KEEP_ALIVE: core::time::Duration = core::time::Duration::from_secs(10); + /// The broker's root CA, DER-encoded. `gen-certs.sh` writes it here. #[cfg(feature = "tls")] static MQTT_CA_DER: &[u8] = include_bytes!("../ca.der"); @@ -397,6 +399,7 @@ async fn main(spawner: Spawner) { MQTT_TX.init([0; 4096]), )) .with_client_id("embassy-demo-001") + .with_keep_alive(MQTT_KEEP_ALIVE) }; // `mqtts://` dials through the same transport as `mqtt://`; the adapter @@ -422,6 +425,7 @@ async fn main(spawner: Spawner) { .with_sntp(stack, "pool.ntp.org"), ) .with_client_id("embassy-demo-001") + .with_keep_alive(MQTT_KEEP_ALIVE) }; let mut builder = AimDbBuilder::new() From d5408729b7deec44df0991d0dec7b5bf07995d3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 19:46:42 +0000 Subject: [PATCH 46/48] feat: add minimum write buffer size for TLS records and implement validation in build process --- aimdb-mqtt-connector/CHANGELOG.md | 11 ++++ aimdb-mqtt-connector/src/embedded/mod.rs | 7 ++- aimdb-mqtt-connector/src/embedded/tls.rs | 4 ++ aimdb-mqtt-connector/tests/tls_broker.rs | 69 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 5a7b1be0..236631f1 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -117,6 +117,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 honour credentials in the URL authority (`mqtt://user:pass@host`), and on both the setter takes precedence over them — it is the only way to name a password that is not URL-safe. +- **An undersized TLS write buffer is refused at `build()`.** It joins the read + buffer, which was already checked. Not for symmetry: `embedded-tls` encodes + the handshake into whichever buffer is larger, and the read-buffer floor makes + that the read buffer, so a small write buffer only splits application data + across more records — legal, merely chatty. The floor is underneath that. + `embedded-tls` guards `len > TLS_RECORD_OVERHEAD` (128 bytes) with a + `debug_assert!`, which a release build — every firmware build — strips, and + past it `len - TLS_RECORD_OVERHEAD` underflows and the writer copies off the + end of the buffer; at exactly the overhead it computes a zero-length payload + and stops making progress. A panic or a hang on the device, in other words, + now a named error at startup. Both floors have tests. - **`with_keep_alive(Duration)`, and a session cadence derived from it.** The CONNECT used to promise whatever nobody had chosen — `rumqttc` hard-coded 30 s, the embedded path sent mountain-mqtt's 60 s because diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index 96189a4c..d33d79b2 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -45,7 +45,7 @@ use crate::embedded::manager::Settings; #[cfg(feature = "embedded-tls")] pub use crate::embedded::tls::TlsOptions; #[cfg(feature = "embedded-tls")] -use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN}; +use crate::embedded::tls::{host_ip_literal, READ_BUF_MIN, WRITE_BUF_MIN}; /// Maximum number of pending MQTT actions and events pub(crate) const CHANNEL_SIZE: usize = 32; @@ -477,6 +477,11 @@ where "TLS read buffer too small — a TLS 1.3 peer may send 16 KB records; provide at least 16 640 bytes", )); } + if options.write_buf.len() < WRITE_BUF_MIN { + return Err(build_err( + "TLS write buffer too small — each record costs 128 bytes of overhead, and below that the writer runs off the end of the buffer; provide at least 256 bytes, or 4 096 for MQTT-sized writes in one record", + )); + } let actions: Arc = Arc::new(ActionChannel::new()); let events: Arc = Arc::new(EventChannel::new()); diff --git a/aimdb-mqtt-connector/src/embedded/tls.rs b/aimdb-mqtt-connector/src/embedded/tls.rs index b77ddaf5..4b088d2e 100644 --- a/aimdb-mqtt-connector/src/embedded/tls.rs +++ b/aimdb-mqtt-connector/src/embedded/tls.rs @@ -36,6 +36,10 @@ const CERT_BUFFER_SIZE: usize = 4096; /// any record larger than the buffer, so `build()` rejects a smaller one. pub(crate) const READ_BUF_MIN: usize = 16_640; +/// Minimum TLS record write buffer, at twice `embedded-tls`'s 128-byte +/// per-record overhead. +pub(crate) const WRITE_BUF_MIN: usize = 256; + /// TLS materials for a `mqtts://` broker connection. /// /// All references are `'static`: the session outlives `build()`, so the buffers diff --git a/aimdb-mqtt-connector/tests/tls_broker.rs b/aimdb-mqtt-connector/tests/tls_broker.rs index b3437e55..9f92b232 100644 --- a/aimdb-mqtt-connector/tests/tls_broker.rs +++ b/aimdb-mqtt-connector/tests/tls_broker.rs @@ -170,3 +170,72 @@ async fn the_embedded_backend_completes_an_mqtts_handshake_against_a_pinned_root seen.subscribed_topics() ); } + +// --------------------------------------------------------------------------- +// Buffer floors, checked at build rather than on the device. +// --------------------------------------------------------------------------- + +/// Build a connector with the given buffer sizes and return the build error. +/// +/// Nothing listens on the port: both checks run before anything is dialled, so +/// a passing build here would hang rather than fail, which is the point. +async fn build_error_with_buffers(read: usize, write: usize) -> String { + use aimdb_core::buffer::BufferCfg; + use aimdb_core::AimDbBuilder; + use aimdb_mqtt_connector::{MqttConnector, TlsOptions}; + use aimdb_tokio_adapter::net::TokioNet; + use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; + + let rng: &'static mut (dyn embedded_tls::CryptoRngCore + Send) = + Box::leak(Box::new(rand::rngs::StdRng::from_entropy())); + let ca_der: &'static [u8] = Box::leak(vec![0u8; 32].into_boxed_slice()); + let read_buf: &'static mut [u8] = Box::leak(vec![0u8; read].into_boxed_slice()); + let write_buf: &'static mut [u8] = Box::leak(vec![0u8; write].into_boxed_slice()); + + let connector = MqttConnector::new(format!("mqtts://{BROKER_HOST}:1")) + .tls( + TokioNet::tcp(), + TlsOptions::new(rng, ca_der, read_buf, write_buf), + ) + .with_client_id("tls-buffer-floor"); + + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(connector); + builder.configure::("temperature", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .link_from("mqtt://sensors/temperature") + .with_deserializer(|_ctx, _d: &[u8]| Ok(0u64)) + .finish(); + }); + + let Err(err) = builder.build().await else { + panic!("a {read}-byte read / {write}-byte write buffer must be refused"); + }; + err.to_string() +} + +/// A read buffer under the 16 640 a TLS 1.3 peer may fill is refused. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undersized_read_buffer_fails_the_build() { + let message = build_error_with_buffers(4_096, 4_096).await; + assert!( + message.contains("read buffer") && message.contains("16 640"), + "the error should name the read buffer and its floor, got: {message}" + ); +} + +/// A write buffer at or under `embedded-tls`'s per-record overhead is refused. +/// +/// `embedded-tls` guards this with a `debug_assert!`, which a release build — +/// every firmware build — strips: past it the writer underflows +/// `len - TLS_RECORD_OVERHEAD` and copies off the end of the buffer. Catching it +/// at build turns a panic on the device into a message at startup. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undersized_write_buffer_fails_the_build() { + let message = build_error_with_buffers(16_640, 128).await; + assert!( + message.contains("write buffer") && message.contains("128"), + "the error should name the write buffer and the record overhead, got: {message}" + ); +} From 05a9cfbad9be0b904bd6d0d1d65b272cf3d682f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 19:54:53 +0000 Subject: [PATCH 47/48] feat: increase maximum properties limit for MQTT packets and add tests for property handling --- aimdb-mqtt-connector/src/embedded/mod.rs | 6 +- .../src/embedded/session_loop.rs | 69 +++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/aimdb-mqtt-connector/src/embedded/mod.rs b/aimdb-mqtt-connector/src/embedded/mod.rs index d33d79b2..d8fcef49 100644 --- a/aimdb-mqtt-connector/src/embedded/mod.rs +++ b/aimdb-mqtt-connector/src/embedded/mod.rs @@ -53,8 +53,10 @@ pub(crate) const CHANNEL_SIZE: usize = 32; /// Buffer size for MQTT packets (4KB) pub(crate) const BUFFER_SIZE: usize = 4096; -/// Maximum properties in MQTT packets -pub(crate) const MAX_PROPERTIES: usize = 16; +/// Maximum properties on any received packet. Exceeding it ends the session, so +/// the headroom is for user properties on an inbound publish, which the +/// publishing peer chooses — broker CONNACKs use about ten. +pub(crate) const MAX_PROPERTIES: usize = 32; /// The runner's collected future type. type EmbassyBoxFuture = Pin + Send + 'static>>; diff --git a/aimdb-mqtt-connector/src/embedded/session_loop.rs b/aimdb-mqtt-connector/src/embedded/session_loop.rs index 310fac37..ec8975aa 100644 --- a/aimdb-mqtt-connector/src/embedded/session_loop.rs +++ b/aimdb-mqtt-connector/src/embedded/session_loop.rs @@ -595,6 +595,75 @@ mod tests { ); } + /// A v5 PUBLISH on topic `t` carrying `n` user properties. + fn publish_with_properties(n: usize) -> Vec { + fn varint(mut n: usize, out: &mut Vec) { + loop { + let mut byte = (n % 128) as u8; + n /= 128; + if n > 0 { + byte |= 128; + } + out.push(byte); + if n == 0 { + return; + } + } + } + + // Each one is `0x26` then two length-prefixed strings. + let mut properties = Vec::new(); + for _ in 0..n { + properties.extend_from_slice(&[0x26, 0x00, 0x01, b'k', 0x00, 0x01, b'v']); + } + + let mut rest = Vec::new(); + rest.extend_from_slice(&1u16.to_be_bytes()); + rest.push(b't'); + varint(properties.len(), &mut rest); + rest.extend_from_slice(&properties); + rest.extend_from_slice(b"x"); + + let mut packet = alloc::vec![0x30u8]; + varint(rest.len(), &mut packet); + packet.extend_from_slice(&rest); + packet + } + + /// The property cap is where `MAX_PROPERTIES` says, and one past it is an + /// error rather than a silent truncation. + /// + /// The cap applies to every received packet, and on an inbound publish it + /// is the *publishing peer* who decides how many properties to attach. One + /// too many ends the session, so a retained publish over the cap is + /// replayed on every resubscribe and reconnect-loops the connector — the + /// same shape as an over-large packet. + #[test] + fn one_property_past_the_cap_is_refused_rather_than_truncated() { + let mut reader = PacketReader::<4096>::new(); + + let at_cap = publish_with_properties(MAX_PROPERTIES); + reader.feed(&at_cap).expect("feed"); + let total = reader.framed_len().expect("framing").expect("complete"); + assert!( + reader.parse::(total).is_ok(), + "a publish at the cap must parse" + ); + reader.consume(total); + + let over_cap = publish_with_properties(MAX_PROPERTIES + 1); + reader.feed(&over_cap).expect("feed"); + let total = reader.framed_len().expect("framing").expect("complete"); + assert_eq!( + reader + .parse::(total) + .err() + .map(|e| alloc::format!("{e:?}")), + Some(alloc::string::String::from("TooManyProperties")), + "one property past the cap must be refused, not quietly dropped" + ); + } + #[test] fn a_deadline_in_the_past_still_sleeps_a_tick() { // Never zero: a zero-length sleep would spin the loop. From 202355d6fecfc1de98a115ff6793a497161cfffe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Schn=C3=B6rch?= Date: Mon, 14 Sep 2026 20:07:26 +0000 Subject: [PATCH 48/48] feat: define `_stext` symbol for STM32H5 linker to prevent section overlap --- examples/embassy-bench-stm32h5/build.rs | 8 ++++++++ examples/embassy-knx-connector-demo/build.rs | 8 ++++++++ examples/embassy-mqtt-connector-demo/build.rs | 8 ++++++++ examples/embassy-serial-connector-demo/build.rs | 8 ++++++++ 4 files changed, 32 insertions(+) diff --git a/examples/embassy-bench-stm32h5/build.rs b/examples/embassy-bench-stm32h5/build.rs index 8cd32d7e..908e91f1 100644 --- a/examples/embassy-bench-stm32h5/build.rs +++ b/examples/embassy-bench-stm32h5/build.rs @@ -1,5 +1,13 @@ fn main() { println!("cargo:rustc-link-arg-bins=--nmagic"); + // cortex-m-rt places `.text` at an explicit `_stext`, which its linker + // script derives as the byte after the vector table. On STM32H5 that table + // is 0x24c bytes, so `.text` would land 4-byte aligned while its contents + // ask for 8 — which rust-lld reports, and CI denies. `_stext` is + // `PROVIDE`d, so defining it here wins; round it up to the next multiple + // of 8. A chip whose vector table outgrows this fails the link with a + // section overlap rather than misplacing code silently. + println!("cargo:rustc-link-arg-bins=--defsym=_stext=0x8000250"); println!("cargo:rustc-link-arg-bins=-Tlink.x"); println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); } diff --git a/examples/embassy-knx-connector-demo/build.rs b/examples/embassy-knx-connector-demo/build.rs index 8cd32d7e..908e91f1 100644 --- a/examples/embassy-knx-connector-demo/build.rs +++ b/examples/embassy-knx-connector-demo/build.rs @@ -1,5 +1,13 @@ fn main() { println!("cargo:rustc-link-arg-bins=--nmagic"); + // cortex-m-rt places `.text` at an explicit `_stext`, which its linker + // script derives as the byte after the vector table. On STM32H5 that table + // is 0x24c bytes, so `.text` would land 4-byte aligned while its contents + // ask for 8 — which rust-lld reports, and CI denies. `_stext` is + // `PROVIDE`d, so defining it here wins; round it up to the next multiple + // of 8. A chip whose vector table outgrows this fails the link with a + // section overlap rather than misplacing code silently. + println!("cargo:rustc-link-arg-bins=--defsym=_stext=0x8000250"); println!("cargo:rustc-link-arg-bins=-Tlink.x"); println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); } diff --git a/examples/embassy-mqtt-connector-demo/build.rs b/examples/embassy-mqtt-connector-demo/build.rs index 8cd32d7e..908e91f1 100644 --- a/examples/embassy-mqtt-connector-demo/build.rs +++ b/examples/embassy-mqtt-connector-demo/build.rs @@ -1,5 +1,13 @@ fn main() { println!("cargo:rustc-link-arg-bins=--nmagic"); + // cortex-m-rt places `.text` at an explicit `_stext`, which its linker + // script derives as the byte after the vector table. On STM32H5 that table + // is 0x24c bytes, so `.text` would land 4-byte aligned while its contents + // ask for 8 — which rust-lld reports, and CI denies. `_stext` is + // `PROVIDE`d, so defining it here wins; round it up to the next multiple + // of 8. A chip whose vector table outgrows this fails the link with a + // section overlap rather than misplacing code silently. + println!("cargo:rustc-link-arg-bins=--defsym=_stext=0x8000250"); println!("cargo:rustc-link-arg-bins=-Tlink.x"); println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); } diff --git a/examples/embassy-serial-connector-demo/build.rs b/examples/embassy-serial-connector-demo/build.rs index 8cd32d7e..908e91f1 100644 --- a/examples/embassy-serial-connector-demo/build.rs +++ b/examples/embassy-serial-connector-demo/build.rs @@ -1,5 +1,13 @@ fn main() { println!("cargo:rustc-link-arg-bins=--nmagic"); + // cortex-m-rt places `.text` at an explicit `_stext`, which its linker + // script derives as the byte after the vector table. On STM32H5 that table + // is 0x24c bytes, so `.text` would land 4-byte aligned while its contents + // ask for 8 — which rust-lld reports, and CI denies. `_stext` is + // `PROVIDE`d, so defining it here wins; round it up to the next multiple + // of 8. A chip whose vector table outgrows this fails the link with a + // section overlap rather than misplacing code silently. + println!("cargo:rustc-link-arg-bins=--defsym=_stext=0x8000250"); println!("cargo:rustc-link-arg-bins=-Tlink.x"); println!("cargo:rustc-link-arg-bins=-Tdefmt.x"); }