diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index ef81b518..b1e3a64e 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,10 +26,7 @@ "terminal.integrated.shell.linux": "/bin/bash", "rust-analyzer.cargo.features": "all", "rust-analyzer.check.command": "clippy", - "rust-analyzer.check.extraArgs": [ - "--all-targets", - "--all-features" - ], + "rust-analyzer.check.allTargets": true, "rust-analyzer.cargo.buildScripts.enable": true, "rust-analyzer.procMacro.enable": true, "files.watcherExclude": { diff --git a/CHANGELOG.md b/CHANGELOG.md index f354f086..f681bef1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Design 034 Phase 3 — sans-io KNX/IP tunneling engine shared by both transports (Issue #135, [review doc §3.7](docs/design/034-technical-debt-review.md)).** The entire tunneling lifecycle — CONNECT_REQUEST/RESPONSE handshake, TUNNELING_REQUEST/ACK sequence + pending-ACK bookkeeping, keepalive (CONNECTIONSTATE_REQUEST) scheduling, ACK-timeout sweeps, and reconnect-with-backoff — now lives **once**, in the new runtime-neutral `aimdb_knx_connector::tunnel` module (`no_std + alloc`, no tokio/embassy imports), driven as a poll-based state machine (events in, `Action`s out, `next_deadline()` for timer arming). `tokio_client.rs` (988 → ~530 lines incl. a new fake-gateway integration test) and `embassy_client.rs` (1,055 → ~450 lines) are reduced to socket shims; the previously untestable handshake/ACK/keepalive/reconnect paths now have 15 host-run unit tests plus a scripted localhost-UDP roundtrip test. Behavioral unifications: Embassy gains the 5 s CONNECT_RESPONSE timeout (previously waited forever), both shims reconnect on fatal socket errors, and the dead tokio-only per-publish ACK oneshot is dropped (it was always `None`); the CONNECT_REQUEST HPAI stays per-transport (`LocalEndpoint`: tokio = real bound address, Embassy = NAT mode). ([aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md)) + - **Design 034 Phase 2 — dyn-safe `RuntimeOps` capability trait (Issue #130, [review doc](docs/design/034-technical-debt-review.md)).** New object-safe trait in `aimdb-executor` (`name` / `now_nanos` / `unix_time` / boxed `sleep` / `log(LogLevel, …)`) so a runtime adapter can travel as `Arc` instead of a generic parameter — the groundwork for removing `R` from the record object graph (#131). Implemented by `TokioAdapter`, `EmbassyAdapter`, and `WasmAdapter`, each covered by a shared behavioral contract test. `BoxFuture`'s canonical definition moves to `aimdb-executor` (re-exported unchanged from `aimdb-core`). ([aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) - **M17 — centralized Embassy connector spine: one audited home for the single-core `unsafe` ([Design 033](docs/design/033-M17-unify-connectors-drop-send.md)).** New `aimdb-embassy-adapter::connectors` module (features `connectors` / `connector-io`) collects the force-`Send` plumbing every Embassy connector used to hand-roll: session transports get `EmbassySessionClient`/`EmbassySessionServer`, `OneShotDialer`/`OneShotListener`/`OneShotCell`, and the framed `EmbassyConnection` + `Framer`; data-plane transports get the `EmbassySink`/`EmbassySource` bridges (over `EmbassySinkRaw`/`EmbassySourceRaw`) that ride core's existing `pump_sink`/`pump_source`, plus `into_box_future` for protocol tasks. The serial Embassy half is now thin sugar (just a COBS `Framer`) with **zero `unsafe`** (down from a 407-line hand-roll with 7 `unsafe impl`s); the MQTT and KNX Embassy halves dropped their hand-rolled publisher/router loops and `SendFutureWrapper` use to ride core's pumps (KNX inbound telegrams now flow through `pump_source`). All connector-crate `unsafe`/`SendFutureWrapper` is gone — confined to the adapter. The std/Tokio side, `aimdb-client`, the WebSocket server, examples, and tests are unchanged. (Chosen over Design 033's original "drop `Send` from the contract", which would have pushed `!Send` onto the std side; see the doc's Implementation Decision.) ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-serial-connector](aimdb-serial-connector/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md)) @@ -44,6 +46,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Design 034 Phase 3 — runtime type parameter `R` removed from the object graph (Issue #131, [review doc §3.2/§3.3](docs/design/034-technical-debt-review.md)).** The runtime is now a *value* (`Arc`, the #130 groundwork) instead of a type parameter; the only generic left on the user-facing object graph is the record type `T`. The full break inventory: + - **Types lose `R`:** `AimDb` → `AimDb`, `AimDbBuilder` → `AimDbBuilder` (the `NoRuntime` typestate is gone — a missing runtime is a `build()` error per the #133 contract), `TypedRecord` → `TypedRecord`, `RecordRegistrar<'a, T, R>` → `RecordRegistrar<'a, T>`, `TransformBuilder` → `TransformBuilder`, `JoinBuilder` → `JoinBuilder`, `RecordT` → `RecordT`, and `ConnectorBuilder` → `ConnectorBuilder` (its `build()` takes `&AimDb`). Turbofish updates: `get_typed_record_by_key::` → `::`, `as_typed::` → `::`. + - **`RuntimeContext` is a concrete struct** wrapping `Arc`. `ctx.time().now()` returns **`u64` nanoseconds** from an arbitrary monotonic epoch (was `R::Instant`); `sleep` takes a plain `core::time::Duration` (plus `sleep_millis`/`sleep_secs` helpers); `millis()`/`secs()`/`micros()`/`duration_since()`/`duration_as_nanos()` are deleted (durations are concrete, instants are integer math); the panicking `extract_from_any` is deleted. + - **`source`/`tap`/`transform`/`transform_join` are inherent methods** on `RecordRegistrar` — the per-adapter extension-trait wrappers and `aimdb-core`'s `ext_macros.rs` are gone, as are the `*_raw` variants (the inherent methods *are* the foundational API; closures keep the `(ctx, producer)` arg order, so user code mostly just drops an import). The adapter ext traits (`TokioRecordRegistrarExt`, `EmbassyRecordRegistrarExt`(+`Custom`), `WasmRecordRegistrarExt`) shrink to the one genuinely adapter-specific step: `.buffer(cfg)` construction. + - **`JoinFanInRuntime` deleted** (with `JoinQueue`/`JoinSender`/`JoinReceiver` and all three per-adapter `join_queue.rs` files): multi-input join fan-in now uses one bounded `async-channel` queue in core — the same primitive the session engine already uses on tokio, Embassy, and WASM. Capacity stays 64 on `std`/wasm32 and rises to 16 on embedded `no_std` (up from Embassy's 8); the queue now closes on every runtime when all forwarders exit (the Embassy queue previously never closed). + - **Runtime accessors:** `runtime_arc()` → `runtime_ops()` (returns `Arc`), new `runtime_ctx()`; the borrowed `AimDb::runtime()` and the type-erased `runtime_any()` are deleted (zero callers in aimdb/aimdb-pro — `&*db.runtime_ops()` covers the borrowed flavor; [follow-up doc §2.5](docs/design/035-review-followups-deferred.md)). `AimDbBuilder::on_start` closures receive `RuntimeContext` (was `Arc`). Context-aware (de)serializers receive the concrete `RuntimeContext` (was `Arc`); `Router::route`'s ctx argument follows. The `RuntimeForProfiling` marker-trait workaround is deleted (profiling clocks ride `RuntimeOps::now_nanos`); the session client engine's clock is `Arc` (was `Arc`). + - **Embassy network capability moves to construction:** the `EmbassyNetwork` runtime trait is deleted (a `dyn RuntimeOps` can't surface adapter-specific capabilities) along with `EmbassyAdapter::new_with_network` — `EmbassyAdapter` is a stateless unit type with **zero `unsafe`**. The Embassy MQTT/KNX connector builders take the `embassy_net::Stack` at construction (`MqttConnectorBuilder::new(url, stack)` / `KnxConnectorBuilder::new(url, stack)`), wrapped in the new force-`Send + Sync` `aimdb_embassy_adapter::connectors::NetStack` so the single-core `unsafe` stays in the one audited module. + - **Downstream follows mechanically:** `aimdb-sync` (`AimDb` handles), `aimdb-persistence` (`.persist()`/`.with_persistence()` ext traits de-genericized), `aimdb-data-contracts::log_tap`, `aimdb-codegen` templates (generated `configure_schema(builder: &mut AimDbBuilder)`), and all examples/tools. + - Acceptance: `grep -rn "extract_from_any|runtime_any|RuntimeForProfiling|JoinFanInRuntime"` over the workspace sources returns nothing; the remaining `dyn Any` in core is data-plane only (`SerializerFn`/`produce_any`/`JoinTrigger`/`TopicProviderAny` — the #131 §6 stretch, tracked as a follow-up). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-executor](aimdb-executor/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md), [aimdb-persistence](aimdb-persistence/CHANGELOG.md), [aimdb-sync](aimdb-sync/CHANGELOG.md)) + - **Design 034 Phase 2 — MQTT knobs move out of core; `ConnectorConfig` pruned (Issue #134, [review doc §3.6](docs/design/034-technical-debt-review.md)).** Core's generic link builders drop `with_qos`/`with_retain` (`with_timeout_ms` stays, de-MQTT'd); the knobs now live in `aimdb-mqtt-connector` as the `MqttLinkExt` (qos, outbound + inbound) and `MqttOutboundLinkExt` (retain, publish-side only) extension traits, pushing the **same** `("qos", …)`/`("retain", …)` option keys the MQTT clients have always read — wire behavior unchanged; importing the trait makes the MQTT intent explicit at the call site (generic escape hatch: `with_config(key, value)`). `ConnectorConfig` loses its never-read typed `qos`/`retain` fields and the speculative Kafka/HTTP/shmem interpretation docs — it keeps `timeout_ms` + `protocol_options`, and core now documents no protocol that lacks an in-tree connector. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md)) - **Design 034 Phase 2 — panic-free builder validation: `build()` reports every configuration mistake at once (Issue #133, [review doc §3.4](docs/design/034-technical-debt-review.md)).** Builder methods never panic on user mistakes anymore. Conflicting `.source()`/`.transform()`/`.link_from()` registrations, missing serializers/deserializers, invalid connector URLs, unregistered schemes, and key-reused-with-different-type are *recorded* (the conflicting registration is skipped) and `build()` returns one `DbError::InvalidConfiguration { errors: Vec }` carrying **all** findings — each with the record key and, where applicable, the connector URL. The worst panic — "requires a buffer" firing at spawn time inside a connector factory closure — is now a build()-time check, which also makes `.buffer()` after `.link_to()`/`.link_from()` legal (order-independent). Duplicate keys and dependency-graph cycles fold into the same collected report (previously distinct `DuplicateRecordKey`/`CyclicDependency` returns from `build()`). Remaining `panic!`/`expect`s in the builder path are internal invariants and say "this is a bug in aimdb-core". ([aimdb-core](aimdb-core/CHANGELOG.md)) diff --git a/Cargo.lock b/Cargo.lock index 5aa914d1..4db18ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3247,7 +3247,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-efbc4aab23acca680b52fda1f70c82cdca01a43f#76ef43967717719cf5b2f01b9a148ef5bac970b2" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-be62608f8f93a21fe76c8f70c0fa9d30c9ab2503#46619beefd7015dffaa1f8e756f614718f0dd0bf" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/_external/embassy b/_external/embassy index b047838a..9e316798 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit b047838a34c2a8cf4a5129cde5c118205b1628e8 +Subproject commit 9e31679810ce57f10d0466fbe62afd3502d98357 diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs index 0ada9ba0..d88ffdb0 100644 --- a/aimdb-client/tests/pump_client.rs +++ b/aimdb-client/tests/pump_client.rs @@ -33,7 +33,7 @@ struct Msg { /// is robust against subscription-registration timing (a fresh subscriber may /// only see values produced after it attaches). async fn mirror_reaches( - db: &Arc>, + db: &Arc, key: &str, want: &serde_json::Value, mut push: impl FnMut(), diff --git a/aimdb-codegen/src/lib.rs b/aimdb-codegen/src/lib.rs index 6711a0d6..04e7d13f 100644 --- a/aimdb-codegen/src/lib.rs +++ b/aimdb-codegen/src/lib.rs @@ -6,7 +6,7 @@ //! - **Mermaid diagram** — `.aimdb/architecture.mermaid`, a read-only graph //! projection of the architecture (see [`generate_mermaid`]) //! - **Rust source** — `src/generated_schema.rs`, compilable AimDB schema -//! using the actual 0.5.x API (see [`generate_rust`]) +//! using the current AimDB API (see [`generate_rust`]) //! //! # Usage //! diff --git a/aimdb-codegen/src/rust.rs b/aimdb-codegen/src/rust.rs index 3993f158..33f9a672 100644 --- a/aimdb-codegen/src/rust.rs +++ b/aimdb-codegen/src/rust.rs @@ -1,7 +1,7 @@ //! Rust source code generator //! //! Converts an [`ArchitectureState`] into compilable Rust source that uses the -//! actual AimDB 0.5.x API: `#[derive(RecordKey)]`, `BufferCfg`, and +//! current AimDB API: `#[derive(RecordKey)]`, `BufferCfg`, and //! `AimDbBuilder::configure()`. //! //! Uses [`quote`] for quasi-quoting token streams and [`prettyplease`] for @@ -383,7 +383,7 @@ pub fn generate_tasks_rs(state: &ArchitectureState, binary_name: &str) -> Option let fn_name = format_ident!("{}", task.name); // Build parameter list - let mut params: Vec = vec![quote! { ctx: RuntimeContext }]; + let mut params: Vec = vec![quote! { ctx: RuntimeContext }]; for input in &task.inputs { let arg_name = format_ident!("{}", to_snake_case(&input.record)); let value_type = format_ident!("{}Value", input.record); @@ -418,7 +418,6 @@ pub fn generate_tasks_rs(state: &ArchitectureState, binary_name: &str) -> Option let file_tokens = quote! { use aimdb_core::{Consumer, DbResult, Producer, RuntimeContext}; - use aimdb_tokio_adapter::TokioAdapter; use #common_crate::*; #(#task_fns)* @@ -557,7 +556,6 @@ fn emit_imports(state: &ArchitectureState) -> TokenStream { use aimdb_core::builder::AimDbBuilder; use aimdb_core::RecordKey; use aimdb_data_contracts::{#(#contract_traits),*}; - use aimdb_executor::RuntimeAdapter; use serde::{Deserialize, Serialize}; } } @@ -731,7 +729,7 @@ fn emit_configure_schema(state: &ArchitectureState) -> TokenStream { /// addresses. Producers, consumers, serializers, and deserializers contain /// business logic and must be provided by application code — they are not /// generated here. - pub fn configure_schema(builder: &mut AimDbBuilder) { + pub fn configure_schema(builder: &mut AimDbBuilder) { #(#record_blocks)* } } @@ -1250,7 +1248,6 @@ pub fn generate_hub_schema_rs(state: &ArchitectureState) -> String { let file_tokens = quote! { use aimdb_core::buffer::BufferCfg; use aimdb_core::builder::AimDbBuilder; - use aimdb_executor::RuntimeAdapter; use #common_crate::*; #configure_fn @@ -1688,7 +1685,7 @@ pub fn {handler}(input: &{in_t}) -> Option<{out_t}> {{\n\ // Pure source fns.push_str(&format!( "pub async fn {}(\n\ - _ctx: aimdb_core::RuntimeContext,\n\ + _ctx: aimdb_core::RuntimeContext,\n\ _producer: aimdb_core::Producer<{out_t}>,\n\ ) {{\n\ todo!(\"implement {}\")\n\ @@ -1699,7 +1696,7 @@ pub fn {handler}(input: &{in_t}) -> Option<{out_t}> {{\n\ // Pure sink / tap fns.push_str(&format!( "pub async fn {}(\n\ - _ctx: aimdb_core::RuntimeContext,\n\ + _ctx: aimdb_core::RuntimeContext,\n\ _consumer: aimdb_core::Consumer<{in_t}>,\n\ ) {{\n\ todo!(\"implement {}\")\n\ @@ -1727,7 +1724,6 @@ pub async fn {task_name}() {{\n\ // This file is scaffolded once — it will not be overwritten on subsequent runs.\n\ // Regenerate signatures: delete this file, then run `aimdb generate --hub`.\n\ \n\ -use aimdb_tokio_adapter::TokioAdapter;\n\ use {common_crate}::*;\n\ \n\ {fns}" @@ -1832,10 +1828,6 @@ url = "mqtt://ota/cmd/{variant}" out.contains("use aimdb_core::RecordKey;"), "Missing RecordKey import:\n{out}" ); - assert!( - out.contains("use aimdb_executor::RuntimeAdapter;"), - "Missing RuntimeAdapter import:\n{out}" - ); assert!( out.contains("use serde::{Deserialize, Serialize};"), "Missing serde import:\n{out}" @@ -1937,9 +1929,7 @@ url = "mqtt://ota/cmd/{variant}" fn configure_schema_function_present() { let out = generated(); assert!( - out.contains( - "pub fn configure_schema(builder: &mut AimDbBuilder)" - ), + out.contains("pub fn configure_schema(builder: &mut AimDbBuilder)"), "Missing configure_schema function:\n{out}" ); } diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 692ccabb..bae4dc2b 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **`build()` reports a missing runtime alongside every other configuration error (issue #133 contract).** The missing-runtime check no longer short-circuits: it is collected as a `ConfigError` and returned in the one `DbError::InvalidConfiguration` with all other findings (previously the collected errors were silently dropped and only a `RuntimeError` surfaced). The error type for a runtime-less build changes accordingly from `DbError::RuntimeError` to `DbError::InvalidConfiguration`. + +### Changed (breaking) + +- **Phase 3 — `R` removed from the object graph (Issue #131, [design doc §3.2/§3.3](../docs/design/034-technical-debt-review.md)).** The runtime travels as `Arc`; records (`T`) are the only generic surface left. `AimDb`, `AimDbBuilder` (no `NoRuntime` typestate), `TypedRecord`, `RecordRegistrar<'a, T>`, `TransformBuilder`, `JoinBuilder`, `RecordT`, and `ConnectorBuilder` are all non-generic over the runtime; `RuntimeContext` is a concrete struct (`time().now()` → `u64` nanos, `sleep(core::time::Duration)` + `sleep_millis`/`sleep_secs`; `millis`/`secs`/`micros`/`duration_since`/`duration_as_nanos`/`extract_from_any` deleted). `source`/`tap`/`transform`/`transform_join` are inherent registrar methods (the `*_raw` variants and `ext_macros.rs` are deleted); connector consumer/producer factories take `&AimDb` (the `Arc` downcast-or-panic dance is gone); context (de)serializers and `Router::route` receive the concrete `RuntimeContext`; `runtime_arc()` → `runtime_ops()` (+ new `runtime_ctx()`), `runtime_any()` and the borrowed `runtime()` accessor deleted (zero callers; `&*db.runtime_ops()` covers the borrowed flavor — [follow-up doc §2.5](../docs/design/035-review-followups-deferred.md)); `on_start` closures receive `RuntimeContext`; the `RuntimeForProfiling` marker is deleted (profiling clocks ride `RuntimeOps::now_nanos`); the session client engine clock is `Arc`. Multi-input join fan-in is one bounded `async-channel` queue in core (capacity 64 on `std` and wasm32 — matching the old tokio/WASM queues — and 16 on embedded `no_std`, up from Embassy's 8). **Close semantics changed on Embassy:** the queue now closes on *all* runtimes once every input forwarder exits, so a `no_std` join handler's `while let Ok(_) = rx.recv().await` loop ends instead of parking forever — treat `Err(QueueClosed)` as end-of-inputs. Input forwarders skip `BufferLagged` (SPMC-ring overflow) and keep forwarding, the same recoverable-lag policy as every other recv loop in core. The `JoinFanInRuntime` GAT family is gone from `aimdb-executor`. + +- **Generic runtime trait re-exports removed from the crate root (Issue #131 follow-up).** `aimdb_core::{RuntimeAdapter, Runtime, TimeOps, Logger, RuntimeInfo}` are gone — core no longer consumes the generic family (the runtime travels as `Arc`), and keeping the re-exports invited `R:`-bounds back into downstream signatures. Import them from `aimdb_executor` directly where an adapter still implements them; `ExecutorError`/`ExecutorResult` stay re-exported. + +### Changed + +- **Session client keepalive is deadline-based.** Activity records a timestamp (one dyn clock read) and the boxed `RuntimeOps::sleep` future stays armed for a full idle window — re-created about once per keepalive interval instead of once per processed frame/command (each re-arm previously heap-allocated through the `dyn RuntimeOps` boundary). Wire behavior unchanged: a Ping still goes out once the link has been idle for `keepalive_interval` ms. +- **`RecordRegistrar::source` closure bound relaxed: `Send + Sync` → `Send`.** The `FnOnce` is taken out of its slot exactly once, so `Sync` bought nothing (it was an artifact of the deleted `ext_macros.rs`); source closures may now capture `!Sync` state (e.g. `Cell`-based sensor state). `tap` already required only `Send`. + ### Added - **`connector-session` feature + `session` module — the shared, runtime-neutral session substrate (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A new `crate::session` module (gated `connector-session`, `no_std + alloc`, also enabled transitively by `std`) carrying the connector-convergence machinery: diff --git a/aimdb-core/README.md b/aimdb-core/README.md index fa1a2fba..d8cb9a21 100644 --- a/aimdb-core/README.md +++ b/aimdb-core/README.md @@ -49,23 +49,23 @@ pub struct Temperature { ### Producer and Consumer -Portable code uses `R: Runtime` — no platform imports needed: +Portable code receives the concrete `RuntimeContext` — no platform imports needed: ```rust /// Producer: reads a sensor and pushes typed values into AimDB. -async fn sensor_producer(ctx: RuntimeContext, producer: Producer) { +async fn sensor_producer(ctx: RuntimeContext, producer: Producer) { loop { let reading = read_sensor().await; producer.produce(Temperature { sensor_id: "outdoor-001".into(), celsius: reading, }).await.ok(); - ctx.time().sleep(ctx.time().secs(1)).await; + ctx.time().sleep_secs(1).await; } } /// Consumer: subscribes to the buffer and reacts to every new value. -async fn temp_logger(ctx: RuntimeContext, consumer: Consumer) { +async fn temp_logger(ctx: RuntimeContext, consumer: Consumer) { let mut reader = consumer.subscribe().unwrap(); while let Ok(temp) = reader.recv().await { ctx.log().info(&format!("{}: {:.1}°C", temp.sensor_id, temp.celsius)); @@ -178,21 +178,19 @@ let runtime = Arc::new(TokioAdapter::new()?); let mut builder = AimDbBuilder::new().runtime(runtime); // On Cortex-M4 — Embassy -let runtime = EmbassyAdapter::new(); +let runtime = Arc::new(EmbassyAdapter::new()); let mut builder = AimDbBuilder::new().runtime(runtime); ``` Everything else — records, keys, producers, consumers, transforms — stays identical across platforms. -Core depends on abstract traits from `aimdb-executor`: -- `RuntimeAdapter` — Platform identification -- `Spawn` — Task creation -- `TimeOps` — Clocks and sleep -- `Logger` — Structured output +Core consumes the runtime through `aimdb-executor`'s dyn-safe `RuntimeOps` +trait (name, monotonic clock, wall clock, sleep, log) — adapters implement it +once and the runtime travels as a value (`Arc`). -Portable code receives a `RuntimeContext` with two accessors: -- `ctx.time()` → `Time` with `.sleep()`, `.now()`, `.secs()`, etc. -- `ctx.log()` → `Log` with `.info()`, `.warn()`, etc. +Portable code receives the concrete `RuntimeContext` with two accessors: +- `ctx.time()` → `Time` with `.sleep(Duration)` / `.sleep_millis()` / `.sleep_secs()`, `.now()` (u64 nanos), `.unix_time()` +- `ctx.log()` → `Log` with `.info()`, `.warn()`, etc. Available adapters: - **[aimdb-tokio-adapter](https://crates.io/crates/aimdb-tokio-adapter)** — Standard library / server environments diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index 72b703a7..7470a462 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -22,14 +22,13 @@ use crate::graph::DependencyGraph; pub type BoxFuture = aimdb_executor::BoxFuture; /// `on_start` task stored in `AimDbBuilder::start_fns`, invoked at `build()`. -type StartFnType = Box) -> BoxFuture + Send>; +type StartFnType = Box BoxFuture + Send>; /// Per-record future collector stored in `AimDbBuilder::spawn_fns`. /// /// At `build()` time each is invoked in topological order; the returned /// `Vec` is appended to the runner's accumulator. -type SpawnFnType = - Box, &Arc>, RecordId) -> DbResult> + Send>; +type SpawnFnType = Box, RecordId) -> DbResult> + Send>; use crate::record_id::{RecordId, RecordKey, StringKey}; use crate::typed_api::{RecordRegistrar, RecordT}; use crate::typed_record::{AnyRecord, AnyRecordExt, RecordFutureCollector, TypedRecord}; @@ -50,9 +49,6 @@ pub struct OutboundRoute { pub topic_provider: Option, } -/// Marker type for untyped builder (before runtime is set) -pub struct NoRuntime; - /// Internal database state /// /// Holds the registry of typed records with multiple index structures for @@ -159,13 +155,9 @@ impl AimDbInner { /// 1. Resolving key to RecordId /// 2. Validating TypeId matches /// 3. Downcasting to the typed record - pub fn get_typed_record_by_key( - &self, - key: impl AsRef, - ) -> DbResult<&TypedRecord> + pub fn get_typed_record_by_key(&self, key: impl AsRef) -> DbResult<&TypedRecord> where T: Send + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { let key_str = key.as_ref(); @@ -174,14 +166,13 @@ impl AimDbInner { .resolve_str(key_str) .ok_or_else(|| DbError::record_key_not_found(key_str))?; - self.get_typed_record_by_id::(id) + self.get_typed_record_by_id::(id) } /// Helper to get a typed record by RecordId with type validation - pub fn get_typed_record_by_id(&self, id: RecordId) -> DbResult<&TypedRecord> + pub fn get_typed_record_by_id(&self, id: RecordId) -> DbResult<&TypedRecord> where T: Send + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { use crate::typed_record::AnyRecordExt; @@ -204,7 +195,7 @@ impl AimDbInner { let record = &self.storages[id.index()]; let typed_record = record - .as_typed::() + .as_typed::() .ok_or_else(|| DbError::InvalidOperation { operation: "get_typed_record_by_id".to_string(), reason: "type mismatch during downcast".to_string(), @@ -278,25 +269,27 @@ impl AimDbInner { /// Database builder for producer-consumer pattern /// /// Provides a fluent API for constructing databases with type-safe record registration. -/// Use `.runtime()` to set the runtime and transition to a typed builder. -pub struct AimDbBuilder { +/// Set the runtime via `.runtime()`; a missing runtime is reported by `build()` +/// like any other configuration mistake (issue #133 contract). +pub struct AimDbBuilder { /// Registered records with their keys (order matters for RecordId assignment) records: Vec<(StringKey, TypeId, Box)>, - /// Runtime adapter - runtime: Option>, + /// Key → index into `records`, so repeated `configure()` calls resolve a + /// key without scanning the vec. + record_index: HashMap, + + /// Runtime capabilities, held as a value (issue #131) + runtime: Option>, /// Connector builders that will be invoked during build() - connector_builders: Vec>>, + connector_builders: Vec>, - /// Per-record future collectors with their keys. Always empty on the - /// `NoRuntime` typestate — `configure()` only exists once `R` is fixed. - spawn_fns: Vec<(StringKey, SpawnFnType)>, + /// Per-record future collectors with their keys. + spawn_fns: Vec<(StringKey, SpawnFnType)>, /// Startup tasks registered via on_start() — spawned after build() completes. - /// Always empty on the `NoRuntime` typestate — `on_start()` only exists - /// once `R` is fixed. - start_fns: Vec>, + start_fns: Vec, /// Generic extension storage for external crates (e.g., persistence, metrics). /// Moved into AimDbInner during build() so it can be read on the live AimDb handle. @@ -309,13 +302,14 @@ pub struct AimDbBuilder { config_errors: Vec, } -impl AimDbBuilder { +impl AimDbBuilder { /// Creates a new database builder without a runtime /// /// Call `.runtime()` to set the runtime adapter. pub fn new() -> Self { Self { records: Vec::new(), + record_index: HashMap::new(), runtime: None, connector_builders: Vec::new(), spawn_fns: Vec::new(), @@ -325,50 +319,16 @@ impl AimDbBuilder { } } - /// Sets the runtime adapter - /// - /// This transitions the builder from untyped to typed with concrete runtime `R`. - /// - /// # Type Safety Note + /// Sets the runtime adapter. /// - /// The `connector_builders`, `spawn_fns` and `start_fns` fields are - /// intentionally reset to `Vec::new()` during this transition: all three - /// are parameterized by the runtime type (`ConnectorBuilder` → - /// `ConnectorBuilder`, etc.), and the `NoRuntime` instantiations are - /// incompatible with — and provably empty before — the typed ones, because - /// `.with_connector()`, `.configure()` and `.on_start()` are only available - /// AFTER calling `.runtime()` (they're defined in the - /// `impl where R: RuntimeAdapter` block, not in `impl AimDbBuilder`). - /// - /// This means the type system **enforces** the correct call order: - /// ```rust,ignore - /// AimDbBuilder::new() - /// .runtime(runtime) // ← Must be called first - /// .with_connector(connector) // ← Now available - /// ``` - /// - /// The `records` are preserved across the transition since they are not - /// parameterized by the runtime type. - pub fn runtime(self, rt: Arc) -> AimDbBuilder - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - AimDbBuilder { - records: self.records, - runtime: Some(rt), - connector_builders: Vec::new(), - spawn_fns: Vec::new(), - start_fns: Vec::new(), - extensions: self.extensions, - config_errors: self.config_errors, - } + /// Accepts any adapter implementing the dyn-safe + /// [`RuntimeOps`](aimdb_executor::RuntimeOps) capability surface + /// (`TokioAdapter`, `EmbassyAdapter`, `WasmAdapter`); the builder stores it + /// as a value — no runtime type parameter (issue #131). + pub fn runtime(mut self, rt: Arc) -> Self { + self.runtime = Some(rt); + self } -} - -impl AimDbBuilder -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ /// Returns a shared reference to the extension storage. /// /// External crates use this to retrieve state stored via `extensions_mut()`. @@ -386,27 +346,26 @@ where /// Registers a task to be spawned after `build()` completes. /// - /// The closure receives an `Arc` (the runtime adapter) and must return a - /// future that runs for as long as needed (e.g. an infinite cleanup loop). - /// Tasks are spawned in registration order, after all record tasks and - /// connectors have been started. + /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) and + /// must return a future that runs for as long as needed (e.g. an infinite + /// cleanup loop). Tasks are spawned in registration order, after all + /// record tasks and connectors have been started. /// /// # Example /// ```rust,ignore - /// builder.on_start(|runtime| async move { + /// builder.on_start(|ctx| async move { /// loop { /// do_cleanup().await; - /// runtime.sleep(Duration::from_secs(3600)).await; + /// ctx.time().sleep_secs(3600).await; /// } /// }); /// ``` pub fn on_start(&mut self, f: F) -> &mut Self where - F: FnOnce(Arc) -> Fut + Send + 'static, + F: FnOnce(crate::RuntimeContext) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { - self.start_fns - .push(Box::new(move |runtime| Box::pin(f(runtime)))); + self.start_fns.push(Box::new(move |ctx| Box::pin(f(ctx)))); self } @@ -452,7 +411,7 @@ where /// ``` pub fn with_connector( mut self, - builder: impl crate::connector::ConnectorBuilder + 'static, + builder: impl crate::connector::ConnectorBuilder + 'static, ) -> Self { self.connector_builders.push(Box::new(builder)); self @@ -489,7 +448,7 @@ where pub fn configure( &mut self, key: impl RecordKey, - f: impl FnOnce(&mut RecordRegistrar<'_, T, R>), + f: impl FnOnce(&mut RecordRegistrar<'_, T>), ) -> &mut Self where T: Send + Sync + 'static + Debug + Clone, @@ -499,7 +458,7 @@ where let type_id = TypeId::of::(); // Find existing record with this key, or create new one - let record_index = self.records.iter().position(|(k, _, _)| k == &record_key); + let record_index = self.record_index.get(&record_key).copied(); let (rec, is_new_record) = match record_index { Some(idx) => { @@ -517,7 +476,7 @@ where return self; } ( - record.as_typed_mut::().expect( + record.as_typed_mut::().expect( "record registry type mismatch despite TypeId check — \ this is a bug in aimdb-core", ), @@ -526,11 +485,12 @@ where } None => { // Create new record + self.record_index.insert(record_key, self.records.len()); self.records - .push((record_key, type_id, Box::new(TypedRecord::::new()))); + .push((record_key, type_id, Box::new(TypedRecord::::new()))); let (_, _, record) = self.records.last_mut().unwrap(); ( - record.as_typed_mut::().expect( + record.as_typed_mut::().expect( "record registry type mismatch despite TypeId check — \ this is a bug in aimdb-core", ), @@ -552,22 +512,16 @@ where if is_new_record { let spawn_key = record_key; - let spawn_fn: SpawnFnType = - Box::new(move |runtime: &Arc, db: &Arc>, id: RecordId| { - let typed_record = db.inner().get_typed_record_by_id::(id)?; - // Resolve the record's key for key-based Producer/Consumer construction. - let key = db - .inner() - .key_for(id) - .map(|k| k.as_str().to_string()) - .unwrap_or_else(|| alloc::format!("__record_{}", id.index())); - RecordFutureCollector::::collect_all_futures( - typed_record, - runtime, - db, - key.as_str(), - ) - }); + let spawn_fn: SpawnFnType = Box::new(move |db: &Arc, id: RecordId| { + let typed_record = db.inner().get_typed_record_by_id::(id)?; + // Resolve the record's key for key-based Producer/Consumer construction. + let key = db + .inner() + .key_for(id) + .map(|k| k.as_str().to_string()) + .unwrap_or_else(|| alloc::format!("__record_{}", id.index())); + RecordFutureCollector::::collect_all_futures(typed_record, db, key.as_str()) + }); self.spawn_fns.push((spawn_key, spawn_fn)); } @@ -577,12 +531,12 @@ where /// Registers a self-registering record type /// - /// The record type must implement `RecordT`. + /// The record type must implement `RecordT`. /// /// Uses the type name as the default key. For custom keys, use `configure()` directly. pub fn register_record(&mut self, cfg: &T::Config) -> &mut Self where - T: RecordT, + T: RecordT, { // Default key is the full type name for backward compatibility let key = StringKey::new(core::any::type_name::()); @@ -591,10 +545,10 @@ where /// Registers a self-registering record type with a custom key /// - /// The record type must implement `RecordT`. + /// The record type must implement `RecordT`. pub fn register_record_with_key(&mut self, key: impl RecordKey, cfg: &T::Config) -> &mut Self where - T: RecordT, + T: RecordT, { self.configure::(key, |reg| T::register(reg, cfg)) } @@ -605,7 +559,7 @@ where /// `runner.run().await`. The database handle is dropped on exit. /// /// For programmatic access to the handle (manual subscriptions, holding the - /// `AimDb` for other uses), prefer `build()` directly. + /// `AimDb` for other uses), prefer `build()` directly. /// /// # Returns /// `DbResult<()>` — Ok once the database starts; the call then blocks until @@ -626,10 +580,7 @@ where /// .run().await // Runs forever /// } /// ``` - pub async fn run(self) -> DbResult<()> - where - R: crate::RuntimeForProfiling, - { + pub async fn run(self) -> DbResult<()> { log_info!("Building database and spawning background tasks..."); let (_db, runner) = self.build().await?; @@ -646,7 +597,7 @@ where /// `build()` collects every future the database needs to drive (producer /// services, consumer taps, transforms with fan-in forwarders, connectors, /// remote-access supervisor, `on_start` tasks) into the [`AimDbRunner`] - /// returned alongside the clone-able [`AimDb`] handle. **No background + /// returned alongside the clone-able [`AimDb`] handle. **No background /// work runs until `runner.run().await` is awaited.** /// /// Use this when you need programmatic access to the handle for manual @@ -658,7 +609,7 @@ where /// Their driving futures are appended to the runner. /// /// # Returns - /// `DbResult<(AimDb, AimDbRunner)>` — the handle (cloneable) and the + /// `DbResult<(AimDb, AimDbRunner)>` — the handle (cloneable) and the /// non-`Clone` runner that owns the collected futures. /// /// # Errors @@ -683,10 +634,7 @@ where /// let handle = db.clone(); // clone freely before runner.run() /// runner.run().await; // drives everything to completion /// ``` - pub async fn build(mut self) -> DbResult<(AimDb, AimDbRunner)> - where - R: crate::RuntimeForProfiling, - { + pub async fn build(mut self) -> DbResult<(AimDb, AimDbRunner)> { use crate::error::ConfigError; // ── Validation pass: collect every configuration mistake before any @@ -731,11 +679,16 @@ where } } - // Ensure runtime is set. Unreachable through the public API — the - // typed builder only exists once `.runtime()` was called. - let runtime = self - .runtime - .ok_or_else(|| DbError::runtime_error("runtime not set (use .runtime())"))?; + // Ensure runtime is set — a missing runtime is a configuration + // mistake, collected like every other so it never hides the rest of + // the findings (issue #133). The runtime is bound at the final check. + if self.runtime.is_none() { + errors.push(ConfigError::new( + "", + None, + "runtime not set (use .runtime())", + )); + } // Build the new index structures let record_count = self.records.len(); @@ -795,10 +748,13 @@ where } }; - // All validation done — report every collected mistake at once. - if !errors.is_empty() { - return Err(DbError::InvalidConfiguration { errors }); - } + // All validation done — report every collected mistake at once. The + // runtime binding lives here so a missing one is reported alongside + // every other finding instead of short-circuiting them (issue #133). + let runtime = match (self.runtime.take(), errors.is_empty()) { + (Some(rt), true) => rt, + _ => return Err(DbError::InvalidConfiguration { errors }), + }; log_debug!( "Dependency graph built successfully ({} nodes, {} edges, topo order: {:?})", @@ -833,7 +789,7 @@ where log_info!("Collecting futures for {} records", self.spawn_fns.len()); // Build a lookup map from spawn_fns for topological ordering - let mut spawn_fn_map: HashMap> = + let mut spawn_fn_map: HashMap = self.spawn_fns.into_iter().collect(); // Execute collectors in topological order — transforms collect after their inputs. @@ -848,7 +804,7 @@ where continue; }; - futures_acc.extend(spawn_fn(&runtime, &db, id)?); + futures_acc.extend(spawn_fn(&db, id)?); } log_info!("Record future collection complete"); @@ -881,7 +837,7 @@ where log_debug!("Collecting {} on_start future(s)", self.start_fns.len()); for start_fn in self.start_fns { - futures_acc.push(start_fn(runtime.clone())); + futures_acc.push(start_fn(crate::RuntimeContext::new(runtime.clone()))); } } @@ -891,7 +847,7 @@ where } } -impl Default for AimDbBuilder { +impl Default for AimDbBuilder { fn default() -> Self { Self::new() } @@ -900,8 +856,8 @@ impl Default for AimDbBuilder { /// Producer-consumer database /// /// A database instance with type-safe record registration and cross-record -/// communication via the Emitter pattern. The type parameter `R` represents -/// the runtime adapter (e.g., TokioAdapter, EmbassyAdapter). +/// communication via the Emitter pattern. The runtime is held as a value +/// (`Arc`) — records are the only generic surface (issue #131). /// /// See `examples/` for usage. /// @@ -911,42 +867,29 @@ impl Default for AimDbBuilder { /// use aimdb_tokio_adapter::TokioAdapter; /// /// let runtime = Arc::new(TokioAdapter); -/// let db: AimDb = AimDbBuilder::new() +/// let (db, runner) = AimDbBuilder::new() /// .runtime(runtime) /// .register_record::(&TemperatureConfig) -/// .build()?; +/// .build().await?; /// ``` -// No struct-level bound: `SpawnFnType` must be a well-formed type even for -// the builder's `NoRuntime` typestate (where it is never instantiated). All -// functionality lives on `R: RuntimeAdapter` impls. -pub struct AimDb { +#[derive(Clone)] +pub struct AimDb { /// Internal state inner: Arc, - /// Runtime adapter with concrete type - runtime: Arc, + /// Runtime capabilities, held as a value + runtime: Arc, /// Shared wall clock for stage profiling, built from the runtime at `build()` time. #[cfg(feature = "profiling")] profiling_clock: crate::profiling::Clock, } -impl Clone for AimDb { - fn clone(&self) -> Self { - Self { - inner: self.inner.clone(), - runtime: self.runtime.clone(), - #[cfg(feature = "profiling")] - profiling_clock: self.profiling_clock.clone(), - } - } -} - // ============================================================================ // AimDbRunner — drives every future the builder collected // ============================================================================ -/// Non-`Clone` runner returned alongside [`AimDb`] from +/// Non-`Clone` runner returned alongside [`AimDb`] from /// [`AimDbBuilder::build()`]. /// /// Owns the complete set of futures that drive the database (producer @@ -993,7 +936,7 @@ impl AimDbRunner { } } -impl AimDb { +impl AimDb { /// Internal accessor for the inner state /// /// Used by adapter crates and internal spawning logic. @@ -1023,10 +966,10 @@ impl AimDb { } /// Builds a database with a closure-based builder pattern - pub async fn build_with(rt: Arc, f: impl FnOnce(&mut AimDbBuilder)) -> DbResult<()> - where - R: crate::RuntimeForProfiling, - { + pub async fn build_with( + rt: Arc, + f: impl FnOnce(&mut AimDbBuilder), + ) -> DbResult<()> { let mut b = AimDbBuilder::new().runtime(rt); f(&mut b); b.run().await @@ -1056,7 +999,7 @@ impl AimDb { { // Single write path via WriteHandle (design 031). For hot paths, // prefer `db.producer::(key)` once and reuse the returned handle. - let typed_rec = self.inner.get_typed_record_by_key::(key)?; + let typed_rec = self.inner.get_typed_record_by_key::(key)?; typed_rec.writer_handle().push(value); Ok(()) } @@ -1083,7 +1026,7 @@ impl AimDb { where T: Send + Sync + 'static + Debug + Clone, { - let typed_rec = self.inner.get_typed_record_by_key::(key)?; + let typed_rec = self.inner.get_typed_record_by_key::(key)?; typed_rec.subscribe() } @@ -1114,7 +1057,7 @@ impl AimDb { // Pre-resolve the typed record so the returned Producer holds a write // handle to the record's buffer/snapshot/metadata directly let key_str: alloc::string::String = key.into(); - let typed_rec = self.inner.get_typed_record_by_key::(&key_str)?; + let typed_rec = self.inner.get_typed_record_by_key::(&key_str)?; Ok(crate::typed_api::Producer::new(typed_rec.writer_handle())) } @@ -1146,7 +1089,7 @@ impl AimDb { // a configured buffer surfaces as `MissingConfiguration` here rather // than panicking later inside `subscribe()`. let key_str: alloc::string::String = key.into(); - let typed_rec = self.inner.get_typed_record_by_key::(&key_str)?; + let typed_rec = self.inner.get_typed_record_by_key::(&key_str)?; let buffer = typed_rec.buffer_handle().ok_or_else(|| { DbError::missing_configuration(alloc::format!("buffer for record '{}'", key_str)) })?; @@ -1183,28 +1126,20 @@ impl AimDb { self.inner.records_of_type::() } - /// Returns a reference to the runtime adapter - /// - /// Provides direct access to the concrete runtime type. - pub fn runtime(&self) -> &R { - &self.runtime - } - - /// Returns an owned `Arc` handle to the runtime adapter. + /// Returns an owned `Arc` handle to the runtime capabilities. /// /// Connectors that hand the runtime to a `'static` engine future (e.g. the - /// session client engine, which needs the adapter's [`TimeOps`](aimdb_executor::TimeOps) - /// clock for reconnect backoff/keepalive) clone it through here. - pub fn runtime_arc(&self) -> Arc { + /// session client engine, which needs the clock for reconnect + /// backoff/keepalive) clone it through here. + pub fn runtime_ops(&self) -> Arc { self.runtime.clone() } - /// Returns the runtime as a type-erased `Arc` - /// - /// Used by connectors to provide `RuntimeContext` to context-aware - /// deserializers during inbound message routing. - pub fn runtime_any(&self) -> Arc { - self.runtime.clone() + /// Returns a [`RuntimeContext`](crate::RuntimeContext) over this + /// database's runtime — the value handed to services and context-aware + /// (de)serializers. + pub fn runtime_ctx(&self) -> crate::RuntimeContext { + crate::RuntimeContext::new(self.runtime.clone()) } /// Lists all registered records (std only) @@ -1317,9 +1252,6 @@ impl AimDb { )> { let mut routes = Vec::new(); - // Convert self to Arc for producer factory - let db_any: Arc = Arc::new(self.clone()); - for record in &self.inner.storages { let inbound_links = record.inbound_connectors(); @@ -1333,7 +1265,7 @@ impl AimDb { let topic = link.resolve_topic(); // Create producer using the stored factory - if let Some(producer) = link.create_producer(db_any.clone()) { + if let Some(producer) = link.create_producer(self) { routes.push((topic, producer, link.deserializer.clone())); } } @@ -1403,11 +1335,6 @@ impl AimDb { pub fn collect_outbound_routes(&self, scheme: &str) -> Vec { let mut routes = Vec::new(); - // Convert self to Arc for consumer factory - // This is necessary because the factory takes Arc to avoid - // needing to know the runtime type R at the factory definition site - let db_any: Arc = Arc::new(self.clone()); - for record in &self.inner.storages { let outbound_links = record.outbound_connectors(); @@ -1426,7 +1353,7 @@ impl AimDb { }; // Create consumer using the stored factory - if let Some(consumer) = link.create_consumer(db_any.clone()) { + if let Some(consumer) = link.create_consumer(self) { routes.push(OutboundRoute { topic: destination, consumer, diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index b3a4538c..a952350a 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -104,16 +104,10 @@ pub type SerializerFn = /// Type alias for context-aware type-erased serializer callbacks /// -/// Like `SerializerFn`, but receives a type-erased runtime context +/// Like `SerializerFn`, but receives the concrete [`RuntimeContext`](crate::RuntimeContext) /// for platform-independent timestamps and logging during serialization. -/// -/// The first argument is the type-erased runtime (as `Arc`), -/// which is downcast to the concrete runtime type via `RuntimeContext::extract_from_any`. pub type ContextSerializerFn = Arc< - dyn Fn( - Arc, - &dyn core::any::Any, - ) -> Result, SerializeError> + dyn Fn(crate::RuntimeContext, &dyn core::any::Any) -> Result, SerializeError> + Send + Sync, >; @@ -470,7 +464,7 @@ pub struct ConnectorLink { /// Consumer factory callback (alloc feature) /// - /// Creates `ConsumerTrait` from `Arc>` to enable type-safe subscription. + /// Creates `ConsumerTrait` from the live [`AimDb`] to enable type-safe subscription. /// The factory captures the record type T at link_to() configuration time, /// allowing the connector to subscribe without knowing T at compile time. /// @@ -533,17 +527,12 @@ impl ConnectorLink { /// Creates a consumer using the stored factory (alloc feature) /// - /// Takes an `Arc` (which should contain `Arc>`) and invokes - /// the consumer factory to create a ConsumerTrait instance. - /// - /// Returns None if no factory is configured. + /// Invokes the consumer factory with the live database to create a + /// ConsumerTrait instance. Returns None if no factory is configured. /// /// Available in both `std` and `no_std + alloc` environments. - pub fn create_consumer( - &self, - db_any: Arc, - ) -> Option> { - self.consumer_factory.as_ref().map(|f| f(db_any)) + pub fn create_consumer(&self, db: &AimDb) -> Option> { + self.consumer_factory.as_ref().map(|f| f(db)) } } @@ -556,16 +545,10 @@ pub type DeserializerFn = /// Type alias for context-aware type-erased deserializer callbacks /// -/// Like `DeserializerFn`, but receives a type-erased runtime context +/// Like `DeserializerFn`, but receives the concrete [`RuntimeContext`](crate::RuntimeContext) /// for platform-independent timestamps and logging during deserialization. -/// -/// The first argument is the type-erased runtime (as `Arc`), -/// which is downcast to the concrete runtime type via `RuntimeContext::extract_from_any`. pub type ContextDeserializerFn = Arc< - dyn Fn( - Arc, - &[u8], - ) -> Result, String> + dyn Fn(crate::RuntimeContext, &[u8]) -> Result, String> + Send + Sync, >; @@ -584,13 +567,12 @@ pub enum DeserializerKind { /// Type alias for producer factory callback (alloc feature) /// -/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ProducerTrait`. -/// This allows capturing the record type T at link_from() time while storing -/// the factory in a type-erased InboundConnectorLink. +/// Takes the live [`AimDb`] and returns a boxed `ProducerTrait`. This allows +/// capturing the record type T at link_from() time while storing the factory +/// in a type-erased InboundConnectorLink. /// /// Available in both `std` and `no_std + alloc` environments. -pub type ProducerFactoryFn = - Arc) -> Box + Send + Sync>; +pub type ProducerFactoryFn = Arc Box + Send + Sync>; /// Topic resolver function for inbound connections (late-binding) /// @@ -634,15 +616,14 @@ pub trait ProducerTrait: Send + Sync { /// Type alias for consumer factory callback (alloc feature) /// -/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ConsumerTrait`. -/// This allows capturing the record type T at link_to() time while storing -/// the factory in a type-erased ConnectorLink. +/// Takes the live [`AimDb`] and returns a boxed `ConsumerTrait`. This allows +/// capturing the record type T at link_to() time while storing the factory +/// in a type-erased ConnectorLink. /// /// Mirrors the ProducerFactoryFn pattern for symmetry between inbound and outbound. /// /// Available in both `std` and `no_std + alloc` environments. -pub type ConsumerFactoryFn = - Arc) -> Box + Send + Sync>; +pub type ConsumerFactoryFn = Arc Box + Send + Sync>; /// Type-erased consumer trait for outbound routing /// @@ -704,7 +685,7 @@ pub struct InboundConnectorLink { /// Producer creation callback (alloc feature) /// - /// Takes `Arc>` and returns `Box`. + /// Takes the live [`AimDb`] and returns `Box`. /// Captures the record type T at link_from() call time. /// /// Available in both `std` and `no_std + alloc` environments. @@ -762,10 +743,7 @@ impl InboundConnectorLink { /// Available in both `std` and `no_std + alloc` environments. pub fn with_producer_factory(mut self, factory: F) -> Self where - F: Fn(Arc) -> Box - + Send - + Sync - + 'static, + F: Fn(&AimDb) -> Box + Send + Sync + 'static, { self.producer_factory = Some(Arc::new(factory)); self @@ -774,11 +752,8 @@ impl InboundConnectorLink { /// Creates a producer using the stored factory. /// /// Available in both `std` and `no_std + alloc` environments. - pub fn create_producer( - &self, - db_any: Arc, - ) -> Option> { - self.producer_factory.as_ref().map(|f| f(db_any)) + pub fn create_producer(&self, db: &AimDb) -> Option> { + self.producer_factory.as_ref().map(|f| f(db)) } /// Resolves the subscription topic for this link @@ -902,31 +877,25 @@ fn parse_connector_url(url: &str) -> DbResult { /// broker_url: String, /// } /// -/// impl ConnectorBuilder for MqttConnectorBuilder -/// where -/// R: aimdb_executor::RuntimeAdapter + 'static, -/// { +/// impl ConnectorBuilder for MqttConnectorBuilder { /// fn build<'a>( /// &'a self, -/// db: &'a AimDb, -/// ) -> Pin>> + Send + 'a>> { +/// db: &'a AimDb, +/// ) -> Pin>> + Send + 'a>> { /// Box::pin(async move { /// let routes = db.collect_inbound_routes(self.scheme()); /// let router = RouterBuilder::from_routes(routes).build(); /// let connector = MqttConnector::new(&self.broker_url, router).await?; -/// Ok(Arc::new(connector) as Arc) +/// Ok(connector.futures()) /// }) /// } -/// +/// /// fn scheme(&self) -> &str { /// "mqtt" /// } /// } /// ``` -pub trait ConnectorBuilder: Send + Sync -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ +pub trait ConnectorBuilder: Send + Sync { /// Build the connector and return its driving futures. /// /// Called during `AimDbBuilder::build()` after the database has been @@ -943,7 +912,7 @@ where #[allow(clippy::type_complexity)] fn build<'a>( &'a self, - db: &'a AimDb, + db: &'a AimDb, ) -> Pin< Box< dyn Future + Send + 'static>>>>> diff --git a/aimdb-core/src/context.rs b/aimdb-core/src/context.rs index 3cf28a78..0134a7d4 100644 --- a/aimdb-core/src/context.rs +++ b/aimdb-core/src/context.rs @@ -1,171 +1,117 @@ //! Runtime context for AimDB services //! -//! Provides a unified interface to runtime capabilities like sleep and timestamp -//! functions, abstracting away the specific runtime adapter implementation. +//! Provides a unified interface to runtime capabilities (time, sleep, logging) +//! as a concrete value wrapping `Arc` — no runtime type +//! parameter (issue #131, design doc 034 §3.2/§3.3). -use aimdb_executor::Runtime; +use aimdb_executor::{BoxFuture, LogLevel, RuntimeOps}; use alloc::sync::Arc; -use core::future::Future; /// Unified runtime context for AimDB services /// -/// Provides access to runtime capabilities (sleep, timestamps) through -/// a unified API, abstracting the underlying runtime implementation. -/// -/// Services receive this context for timing operations without knowing -/// about the specific runtime adapter. +/// Wraps the dyn-safe [`RuntimeOps`] capability surface so services can use +/// timing and logging without knowing the runtime adapter type. Cheap to +/// clone (one `Arc`). #[derive(Clone)] -pub struct RuntimeContext -where - R: Runtime, -{ - runtime: Arc, +pub struct RuntimeContext { + ops: Arc, } -impl RuntimeContext -where - R: Runtime, -{ - /// Create a new RuntimeContext (wraps the runtime in an Arc internally) - pub fn new(runtime: R) -> Self { - Self { - runtime: Arc::new(runtime), - } - } - - /// Create from an existing Arc to avoid double-wrapping - pub fn from_arc(runtime: Arc) -> Self { - Self { runtime } +impl RuntimeContext { + /// Create a new RuntimeContext from the dyn-safe runtime capabilities. + pub fn new(ops: Arc) -> Self { + Self { ops } } - /// Extract runtime context from type-erased Arc - /// - /// This is a helper for runtime adapters to convert the raw `Arc` - /// context passed to `.source_raw()` and `.tap_raw()` into a typed `RuntimeContext`. - /// - /// # Panics - /// Panics if the runtime type doesn't match `R`. - pub fn extract_from_any(ctx_any: Arc) -> Self { - let runtime = ctx_any - .downcast::() - .expect("Runtime type mismatch - expected matching runtime adapter"); - Self::from_arc(runtime) - } -} - -impl RuntimeContext -where - R: Runtime, -{ /// Access time utilities /// - /// Returns a time accessor for duration creation, sleep, and timing operations. - pub fn time(&self) -> Time<'_, R> { - Time { ctx: self } + /// Returns a time accessor for clock reads and sleeping. + pub fn time(&self) -> Time<'_> { + Time { ops: &*self.ops } } /// Access logging utilities - pub fn log(&self) -> Log<'_, R> { - Log { ctx: self } + pub fn log(&self) -> Log<'_> { + Log { ops: &*self.ops } } - /// Get access to the underlying runtime - /// - /// This provides direct access to the runtime for advanced use cases. - pub fn runtime(&self) -> &R { - &self.runtime + /// Direct access to the underlying runtime capabilities. + pub fn ops(&self) -> &Arc { + &self.ops } } -impl RuntimeContext -where - R: Runtime, -{ - /// Create a RuntimeContext from a runtime adapter - pub fn from_runtime(runtime: R) -> Self { - Self::new(runtime) - } -} - -/// Create a RuntimeContext from any Runtime implementation -pub fn create_runtime_context(runtime: R) -> RuntimeContext -where - R: Runtime, -{ - RuntimeContext::from_runtime(runtime) -} - /// Time utilities accessor for RuntimeContext /// -/// Provides duration creation, sleep, and timing measurements. -pub struct Time<'a, R: Runtime> { - ctx: &'a RuntimeContext, +/// Durations are plain [`core::time::Duration`]; instants are `u64` +/// nanoseconds from an arbitrary monotonic epoch (only differences between +/// two readings are meaningful). +pub struct Time<'a> { + ops: &'a dyn RuntimeOps, } -impl<'a, R: Runtime> Time<'a, R> { - /// Create a duration from milliseconds - pub fn millis(&self, millis: u64) -> R::Duration { - self.ctx.runtime.millis(millis) - } - - /// Create a duration from seconds - pub fn secs(&self, secs: u64) -> R::Duration { - self.ctx.runtime.secs(secs) - } - - /// Create a duration from microseconds - pub fn micros(&self, micros: u64) -> R::Duration { - self.ctx.runtime.micros(micros) +impl Time<'_> { + /// Monotonic clock reading in nanoseconds from an arbitrary epoch. + /// + /// Never decreases; saturates at `u64::MAX` rather than wrapping. + pub fn now(&self) -> u64 { + self.ops.now_nanos() } - /// Sleep for the specified duration - pub fn sleep(&self, duration: R::Duration) -> impl Future + Send + '_ { - self.ctx.runtime.sleep(duration) + /// Wall-clock time as `(seconds, nanoseconds)` since the Unix epoch, if + /// the runtime has a real-time clock (`None` on e.g. a bare MCU without + /// an RTC anchor). + pub fn unix_time(&self) -> Option<(u64, u32)> { + self.ops.unix_time() } - /// Get the current timestamp - pub fn now(&self) -> R::Instant { - self.ctx.runtime.now() + /// Completes after at least `duration` has elapsed. + /// + /// The future is boxed (it crosses the `dyn RuntimeOps` boundary), so each + /// call costs one small heap allocation. That is fine for periodic loops + /// and timeouts; in a no_std hot path that sleeps every iteration, await + /// the adapter's native timer (e.g. `embassy_time::Timer`) directly + /// instead. + pub fn sleep(&self, duration: core::time::Duration) -> BoxFuture { + self.ops.sleep(duration) } - /// Get the duration between two instants - /// - /// Returns None if `later` is before `earlier`. - pub fn duration_since(&self, later: R::Instant, earlier: R::Instant) -> Option { - self.ctx.runtime.duration_since(later, earlier) + /// Convenience: sleep for `ms` milliseconds. + pub fn sleep_millis(&self, ms: u64) -> BoxFuture { + self.ops.sleep(core::time::Duration::from_millis(ms)) } - /// Number of whole nanoseconds in a duration (runtime-agnostic). - pub fn duration_as_nanos(&self, duration: R::Duration) -> u64 { - self.ctx.runtime.duration_as_nanos(duration) + /// Convenience: sleep for `secs` seconds. + pub fn sleep_secs(&self, secs: u64) -> BoxFuture { + self.ops.sleep(core::time::Duration::from_secs(secs)) } } /// Log utilities accessor for RuntimeContext /// /// Provides structured logging operations. -pub struct Log<'a, R: Runtime> { - ctx: &'a RuntimeContext, +pub struct Log<'a> { + ops: &'a dyn RuntimeOps, } -impl<'a, R: Runtime> Log<'a, R> { +impl Log<'_> { /// Log an informational message pub fn info(&self, message: &str) { - self.ctx.runtime.info(message) + self.ops.log(LogLevel::Info, message) } /// Log a debug message pub fn debug(&self, message: &str) { - self.ctx.runtime.debug(message) + self.ops.log(LogLevel::Debug, message) } /// Log a warning message pub fn warn(&self, message: &str) { - self.ctx.runtime.warn(message) + self.ops.log(LogLevel::Warn, message) } /// Log an error message pub fn error(&self, message: &str) { - self.ctx.runtime.error(message) + self.ops.log(LogLevel::Error, message) } } diff --git a/aimdb-core/src/ext_macros.rs b/aimdb-core/src/ext_macros.rs deleted file mode 100644 index 6baae480..00000000 --- a/aimdb-core/src/ext_macros.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Extension trait macros for runtime adapters -//! -//! This module provides macros to reduce boilerplate when implementing -//! extension traits for different runtime adapters. The pattern is nearly -//! identical across all adapters, differing only in: -//! - Trait name -//! - Runtime adapter type -//! - Buffer implementation type - -/// Generate an extension trait for convenient record configuration -/// -/// This macro generates both the trait definition and implementation for -/// a runtime adapter's extension methods (buffer, source, tap). -/// -/// # Arguments -/// -/// * `$trait_name` - Name of the extension trait (e.g., `TokioRecordRegistrarExt`) -/// * `$runtime` - Runtime adapter type (e.g., `TokioAdapter`) -/// * `$buffer` - Buffer implementation type (e.g., `TokioBuffer`) -/// * `$feature_gate` - Optional feature flag to gate the implementation -/// * `$buffer_new` - Expression to create the buffer (handles const generics for Embassy) -/// -/// # Example -/// -/// ```ignore -/// // In tokio adapter -/// impl_record_registrar_ext! { -/// TokioRecordRegistrarExt, -/// TokioAdapter, -/// TokioBuffer, -/// "tokio-runtime", -/// |cfg| TokioBuffer::::new(cfg) -/// } -/// -/// // In embassy adapter -/// impl_record_registrar_ext! { -/// EmbassyRecordRegistrarExt, -/// EmbassyAdapter, -/// EmbassyBuffer, -/// ["embassy-runtime", "embassy-sync"], -/// |cfg| EmbassyBuffer::::new(cfg) -/// } -/// ``` -#[macro_export] -macro_rules! impl_record_registrar_ext { - // Version with single feature gate - ( - $trait_name:ident, - $runtime:ty, - $buffer:ty, - $feature:literal, - $buffer_new:expr - ) => { - /// Extension trait for convenient configuration with this runtime - /// - /// This trait provides high-level convenience methods for configuring records, - /// automatically handling buffer creation and runtime context extraction. - pub trait $trait_name<'a, T> - where - T: Send + Sync + Clone + core::fmt::Debug + 'static, - { - /// Configures a buffer using inline configuration - fn buffer( - &mut self, - cfg: $crate::buffer::BufferCfg, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime>; - - /// Registers a producer with automatic runtime context injection - fn source( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Producer) -> Fut - + Send - + Sync - + 'static, - Fut: core::future::Future + Send + 'static; - - /// Registers a consumer with automatic runtime context injection - fn tap( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Consumer) -> Fut - + Send - + 'static, - Fut: core::future::Future + Send + 'static; - - /// Single-input reactive transform. - /// - /// Derives this record from an input record. Panics if a `.source()` or - /// another `.transform()` is already registered. - fn transform( - &mut self, - input_key: impl $crate::RecordKey, - build_fn: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - I: Send + Sync + Clone + core::fmt::Debug + 'static, - F: FnOnce( - $crate::transform::TransformBuilder, - ) -> $crate::transform::TransformPipeline; - - } - - #[cfg(feature = $feature)] - impl<'a, T> $trait_name<'a, T> for $crate::RecordRegistrar<'a, T, $runtime> - where - T: Send + Sync + Clone + core::fmt::Debug + 'static, - { - fn buffer( - &mut self, - cfg: $crate::buffer::BufferCfg, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> { - use $crate::buffer::Buffer; - - extern crate alloc; - let buffer = alloc::boxed::Box::new($buffer_new(&cfg)); - // Record the cfg so buffer_info() reports the real buffer - // type/capacity for the dependency graph (std and no_std). - self.buffer_with_cfg(buffer, cfg) - } - - fn source( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Producer) -> Fut - + Send - + Sync - + 'static, - Fut: core::future::Future + Send + 'static, - { - self.source_raw(|producer, ctx_any| { - let ctx = $crate::RuntimeContext::extract_from_any(ctx_any); - f(ctx, producer) - }) - } - - fn tap( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Consumer) -> Fut - + Send - + 'static, - Fut: core::future::Future + Send + 'static, - { - self.tap_raw(|consumer, ctx_any| { - let ctx = $crate::RuntimeContext::extract_from_any(ctx_any); - f(ctx, consumer) - }) - } - - fn transform( - &mut self, - input_key: impl $crate::RecordKey, - build_fn: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - I: Send + Sync + Clone + core::fmt::Debug + 'static, - F: FnOnce( - $crate::transform::TransformBuilder, - ) -> $crate::transform::TransformPipeline, - { - self.transform_raw::(input_key, build_fn) - } - - } - }; - - // Version with multiple feature gates (all must be enabled) - ( - $trait_name:ident, - $runtime:ty, - $buffer:ty, - [$($feature:literal),+], - $buffer_new:expr - ) => { - /// Extension trait for convenient configuration with this runtime - /// - /// This trait provides high-level convenience methods for configuring records, - /// automatically handling buffer creation and runtime context extraction. - pub trait $trait_name<'a, T> - where - T: Send + Sync + Clone + core::fmt::Debug + 'static, - { - /// Configures a buffer using inline configuration - fn buffer( - &mut self, - cfg: $crate::buffer::BufferCfg, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime>; - - /// Registers a producer with automatic runtime context injection - fn source( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Producer) -> Fut - + Send - + Sync - + 'static, - Fut: core::future::Future + Send + 'static; - - /// Registers a consumer with automatic runtime context injection - fn tap( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Consumer) -> Fut - + Send - + 'static, - Fut: core::future::Future + Send + 'static; - - /// Single-input reactive transform. - fn transform( - &mut self, - input_key: impl $crate::RecordKey, - build_fn: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - I: Send + Sync + Clone + core::fmt::Debug + 'static, - F: FnOnce( - $crate::transform::TransformBuilder, - ) -> $crate::transform::TransformPipeline; - - } - - #[cfg(all($(feature = $feature),+))] - impl<'a, T> $trait_name<'a, T> for $crate::RecordRegistrar<'a, T, $runtime> - where - T: Send + Sync + Clone + core::fmt::Debug + 'static, - { - fn buffer( - &mut self, - cfg: $crate::buffer::BufferCfg, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> { - use $crate::buffer::Buffer; - - extern crate alloc; - let buffer = alloc::boxed::Box::new($buffer_new(&cfg)); - // Record the cfg so buffer_info() reports the real buffer - // type/capacity for the dependency graph (std and no_std). - self.buffer_with_cfg(buffer, cfg) - } - - fn source( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Producer) -> Fut - + Send - + Sync - + 'static, - Fut: core::future::Future + Send + 'static, - { - self.source_raw(|producer, ctx_any| { - let ctx = $crate::RuntimeContext::extract_from_any(ctx_any); - f(ctx, producer) - }) - } - - fn tap( - &mut self, - f: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - F: FnOnce($crate::RuntimeContext<$runtime>, $crate::Consumer) -> Fut - + Send - + 'static, - Fut: core::future::Future + Send + 'static, - { - self.tap_raw(|consumer, ctx_any| { - let ctx = $crate::RuntimeContext::extract_from_any(ctx_any); - f(ctx, consumer) - }) - } - - fn transform( - &mut self, - input_key: impl $crate::RecordKey, - build_fn: F, - ) -> &mut $crate::RecordRegistrar<'a, T, $runtime> - where - I: Send + Sync + Clone + core::fmt::Debug + 'static, - F: FnOnce( - $crate::transform::TransformBuilder, - ) -> $crate::transform::TransformPipeline, - { - self.transform_raw::(input_key, build_fn) - } - - } - }; -} diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 97fc8965..9d5cbf8a 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -28,7 +28,6 @@ pub mod codec; pub mod connector; pub mod context; mod error; -pub mod ext_macros; pub mod extensions; pub mod graph; #[cfg(feature = "profiling")] @@ -45,36 +44,18 @@ pub mod transport; pub mod typed_api; pub mod typed_record; -/// Marker trait used to add a `TimeOps` requirement to runtime-agnostic builder -/// methods *only* when the `profiling` feature is enabled (stage-duration timing -/// needs the runtime clock). -/// -/// When `profiling` is off this is a blanket no-op (every `R` implements it), so -/// the public API is unchanged. When `profiling` is on it requires -/// [`aimdb_executor::TimeOps`], which every real runtime adapter already provides -/// (it is part of [`aimdb_executor::Runtime`]). -#[cfg(feature = "profiling")] -pub trait RuntimeForProfiling: aimdb_executor::TimeOps {} -#[cfg(feature = "profiling")] -impl RuntimeForProfiling for R {} - -/// See the `profiling`-enabled definition above. Blanket no-op when `profiling` -/// is disabled. -#[cfg(not(feature = "profiling"))] -pub trait RuntimeForProfiling {} -#[cfg(not(feature = "profiling"))] -impl RuntimeForProfiling for R {} - // Public API exports pub use context::RuntimeContext; pub use error::{ConfigError, DbError, DbResult}; pub use extensions::Extensions; -// Runtime trait re-exports from aimdb-executor -// These traits define the platform-specific execution, timing, and logging capabilities -pub use aimdb_executor::{ - ExecutorError, ExecutorResult, Logger, Runtime, RuntimeAdapter, RuntimeInfo, TimeOps, -}; +// Error/result re-exports from aimdb-executor. The generic runtime trait +// family (`RuntimeAdapter`, `Runtime`, `TimeOps`, `Logger`, `RuntimeInfo`) is +// no longer consumed by core — the runtime travels as +// `Arc` (issue #131) — so it is not +// re-exported here; import those traits from `aimdb_executor` directly if an +// adapter still needs them. +pub use aimdb_executor::{ExecutorError, ExecutorResult}; // Producer-Consumer Pattern exports pub use builder::OutboundRoute; diff --git a/aimdb-core/src/profiling/mod.rs b/aimdb-core/src/profiling/mod.rs index 95c2f2e3..9b71f32c 100644 --- a/aimdb-core/src/profiling/mod.rs +++ b/aimdb-core/src/profiling/mod.rs @@ -38,16 +38,10 @@ use crate::DbError; /// (cheaply, behind `Arc`) with the per-stage instrumentation. pub(crate) type Clock = Arc u64 + Send + Sync>; -/// Builds a [`Clock`] from a runtime adapter. -pub(crate) fn make_clock(rt: Arc) -> Clock { - let epoch = rt.now(); - Arc::new(move || { - let now = rt.now(); - match rt.duration_since(now, epoch.clone()) { - Some(d) => rt.duration_as_nanos(d), - None => 0, - } - }) +/// Builds a [`Clock`] from the dyn-safe runtime capabilities. +pub(crate) fn make_clock(rt: Arc) -> Clock { + let epoch = rt.now_nanos(); + Arc::new(move || rt.now_nanos().saturating_sub(epoch)) } /// Sentinel for "no previous `produce()` recorded yet". diff --git a/aimdb-core/src/remote/stream.rs b/aimdb-core/src/remote/stream.rs index 78e9ce41..ae518f7b 100644 --- a/aimdb-core/src/remote/stream.rs +++ b/aimdb-core/src/remote/stream.rs @@ -26,13 +26,10 @@ use futures_util::stream::unfold; /// - [`DbError::InvalidRecordId`] if the resolved id has no storage. /// - Any error returned by `subscribe_json()` (e.g. the record was not /// configured with `.with_remote_access()`). -pub(crate) fn stream_record_updates( - db: &AimDb, +pub(crate) fn stream_record_updates( + db: &AimDb, record_key: &str, -) -> DbResult + Send + 'static> -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ +) -> DbResult + Send + 'static> { let inner = db.inner(); let id = inner .resolve_str(record_key) diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 894a722f..b1efa373 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -77,7 +77,7 @@ impl Router { /// # Arguments /// * `resource_id` - Resource identifier (topic, path, segment name, etc.) /// * `payload` - Raw message payload bytes - /// * `ctx` - Optional type-erased runtime context for context-aware deserializers + /// * `ctx` - Optional runtime context for context-aware deserializers /// /// # Returns /// * `Ok(())` - Always returns Ok, even if no routes matched or processing failed. @@ -93,7 +93,7 @@ impl Router { &self, resource_id: &str, payload: &[u8], - ctx: Option<&Arc>, + ctx: Option<&crate::RuntimeContext>, ) -> Result<(), String> { let mut routed = false; let mut matched = false; @@ -466,8 +466,9 @@ mod tests { let router = Router::new(routes); - // Provide a dummy context (just an i32 wrapped in Arc) - let ctx: Arc = Arc::new(0i32); + // Provide a dummy context backed by the shared no-op RuntimeOps. + let ctx = + crate::RuntimeContext::new(Arc::new(aimdb_executor::test_support::NoopRuntimeOps)); router .route("ctx/resource", b"dummy", Some(&ctx)) .await diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs index 4eaa3041..df4aa5d7 100644 --- a/aimdb-core/src/session/aimx/dispatch.rs +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -33,18 +33,18 @@ use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage}; use crate::session::{ AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, SessionCtx, }; -use crate::{AimDb, DbError, RuntimeAdapter}; +use crate::{AimDb, DbError}; /// The shared AimX dispatch — `authenticate` (peer-only) + the `AimxSession` /// factory. One `Arc` is shared across every accepted connection. -pub struct AimxDispatch { - db: Arc>, +pub struct AimxDispatch { + db: Arc, config: Arc, } -impl AimxDispatch { +impl AimxDispatch { /// Build a dispatch over `db` with the given remote-access `config`. - pub fn new(db: Arc>, config: AimxConfig) -> Self { + pub fn new(db: Arc, config: AimxConfig) -> Self { Self { db, config: Arc::new(config), @@ -52,10 +52,7 @@ impl AimxDispatch { } } -impl Dispatch for AimxDispatch -where - R: RuntimeAdapter + 'static, -{ +impl Dispatch for AimxDispatch { fn authenticate<'a>( &'a self, _peer: &'a PeerInfo, @@ -79,17 +76,14 @@ where /// One AimX connection's mutable dispatch state. The engine owns this by value /// and threads `&mut self` into each method, so `drain_readers` (lazy per-record /// cursors) need no lock. -struct AimxSession { - db: Arc>, +struct AimxSession { + db: Arc, config: Arc, /// Per-record drain readers, created lazily on first `record.drain`. drain_readers: HashMap>, } -impl Session for AimxSession -where - R: RuntimeAdapter + 'static, -{ +impl Session for AimxSession { fn call<'a>( &'a mut self, method: &'a str, @@ -136,10 +130,7 @@ where } } -impl AimxSession -where - R: RuntimeAdapter + 'static, -{ +impl AimxSession { /// Match the method and produce its JSON result (or an [`RpcError`]). async fn dispatch_call(&mut self, method: &str, params: Value) -> Result { match method { diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs index 6062efb1..66bbc595 100644 --- a/aimdb-core/src/session/client.rs +++ b/aimdb-core/src/session/client.rs @@ -8,7 +8,7 @@ //! mirrors records over the same engine. //! //! Runtime-neutral: the only runtime-specific primitive is *time* (reconnect -//! backoff + keepalive), via the adapter's [`TimeOps`] clock; everything else is +//! backoff + keepalive), via the adapter's dyn-safe `RuntimeOps` clock; everything else is //! `futures` channels. The demux loop uses the same **extract-then-act** shape as //! the server (compute a [`ClientStep`], then act once the arm borrows release). @@ -17,7 +17,6 @@ use alloc::string::{String, ToString}; use alloc::sync::Arc; use alloc::vec::Vec; -use aimdb_executor::TimeOps; use async_channel::{Receiver, Sender}; use futures_channel::oneshot; use futures_util::{select_biased, FutureExt, StreamExt}; @@ -28,10 +27,10 @@ use super::{ }; use crate::connector::SerializerKind; use crate::router::RouterBuilder; -use crate::{AimDb, RuntimeAdapter}; +use crate::AimDb; /// Client engine knobs. Durations are in **milliseconds** so the engine stays -/// `no_std`-clean; the adapter's [`TimeOps`] turns them into its native `Duration`. +/// `no_std`-clean; plain milliseconds turned into `core::time::Duration` for the clock. #[derive(Debug, Clone)] pub struct ClientConfig { /// Redial after a dropped/failed connection instead of ending the engine. @@ -47,8 +46,8 @@ pub struct ClientConfig { pub max_reconnect_delay: u64, /// Maximum redial attempts before giving up. `0` = unlimited (default). pub max_reconnect_attempts: usize, - /// Send a keepalive `Ping` after this many ms of an idle connection; the timer - /// re-arms each iteration, so traffic resets it. `None` (default) disables it. + /// Send a keepalive `Ping` after this many ms of an idle connection; any + /// traffic resets the idle window. `None` (default) disables it. pub keepalive_interval: Option, /// Cap on caller commands buffered while disconnected (oldest dropped past it). /// Defaults to `usize::MAX` (unbounded). @@ -172,19 +171,19 @@ impl ClientHandle { /// `ClientHandle` clones are dropped (graceful stop) — or, with /// [`ClientConfig::reconnect`] off, until the first disconnect. /// -/// `clock` is the adapter's [`TimeOps`] runtime (e.g. `db.runtime_arc()`); the -/// engine uses it for the reconnect backoff and keepalive — the *only* runtime -/// dependency, so the rest of the engine is runtime-neutral. -pub fn run_client( +/// `clock` is the adapter's dyn-safe [`RuntimeOps`](aimdb_executor::RuntimeOps) +/// (e.g. `db.runtime_ops()`); the engine uses it for the reconnect backoff and +/// keepalive — the *only* runtime dependency, so the rest of the engine is +/// runtime-neutral. +pub fn run_client( dialer: D, codec: C, config: ClientConfig, - clock: Arc, + clock: Arc, ) -> (ClientHandle, BoxFut<'static, ()>) where D: Dialer + 'static, C: EnvelopeCodec + 'static, - R: TimeOps + 'static, { let (cmd_tx, cmd_rx) = async_channel::unbounded(); let handle = ClientHandle { cmd_tx }; @@ -227,16 +226,15 @@ enum ClientStep { Cmd(Option), } -async fn client_loop( +async fn client_loop( dialer: D, codec: C, config: ClientConfig, cmd_rx: Receiver, - clock: Arc, + clock: Arc, ) where D: Dialer, C: EnvelopeCodec, - R: TimeOps, { // Whenever the engine returns, fail any buffered/in-flight calls (see guard). let _drain = DrainOnExit(&cmd_rx); @@ -272,11 +270,11 @@ async fn client_loop( /// Decide whether to redial: honor `reconnect`, the attempt cap, the offline-queue /// bound, and the exponential backoff sleep (via the runtime clock). Returns /// `true` to retry, `false` to stop the engine. -async fn reconnect_after( +async fn reconnect_after( attempt: &mut usize, config: &ClientConfig, cmd_rx: &Receiver, - clock: &R, + clock: &dyn aimdb_executor::RuntimeOps, ) -> bool { if !config.reconnect { return false; @@ -291,7 +289,9 @@ async fn reconnect_after( } bound_offline_queue(cmd_rx, config.max_offline_queue); clock - .sleep(clock.millis(backoff_delay(config, *attempt))) + .sleep(core::time::Duration::from_millis(backoff_delay( + config, *attempt, + ))) .await; true } @@ -301,16 +301,15 @@ async fn reconnect_after( /// subscription channels) interleaved with caller commands. Pending state is /// per-connection: a disconnect fails outstanding calls (their `oneshot` /// senders drop → callers see [`RpcError::Internal`]). -async fn drive_connection( +async fn drive_connection( mut conn: Box, codec: &C, cmd_rx: &Receiver, config: &ClientConfig, - clock: &R, + clock: &dyn aimdb_executor::RuntimeOps, ) -> Ended where C: EnvelopeCodec + ?Sized, - R: TimeOps, { let mut next_id: u64 = 1; let mut pending: HashMap>> = HashMap::new(); @@ -319,6 +318,13 @@ where let mut subs: HashMap> = HashMap::new(); let mut out = Vec::new(); let keepalive_ms = config.keepalive_interval; + // Keepalive is deadline-based: activity only records a timestamp (one dyn + // clock read, no allocation) and the boxed `clock.sleep` stays armed for a + // full idle window — re-created roughly once per interval, not on every + // processed frame (`dyn RuntimeOps::sleep` heap-allocates its future). + let mut last_activity = clock.now_nanos(); + let mut keepalive_timer = + keepalive_ms.map(|ms| clock.sleep(core::time::Duration::from_millis(ms)).fuse()); // Handshake-as-caller: prove the link with Ping/Pong before serving commands. if config.sends_hello { @@ -340,21 +346,19 @@ where // Biased toward the server read. The select only decides the next step. let step = { let mut recv = conn.recv().fuse(); - // Idle keepalive, re-armed each iteration; with no interval it parks on - // `pending()` forever. `!Unpin`, so pin it for the arm. - let mut keepalive = core::pin::pin!(async { - match keepalive_ms { - Some(ms) => clock.sleep(clock.millis(ms)).await, - None => core::future::pending::<()>().await, - } - } - .fuse()); + // The armed idle timer (see above); with no interval it parks on + // `pending()` forever. `Either` re-borrows the persistent timer, + // so this is allocation-free per iteration. + let mut keepalive = match keepalive_timer.as_mut() { + Some(timer) => futures_util::future::Either::Left(timer), + None => futures_util::future::Either::Right(futures_util::future::pending::<()>()), + }; // `recv()` is `!Unpin`, so pin it for the arm. let mut cmd = core::pin::pin!(cmd_rx.recv().fuse()); select_biased! { // ---- inbound from server: Reply / Event / Snapshot / Pong -- r = recv => ClientStep::Inbound(r), - // ---- keepalive: send a Ping when the idle timer fires ------ + // ---- keepalive: the idle timer fired ------------------------ _ = keepalive => ClientStep::Keepalive, // ---- caller commands from ClientHandle --------------------- // `recv()` errors only when every `ClientHandle` is dropped → `None`. @@ -362,6 +366,12 @@ where } }; + // Frames and commands are link activity; only a genuinely idle link + // needs a Ping. + if !matches!(step, ClientStep::Keepalive) { + last_activity = clock.now_nanos(); + } + match step { ClientStep::Inbound(recv) => { let frame = match recv { @@ -402,11 +412,31 @@ where } ClientStep::Keepalive => { - out.clear(); - if codec.encode_inbound(Inbound::Ping, &mut out).is_ok() - && conn.send(&out).await.is_err() - { - return Ended::Disconnected; + // `keepalive_timer` is `Some` whenever this step fires. + let interval_ms = keepalive_ms.unwrap_or(0); + let idle_ms = clock.now_nanos().saturating_sub(last_activity) / 1_000_000; + if idle_ms >= interval_ms { + // Genuinely idle for a full window: ping and re-arm. + out.clear(); + if codec.encode_inbound(Inbound::Ping, &mut out).is_ok() + && conn.send(&out).await.is_err() + { + return Ended::Disconnected; + } + last_activity = clock.now_nanos(); + keepalive_timer = Some( + clock + .sleep(core::time::Duration::from_millis(interval_ms)) + .fuse(), + ); + } else { + // Activity happened while the timer was armed: no ping, + // re-arm for the remainder of the idle window. + keepalive_timer = Some( + clock + .sleep(core::time::Duration::from_millis(interval_ms - idle_ms)) + .fuse(), + ); } } @@ -488,16 +518,9 @@ where /// /// Reconnect caveat: inbound pumps subscribe once and are not replayed across a /// reconnect (see [`ClientConfig::reconnect`]); outbound mirroring is unaffected. -pub fn pump_client( - db: &AimDb, - scheme: &str, - handle: &ClientHandle, -) -> Vec> -where - R: RuntimeAdapter + 'static, -{ - // The type-erased runtime context for context-aware (de)serializers. - let ctx = db.runtime_any(); +pub fn pump_client(db: &AimDb, scheme: &str, handle: &ClientHandle) -> Vec> { + // The runtime context for context-aware (de)serializers. + let ctx = db.runtime_ctx(); let mut pumps: Vec> = Vec::new(); // --- outbound: local record updates -> remote `write` ------------------ diff --git a/aimdb-core/src/session/connector.rs b/aimdb-core/src/session/connector.rs index 8301799f..f2ddc098 100644 --- a/aimdb-core/src/session/connector.rs +++ b/aimdb-core/src/session/connector.rs @@ -25,8 +25,6 @@ use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; -use aimdb_executor::{RuntimeAdapter, TimeOps}; - use crate::builder::AimDb; use crate::connector::ConnectorBuilder; use crate::session::{ @@ -81,19 +79,18 @@ impl SessionClientConnector { } } -impl ConnectorBuilder for SessionClientConnector +impl ConnectorBuilder for SessionClientConnector where - R: TimeOps + 'static, D: Dialer + Clone + Send + Sync + 'static, C: EnvelopeCodec + Clone + 'static, { - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { Box::pin(async move { let (handle, engine_fut) = run_client( self.dialer.clone(), self.codec.clone(), self.config.clone(), - db.runtime_arc(), + db.runtime_ops(), ); // One pump future per route; each holds a `ClientHandle` clone, so the // engine stays alive as long as any mirror runs. `handle` drops here. @@ -118,7 +115,7 @@ where /// Two factories keep it transport- and protocol-agnostic: /// - `listener_factory` runs at `build` time and returns `DbResult`, so the /// bind happens there and any error surfaces synchronously from `build`. -/// - `dispatch_factory` turns the live `&AimDb` into an `Arc` +/// - `dispatch_factory` turns the live `&AimDb` into an `Arc` /// (e.g. an `AimxDispatch`), so the spine never names a concrete protocol. pub struct SessionServerConnector { scheme: String, @@ -154,15 +151,14 @@ impl SessionServerConnector { } } -impl ConnectorBuilder for SessionServerConnector +impl ConnectorBuilder for SessionServerConnector where - R: RuntimeAdapter + 'static, L: Listener + 'static, C: EnvelopeCodec + Clone + 'static, LF: Fn() -> DbResult + Send + Sync + 'static, - DF: Fn(&AimDb) -> Arc + Send + Sync + 'static, + DF: Fn(&AimDb) -> Arc + Send + Sync + 'static, { - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { // Bind synchronously so a bind error surfaces from `build`. let listener = (self.listener_factory)(); let dispatch = (self.dispatch_factory)(db); diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs index e0d59640..3bb465b2 100644 --- a/aimdb-core/src/session/pump.rs +++ b/aimdb-core/src/session/pump.rs @@ -36,10 +36,7 @@ use crate::transport::{Connector, ConnectorConfig}; /// /// The publisher future terminates when its subscription yields an error (e.g. the /// record buffer closed), matching the legacy hand-rolled loop. -pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ +pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec { let routes = db.collect_outbound_routes(scheme); let mut futures: Vec = Vec::with_capacity(routes.len()); @@ -52,7 +49,7 @@ where } in routes { let sink = sink.clone(); - let runtime_ctx = db.runtime_any(); + let runtime_ctx = db.runtime_ctx(); let cfg = ConnectorConfig::from_query(&config); futures.push(Box::pin(async move { @@ -147,13 +144,10 @@ where /// /// [`Router`]: crate::router::Router /// [`Router::route`]: crate::router::Router::route -pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> Vec -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ +pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> Vec { let routes = db.collect_inbound_routes(scheme); let router = Arc::new(RouterBuilder::from_routes(routes).build()); - let ctx = db.runtime_any(); + let ctx = db.runtime_ctx(); vec![Box::pin(async move { log_info!( diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 6455768b..c89639e4 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -9,11 +9,36 @@ use alloc::{ vec::Vec, }; -use aimdb_executor::{ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; +use aimdb_executor::{ExecutorError, ExecutorResult}; use crate::transform::{CollectedTransform, TransformDescriptor}; use crate::typed_record::BoxFuture; +// ============================================================================ +// Fan-in queue +// ============================================================================ + +/// Capacity of the bounded fan-in channel between input forwarders and the +/// trigger loop. Sized per target to match the per-adapter GAT queue family +/// this replaces: 64 on std and wasm32 (tokio/WASM used 64 — a blocked +/// forwarder stops draining its input buffer, so burst headroom matters +/// there; the WASM adapter builds core without `std`, hence the target cfg). +#[cfg(any(feature = "std", target_arch = "wasm32"))] +const JOIN_QUEUE_CAPACITY: usize = 64; +/// Embedded no_std: the Embassy queue this replaces used 8; 16 adds burst +/// headroom at a few words of RAM per slot. +#[cfg(not(any(feature = "std", target_arch = "wasm32")))] +const JOIN_QUEUE_CAPACITY: usize = 16; + +/// One bounded queue for all runtimes — the same `async-channel` primitive the +/// session engine already uses on tokio, Embassy, and WASM. +fn join_channel() -> ( + async_channel::Sender, + async_channel::Receiver, +) { + async_channel::bounded(JOIN_QUEUE_CAPACITY) +} + // ============================================================================ // JoinTrigger // ============================================================================ @@ -45,10 +70,10 @@ impl JoinTrigger { } // ============================================================================ -// JoinEventRx — type-erased trigger receiver +// JoinEventRx — trigger receiver // ============================================================================ -/// Type-erased receiver for join trigger events. +/// Receiver for join trigger events. /// /// Obtained as the first argument to the [`JoinBuilder::on_triggers`] closure. /// Call `.recv().await` in a loop to consume trigger events from all input forwarders. @@ -71,41 +96,24 @@ impl JoinTrigger { /// }) /// ``` pub struct JoinEventRx { - inner: Box, + inner: async_channel::Receiver, } impl JoinEventRx { - fn new + Send + 'static>(inner: R) -> Self { - Self { - inner: Box::new(inner), - } + fn new(inner: async_channel::Receiver) -> Self { + Self { inner } } /// Receive the next trigger event. /// - /// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all inputs are closed. - /// - /// # Runtime portability - /// - /// On Tokio and WASM, the channel closes once every input forwarder has - /// dropped its sender, and `recv` returns `Err`, ending any + /// Returns `Ok(JoinTrigger)` when an input fires, or `Err` when all input + /// forwarders have dropped their senders — on every runtime, ending any /// `while let Ok(_) = rx.recv().await` loop. - /// - /// On Embassy the channel **never** closes — this branch is unreachable - /// and the loop runs for the device lifetime. Portable handlers should - /// not rely on the loop exiting to release resources. pub async fn recv(&mut self) -> ExecutorResult { - self.inner.recv_boxed().await - } -} - -trait DynJoinRx: Send { - fn recv_boxed<'a>(&'a mut self) -> BoxFuture<'a, ExecutorResult>; -} - -impl + Send> DynJoinRx for R { - fn recv_boxed<'a>(&'a mut self) -> BoxFuture<'a, ExecutorResult> { - Box::pin(self.recv()) + self.inner + .recv() + .await + .map_err(|_| ExecutorError::QueueClosed) } } @@ -114,11 +122,11 @@ impl + Send> DynJoinRx for R { // ============================================================================ /// Type-erased factory for creating a forwarder task for one join input. -type JoinInputFactory = Box< +type JoinInputFactory = Box< dyn FnOnce( - Arc>, + Arc, usize, - <::JoinQueue as JoinQueue>::Sender, + async_channel::Sender, ) -> BoxFuture<'static, ()> + Send + Sync, @@ -126,21 +134,19 @@ type JoinInputFactory = Box< /// Configures a multi-input join transform. /// -/// Available on any runtime that implements [`aimdb_executor::JoinFanInRuntime`]. -/// The fan-in queue (bounded channel between input forwarders and the trigger -/// loop) is created by the runtime adapter at database startup — capacity is an -/// internal constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). +/// Available on every runtime. The fan-in queue (bounded channel between input +/// forwarders and the trigger loop) lives in core; its capacity is +/// [`JOIN_QUEUE_CAPACITY`]. /// /// Obtain via [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). -pub struct JoinBuilder { - inputs: Vec<(String, JoinInputFactory)>, - _phantom: PhantomData<(O, R)>, +pub struct JoinBuilder { + inputs: Vec<(String, JoinInputFactory)>, + _phantom: PhantomData, } -impl JoinBuilder +impl JoinBuilder where O: Send + Sync + Clone + Debug + 'static, - R: JoinFanInRuntime + 'static, { pub(crate) fn new() -> Self { Self { @@ -157,11 +163,8 @@ where let key_str = key.as_str().to_string(); let key_for_factory = key_str.clone(); - type Tx = - <::JoinQueue as JoinQueue>::Sender; - - let factory: JoinInputFactory = - Box::new(move |db: Arc>, index: usize, tx: Tx| { + let factory: JoinInputFactory = Box::new( + move |db: Arc, index: usize, tx: async_channel::Sender| { Box::pin(async move { let consumer = match db.consumer::(&key_for_factory) { Ok(c) => c, @@ -177,7 +180,24 @@ where }; let mut reader = consumer.subscribe(); - while let Ok(value) = reader.recv().await { + loop { + let value = match reader.recv().await { + Ok(v) => v, + // SPMC-ring overflow: the reader recovers (cursor + // resets to the oldest live value), so a transient + // lag must not permanently silence this input — + // same policy as every other recv loop in core. + Err(crate::DbError::BufferLagged { .. }) => { + log_warn!( + "🔄 Join input '{}' (index {}) lagged behind, some values skipped", + key_for_factory, + index + ); + continue; + } + // Buffer closed / fatal — the input record is gone. + Err(_) => break, + }; let trigger = JoinTrigger::Input { index, value: Box::new(value), @@ -187,7 +207,8 @@ where } } }) as BoxFuture<'static, ()> - }); + }, + ); self.inputs.push((key_str, factory)); self @@ -217,7 +238,7 @@ where /// } /// }) /// ``` - pub fn on_triggers(self, handler: F) -> JoinPipeline + pub fn on_triggers(self, handler: F) -> JoinPipeline where F: FnOnce(JoinEventRx, crate::Producer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, @@ -229,8 +250,8 @@ where JoinPipeline { spawn_factory: Box::new(move |_| TransformDescriptor { input_keys: input_keys_for_descriptor, - build_fn: Box::new(move |producer, db, runtime, output_key| { - build_join_collected(db, inputs, producer, handler, runtime, output_key) + build_fn: Box::new(move |producer, db, output_key| { + build_join_collected(db, inputs, producer, handler, output_key) }), }), } @@ -241,16 +262,15 @@ where /// /// Produced by [`JoinBuilder::on_triggers`] and consumed by /// [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). Not normally constructed directly. -pub struct JoinPipeline { - pub(crate) spawn_factory: Box TransformDescriptor + Send>, +pub struct JoinPipeline { + pub(crate) spawn_factory: Box TransformDescriptor + Send>, } -impl JoinPipeline +impl JoinPipeline where O: Send + Sync + Clone + Debug + 'static, - R: JoinFanInRuntime + 'static, { - pub(crate) fn into_descriptor(self) -> TransformDescriptor { + pub(crate) fn into_descriptor(self) -> TransformDescriptor { (self.spawn_factory)(()) } } @@ -259,17 +279,15 @@ where // Join Transform Build (forwarders + handler future, both collected at build time) // ============================================================================ -fn build_join_collected( - db: Arc>, - inputs: Vec<(String, JoinInputFactory)>, +fn build_join_collected( + db: Arc, + inputs: Vec<(String, JoinInputFactory)>, producer: crate::Producer, handler: F, - runtime: Arc, output_key: &str, ) -> CollectedTransform where O: Send + Sync + Clone + Debug + 'static, - R: JoinFanInRuntime + 'static, F: FnOnce(JoinEventRx, crate::Producer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { @@ -284,21 +302,7 @@ where output_key ); - let queue = match runtime.create_join_queue::() { - Ok(q) => q, - Err(_e) => { - log_error!( - "🔄 Join transform '{}' FATAL: failed to create join queue", - output_key - ); - // Empty collected transform — caller still receives a valid descriptor. - return CollectedTransform { - task_future: Box::pin(async {}), - fanin_futures: Vec::new(), - }; - } - }; - let (tx, rx) = queue.split(); + let (tx, rx) = join_channel(); // Build all forwarder futures eagerly; they will be driven by AimDbRunner. let fanin_futures: Vec> = inputs diff --git a/aimdb-core/src/transform/mod.rs b/aimdb-core/src/transform/mod.rs index 39c83b72..a26c28e6 100644 --- a/aimdb-core/src/transform/mod.rs +++ b/aimdb-core/src/transform/mod.rs @@ -37,7 +37,7 @@ pub(crate) struct CollectedTransform { pub fanin_futures: Vec>, } -pub(crate) struct TransformDescriptor +pub(crate) struct TransformDescriptor where T: Send + 'static + Debug + Clone, { @@ -47,16 +47,13 @@ where /// /// Receives: /// - `Producer` — the pre-resolved write handle for the output record. - /// - `Arc>` — used by input forwarders to resolve their input + /// - `Arc` — used by input forwarders to resolve their input /// consumers at startup time. - /// - `Arc` — the runtime adapter (e.g. for `create_join_queue`). /// - `&str` — the output record's key, threaded through so the transform /// task can include it in tracing messages even though `Producer` no /// longer carries a `.key()` accessor (design 029, M14). Important when /// multiple records share type `T` under different keys. #[allow(clippy::type_complexity)] - pub build_fn: Box< - dyn FnOnce(crate::Producer, Arc>, Arc, &str) -> CollectedTransform - + Send, - >, + pub build_fn: + Box, Arc, &str) -> CollectedTransform + Send>, } diff --git a/aimdb-core/src/transform/single.rs b/aimdb-core/src/transform/single.rs index 6b0ec279..0aa5ca1f 100644 --- a/aimdb-core/src/transform/single.rs +++ b/aimdb-core/src/transform/single.rs @@ -16,18 +16,17 @@ use crate::transform::{CollectedTransform, TransformDescriptor}; /// Configures a single-input transform pipeline. /// -/// Created by `RecordRegistrar::transform_raw()`. Use `.map()` for stateless +/// Created by `RecordRegistrar::transform()`. Use `.map()` for stateless /// transforms or `.with_state()` for stateful transforms. -pub struct TransformBuilder { +pub struct TransformBuilder { input_key: String, - _phantom: PhantomData<(I, O, R)>, + _phantom: PhantomData<(I, O)>, } -impl TransformBuilder +impl TransformBuilder where I: Send + Sync + Clone + Debug + 'static, O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, { pub(crate) fn new(input_key: String) -> Self { Self { @@ -37,7 +36,7 @@ where } /// Stateless 1:1 map. Returning `None` skips output for this input value. - pub fn map(self, f: F) -> TransformPipeline + pub fn map(self, f: F) -> TransformPipeline where F: Fn(&I) -> Option + Send + Sync + 'static, { @@ -45,7 +44,7 @@ where input_key: self.input_key, spawn_factory: Box::new(move |input_key| { let transform_fn = move |val: &I, _state: &mut ()| f(val); - create_single_transform_descriptor::(input_key, (), transform_fn) + create_single_transform_descriptor::(input_key, (), transform_fn) }), _phantom_i: PhantomData, } @@ -55,7 +54,7 @@ where pub fn with_state( self, initial: S, - ) -> StatefulTransformBuilder { + ) -> StatefulTransformBuilder { StatefulTransformBuilder { input_key: self.input_key, initial_state: initial, @@ -65,21 +64,20 @@ where } /// Intermediate builder for stateful single-input transforms. -pub struct StatefulTransformBuilder { +pub struct StatefulTransformBuilder { input_key: String, initial_state: S, - _phantom: PhantomData<(I, O, R)>, + _phantom: PhantomData<(I, O)>, } -impl StatefulTransformBuilder +impl StatefulTransformBuilder where I: Send + Sync + Clone + Debug + 'static, O: Send + Sync + Clone + Debug + 'static, S: Send + Sync + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, { /// Called for each input value. Receives mutable state, returns optional output. - pub fn on_value(self, f: F) -> TransformPipeline + pub fn on_value(self, f: F) -> TransformPipeline where F: Fn(&I, &mut S) -> Option + Send + Sync + 'static, { @@ -87,7 +85,7 @@ where TransformPipeline { input_key: self.input_key, spawn_factory: Box::new(move |input_key| { - create_single_transform_descriptor::(input_key, initial, f) + create_single_transform_descriptor::(input_key, initial, f) }), _phantom_i: PhantomData, } @@ -95,56 +93,48 @@ where } /// Completed single-input transform pipeline, ready to be stored in `TypedRecord`. -pub struct TransformPipeline< - I, - O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, -> { +pub struct TransformPipeline { pub(crate) input_key: String, - pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, + pub(crate) spawn_factory: Box TransformDescriptor + Send + Sync>, pub(crate) _phantom_i: PhantomData, } -impl TransformPipeline +impl TransformPipeline where I: Send + Sync + Clone + Debug + 'static, O: Send + Sync + Clone + Debug + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, { - pub(crate) fn into_descriptor(self) -> TransformDescriptor { + pub(crate) fn into_descriptor(self) -> TransformDescriptor { (self.spawn_factory)(self.input_key) } } -fn create_single_transform_descriptor( +fn create_single_transform_descriptor( input_key: String, initial_state: S, transform_fn: impl Fn(&I, &mut S) -> Option + Send + Sync + 'static, -) -> TransformDescriptor +) -> TransformDescriptor where I: Send + Sync + Clone + Debug + 'static, O: Send + Sync + Clone + Debug + 'static, S: Send + Sync + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, { let input_key_clone = input_key.clone(); let input_keys = vec![input_key]; TransformDescriptor { input_keys, - build_fn: Box::new( - move |producer, db, _runtime, output_key| CollectedTransform { - task_future: Box::pin(run_single_transform::( - db, - input_key_clone, - output_key.to_string(), - producer, - initial_state, - transform_fn, - )), - fanin_futures: alloc::vec::Vec::new(), - }, - ), + build_fn: Box::new(move |producer, db, output_key| CollectedTransform { + task_future: Box::pin(run_single_transform::( + db, + input_key_clone, + output_key.to_string(), + producer, + initial_state, + transform_fn, + )), + fanin_futures: alloc::vec::Vec::new(), + }), } } @@ -153,8 +143,8 @@ where // ============================================================================ #[allow(unused_variables)] -pub(crate) async fn run_single_transform( - db: Arc>, +pub(crate) async fn run_single_transform( + db: Arc, input_key: String, output_key: String, producer: crate::Producer, @@ -164,7 +154,6 @@ pub(crate) async fn run_single_transform( I: Send + Sync + Clone + Debug + 'static, O: Send + Sync + Clone + Debug + 'static, S: Send + 'static, - R: aimdb_executor::RuntimeAdapter + 'static, { // `Producer` no longer exposes a `.key()` accessor (design 029) — the // output key is threaded in by the transform descriptor so diagnostics diff --git a/aimdb-core/src/typed_api.rs b/aimdb-core/src/typed_api.rs index ca63e748..f4e2ebcf 100644 --- a/aimdb-core/src/typed_api.rs +++ b/aimdb-core/src/typed_api.rs @@ -9,15 +9,14 @@ //! # Producer Example //! //! ```rust,ignore -//! #[service] -//! async fn temperature_producer( -//! ctx: RuntimeContext, +//! async fn temperature_producer( +//! ctx: RuntimeContext, //! producer: Producer, //! ) { //! loop { //! let temp = read_sensor().await; //! producer.produce(temp); -//! ctx.time().sleep(ctx.time().secs(1)).await; +//! ctx.time().sleep_secs(1).await; //! } //! } //! ``` @@ -25,9 +24,8 @@ //! # Consumer Example //! //! ```rust,ignore -//! #[service] -//! async fn temperature_monitor( -//! ctx: RuntimeContext, +//! async fn temperature_monitor( +//! ctx: RuntimeContext, //! consumer: Consumer, //! ) { //! let mut rx = consumer.subscribe(); @@ -41,9 +39,9 @@ //! //! ```rust,ignore //! builder.configure::("sensors.outdoor", |reg| { -//! reg.buffer(buffer) -//! .source(|producer, ctx| temperature_service(ctx, producer)) -//! .tap(|consumer| temperature_logger(consumer)) +//! reg.buffer(cfg) +//! .source(temperature_producer) +//! .tap(temperature_monitor) //! .link_to("mqtt://sensors/temp") //! .with_serializer_raw(|t| serde_json::to_vec(t)) //! .finish(); @@ -330,15 +328,11 @@ pub enum StageKind { /// Registrar for configuring a typed record /// /// Provides a fluent API for registering producer and consumer functions. -pub struct RecordRegistrar< - 'a, - T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, -> { +pub struct RecordRegistrar<'a, T: Send + Sync + 'static + Debug + Clone> { /// The typed record being configured - pub(crate) rec: &'a mut TypedRecord, + pub(crate) rec: &'a mut TypedRecord, /// Connector builders indexed by scheme - pub(crate) connector_builders: &'a [Box>], + pub(crate) connector_builders: &'a [Box], /// The record key for this record pub(crate) record_key: String, /// Extension storage from the builder — allows external crates (e.g. @@ -350,10 +344,9 @@ pub struct RecordRegistrar< pub(crate) last_stage: Option<(StageKind, usize)>, } -impl<'a, T, R> RecordRegistrar<'a, T, R> +impl<'a, T> RecordRegistrar<'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { /// Returns a reference to the builder's extension storage. /// @@ -384,22 +377,23 @@ where self } - /// Registers a producer service for this record type (low-level API) + /// Registers a producer service for this record type. /// - /// **Note:** This is the foundational API used by runtime adapter implementations. - /// Most users should use the higher-level `source()` method provided by runtime - /// adapter extension traits (e.g., `TokioRecordRegistrarExt::source()`) which - /// automatically extract the typed `RuntimeContext`. + /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) + /// (time + logging capabilities) and a pre-resolved [`Producer`]; it is + /// collected at `build()` time and driven by the `AimDbRunner`. /// - /// This method accepts the raw runtime context as `Arc` and is used by: - /// - Runtime adapter implementations to provide convenient wrappers - /// - Internal connector implementations - /// - Advanced use cases requiring direct control - pub fn source_raw(&mut self, f: F) -> &mut Self + /// ```rust,ignore + /// reg.source(|ctx, producer| async move { + /// loop { + /// producer.produce(read_sensor().await); + /// ctx.time().sleep_secs(1).await; + /// } + /// }); + /// ``` + pub fn source(&mut self, f: F) -> &mut Self where - F: FnOnce(crate::Producer, Arc) -> Fut - + Send - + 'static, + F: FnOnce(crate::RuntimeContext, crate::Producer) -> Fut + Send + 'static, Fut: Future + Send + 'static, { self.rec.set_producer(f); @@ -415,22 +409,23 @@ where self } - /// Register a side-effect observer that taps into the data stream (low-level API) + /// Register a side-effect observer that taps into the data stream. /// - /// **Note:** This is the foundational API used by runtime adapter and connector implementations. - /// Most users should use the higher-level `tap()` method provided by runtime - /// adapter extension traits (e.g., `TokioRecordRegistrarExt::tap()`) which - /// automatically extract the typed `RuntimeContext`. + /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) and a + /// pre-resolved [`Consumer`]; it is collected at `build()` time and + /// driven by the `AimDbRunner`. Multiple taps per record are allowed. /// - /// This method accepts the raw runtime context as `Arc` and is used by: - /// - Runtime adapter implementations to provide convenient wrappers - /// - Internal connector implementations (e.g., `.link_to()` creates consumers via this method) - /// - Advanced use cases requiring direct control - pub fn tap_raw(&mut self, f: F) -> &mut Self + /// ```rust,ignore + /// reg.tap(|ctx, consumer| async move { + /// let mut rx = consumer.subscribe(); + /// while let Ok(value) = rx.recv().await { + /// ctx.log().info("observed value"); + /// } + /// }); + /// ``` + pub fn tap(&mut self, f: F) -> &mut Self where - F: FnOnce(crate::Consumer, Arc) -> Fut - + Send - + 'static, + F: FnOnce(crate::RuntimeContext, crate::Consumer) -> Fut + Send + 'static, Fut: Future + Send + 'static, T: Sync, { @@ -505,12 +500,10 @@ where self } - /// Register a single-input reactive transform (low-level API). + /// Register a single-input reactive transform. /// - /// **Note:** This is the foundational API. Most users should use the higher-level - /// `transform()` method provided by runtime adapter extension traits. - /// - /// Panics if a `.source()` or another `.transform()` is already registered. + /// Conflicts with `.source()` and other `.transform()`s are recorded and + /// reported from `build()`. /// /// # Type Parameters /// * `I` - The input record type to subscribe to @@ -518,19 +511,15 @@ where /// # Arguments /// * `input_key` - The record key to subscribe to as input /// * `build_fn` - Closure that configures the transform pipeline via `TransformBuilder` - pub fn transform_raw( - &mut self, - input_key: impl crate::RecordKey, - build_fn: F, - ) -> &mut Self + pub fn transform(&mut self, input_key: impl crate::RecordKey, build_fn: F) -> &mut Self where I: Send + Sync + Clone + Debug + 'static, F: FnOnce( - crate::transform::TransformBuilder, - ) -> crate::transform::TransformPipeline, + crate::transform::TransformBuilder, + ) -> crate::transform::TransformPipeline, { let input_key_str = input_key.as_str().to_string(); - let builder = crate::transform::TransformBuilder::::new(input_key_str); + let builder = crate::transform::TransformBuilder::::new(input_key_str); let pipeline = build_fn(builder); let descriptor = pipeline.into_descriptor(); self.rec.set_transform(descriptor); @@ -540,15 +529,16 @@ where self } - /// Register a multi-input join transform (low-level API). + /// Multi-input reactive transform (join). /// - /// Panics if a `.source()` or another `.transform()` is already registered. - pub fn transform_join_raw(&mut self, build_fn: F) -> &mut Self + /// Derives this record from multiple input records. Available on every + /// runtime. Conflicts with `.source()` and other `.transform()`s are + /// recorded and reported from `build()`. + pub fn transform_join(&mut self, build_fn: F) -> &mut Self where - R: aimdb_executor::JoinFanInRuntime, - F: FnOnce(crate::transform::JoinBuilder) -> crate::transform::JoinPipeline, + F: FnOnce(crate::transform::JoinBuilder) -> crate::transform::JoinPipeline, { - let builder = crate::transform::JoinBuilder::::new(); + let builder = crate::transform::JoinBuilder::::new(); let pipeline = build_fn(builder); let descriptor = pipeline.into_descriptor(); self.rec.set_transform(descriptor); @@ -556,19 +546,6 @@ where self } - /// Multi-input reactive transform (join). - /// - /// Derives this record from multiple input records. Available on any runtime - /// that implements `JoinFanInRuntime`. Panics if a `.source()` or another - /// `.transform()` is already registered. - pub fn transform_join(&mut self, build_fn: F) -> &mut Self - where - R: aimdb_executor::JoinFanInRuntime, - F: FnOnce(crate::transform::JoinBuilder) -> crate::transform::JoinPipeline, - { - self.transform_join_raw(build_fn) - } - /// Link TO external system (outbound: AimDB → External) /// /// Subscribes to buffer updates and publishes them to an external system. @@ -583,7 +560,7 @@ where /// .finish() /// }); /// ``` - pub fn link_to(&mut self, url: &str) -> OutboundConnectorBuilder<'_, 'a, T, R> { + pub fn link_to(&mut self, url: &str) -> OutboundConnectorBuilder<'_, 'a, T> { OutboundConnectorBuilder { registrar: self, url: url.to_string(), @@ -608,7 +585,7 @@ where /// .finish() /// }); /// ``` - pub fn link_from(&mut self, url: &str) -> InboundConnectorBuilder<'_, 'a, T, R> { + pub fn link_from(&mut self, url: &str) -> InboundConnectorBuilder<'_, 'a, T> { InboundConnectorBuilder { registrar: self, url: url.to_string(), @@ -628,13 +605,8 @@ where /// /// `'r` is the borrow of the registrar taken by `link_to()`; `'a` is the /// registrar's own borrow of the record being configured. -pub struct OutboundConnectorBuilder< - 'r, - 'a, - T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, -> { - registrar: &'r mut RecordRegistrar<'a, T, R>, +pub struct OutboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + Clone> { + registrar: &'r mut RecordRegistrar<'a, T>, url: String, config: Vec<(String, String)>, serializer: Option>, @@ -642,10 +614,9 @@ pub struct OutboundConnectorBuilder< topic_provider: Option, } -impl<'r, 'a, T, R> OutboundConnectorBuilder<'r, 'a, T, R> +impl<'r, 'a, T> OutboundConnectorBuilder<'r, 'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { /// Adds a configuration option to the connector pub fn with_config(mut self, key: &str, value: &str) -> Self { @@ -669,8 +640,9 @@ where /// Sets a context-aware serialization callback /// - /// The closure receives a `RuntimeContext` for platform-independent - /// timestamps and logging, plus the typed value being serialized. + /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for + /// platform-independent timestamps and logging, plus the typed value being + /// serialized. /// /// # Example /// @@ -684,15 +656,13 @@ where /// ``` pub fn with_serializer(mut self, f: F) -> Self where - F: Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> + F: Fn(crate::RuntimeContext, &T) -> Result, crate::connector::SerializeError> + Send + Sync + 'static, - R: aimdb_executor::Runtime + Send + Sync, { let f = Arc::new(f); - self.context_serializer = Some(Arc::new(move |ctx_any, value_any| { - let ctx = crate::RuntimeContext::::extract_from_any(ctx_any); + self.context_serializer = Some(Arc::new(move |ctx, value_any| { if let Some(value) = value_any.downcast_ref::() { (f)(ctx, value) } else { @@ -767,7 +737,7 @@ where /// /// The buffer requirement is validated by `build()` (calling `.buffer()` /// after `.link_to()` is fine). - pub fn finish(self) -> &'r mut RecordRegistrar<'a, T, R> { + pub fn finish(self) -> &'r mut RecordRegistrar<'a, T> { use crate::connector::{ConnectorLink, ConnectorUrl}; use crate::error::ConfigError; @@ -860,34 +830,29 @@ where // failures here are aimdb bugs, not user mistakes. { let record_key = self.registrar.record_key.clone(); - link.consumer_factory = Some(Arc::new( - move |db_any: Arc| { - let db_ref = db_any.downcast_ref::>().expect( - "consumer factory: AimDb downcast failed — this is a bug in aimdb-core", - ); - let typed_rec = db_ref - .inner() - .get_typed_record_by_key::(&record_key) - .unwrap_or_else(|e| { - panic!( - "consumer factory: record '{record_key}' lookup failed ({e:?}) — \ - this is a bug in aimdb-core" - ) - }); - let buffer = typed_rec.buffer_handle().unwrap_or_else(|| { + link.consumer_factory = Some(Arc::new(move |db: &AimDb| { + let typed_rec = db + .inner() + .get_typed_record_by_key::(&record_key) + .unwrap_or_else(|e| { panic!( - "consumer factory: record '{record_key}' has no buffer despite \ - build()-time validation — this is a bug in aimdb-core" + "consumer factory: record '{record_key}' lookup failed ({e:?}) — \ + this is a bug in aimdb-core" ) }); + let buffer = typed_rec.buffer_handle().unwrap_or_else(|| { + panic!( + "consumer factory: record '{record_key}' has no buffer despite \ + build()-time validation — this is a bug in aimdb-core" + ) + }); - #[allow(unused_mut)] - let mut consumer = Consumer::::new(buffer); - #[cfg(feature = "profiling")] - consumer.set_profiling(link_metrics.clone(), db_ref.profiling_clock().clone()); - Box::new(consumer) as Box - }, - )); + #[allow(unused_mut)] + let mut consumer = Consumer::::new(buffer); + #[cfg(feature = "profiling")] + consumer.set_profiling(link_metrics.clone(), db.profiling_clock().clone()); + Box::new(consumer) as Box + })); } // Store the connector link - consumers will be created later in build() @@ -908,13 +873,8 @@ type TypedDeserializerFn = Arc Result + Send + Sy /// /// `'r` is the borrow of the registrar taken by `link_from()`; `'a` is the /// registrar's own borrow of the record being configured. -pub struct InboundConnectorBuilder< - 'r, - 'a, - T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, -> { - registrar: &'r mut RecordRegistrar<'a, T, R>, +pub struct InboundConnectorBuilder<'r, 'a, T: Send + Sync + 'static + Debug + Clone> { + registrar: &'r mut RecordRegistrar<'a, T>, url: String, config: Vec<(String, String)>, deserializer: Option>, @@ -922,10 +882,9 @@ pub struct InboundConnectorBuilder< topic_resolver: Option, } -impl<'r, 'a, T, R> InboundConnectorBuilder<'r, 'a, T, R> +impl<'r, 'a, T> InboundConnectorBuilder<'r, 'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { /// Adds a configuration option to the connector pub fn with_config(mut self, key: &str, value: &str) -> Self { @@ -959,8 +918,9 @@ where /// Sets a context-aware deserialization callback /// - /// The closure receives a `RuntimeContext` for platform-independent - /// timestamps and logging, plus the raw bytes from the external system. + /// The closure receives the [`RuntimeContext`](crate::RuntimeContext) for + /// platform-independent timestamps and logging, plus the raw bytes from + /// the external system. /// /// # Example /// @@ -974,12 +934,10 @@ where /// ``` pub fn with_deserializer(mut self, f: F) -> Self where - F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, - R: aimdb_executor::Runtime + Send + Sync, + F: Fn(crate::RuntimeContext, &[u8]) -> Result + Send + Sync + 'static, { let f = Arc::new(f); - self.context_deserializer = Some(Arc::new(move |ctx_any, bytes| { - let ctx = crate::RuntimeContext::::extract_from_any(ctx_any); + self.context_deserializer = Some(Arc::new(move |ctx, bytes| { (f)(ctx, bytes).map(|val| Box::new(val) as Box) })); self.deserializer = None; // mutually exclusive @@ -1043,7 +1001,7 @@ where /// /// The buffer requirement is validated by `build()` (calling `.buffer()` /// after `.link_from()` is fine). - pub fn finish(self) -> &'r mut RecordRegistrar<'a, T, R> { + pub fn finish(self) -> &'r mut RecordRegistrar<'a, T> { use crate::connector::{ConnectorUrl, DeserializerKind, InboundConnectorLink}; use crate::error::ConfigError; @@ -1133,13 +1091,10 @@ where // validated, so failures here are aimdb bugs, not user mistakes. { let record_key = self.registrar.record_key.clone(); - link = link.with_producer_factory(move |db_any| { - let db = db_any.downcast::>().expect( - "producer factory: AimDb downcast failed — this is a bug in aimdb-core", - ); + link = link.with_producer_factory(move |db: &AimDb| { let typed_rec = db .inner() - .get_typed_record_by_key::(&record_key) + .get_typed_record_by_key::(&record_key) .unwrap_or_else(|e| { panic!( "producer factory: record '{record_key}' lookup failed ({e:?}) — \ @@ -1165,14 +1120,12 @@ where /// /// Records implementing this trait register their producer and consumer /// functions, encapsulating behavior with their type. -pub trait RecordT: - Send + Sync + 'static + Debug + Clone -{ +pub trait RecordT: Send + Sync + 'static + Debug + Clone { /// Configuration type for this record type Config; /// Registers producer and consumer functions - fn register(reg: &mut RecordRegistrar<'_, Self, R>, cfg: &Self::Config); + fn register(reg: &mut RecordRegistrar<'_, Self>, cfg: &Self::Config); } #[cfg(test)] @@ -1216,47 +1169,9 @@ mod tests { // Test infrastructure for InboundConnectorBuilder deserializer tests // ==================================================================== - /// Minimal mock runtime for context tests - struct MockRuntime; - - impl aimdb_executor::RuntimeAdapter for MockRuntime { - fn runtime_name() -> &'static str { - "mock" - } - } - - impl aimdb_executor::TimeOps for MockRuntime { - type Instant = u64; - type Duration = u64; - fn now(&self) -> u64 { - 0 - } - fn duration_since(&self, _later: u64, _earlier: u64) -> Option { - Some(0) - } - fn millis(&self, ms: u64) -> u64 { - ms - } - fn secs(&self, secs: u64) -> u64 { - secs * 1000 - } - fn micros(&self, micros: u64) -> u64 { - micros - } - fn sleep(&self, _duration: u64) -> impl Future + Send { - core::future::ready(()) - } - fn duration_as_nanos(&self, duration: u64) -> u64 { - duration - } - } - - impl aimdb_executor::Logger for MockRuntime { - fn info(&self, _message: &str) {} - fn debug(&self, _message: &str) {} - fn warn(&self, _message: &str) {} - fn error(&self, _message: &str) {} - } + /// Minimal mock runtime for context tests — the builder only needs the + /// dyn-safe `RuntimeOps` surface, supplied by the shared test stub. + use aimdb_executor::test_support::NoopRuntimeOps as MockRuntime; /// Minimal mock buffer so `has_buffer()` returns true struct MockBuffer; @@ -1276,10 +1191,10 @@ mod tests { scheme: String, } - impl crate::connector::ConnectorBuilder for MockConnectorBuilder { + impl crate::connector::ConnectorBuilder for MockConnectorBuilder { fn build<'a>( &'a self, - _db: &'a crate::AimDb, + _db: &'a crate::AimDb, ) -> Pin< Box< dyn Future< @@ -1298,10 +1213,10 @@ mod tests { /// Helper: build a RecordRegistrar wired to a TypedRecord with a buffer and a /// mock connector builder for the given scheme. fn make_registrar<'a>( - rec: &'a mut crate::typed_record::TypedRecord, - builders: &'a [Box>], + rec: &'a mut crate::typed_record::TypedRecord, + builders: &'a [Box], extensions: &'a crate::extensions::Extensions, - ) -> RecordRegistrar<'a, TestRecord, MockRuntime> { + ) -> RecordRegistrar<'a, TestRecord> { RecordRegistrar { rec, connector_builders: builders, @@ -1319,10 +1234,10 @@ mod tests { fn inbound_finish_stores_raw_deserializer_kind() { use crate::connector::DeserializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1361,10 +1276,10 @@ mod tests { fn inbound_finish_stores_context_deserializer_kind() { use crate::connector::DeserializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1373,7 +1288,7 @@ mod tests { let mut reg = make_registrar(&mut rec, &builders, &extensions); reg.link_from("mqtt://broker/topic") - .with_deserializer(|_ctx: crate::RuntimeContext, bytes: &[u8]| { + .with_deserializer(|_ctx: crate::RuntimeContext, bytes: &[u8]| { Ok(TestRecord { value: bytes.len() as i32 * 10, }) @@ -1395,10 +1310,10 @@ mod tests { fn inbound_raw_overrides_previous_context_deserializer() { use crate::connector::DeserializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1408,7 +1323,7 @@ mod tests { // Set context first, then override with raw — raw should win reg.link_from("mqtt://broker/topic") - .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { Ok(TestRecord { value: 0 }) }) .with_deserializer_raw(|bytes: &[u8]| { @@ -1428,10 +1343,10 @@ mod tests { fn inbound_context_overrides_previous_raw_deserializer() { use crate::connector::DeserializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1442,7 +1357,7 @@ mod tests { // Set raw first, then override with context — context should win reg.link_from("mqtt://broker/topic") .with_deserializer_raw(|_bytes: &[u8]| Ok(TestRecord { value: 0 })) - .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { + .with_deserializer(|_ctx: crate::RuntimeContext, _bytes: &[u8]| { Ok(TestRecord { value: 99 }) }) .finish(); @@ -1455,7 +1370,7 @@ mod tests { /// Drains the configuration errors a registrar/setter recorded on `rec`. fn drain_errors( - rec: &mut crate::typed_record::TypedRecord, + rec: &mut crate::typed_record::TypedRecord, ) -> Vec { use crate::typed_record::AnyRecord; rec.drain_config_errors() @@ -1463,10 +1378,10 @@ mod tests { #[test] fn inbound_finish_without_deserializer_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1495,10 +1410,10 @@ mod tests { fn outbound_finish_stores_raw_serializer_kind() { use crate::connector::SerializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1532,10 +1447,10 @@ mod tests { fn outbound_finish_stores_context_serializer_kind() { use crate::connector::SerializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1544,11 +1459,9 @@ mod tests { let mut reg = make_registrar(&mut rec, &builders, &extensions); reg.link_to("mqtt://broker/topic") - .with_serializer( - |_ctx: crate::RuntimeContext, record: &TestRecord| { - Ok(record.value.to_le_bytes().to_vec()) - }, - ) + .with_serializer(|_ctx: crate::RuntimeContext, record: &TestRecord| { + Ok(record.value.to_le_bytes().to_vec()) + }) .finish(); assert_eq!(rec.outbound_connectors().len(), 1); @@ -1567,10 +1480,10 @@ mod tests { fn outbound_raw_overrides_previous_context_serializer() { use crate::connector::SerializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1580,9 +1493,7 @@ mod tests { // Set context first, then override with raw — raw should win reg.link_to("mqtt://broker/topic") - .with_serializer( - |_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![0]), - ) + .with_serializer(|_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![0])) .with_serializer_raw(|record: &TestRecord| Ok(record.value.to_le_bytes().to_vec())) .finish(); @@ -1597,10 +1508,10 @@ mod tests { fn outbound_context_overrides_previous_raw_serializer() { use crate::connector::SerializerKind; - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1611,9 +1522,7 @@ mod tests { // Set raw first, then override with context — context should win reg.link_to("mqtt://broker/topic") .with_serializer_raw(|_record: &TestRecord| Ok(vec![0])) - .with_serializer( - |_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![99]), - ) + .with_serializer(|_ctx: crate::RuntimeContext, _record: &TestRecord| Ok(vec![99])) .finish(); let ser = rec.outbound_connectors()[0] @@ -1625,10 +1534,10 @@ mod tests { #[test] fn outbound_finish_without_serializer_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1651,11 +1560,11 @@ mod tests { #[test] fn finish_with_unregistered_scheme_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); // No connector builders registered at all - let builders: Vec>> = vec![]; + let builders: Vec> = vec![]; let extensions = crate::extensions::Extensions::new(); let mut reg = make_registrar(&mut rec, &builders, &extensions); @@ -1677,12 +1586,11 @@ mod tests { // ==================================================================== /// Helper: build a `TransformDescriptor` with a no-op spawn function. - fn dummy_transform_descriptor() -> crate::transform::TransformDescriptor - { - crate::transform::TransformDescriptor:: { + fn dummy_transform_descriptor() -> crate::transform::TransformDescriptor { + crate::transform::TransformDescriptor:: { input_keys: vec![], build_fn: Box::new( - |_p, _db, _ctx, _output_key| crate::transform::CollectedTransform { + |_p, _db, _output_key| crate::transform::CollectedTransform { task_future: Box::pin(async {}), fanin_futures: vec![], }, @@ -1692,11 +1600,11 @@ mod tests { #[test] fn link_from_after_source_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - rec.set_producer(|_p, _ctx| async move {}); + rec.set_producer(|_ctx, _p| async move {}); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1718,11 +1626,11 @@ mod tests { #[test] fn link_from_after_transform_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); rec.set_transform(dummy_transform_descriptor()); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1744,10 +1652,10 @@ mod tests { #[test] fn source_after_link_from_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1759,7 +1667,7 @@ mod tests { .finish(); } - rec.set_producer(|_p, _ctx| async move {}); + rec.set_producer(|_ctx, _p| async move {}); assert!(!rec.has_producer(), "conflicting producer must be skipped"); let errors = drain_errors(&mut rec); @@ -1771,10 +1679,10 @@ mod tests { #[test] fn transform_after_link_from_records_error() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1801,10 +1709,10 @@ mod tests { #[test] fn multiple_link_from_allowed() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = + let builders: Vec> = vec![Box::new(MockConnectorBuilder { scheme: "mqtt".to_string(), })]; @@ -1833,15 +1741,15 @@ mod tests { /// statements in a configure-style closure must compile. #[test] fn registrar_allows_separate_statements() { - let mut rec = crate::typed_record::TypedRecord::::new(); + let mut rec = crate::typed_record::TypedRecord::::new(); rec.set_buffer(Box::new(MockBuffer)); - let builders: Vec>> = vec![]; + let builders: Vec> = vec![]; let extensions = crate::extensions::Extensions::new(); let mut reg = make_registrar(&mut rec, &builders, &extensions); - reg.source_raw(|_p, _ctx| async move {}); - reg.tap_raw(|_c, _ctx| async move {}); + reg.source(|_ctx, _p| async move {}); + reg.tap(|_ctx, _c| async move {}); assert!(rec.has_producer()); assert_eq!(rec.consumer_count(), 1); @@ -1858,10 +1766,10 @@ mod tests { /// that must get through the connector phase. struct NoopConnectorBuilder; - impl crate::connector::ConnectorBuilder for NoopConnectorBuilder { + impl crate::connector::ConnectorBuilder for NoopConnectorBuilder { fn build<'a>( &'a self, - _db: &'a crate::AimDb, + _db: &'a crate::AimDb, ) -> Pin< Box< dyn Future< @@ -1889,8 +1797,8 @@ mod tests { }); // Mistake 2: two .source() registrations on one record builder.configure::("rec.b", |reg| { - reg.source_raw(|_p, _ctx| async move {}); - reg.source_raw(|_p, _ctx| async move {}); + reg.source(|_ctx, _p| async move {}); + reg.source(|_ctx, _p| async move {}); }); // Mistake 3: key re-registered with a different type builder.configure::("rec.c", |_reg| {}); diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index ad80d940..9ed98e1b 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -170,21 +170,19 @@ pub(crate) type BoxFuture<'a, T> = core::pin::Pin + Send + 'a>>; /// Type alias for consumer service closure stored in TypedRecord -/// Each consumer receives a Consumer handle for subscribing to the buffer -/// and a runtime context (Arc) for accessing runtime capabilities -type ConsumerServiceFn = Box< - dyn FnOnce(crate::Consumer, Arc) -> BoxFuture<'static, ()> + Send, ->; +/// Each consumer receives the [`RuntimeContext`](crate::RuntimeContext) and a +/// Consumer handle for subscribing to the buffer +type ConsumerServiceFn = + Box) -> BoxFuture<'static, ()> + Send>; /// Type alias for producer service closure stored in TypedRecord -/// Takes (Producer, RuntimeContext) and returns a Future +/// Takes (RuntimeContext, Producer) and returns a Future /// This will be auto-spawned during build(). /// `Send` only — `Sync` is not required because the closure is taken out of /// `Mutex>` exactly once via `.take()`, and `Mutex: Sync` /// already holds when `T: Send`. Matches the consumer-side `ConsumerServiceFn`. -type ProducerServiceFn = Box< - dyn FnOnce(crate::Producer, Arc) -> BoxFuture<'static, ()> + Send, ->; +type ProducerServiceFn = + Box) -> BoxFuture<'static, ()> + Send>; /// Type-erased trait for records /// @@ -372,44 +370,28 @@ pub trait AnyRecordExt { /// /// # Type Parameters /// * `T` - The expected record type - /// * `R` - The runtime type /// /// # Returns - /// `Some(&TypedRecord)` if types match, `None` otherwise - fn as_typed( - &self, - ) -> Option<&TypedRecord>; + /// `Some(&TypedRecord)` if types match, `None` otherwise + fn as_typed(&self) -> Option<&TypedRecord>; /// Attempts to downcast to a mutable typed record reference /// /// # Type Parameters /// * `T` - The expected record type - /// * `R` - The runtime type /// /// # Returns - /// `Some(&mut TypedRecord)` if types match, `None` otherwise - fn as_typed_mut< - T: Send + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, - >( - &mut self, - ) -> Option<&mut TypedRecord>; + /// `Some(&mut TypedRecord)` if types match, `None` otherwise + fn as_typed_mut(&mut self) -> Option<&mut TypedRecord>; } impl AnyRecordExt for Box { - fn as_typed( - &self, - ) -> Option<&TypedRecord> { - self.as_any().downcast_ref::>() + fn as_typed(&self) -> Option<&TypedRecord> { + self.as_any().downcast_ref::>() } - fn as_typed_mut< - T: Send + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, - >( - &mut self, - ) -> Option<&mut TypedRecord> { - self.as_any_mut().downcast_mut::>() + fn as_typed_mut(&mut self) -> Option<&mut TypedRecord> { + self.as_any_mut().downcast_mut::>() } } @@ -428,24 +410,20 @@ where { /// Collects all futures (producer, transform, consumers) for a record. /// - /// Downcasts the type-erased `AnyRecord` to `TypedRecord` then returns + /// Downcasts the type-erased `AnyRecord` to `TypedRecord` then returns /// every future the record contributes to the runner. Source/transform are /// mutually exclusive — at most one will appear. - pub fn collect_all_futures( + pub fn collect_all_futures( record: &dyn AnyRecord, - runtime: &Arc, - db: &Arc>, + db: &Arc, record_key: &str, - ) -> crate::DbResult>> - where - R: aimdb_executor::RuntimeAdapter + 'static, - { + ) -> crate::DbResult>> { use crate::DbError; - // Downcast to TypedRecord - let typed_record: &TypedRecord = record + // Downcast to TypedRecord + let typed_record: &TypedRecord = record .as_any() - .downcast_ref::>() + .downcast_ref::>() .ok_or_else(|| DbError::RecordNotFound { record_name: core::any::type_name::().to_string(), })?; @@ -453,17 +431,17 @@ where let mut futures = Vec::new(); if typed_record.has_producer() { - if let Some(f) = typed_record.collect_producer_future(runtime, db, record_key)? { + if let Some(f) = typed_record.collect_producer_future(db, record_key)? { futures.push(f); } } if typed_record.has_transform() { - futures.extend(typed_record.collect_transform_futures(runtime, db, record_key)?); + futures.extend(typed_record.collect_transform_futures(db, record_key)?); } if typed_record.consumer_count() > 0 { - futures.extend(typed_record.collect_consumer_futures(runtime, db, record_key)?); + futures.extend(typed_record.collect_consumer_futures(db, record_key)?); } Ok(futures) @@ -473,10 +451,7 @@ where /// Typed record storage with producer/consumer functions /// /// Stores type-safe producer and consumer functions with optional buffering for async dispatch. -pub struct TypedRecord< - T: Send + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, -> { +pub struct TypedRecord { /// Optional producer service - a task that generates data /// This will be auto-spawned during build() if present /// Stored as `FnOnce` that takes (`Producer`, `RuntimeContext`) and returns a `Future` @@ -491,7 +466,7 @@ pub struct TypedRecord< /// Transform descriptor — mutually exclusive with producer. /// If set, this record is a reactive derivation from one or more input records. /// Uses the same Mutex pattern for take()-during-spawn. - transform: Mutex>>, + transform: Mutex>>, /// Optional buffer for async dispatch /// When present, produce() enqueues to buffer instead of direct call @@ -541,9 +516,7 @@ pub struct TypedRecord< config_errors: Vec, } -impl - TypedRecord -{ +impl TypedRecord { /// Creates a new empty typed record /// /// Call `.with_remote_access()` to enable JSON (std only). @@ -598,7 +571,7 @@ impl(&mut self, f: F) where - F: FnOnce(crate::Producer, Arc) -> Fut + Send + 'static, + F: FnOnce(crate::RuntimeContext, crate::Producer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { // Check for existing transform (mutual exclusion) @@ -632,9 +605,9 @@ impl, - ctx: Arc| - -> BoxFuture<'static, ()> { Box::pin(f(producer, ctx)) }, + move |ctx: crate::RuntimeContext, + producer: crate::Producer| + -> BoxFuture<'static, ()> { Box::pin(f(ctx, producer)) }, ); // Store it in the mutex @@ -648,12 +621,12 @@ impl`, runtime context, and returns a Future + /// * `f` - A function that takes the `RuntimeContext` and a `Consumer`, and returns a Future /// /// # Example /// /// ```rust,ignore - /// record.add_consumer(|consumer, ctx_any| async move { + /// record.add_consumer(|ctx, consumer| async move { /// let mut rx = consumer.subscribe(); /// while let Ok(value) = rx.recv().await { /// println!("Consumer: {:?}", value); @@ -662,14 +635,14 @@ impl(&mut self, f: F) where - F: FnOnce(crate::Consumer, Arc) -> Fut + Send + 'static, + F: FnOnce(crate::RuntimeContext, crate::Consumer) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { // Box the future to make it trait object compatible let boxed = Box::new( - move |consumer: crate::Consumer, - ctx_any: Arc| - -> BoxFuture<'static, ()> { Box::pin(f(consumer, ctx_any)) }, + move |ctx: crate::RuntimeContext, + consumer: crate::Consumer| + -> BoxFuture<'static, ()> { Box::pin(f(ctx, consumer)) }, ); lock(&self.consumers).push(boxed); @@ -682,10 +655,7 @@ impl, - ) { + pub(crate) fn set_transform(&mut self, descriptor: crate::transform::TransformDescriptor) { // Enforce mutual exclusion with .source() if lock(&self.producer).is_some() { self.push_config_error(crate::error::ConfigError::new( @@ -773,12 +743,10 @@ impl, - db: &Arc>, + db: &Arc, record_key: &str, ) -> crate::DbResult>> where - R: aimdb_executor::RuntimeAdapter, T: Sync, { // Take the transform descriptor (can only collect once) @@ -794,7 +762,7 @@ impl bound to a pre-resolved write handle for this record. let producer = crate::typed_api::Producer::new(self.writer_handle()); - let collected = (desc.build_fn)(producer, db.clone(), runtime.clone(), record_key); + let collected = (desc.build_fn)(producer, db.clone(), record_key); let mut out = Vec::with_capacity(1 + collected.fanin_futures.len()); out.push(collected.task_future); @@ -986,19 +954,13 @@ impl, - db: &Arc>, + db: &Arc, record_key: &str, ) -> crate::DbResult>> where - R: aimdb_executor::RuntimeAdapter, T: Sync, { - // `db` carries the profiling clock; `record_key` shows up in the no-buffer - // error message (std only). Both are unused when their feature is off — - // silence them narrowly so an unused `runtime` would still warn. - #[cfg(not(feature = "profiling"))] - let _ = db; + // `record_key` shows up in the no-buffer error message (std only). #[cfg(not(feature = "std"))] let _ = record_key; @@ -1012,7 +974,7 @@ impl = runtime.clone(); - - futures.push(consumer_fn(consumer, runtime_any)); + futures.push(consumer_fn(db.runtime_ctx(), consumer)); } Ok(futures) @@ -1051,20 +1010,12 @@ impl, - db: &Arc>, + db: &Arc, record_key: &str, ) -> crate::DbResult>> where - R: aimdb_executor::RuntimeAdapter, T: Sync, { - // `db` is only consulted under `profiling` for the clock; silence the - // unused-variable warning narrowly so an unused `runtime` would still warn. - // `record_key` is silenced inside the `if let Some(...)` branch below. - #[cfg(not(feature = "profiling"))] - let _ = db; - // Take the producer service (can only collect once) let service = lock(&self.producer).take(); @@ -1086,9 +1037,7 @@ impl = runtime.clone(); - - Ok(Some(service_fn(producer, ctx))) + Ok(Some(service_fn(db.runtime_ctx(), producer))) } else { Ok(None) } @@ -1160,9 +1109,7 @@ impl Default - for TypedRecord -{ +impl Default for TypedRecord { fn default() -> Self { Self::new() } @@ -1172,9 +1119,7 @@ impl - AnyRecord for TypedRecord -{ +impl AnyRecord for TypedRecord { fn validate(&self) -> Result<(), &'static str> { // Producer service is optional - some records are driven by external events // Consumer is also optional - records can be accessed via: diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs index 50829042..f323e547 100644 --- a/aimdb-core/tests/session_engine.rs +++ b/aimdb-core/tests/session_engine.rs @@ -23,45 +23,11 @@ use aimdb_core::session::{ SessionConfig, SessionCtx, SessionLimits, TransportError, TransportResult, }; -/// Minimal [`TimeOps`](aimdb_executor::TimeOps) clock for the engine tests -/// (aimdb-core can't depend on a runtime adapter — that would be a cycle). -/// Backs the reconnect/keepalive seam with `tokio::time`; these tests run with -/// `reconnect: false` and no keepalive, so it is never actually awaited. -#[derive(Clone, Copy)] -struct TestClock; - -impl aimdb_executor::RuntimeAdapter for TestClock { - fn runtime_name() -> &'static str { - "test-clock" - } -} - -impl aimdb_executor::TimeOps for TestClock { - type Instant = std::time::Instant; - type Duration = Duration; - - fn now(&self) -> Self::Instant { - std::time::Instant::now() - } - fn duration_since(&self, later: Self::Instant, earlier: Self::Instant) -> Option { - later.checked_duration_since(earlier) - } - fn millis(&self, ms: u64) -> Duration { - Duration::from_millis(ms) - } - fn secs(&self, secs: u64) -> Duration { - Duration::from_secs(secs) - } - fn micros(&self, micros: u64) -> Duration { - Duration::from_micros(micros) - } - fn sleep(&self, duration: Duration) -> impl core::future::Future + Send { - tokio::time::sleep(duration) - } - fn duration_as_nanos(&self, duration: Duration) -> u64 { - duration.as_nanos().min(u64::MAX as u128) as u64 - } -} +/// Engine-test clock (aimdb-core can't depend on a runtime adapter — that +/// would be a cycle). These tests run with `reconnect: false` and no +/// keepalive, so the clock seam is never awaited and the shared no-op stub +/// suffices. +use aimdb_executor::test_support::NoopRuntimeOps as TestClock; // =========================================================================== // Channel-backed transport (Layer 1) diff --git a/aimdb-data-contracts/src/observable.rs b/aimdb-data-contracts/src/observable.rs index e3c436b2..10743f27 100644 --- a/aimdb-data-contracts/src/observable.rs +++ b/aimdb-data-contracts/src/observable.rs @@ -28,13 +28,12 @@ use crate::Observable; /// }); /// ``` #[cfg(feature = "observable")] -pub async fn log_tap( - ctx: aimdb_core::RuntimeContext, +pub async fn log_tap( + ctx: aimdb_core::RuntimeContext, consumer: aimdb_core::typed_api::Consumer, node_id: &'static str, ) where T: Observable + Send + Sync + Clone + core::fmt::Debug + 'static, - R: aimdb_executor::Runtime + aimdb_executor::Logger + Send + Sync + 'static, { let log = ctx.log(); diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index 0ad85ac3..f41bb3ab 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 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). + ### Added - **`RuntimeOps` implemented for `EmbassyAdapter` (Issue #130, design 034 Phase 2).** The dyn-safe capability surface from `aimdb-executor`, gated on `embassy-time` like `TimeOps`: `now_nanos()` is boot-anchored uptime at microsecond granularity (the portable lower bound), `sleep` boxes `embassy_time::Timer::after`, `unix_time` rides the `set_unix_time` anchor, `log` forwards to the defmt-backed `Logger`. Covered by the shared contract test on the host (the test time driver now wakes immediately on `schedule_wake`, so already-expired timers complete; non-zero sleeps remain unusable on the pinned-at-0 host clock). diff --git a/aimdb-embassy-adapter/README.md b/aimdb-embassy-adapter/README.md index 0a27db45..cf8ae075 100644 --- a/aimdb-embassy-adapter/README.md +++ b/aimdb-embassy-adapter/README.md @@ -191,7 +191,7 @@ Embassy time integration: use aimdb_embassy_adapter::EmbassyAdapter; use embassy_time::Duration; -let adapter = EmbassyAdapter::new().unwrap(); +let adapter = EmbassyAdapter::new(); // Get current instant let now = adapter.now(); diff --git a/aimdb-embassy-adapter/src/connectors.rs b/aimdb-embassy-adapter/src/connectors.rs index 9f5f52eb..1faa37ea 100644 --- a/aimdb-embassy-adapter/src/connectors.rs +++ b/aimdb-embassy-adapter/src/connectors.rs @@ -41,8 +41,7 @@ use aimdb_core::session::{ EnvelopeCodec, Listener, Payload, SessionConfig, Source, TransportError, TransportResult, }; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; -use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; -use aimdb_executor::TimeOps; +use aimdb_core::{AimDb, DbError, DbResult}; use crate::SendFutureWrapper; @@ -141,6 +140,49 @@ where Box::pin(SendFutureWrapper(fut)) } +/// Force-`Send + Sync` handle to the Embassy network stack. +/// +/// `embassy_net::Stack` is `!Sync` (internal `RefCell`), so a +/// [`ConnectorBuilder`] (which must be `Send + Sync`) cannot hold the bare +/// `&'static Stack`. Network connectors (MQTT, KNX) take the stack at +/// construction and wrap it here — keeping the single-core `unsafe` in this +/// audited module instead of in every connector crate. Replaces the deleted +/// `EmbassyNetwork` runtime trait (issue #131: the runtime travels as +/// `Arc`, which cannot surface adapter-specific capabilities). +#[cfg(feature = "embassy-net-support")] +#[derive(Clone, Copy)] +pub struct NetStack(&'static embassy_net::Stack<'static>); + +// SAFETY: single-core cooperative Embassy executor — see the module-level invariant. +#[cfg(feature = "embassy-net-support")] +unsafe impl Send for NetStack {} +// SAFETY: same invariant; the stack's `RefCell` is never borrowed from another thread. +#[cfg(feature = "embassy-net-support")] +unsafe impl Sync for NetStack {} + +#[cfg(feature = "embassy-net-support")] +impl NetStack { + /// Wrap the device's network stack for storage inside a connector builder. + /// + /// # Safety + /// + /// `embassy_net::Stack` is `!Sync` (internal `RefCell`), and `NetStack` + /// force-implements `Send + Sync` on top of it. The caller must uphold the + /// module-level invariant: every future that touches this stack — + /// including the connector protocol task the builder spawns — is polled on + /// the same single-core cooperative executor. Constructing one on a + /// multicore / multi-executor setup (a second core's executor or an + /// interrupt executor also driving network futures) is undefined behavior. + pub unsafe fn new(stack: &'static embassy_net::Stack<'static>) -> Self { + Self(stack) + } + + /// The wrapped stack reference. + pub fn get(&self) -> &'static embassy_net::Stack<'static> { + self.0 + } +} + // =========================================================================== // Session spine — the Embassy duals of `SessionClientConnector` / `…Server`. // =========================================================================== @@ -283,20 +325,19 @@ impl EmbassySessionClient { } } -impl ConnectorBuilder for EmbassySessionClient +impl ConnectorBuilder for EmbassySessionClient where - R: TimeOps + 'static, D: Dialer + 'static, C: EnvelopeCodec + Clone + 'static, { - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { Box::pin(SendFutureWrapper(async move { let dialer = self.dialer.take_required()?; let (handle, engine) = run_client( dialer, self.codec.clone(), self.config.clone(), - db.runtime_arc(), + db.runtime_ops(), ); // One pump future per route; each holds a `ClientHandle` clone, so the // engine stays alive as long as any mirror runs. @@ -344,14 +385,13 @@ impl EmbassySessionServer { } } -impl ConnectorBuilder for EmbassySessionServer +impl ConnectorBuilder for EmbassySessionServer where - R: RuntimeAdapter + 'static, L: Listener + 'static, C: EnvelopeCodec + Clone + 'static, - DF: Fn(&AimDb) -> Arc + Send + Sync, + DF: Fn(&AimDb) -> Arc + Send + Sync, { - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { Box::pin(SendFutureWrapper(async move { let listener = self.listener.take_required()?; let dispatch = (self.dispatch_factory)(db); diff --git a/aimdb-embassy-adapter/src/join_queue.rs b/aimdb-embassy-adapter/src/join_queue.rs deleted file mode 100644 index 75d17882..00000000 --- a/aimdb-embassy-adapter/src/join_queue.rs +++ /dev/null @@ -1,176 +0,0 @@ -extern crate alloc; - -use aimdb_executor::{ExecutorResult, JoinQueue, JoinReceiver, JoinSender}; -use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; -use embassy_sync::channel::Channel; - -#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] -use crate::runtime::EmbassyAdapter; -#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] -use aimdb_executor::JoinFanInRuntime; - -/// Internal queue capacity for Embassy join fan-in. -/// -/// Uses a compile-time constant because `embassy_sync::channel::Channel` requires -/// `N` as a const generic. Cannot be overridden per-call — this is the fixed -/// default for all Embassy join queues. -const CAPACITY: usize = 8; - -type EmbassyChan = Channel; - -// ============================================================================ -// EmbassyJoinQueue -// ============================================================================ - -pub struct EmbassyJoinQueue { - channel: &'static EmbassyChan, -} - -/// Sender half — just a `&'static Channel` reference, cheap to clone. -pub struct EmbassyJoinSender { - channel: &'static EmbassyChan, -} - -/// Receiver half — also a `&'static Channel` reference. -pub struct EmbassyJoinReceiver { - channel: &'static EmbassyChan, -} - -impl Clone for EmbassyJoinSender { - fn clone(&self) -> Self { - Self { - channel: self.channel, - } - } -} - -impl JoinQueue for EmbassyJoinQueue { - type Sender = EmbassyJoinSender; - type Receiver = EmbassyJoinReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - ( - EmbassyJoinSender { - channel: self.channel, - }, - EmbassyJoinReceiver { - channel: self.channel, - }, - ) - } -} - -impl JoinSender for EmbassyJoinSender { - async fn send(&self, item: T) -> ExecutorResult<()> { - // Blocks when full (bounded backpressure). Embassy channels do not close, - // so this never returns Err in normal operation. - self.channel.send(item).await; - Ok(()) - } -} - -impl JoinReceiver for EmbassyJoinReceiver { - async fn recv(&mut self) -> ExecutorResult { - // Embassy channels do not close — this blocks until a message arrives. - // On embedded targets the join loop runs for the device lifetime. - Ok(self.channel.receive().await) - } -} - -// ============================================================================ -// JoinFanInRuntime for EmbassyAdapter -// ============================================================================ - -#[cfg(all(feature = "embassy-runtime", feature = "alloc"))] -impl JoinFanInRuntime for EmbassyAdapter { - type JoinQueue = EmbassyJoinQueue; - - fn create_join_queue(&self) -> ExecutorResult> { - // Leak the channel to obtain a 'static reference. - // Called once per join transform at database startup — the leak is intentional - // and matches the DB lifetime on embedded targets. - let channel: &'static EmbassyChan = - alloc::boxed::Box::leak(alloc::boxed::Box::new(Channel::new())); - Ok(EmbassyJoinQueue { channel }) - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -// These tests cover: roundtrip ordering, bounded backpressure, and sender cloning. -// Embassy channels do not close — there are no QueueClosed scenarios to test. -// -// They run on the host: the queue types depend only on `embassy_sync::Channel` -// and `futures::executor::block_on` (the runtime-specific `JoinFanInRuntime` -// impl above carries its own `embassy-runtime` gate), so this module is gated on -// `embassy-sync` rather than `embassy-runtime` and never pulls embassy-executor's -// cortex-m assembly into the host test build. `make test` exercises them via: -// cargo test -p aimdb-embassy-adapter \ -// --no-default-features --features "alloc,embassy-sync,embassy-time" -// (the `critical-section/std` dev-dep satisfies the `CriticalSectionRawMutex` -// link target; defmt/time-driver stubs live in `buffer.rs`'s test module). -#[cfg(test)] -mod tests { - use super::*; - use futures::executor::block_on; - - fn make_channel() -> &'static EmbassyChan { - extern crate alloc; - alloc::boxed::Box::leak(alloc::boxed::Box::new(Channel::new())) - } - - fn make_queue() -> (EmbassyJoinSender, EmbassyJoinReceiver) { - EmbassyJoinQueue { - channel: make_channel(), - } - .split() - } - - #[test] - fn roundtrip_send_recv() { - let (tx, mut rx) = make_queue(); - block_on(async { - tx.send(1).await.unwrap(); - tx.send(2).await.unwrap(); - tx.send(3).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 1); - assert_eq!(rx.recv().await.unwrap(), 2); - assert_eq!(rx.recv().await.unwrap(), 3); - }); - } - - #[test] - fn bounded_capacity_8() { - // Fill to CAPACITY without consuming, then assert an extra send is Pending. - let channel: &'static EmbassyChan = make_channel(); - block_on(async { - for i in 0..CAPACITY as u32 { - channel.send(i).await; - } - }); - // One more send should not resolve immediately (channel is full) - let mut polled = false; - let send_fut = channel.send(99u32); - futures::pin_mut!(send_fut); - let waker = futures::task::noop_waker(); - let mut cx = core::task::Context::from_waker(&waker); - if core::future::Future::poll(send_fut.as_mut(), &mut cx) == core::task::Poll::Pending { - polled = true; - } - assert!(polled, "send should be Pending when channel is at capacity"); - } - - #[test] - fn clone_sender_routes_to_same_receiver() { - let (tx, mut rx) = make_queue(); - let tx2 = tx.clone(); - block_on(async { - tx.send(10).await.unwrap(); - tx2.send(20).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 10); - assert_eq!(rx.recv().await.unwrap(), 20); - }); - } -} diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 80749402..57c996f7 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -62,9 +62,6 @@ extern crate alloc; #[cfg(all(not(feature = "std"), feature = "embassy-sync"))] pub mod buffer; -#[cfg(all(not(feature = "std"), feature = "embassy-sync"))] -pub mod join_queue; - #[cfg(not(feature = "std"))] mod error; @@ -94,10 +91,6 @@ pub use send_wrapper::SendFutureWrapper; #[cfg(not(feature = "std"))] pub use runtime::EmbassyAdapter; -// Network trait export -#[cfg(all(not(feature = "std"), feature = "embassy-net-support"))] -pub use runtime::EmbassyNetwork; - // Buffer implementation exports #[cfg(all(not(feature = "std"), feature = "embassy-sync"))] pub use buffer::EmbassyBuffer; @@ -162,14 +155,36 @@ pub async fn yield_now() { } } -// Generate extension trait for Embassy adapter using the macro +/// Buffer-construction extension for [`aimdb_core::RecordRegistrar`]. +/// +/// Buffer construction is the one genuinely adapter-specific registration +/// step left after issue #131 — `source()` / `tap()` / `transform()` are +/// inherent methods on the registrar. This trait adds `.buffer(cfg)` backed +/// by [`EmbassyBuffer`] with conservative default const generics +/// (`CAP=16`, `SUBS=4`, `PUBS=4`, `WATCH_N=1`); use +/// [`EmbassyRecordRegistrarExtCustom::buffer_sized`] for precise sizing. #[cfg(all(not(feature = "std"), feature = "embassy-sync"))] -aimdb_core::impl_record_registrar_ext! { - EmbassyRecordRegistrarExt, - EmbassyAdapter, - EmbassyBuffer, - ["embassy-runtime", "embassy-sync"], - |cfg| EmbassyBuffer::::new(cfg) +pub trait EmbassyRecordRegistrarExt +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + /// Configures an [`EmbassyBuffer`] from the given configuration. + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self; +} + +#[cfg(all(feature = "embassy-runtime", feature = "embassy-sync"))] +impl EmbassyRecordRegistrarExt for aimdb_core::RecordRegistrar<'_, T> +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self { + use aimdb_core::buffer::Buffer; + use alloc::boxed::Box; + let buffer = Box::new(EmbassyBuffer::::new(&cfg)); + // Record the cfg so buffer_info() reports the real buffer + // type/capacity for the dependency graph. + self.buffer_with_cfg(buffer, cfg) + } } /// Buffer type selection for Embassy adapters @@ -255,7 +270,7 @@ where fn buffer_sized( &mut self, buffer_type: EmbassyBufferType, - ) -> &mut aimdb_core::RecordRegistrar<'a, T, EmbassyAdapter>; + ) -> &mut aimdb_core::RecordRegistrar<'a, T>; /// Registers a producer with additional context (e.g., hardware peripherals) /// @@ -279,7 +294,7 @@ where /// }); /// /// async fn button_handler( - /// ctx: RuntimeContext, + /// ctx: RuntimeContext, /// producer: Producer, /// button: ExtiInput<'static>, /// ) { @@ -292,26 +307,22 @@ where &mut self, context: Ctx, f: F, - ) -> &mut aimdb_core::RecordRegistrar<'a, T, EmbassyAdapter> + ) -> &mut aimdb_core::RecordRegistrar<'a, T> where - Ctx: Send + Sync + 'static, - F: FnOnce(aimdb_core::RuntimeContext, aimdb_core::Producer, Ctx) -> Fut - + Send - + Sync - + 'static, + Ctx: Send + 'static, + F: FnOnce(aimdb_core::RuntimeContext, aimdb_core::Producer, Ctx) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static; } #[cfg(all(feature = "embassy-runtime", feature = "embassy-sync"))] -impl<'a, T> EmbassyRecordRegistrarExtCustom<'a, T> - for aimdb_core::RecordRegistrar<'a, T, EmbassyAdapter> +impl<'a, T> EmbassyRecordRegistrarExtCustom<'a, T> for aimdb_core::RecordRegistrar<'a, T> where T: Send + Sync + Clone + core::fmt::Debug + 'static, { fn buffer_sized( &mut self, buffer_type: EmbassyBufferType, - ) -> &mut aimdb_core::RecordRegistrar<'a, T, EmbassyAdapter> { + ) -> &mut aimdb_core::RecordRegistrar<'a, T> { use aimdb_core::buffer::{Buffer, BufferCfg}; use alloc::boxed::Box; @@ -343,18 +354,12 @@ where &mut self, context: Ctx, f: F, - ) -> &mut aimdb_core::RecordRegistrar<'a, T, EmbassyAdapter> + ) -> &mut aimdb_core::RecordRegistrar<'a, T> where - Ctx: Send + Sync + 'static, - F: FnOnce(aimdb_core::RuntimeContext, aimdb_core::Producer, Ctx) -> Fut - + Send - + Sync - + 'static, + Ctx: Send + 'static, + F: FnOnce(aimdb_core::RuntimeContext, aimdb_core::Producer, Ctx) -> Fut + Send + 'static, Fut: core::future::Future + Send + 'static, { - self.source_raw(|producer, ctx_any| { - let ctx = aimdb_core::RuntimeContext::extract_from_any(ctx_any); - f(ctx, producer, context) - }) + self.source(|ctx, producer| f(ctx, producer, context)) } } diff --git a/aimdb-embassy-adapter/src/runtime.rs b/aimdb-embassy-adapter/src/runtime.rs index 4cd9da3d..3ae49d5f 100644 --- a/aimdb-embassy-adapter/src/runtime.rs +++ b/aimdb-embassy-adapter/src/runtime.rs @@ -3,122 +3,39 @@ //! This module provides the Embassy-specific implementation of AimDB's runtime traits, //! enabling async task execution in embedded environments using Embassy. -use aimdb_core::{DbError, DbResult}; -use aimdb_executor::{ExecutorResult, RuntimeAdapter}; +use aimdb_executor::RuntimeAdapter; #[cfg(feature = "tracing")] use tracing::debug; -#[cfg(feature = "embassy-net-support")] -use embassy_net::Stack; - -/// Trait for accessing Embassy network stack -/// -/// This trait provides connectors with access to the network stack for -/// creating TCP/UDP connections. It's implemented by EmbassyAdapter when -/// the `embassy-net-support` feature is enabled. -/// -/// # Example -/// ```rust,ignore -/// use aimdb_embassy_adapter::EmbassyNetwork; -/// -/// fn create_mqtt_connector(runtime: &R) { -/// let network = runtime.network_stack(); -/// // Use network to create TCP connection -/// } -/// ``` -#[cfg(feature = "embassy-net-support")] -pub trait EmbassyNetwork { - /// Get a reference to the Embassy network stack - /// - /// Returns a reference to the static network stack that can be used - /// for creating network connections (TCP, UDP, etc.). - fn network_stack(&self) -> &'static Stack<'static>; -} - /// Embassy runtime adapter for async task execution in embedded systems. /// -/// When the `embassy-net-support` feature is enabled it holds a `&'static Stack<'static>` -/// for connector use; otherwise it is effectively a unit type. All futures are -/// driven by the `AimDbRunner` returned from `AimDbBuilder::build()`, which is -/// awaited inside the Embassy main task. -/// -/// # Safety -/// -/// `embassy_net::Stack` contains a `RefCell` and is `!Sync`. Embassy executors -/// run cooperatively on a single core with no preemption or thread migration, -/// so `unsafe impl Send/Sync` is sound here. This is the only `unsafe` left in -/// the adapter after issue #88 removed the `Spawn` impl and task pool. +/// A unit type: the runtime travels as `Arc` (issue #131) and +/// network connectors take the `embassy_net::Stack` at construction (wrapped in +/// [`NetStack`](crate::connectors::NetStack)), so the adapter carries no state +/// and no `unsafe`. All futures are driven by the `AimDbRunner` returned from +/// `AimDbBuilder::build()`, which is awaited inside the Embassy main task. /// /// # Example /// ```rust,no_run /// # #[cfg(not(feature = "std"))] /// # { /// use aimdb_embassy_adapter::EmbassyAdapter; -/// use aimdb_core::RuntimeAdapter; /// -/// # async fn example() -> aimdb_core::DbResult<()> { -/// let adapter = EmbassyAdapter::new()?; -/// # Ok(()) -/// # } +/// let adapter = EmbassyAdapter::new(); /// # } /// ``` -#[derive(Clone)] -pub struct EmbassyAdapter { - #[cfg(feature = "embassy-net-support")] - network: Option<&'static Stack<'static>>, - #[cfg(not(feature = "embassy-net-support"))] - _phantom: core::marker::PhantomData<()>, -} - -// SAFETY: Embassy executors run cooperatively on a single core. The -// `embassy_net::Stack` (when present) is `!Sync` only because of its internal -// `RefCell`; in a single-threaded executor no concurrent access is possible. -unsafe impl Send for EmbassyAdapter {} -unsafe impl Sync for EmbassyAdapter {} - -impl core::fmt::Debug for EmbassyAdapter { - fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - let mut s = f.debug_struct("EmbassyAdapter"); - #[cfg(feature = "embassy-net-support")] - s.field("network", &self.network.is_some()); - #[cfg(not(feature = "embassy-net-support"))] - s.field("_phantom", &"no features enabled"); - s.finish() - } -} +#[derive(Clone, Debug, Default)] +pub struct EmbassyAdapter; impl EmbassyAdapter { - /// Creates a new EmbassyAdapter without a network stack. - /// - /// # Returns - /// `Ok(EmbassyAdapter)` — always succeeds. - pub fn new() -> ExecutorResult { + /// Creates a new EmbassyAdapter. Infallible — the adapter is a stateless + /// unit type. + pub fn new() -> Self { #[cfg(feature = "tracing")] - debug!("Creating EmbassyAdapter (no network)"); - - Ok(Self { - #[cfg(feature = "embassy-net-support")] - network: None, - #[cfg(not(feature = "embassy-net-support"))] - _phantom: core::marker::PhantomData, - }) - } + debug!("Creating EmbassyAdapter"); - /// Creates a new EmbassyAdapter returning `DbResult` for backward compatibility. - pub fn new_db_result() -> DbResult { - Self::new().map_err(DbError::from) - } - - /// Creates a new EmbassyAdapter with a network stack - #[cfg(feature = "embassy-net-support")] - pub fn new_with_network(network: &'static Stack<'static>) -> Self { - #[cfg(feature = "tracing")] - debug!("Creating EmbassyAdapter with network"); - - Self { - network: Some(network), - } + Self } /// Anchors wall-clock time so [`TimeOps::unix_time`](aimdb_executor::TimeOps::unix_time) @@ -138,12 +55,6 @@ impl EmbassyAdapter { } } -impl Default for EmbassyAdapter { - fn default() -> Self { - Self::new().expect("EmbassyAdapter::default() should not fail") - } -} - // Trait implementations for the simplified core adapter interfaces impl RuntimeAdapter for EmbassyAdapter { @@ -278,8 +189,8 @@ impl aimdb_executor::Logger for EmbassyAdapter { // Runtime trait is auto-implemented when RuntimeAdapter + TimeOps + Logger are all implemented -// Host-run contract test for the dyn-safe RuntimeOps surface. Gated like the -// join-queue tests: `embassy-sync` (not `embassy-runtime`) so the cortex-m +// Host-run contract test for the dyn-safe RuntimeOps surface. Gated on +// `embassy-sync` (not `embassy-runtime`) so the cortex-m // executor never enters the host build; the defmt/time-driver stubs shared by // the lib test binary live in `buffer.rs`'s test module. The stub clock is // pinned at 0, so only `Duration::ZERO` sleeps complete — see @@ -294,19 +205,9 @@ mod runtime_ops_tests { #[test] fn runtime_ops_contract() { // `Arc` must be constructible from the adapter. - let ops: Arc = Arc::new(EmbassyAdapter::new().unwrap()); + let ops: Arc = Arc::new(EmbassyAdapter::new()); block_on(aimdb_executor::test_support::assert_runtime_ops_contract( ops.as_ref(), )); } } - -// Implement EmbassyNetwork trait for accessing network stack -#[cfg(feature = "embassy-net-support")] -impl EmbassyNetwork for EmbassyAdapter { - fn network_stack(&self) -> &'static Stack<'static> { - self.network.expect( - "Network stack not available - connectors requiring network access need the 'embassy-net-support' feature enabled and must use EmbassyAdapter::new_with_network() to provide a network stack", - ) - } -} diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs index def5638b..3da31fa0 100644 --- a/aimdb-embassy-adapter/tests/session_smoke.rs +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -24,6 +24,24 @@ use aimdb_core::session::{ }; use aimdb_embassy_adapter::EmbassyAdapter; +// No-op defmt logger so the binary links: the engine holds the adapter as +// `Arc` (issue #131), whose vtable references the `log` path +// (`defmt` on Embassy) even though this smoke never logs. +#[defmt::global_logger] +struct TestLogger; + +unsafe impl defmt::Logger for TestLogger { + 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") +} + // Trivial host time driver so `embassy_time` links (the happy path never awaits // `clock.sleep`, so `now`/`schedule_wake` are never actually exercised). struct TestTimeDriver; diff --git a/aimdb-executor/CHANGELOG.md b/aimdb-executor/CHANGELOG.md index 7087a2eb..f12dd788 100644 --- a/aimdb-executor/CHANGELOG.md +++ b/aimdb-executor/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **`JoinFanInRuntime`/`JoinQueue`/`JoinSender`/`JoinReceiver` deleted (Issue #131, design doc 034 §3.2/§3.3).** Multi-input join fan-in now uses one bounded `async-channel` queue inside `aimdb-core` — the same primitive the session engine already uses on every runtime — so the per-adapter GAT queue family (and each adapter's `join_queue.rs`) has no reason to exist. `RuntimeOps` (from #130) is now consumed by core: it is the value-level runtime surface behind `AimDb`/`RuntimeContext`. + ### Removed (breaking) - **`Spawn` trait deleted (Issue #88).** Including the `SpawnToken` associated type and the `ExecutorError::SpawnFailed` variant. The `Runtime` bundle trait is now `RuntimeAdapter + TimeOps + Logger` (no `Spawn` supertrait). Task execution moves to `AimDbRunner::run()` in `aimdb-core`, which drives every collected future via a single `FuturesUnordered`. Custom adapters must drop `impl Spawn`. diff --git a/aimdb-executor/src/join.rs b/aimdb-executor/src/join.rs deleted file mode 100644 index 4386ce82..00000000 --- a/aimdb-executor/src/join.rs +++ /dev/null @@ -1,36 +0,0 @@ -use core::future::Future; - -use crate::{ExecutorResult, RuntimeAdapter}; - -/// Runtime capability for creating join fan-in queues. -/// -/// Implemented by each runtime adapter. Queue capacity is an internal -/// constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). -pub trait JoinFanInRuntime: RuntimeAdapter { - type JoinQueue: JoinQueue; - - fn create_join_queue(&self) -> ExecutorResult>; -} - -/// A bounded fan-in queue that can be split into a sender/receiver pair. -pub trait JoinQueue { - type Sender: JoinSender + Clone + Send + 'static; - type Receiver: JoinReceiver + Send + 'static; - - fn split(self) -> (Self::Sender, Self::Receiver); -} - -/// The sending half of a join fan-in queue. -/// -/// `send` may await when the queue is full (bounded backpressure). -/// Returns `Err(QueueClosed)` if the receiver has been dropped. -pub trait JoinSender { - fn send(&self, item: T) -> impl Future> + Send + '_; -} - -/// The receiving half of a join fan-in queue. -/// -/// Returns `Err(QueueClosed)` when all senders have been dropped. -pub trait JoinReceiver { - fn recv(&mut self) -> impl Future> + Send + '_; -} diff --git a/aimdb-executor/src/lib.rs b/aimdb-executor/src/lib.rs index ea2c9a73..6145a35f 100644 --- a/aimdb-executor/src/lib.rs +++ b/aimdb-executor/src/lib.rs @@ -29,12 +29,10 @@ extern crate alloc; use core::future::Future; -pub mod join; pub mod ops; #[doc(hidden)] pub mod test_support; -pub use join::{JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender}; pub use ops::{BoxFuture, LogLevel, RuntimeOps}; // ============================================================================ diff --git a/aimdb-executor/src/ops.rs b/aimdb-executor/src/ops.rs index 01eb7cfb..0a78e727 100644 --- a/aimdb-executor/src/ops.rs +++ b/aimdb-executor/src/ops.rs @@ -21,8 +21,9 @@ //! [`TimeOps`](crate::TimeOps) is impossible: `now_nanos` needs an epoch //! anchor, and `TimeOps::Instant` is opaque with no place to store one. //! -//! This trait is groundwork: `aimdb-core` does not consume it yet (that is -//! the follow-up de-genericization, issue #131). +//! Since the de-genericization (issue #131) this is the runtime contract +//! `aimdb-core` consumes: `AimDb`/`RuntimeContext` hold the runtime as +//! `Arc`, and adapters hand one to `AimDbBuilder::runtime`. /// Heap-pinned, `Send`, `'static` future — the unit of work AimDB drives. /// diff --git a/aimdb-executor/src/test_support.rs b/aimdb-executor/src/test_support.rs index 8ff6ef1e..10f17c74 100644 --- a/aimdb-executor/src/test_support.rs +++ b/aimdb-executor/src/test_support.rs @@ -2,10 +2,35 @@ //! //! Each adapter crate calls [`assert_runtime_ops_contract`] from a test running //! under its own executor (`#[tokio::test]`, `block_on`, `#[wasm_bindgen_test]`), -//! mirroring how the join-queue behavioral tests are duplicated per adapter. +//! so the one contract is exercised against every runtime implementation. use crate::{LogLevel, RuntimeOps}; +/// A no-op [`RuntimeOps`] for tests that need a runtime value (a +/// `RuntimeContext`, an engine clock) but no real runtime behind it: the +/// clock is pinned at `0`, sleeps complete immediately, logs are discarded. +/// +/// One shared stub instead of a hand-rolled copy per test module, so a +/// `RuntimeOps` change is fixed here and in the real adapters only. +#[derive(Clone, Copy, Debug, Default)] +pub struct NoopRuntimeOps; + +impl RuntimeOps for NoopRuntimeOps { + fn name(&self) -> &'static str { + "noop" + } + fn now_nanos(&self) -> u64 { + 0 + } + fn unix_time(&self) -> Option<(u64, u32)> { + None + } + fn sleep(&self, _d: core::time::Duration) -> crate::BoxFuture { + alloc::boxed::Box::pin(core::future::ready(())) + } + fn log(&self, _level: LogLevel, _msg: &str) {} +} + /// Asserts the behavioral contract every `RuntimeOps` implementation must hold. /// /// Uses `Duration::ZERO` for the sleep check: the Embassy host-test time diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 0a9d81ab..c02afccd 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,8 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- **Heartbeat-response liveness — a dead send path or expired gateway channel now reconnects (review follow-up to #135).** The engine tracks each CONNECTIONSTATE_REQUEST and drops the connection when the gateway's CONNECTIONSTATE_RESPONSE doesn't arrive within the new `TunnelConfig::heartbeat_response_timeout_ms` (default 10 s, the KNX spec timeout) or reports a non-zero status (e.g. the gateway expired the channel during an outage). This restores the old tokio client's recovery from silently-failing sends — the recv path of an unconnected UDP socket never errors, so without it a route flap left the tunnel `Connected` forever with a stale channel id — and adds genuine liveness detection on both runtimes. +- **Pending-ACK tracking is accurate under send failures and bursts.** A frame the transport could not hand to the socket is untracked (`TunnelIo::send` reports success; previously the 3 s sweep warned "ACK timeout" for a telegram that never left the host), and a burst deeper than the 16-entry pending map evicts-and-reports the oldest entry instead of silently dropping its timeout reporting. +- **Tokio: the gateway address is validated in `build()` (issue #133 contract).** A typo'd IP — or a hostname, which `SocketAddr` parsing never resolves — now fails `ConnectorBuilder::build` like on Embassy, instead of producing a healthy-looking connector whose task parked forever logging once per hour. +- **No tight re-poll of an erroring socket during backoff.** Every socket error tears the erroring socket down (an already-running backoff keeps its deadline), and both shims wait out the engine's backoff deadline before binding the fresh socket — matching the old clients' teardown-sleep-rebind pacing. + +### Added + +- **Sans-io KNX/IP tunneling engine — one lifecycle implementation for both transports (Issue #135, [design doc §3.7](../docs/design/034-technical-debt-review.md)).** New runtime-neutral `tunnel` module (`no_std + alloc`): a poll-based `TunnelEngine` owning the CONNECT handshake, channel id, wrapping sequence counters, pending-ACK map (with the non-standard 4-byte-ACK fallback parse), keepalive schedule, ACK-timeout sweep, and reconnect backoff — events in (`handle_datagram`/`handle_command`/`handle_socket_error` + `poll(now)`), `Action`s out (`Send`/`Telegram`/`ResetSocket`/`AckTimeout`), `next_deadline()` for timer arming. `tokio_client.rs` and `embassy_client.rs` are reduced to socket shims; the lifecycle is covered by 15 host unit tests plus a fake-gateway localhost-UDP integration roundtrip. Behavior notes: Embassy gains the 5 s CONNECT_RESPONSE timeout; both shims reconnect on fatal *recv* errors, while a failed outbound send is logged and the tunnel kept (transient `ENOBUFS`/route flaps must not force a 5 s re-handshake); a persistently dead send path is caught by the heartbeat-response timeout (see *Fixed*); truncated `TUNNELING_REQUEST`s (no full connection header) are dropped without an ACK instead of being ACKed with a fabricated sequence number, and each datagram is parsed exactly once; the CONNECT_REQUEST HPAI stays per-transport (`LocalEndpoint`: tokio = real bound address, Embassy = NAT); commands queue (not drop) during a reconnect cycle, as before. The action-drain policy is shared (`tunnel::drain_actions` over a per-transport `TunnelIo`), and publish validation is shared (`GroupWrite::try_new` — destination checked before payload size on both runtimes; the tokio client previously reported `MessageTooLarge` first). The tokio connection task survives an inbound-only configuration (no `link_to` routes): a closed command channel only disables its select arm instead of exiting the task that routes inbound telegrams. + +### Changed (breaking) + +- **Issue #135:** the tokio `KnxCommand` oneshot ack-response channel is deleted (it was dead code — `KnxSink` always sent `None`); the Embassy module's `KnxCommand`/`KnxCommandKind`/`GroupWriteData` types are replaced by the engine's `tunnel::GroupWrite`. +- **Issue #131:** the Embassy `KnxConnectorBuilder::new` takes the network stack — `KnxConnectorBuilder::new(gateway_url, stack)` — since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls are non-generic (`build(&self, db: &AimDb)`). + ### Changed +- **Embassy connection loop: the connected/disconnected select fork is collapsed into one `select3` ([follow-up doc §2.1](../docs/design/035-review-followups-deferred.md)).** The command arm is conditional (`pending()` while connecting / backing off) instead of the whole select shape, so the datagram Ok/Err handling exists once and the two branches can no longer drift. Queue-while-disconnected semantics are unchanged: commands keep buffering in the static channel and flush after the handshake. Since both shims gate command intake on connectivity, the engine's disconnected drop path is unreachable in production — the shims' "Not connected, dropping GroupWrite" warnings are removed and `TunnelEngine::handle_command`'s `false` return is documented as a defensive contract (engine unit tests cover it). No API change. - **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 KNX 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 telegram read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers: the connector now writes only a `KnxSink` (`Connector`, parses the destination group address and forwards a fire-and-forget `GroupValueWrite`) and a `KnxSource` (`Source`, yields each inbound `(group_address, payload)`) and composes the pumps in `build()`. The routing `Router` is (re)built inside `pump_source`. `std` enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. - **Outbound publishers survive a consumer lag (Tokio + Embassy).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. diff --git a/aimdb-knx-connector/Cargo.toml b/aimdb-knx-connector/Cargo.toml index 2c50a0d9..f3273978 100644 --- a/aimdb-knx-connector/Cargo.toml +++ b/aimdb-knx-connector/Cargo.toml @@ -25,7 +25,6 @@ embassy-runtime = [ "embassy-sync", "embassy-net", "embassy-futures", - "heapless", "static_cell", ] tracing = ["dep:tracing", "aimdb-core/tracing"] @@ -76,8 +75,9 @@ embassy-net = { version = "0.9.0", path = "../_external/embassy/embassy-net", op "proto-ipv4", ] } -# Embedded utilities -heapless = { workspace = true, optional = true } +# Embedded utilities (heapless is unconditional: the shared sans-io tunnel +# engine uses stack-allocated frames on both runtimes) +heapless = { workspace = true } static_cell = { version = "2.0", optional = true } # Optional observability diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index 8087ae2e..196a7d2a 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -1,22 +1,25 @@ -//! Embassy runtime adapter for KNX/IP connector +//! Embassy transport shim for the KNX/IP connector //! -//! This module provides KNX/IP connectivity for Embassy-based embedded systems. +//! This module contributes only socket glue for embedded systems: an +//! `embassy-net` UDP socket, the static channels between the pumps and the +//! connection task, and a select loop driving the shared sans-io +//! [`TunnelEngine`](crate::tunnel::TunnelEngine). The entire tunneling +//! lifecycle (handshake, ACK bookkeeping, keepalive, reconnect backoff) lives +//! in [`crate::tunnel`]. //! //! # Architecture //! -//! This crate contributes only the KNX/IP **protocol**: UDP socket management with -//! `embassy-net` and the tunnelling state machine (CONNECT_REQUEST/RESPONSE, -//! TUNNELING_ACK, heartbeat, telegram parsing). The data-flow is shared: -//! -//! - **Outbound** (records → telegrams) rides core's `pump_sink` via the existing +//! - **Outbound** (records → telegrams) rides core's `pump_sink` via the //! [`Connector`](aimdb_core::transport::Connector) impl (commands go onto a //! `CriticalSectionRawMutex` channel the connection task drains). -//! - **Inbound** (telegrams → records) rides core's `pump_source`: the connection -//! task parses each telegram and pushes `(group-address, payload)` onto an inbound -//! channel that [`KnxSource`] drains. +//! - **Inbound** (telegrams → records) rides core's `pump_source`: the +//! connection task pushes `(group-address, payload)` onto an inbound channel +//! that [`KnxSource`] drains. //! - The connection task is force-`Send`ed once via //! [`into_box_future`](aimdb_embassy_adapter::connectors::into_box_future); the -//! crate carries no `unsafe`. +//! only `unsafe` in this crate is the audited +//! [`NetStack::new`](aimdb_embassy_adapter::connectors::NetStack) call in the +//! builder (single-core cooperative executor invariant). //! //! # Usage //! @@ -28,7 +31,7 @@ //! let db = AimDbBuilder::new() //! .runtime(embassy_adapter) //! .with_connector( -//! KnxConnectorBuilder::new("knx://192.168.1.19:3671") +//! KnxConnectorBuilder::new("knx://192.168.1.19:3671", stack) //! ) //! .configure::(|reg| { //! // Inbound: Monitor KNX bus for light state changes @@ -39,6 +42,7 @@ //! .build().await?; //! ``` +use crate::tunnel::{drain_actions, GroupWrite, TunnelConfig, TunnelEngine, TunnelIo}; use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; use aimdb_core::session::{pump_sink, pump_source, Payload}; @@ -47,35 +51,12 @@ use aimdb_embassy_adapter::connectors::into_box_future; use alloc::boxed::Box; use alloc::string::{String, ToString}; use alloc::sync::Arc; -use alloc::vec; use alloc::vec::Vec; use core::future::Future; use core::pin::Pin; use core::str::FromStr; use embassy_net::udp::{PacketMetadata, UdpSocket}; use embassy_net::{IpAddress, Ipv4Address, Stack}; -use knx_pico::protocol::{ - CEMIFrame, ConnectRequest, ConnectResponse, ConnectionHeader, ConnectionStateRequest, Hpai, - KnxnetIpFrame, ServiceType, TunnelingAck, TunnelingRequest, -}; - -/// Command sent to KNX connection task for outbound publishing -/// Max data length: 254 bytes (KNX/IP max APDU) -pub struct KnxCommand { - pub kind: KnxCommandKind, -} - -pub enum KnxCommandKind { - /// Send GroupValueWrite telegram - GroupWrite(Box), -} - -/// Data for GroupValueWrite command (boxed to reduce enum size) -pub struct GroupWriteData { - pub group_addr: GroupAddress, - pub data: heapless::Vec, -} - use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; use embassy_sync::channel::{Channel, Receiver, Sender}; use static_cell::StaticCell; @@ -84,9 +65,13 @@ use static_cell::StaticCell; /// connection task, drained by [`KnxSource`] into core's `pump_source`. type InboundItem = (String, Payload); +/// Outbound command item — boxed so the static channel stores pointers +/// instead of full `MAX_APDU`-sized payloads. +type CommandItem = Box; + /// `'static` reference to the outbound command channel. type CommandChannelRef = - &'static Channel; + &'static Channel; /// Sender / receiver halves of the inbound telegram channel. type InboundSender = Sender<'static, CriticalSectionRawMutex, InboundItem, KNX_INBOUND_QUEUE_SIZE>; type InboundReceiver = @@ -101,12 +86,12 @@ const KNX_COMMAND_QUEUE_SIZE: usize = 32; /// Static channel for KNX commands (capacity: [`KNX_COMMAND_QUEUE_SIZE`]) static KNX_COMMAND_CHANNEL: StaticCell< - Channel, + Channel, > = StaticCell::new(); /// Get or initialize the command channel fn get_command_channel( -) -> &'static Channel { +) -> &'static Channel { KNX_COMMAND_CHANNEL.init(Channel::new()) } @@ -130,7 +115,7 @@ fn get_inbound_channel( /// inbound channels use `CriticalSectionRawMutex` (`Send`), so this is a plain /// `Source` — no force-`Send` wrapper needed. struct KnxSource { - receiver: Receiver<'static, CriticalSectionRawMutex, InboundItem, KNX_INBOUND_QUEUE_SIZE>, + receiver: InboundReceiver, } impl aimdb_core::session::Source for KnxSource { @@ -142,6 +127,7 @@ impl aimdb_core::session::Source for KnxSource { /// KNX connector builder for Embassy runtime pub struct KnxConnectorBuilder { gateway_url: heapless::String<128>, + stack: aimdb_embassy_adapter::connectors::NetStack, } impl KnxConnectorBuilder { @@ -149,24 +135,28 @@ impl KnxConnectorBuilder { /// /// # Arguments /// * `gateway_url` - KNX gateway URL (e.g., "knx://192.168.1.19:3671") - pub fn new(gateway_url: &str) -> Self { + /// * `stack` - The device's network stack (the runtime travels as + /// `Arc` since issue #131 and cannot surface it) + pub fn new(gateway_url: &str, stack: &'static Stack<'static>) -> Self { Self { gateway_url: heapless::String::try_from(gateway_url) .unwrap_or_else(|_| heapless::String::new()), + // SAFETY: AimDB's Embassy integration requires a single-core + // cooperative executor (the adapter's module-level invariant); + // every future touching this stack — including the connection + // task built from this builder — is polled on that executor. + stack: unsafe { aimdb_embassy_adapter::connectors::NetStack::new(stack) }, } } } type BoxFuture = Pin + Send + 'static>>; -/// Implement ConnectorBuilder trait for Embassy runtime with network stack access -impl ConnectorBuilder for KnxConnectorBuilder -where - R: aimdb_executor::RuntimeAdapter + aimdb_embassy_adapter::EmbassyNetwork + 'static, -{ +/// Implement ConnectorBuilder trait for Embassy +impl ConnectorBuilder for KnxConnectorBuilder { fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { // No `.await` here, so the build future is `Send` without a wrapper: the // tunnelling connection task (which holds the `!Send` UDP socket) is @@ -175,7 +165,7 @@ where // `CriticalSectionRawMutex`, i.e. `Send`). Box::pin(async move { let (command_channel, inbound_rx, connection_task) = - KnxConnectorImpl::setup(self.gateway_url.as_str(), db.runtime()).map_err(|e| { + KnxConnectorImpl::setup(self.gateway_url.as_str(), self.stack).map_err(|e| { #[cfg(feature = "defmt")] defmt::error!("Failed to build KNX connector"); aimdb_core::DbError::runtime_error(alloc::format!( @@ -206,93 +196,9 @@ where } } -/// Pending ACK entry for outbound telegram (Embassy, no oneshot channels) -struct PendingAck { - sent_at: embassy_time::Instant, -} - -/// Connection state shared within the connection task -struct ChannelState { - /// KNXnet/IP channel ID from CONNECT_RESPONSE - channel_id: u8, - /// Connection status - connected: bool, - /// Last received sequence counter (inbound telegrams) - inbound_seq: u8, - /// Next sequence counter to use for outbound telegrams - outbound_seq: u8, - /// Pending ACKs waiting for confirmation (seq -> PendingAck) - pending_acks: heapless::FnvIndexMap, -} - -impl ChannelState { - fn new() -> Self { - Self { - channel_id: 0, - connected: false, - inbound_seq: 0, - outbound_seq: 0, - pending_acks: heapless::FnvIndexMap::new(), - } - } - - fn set_channel_id(&mut self, channel_id: u8) { - self.channel_id = channel_id; - self.connected = true; - } - - fn next_outbound_seq(&mut self) -> u8 { - let seq = self.outbound_seq; - self.outbound_seq = self.outbound_seq.wrapping_add(1); - seq - } - - /// Track a pending ACK for an outbound telegram - fn add_pending_ack(&mut self, seq: u8) { - let _ = self.pending_acks.insert( - seq, - PendingAck { - sent_at: embassy_time::Instant::now(), - }, - ); - } - - /// Complete a pending ACK (received confirmation) - fn complete_ack(&mut self, seq: u8) -> bool { - self.pending_acks.remove(&seq).is_some() - } - - /// Check for timed-out ACKs (> 3 seconds) and return timed out sequences - fn check_ack_timeouts(&mut self) -> heapless::Vec { - let now = embassy_time::Instant::now(); - let mut timed_out = heapless::Vec::new(); - - // Collect sequences to remove - let to_remove: heapless::Vec = self - .pending_acks - .iter() - .filter_map(|(&seq, pending)| { - if now.duration_since(pending.sent_at) > embassy_time::Duration::from_secs(3) { - Some(seq) - } else { - None - } - }) - .collect(); - - // Remove timed-out entries - for seq in &to_remove { - self.pending_acks.remove(seq); - let _ = timed_out.push(*seq); - } - - timed_out - } -} - /// Internal KNX connector implementation pub struct KnxConnectorImpl { - command_channel: &'static Channel, + command_channel: CommandChannelRef, } impl KnxConnectorImpl { @@ -301,13 +207,10 @@ impl KnxConnectorImpl { /// Synchronous (no `.await`) so the caller's `build` future stays `Send`. /// Returns the command channel (for outbound `pump_sink`), the inbound /// receiver (for `pump_source`), and the force-`Send` connection task. - fn setup( + fn setup( gateway_url: &str, - runtime: &R, - ) -> Result<(CommandChannelRef, InboundReceiver, BoxFuture), &'static str> - where - R: aimdb_executor::RuntimeAdapter + aimdb_embassy_adapter::EmbassyNetwork + 'static, - { + stack: aimdb_embassy_adapter::connectors::NetStack, + ) -> Result<(CommandChannelRef, InboundReceiver, BoxFuture), &'static str> { // Parse the gateway URL let connector_url = ConnectorUrl::parse(gateway_url).map_err(|_| "Invalid KNX URL")?; @@ -321,7 +224,7 @@ impl KnxConnectorImpl { let gateway_ip = Ipv4Address::from_str(&host).map_err(|_| "Invalid gateway IP address")?; // Get network stack for background task - let network = runtime.network_stack(); + let network = stack.get(); // Channels: outbound commands (publish → task) and inbound telegrams (task → pump_source). let command_channel = get_command_channel(); @@ -334,13 +237,8 @@ impl KnxConnectorImpl { #[cfg(feature = "defmt")] defmt::trace!("KNX background task starting for {}:{}", gateway_ip, port); - // Run the connection listener (this never returns under normal conditions) - #[allow(unreachable_code)] - { - let _: () = - Self::connection_task(network, gateway_ip, port, command_channel, inbound_tx) - .await; - } + // Run the connection task (this never returns). + connection_task(network, gateway_ip, port, command_channel, inbound_tx).await; }); #[cfg(feature = "defmt")] @@ -348,63 +246,66 @@ impl KnxConnectorImpl { Ok((command_channel, inbound_rx, knx_task_future)) } +} - /// Background task that maintains KNX connection and receives telegrams - async fn connection_task( - stack: &'static Stack<'static>, - gateway_addr: Ipv4Address, - gateway_port: u16, - command_channel: CommandChannelRef, - inbound_tx: InboundSender, - ) { - loop { - #[cfg(feature = "defmt")] - defmt::info!( - "🔌 Connecting to KNX gateway {}:{}", - gateway_addr, - gateway_port - ); - - match Self::connect_and_listen( - stack, - gateway_addr, - gateway_port, - command_channel, - inbound_tx, - ) - .await - { - Ok(()) => { - #[cfg(feature = "defmt")] - defmt::warn!("KNX connection ended normally (unexpected)"); - } - Err(_e) => { - #[cfg(feature = "defmt")] - defmt::error!("❌ KNX connection error: {:?}", _e); - } - } +// Implement the Connector trait +impl aimdb_core::transport::Connector for KnxConnectorImpl { + fn publish( + &self, + resource_id: &str, + _config: &aimdb_core::transport::ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> + { + // Validation shared with the tokio shim (same checks, same order); + // boxed so the static channel stores pointers, not full payloads. + let cmd = match GroupWrite::try_new(resource_id, payload) { + Ok(cmd) => Box::new(cmd), + Err(e) => return Box::pin(async move { Err(e) }), + }; + let command_channel = self.command_channel; - // Wait before reconnecting - #[cfg(feature = "defmt")] - defmt::trace!("Reconnecting to KNX gateway in 5 seconds..."); + Box::pin(async move { + // Send command to background task via channel + command_channel.send(cmd).await; - embassy_time::Timer::after(embassy_time::Duration::from_secs(5)).await; - } + Ok(()) + }) } +} - /// Connect to KNX gateway and listen for telegrams - async fn connect_and_listen( - stack: &'static Stack<'static>, - gateway_addr: Ipv4Address, - gateway_port: u16, - command_channel: CommandChannelRef, - inbound_tx: InboundSender, - ) -> Result<(), &'static str> { - // Create UDP socket with static buffers - let mut rx_meta = [PacketMetadata::EMPTY; 4]; - let mut rx_buffer = [0; 512]; - let mut tx_meta = [PacketMetadata::EMPTY; 4]; - let mut tx_buffer = [0; 512]; +/// Current monotonic time in milliseconds for the engine. +fn now_ms() -> u64 { + embassy_time::Instant::now().as_millis() +} + +/// The connection task: socket I/O around the shared [`TunnelEngine`]. +/// +/// Creates a UDP socket (recreating it whenever the engine asks for a reset), +/// then loops: fire engine deadlines, apply the engine's actions, and select +/// over inbound datagrams, outbound commands, and the next engine deadline. +async fn connection_task( + stack: &'static Stack<'static>, + gateway_addr: Ipv4Address, + gateway_port: u16, + command_channel: CommandChannelRef, + inbound_tx: InboundSender, +) { + let mut engine = TunnelEngine::new(TunnelConfig::default(), now_ms()); + + // Socket buffers outlive each per-connection socket below. + let mut rx_meta = [PacketMetadata::EMPTY; 4]; + let mut rx_buffer = [0; 512]; + let mut tx_meta = [PacketMetadata::EMPTY; 4]; + let mut tx_buffer = [0; 512]; + + loop { + #[cfg(feature = "defmt")] + defmt::info!( + "🔌 Connecting to KNX gateway {}:{}", + gateway_addr, + gateway_port + ); let mut socket = UdpSocket::new( *stack, @@ -414,642 +315,151 @@ impl KnxConnectorImpl { &mut tx_buffer, ); - // Bind to any local address - socket.bind(0).map_err(|_| "Failed to bind socket")?; - - // Build CONNECT_REQUEST - let connect_request = Self::build_connect_request(); - - // Send CONNECT_REQUEST - socket - .send_to( - &connect_request, - (IpAddress::Ipv4(gateway_addr), gateway_port), - ) - .await - .map_err(|_| "Failed to send CONNECT_REQUEST")?; - - #[cfg(feature = "defmt")] - defmt::debug!("Sent CONNECT_REQUEST"); - - // Wait for CONNECT_RESPONSE - let mut recv_buf = [0u8; 512]; - let (len, _peer) = socket - .recv_from(&mut recv_buf) - .await - .map_err(|_| "Failed to receive CONNECT_RESPONSE")?; - - let channel_id = Self::parse_connect_response(&recv_buf[..len])?; - - #[cfg(feature = "defmt")] - defmt::info!("✅ Connected to KNX gateway, channel_id: {}", channel_id); - - // Initialize connection state - let mut state = ChannelState::new(); - state.set_channel_id(channel_id); - - // Create heartbeat ticker (every 55 seconds) - let mut heartbeat_ticker = - embassy_time::Ticker::every(embassy_time::Duration::from_secs(55)); - - // ACK timeout checker (every 500ms) - let mut ack_timeout_ticker = - embassy_time::Ticker::every(embassy_time::Duration::from_millis(500)); - - // Main event loop: inbound telegrams, outbound commands, heartbeat, and ACK timeouts - loop { - use embassy_futures::select::{select4, Either4}; - - let mut recv_buf = [0u8; 512]; - - // Set up four concurrent operations - let recv_fut = socket.recv_from(&mut recv_buf); - let cmd_fut = command_channel.receive(); - let heartbeat_fut = heartbeat_ticker.next(); - let ack_timeout_fut = ack_timeout_ticker.next(); - - match select4(recv_fut, cmd_fut, heartbeat_fut, ack_timeout_fut).await { - // Inbound: Process received telegram from KNX gateway - Either4::First(result) => { - match result { - Ok((len, _peer)) => { - // Minimum KNX/IP header is 6 bytes - if len < 6 { - #[cfg(feature = "defmt")] - defmt::warn!("Received malformed packet (len={})", len); - continue; - } - - // Check service type - let service_type = u16::from_be_bytes([recv_buf[2], recv_buf[3]]); - - // Handle TUNNELING_ACK (0x0421) - acknowledgment for our outbound telegrams - if Self::is_tunneling_ack(&recv_buf[..len]) { - #[cfg(feature = "defmt")] - defmt::debug!( - "Received TUNNELING_ACK: {=[u8]:02x}", - &recv_buf[..len] - ); - - // Parse ACK - try knx-pico parser first, fallback to manual parsing - // Some gateways send non-standard ACK format (missing status byte) - let ack_seq = if let Ok(frame) = - KnxnetIpFrame::parse(&recv_buf[..len]) - { - if let Ok(ack) = TunnelingAck::parse(frame.body()) { - // Standard parsing succeeded - ack.connection_header.sequence_counter - } else if frame.body().len() >= 4 { - // Fallback: manually extract sequence from ConnectionHeader - // Body format: [struct_len, channel_id, seq, status] - // Gateway may send 4 bytes instead of 5 (missing final status byte) - let seq = frame.body()[2]; - - #[cfg(feature = "defmt")] - defmt::debug!("Using fallback ACK parsing (non-standard gateway format)"); - - seq - } else { - #[cfg(feature = "defmt")] - defmt::warn!( - "Failed to parse TUNNELING_ACK body, raw: {=[u8]:02x}", - &recv_buf[..len] - ); - continue; - } - } else { - #[cfg(feature = "defmt")] - defmt::warn!( - "Failed to parse frame as TUNNELING_ACK, raw: {=[u8]:02x}", - &recv_buf[..len] - ); - continue; - }; - - if state.complete_ack(ack_seq) { - #[cfg(feature = "defmt")] - defmt::trace!("✅ Received TUNNELING_ACK for seq={}", ack_seq); - } else { - #[cfg(feature = "defmt")] - defmt::warn!( - "⚠️ Unexpected TUNNELING_ACK for seq={}", - ack_seq - ); - } - continue; - } - - // Handle CONNECTIONSTATE_RESPONSE (0x0208) - 8 bytes - if service_type == 0x0208 { - #[cfg(feature = "defmt")] - defmt::trace!("Received CONNECTIONSTATE_RESPONSE"); - continue; - } - - // Handle DISCONNECT_RESPONSE (0x020A) - 8 bytes - if service_type == 0x020A { - #[cfg(feature = "defmt")] - defmt::warn!("Received DISCONNECT_RESPONSE from gateway"); - continue; - } - - // Check if this is a TUNNELING_REQUEST using knx-pico - if !Self::is_tunneling_request(&recv_buf[..len]) { - #[cfg(feature = "defmt")] - defmt::trace!("Ignoring non-TUNNELING_REQUEST frame"); - continue; - } - - // For TUNNELING_REQUEST we need at least 10 bytes - if len < 10 { - #[cfg(feature = "defmt")] - defmt::warn!("Received too short TUNNELING_REQUEST (len={})", len); - continue; - } - - // Extract sequence counter from TUNNELING_REQUEST (byte 8) - let received_seq = if len > 8 { recv_buf[8] } else { 0 }; - state.inbound_seq = received_seq; - - // Send TUNNELING_ACK with the same sequence number - let ack = Self::build_tunneling_ack(state.channel_id, received_seq); - let _ = socket - .send_to(&ack, (IpAddress::Ipv4(gateway_addr), gateway_port)) - .await; - - #[cfg(feature = "defmt")] - defmt::trace!("Sent TUNNELING_ACK with seq={}", received_seq); - - // Parse and forward telegram to `pump_source` (via the - // inbound channel). Non-blocking: drop + log on a full - // channel rather than stalling the protocol loop, matching - // the router's drop-on-overflow behaviour. - if let Some((addr, data)) = Self::parse_telegram(&recv_buf[..len]) { - let resource_id = addr.to_string(); - - #[cfg(feature = "defmt")] - defmt::trace!( - "KNX telegram: {} (len={}) -> routing", - resource_id.as_str(), - data.len() - ); - - if inbound_tx - .try_send((resource_id, Payload::from(data))) - .is_err() - { - #[cfg(feature = "defmt")] - defmt::warn!("KNX inbound channel full; dropped telegram"); - } - } else { - #[cfg(feature = "defmt")] - defmt::trace!("❌ Failed to parse telegram (len={})", len); - } - } - Err(_) => { - return Err("Socket receive error"); - } - } - } - - // Outbound: Process command from publish() calls - Either4::Second(cmd) => { - Self::handle_outbound_command( - cmd, - &mut state, - &socket, - gateway_addr, - gateway_port, - ) - .await; - } - - // Heartbeat: Send keepalive to gateway - Either4::Third(_) => { - Self::send_heartbeat(&socket, gateway_addr, gateway_port, &state).await; - } - - // ACK timeout checker: Check for expired ACKs - Either4::Fourth(_) => { - let timed_out = state.check_ack_timeouts(); - if !timed_out.is_empty() { - #[cfg(feature = "defmt")] - defmt::warn!("⚠️ ACK timeouts for sequences: {:?}", timed_out.as_slice()); - } - } - } - } - } - - /// Handle outbound command (send GroupValueWrite) - async fn handle_outbound_command( - cmd: KnxCommand, - state: &mut ChannelState, - socket: &UdpSocket<'_>, - gateway_addr: Ipv4Address, - gateway_port: u16, - ) { - let KnxCommandKind::GroupWrite(data_box) = cmd.kind; - - if !state.connected { + if socket.bind(0).is_err() { #[cfg(feature = "defmt")] - defmt::warn!("Not connected, dropping GroupWrite"); - return; + defmt::error!("Failed to bind KNX socket, retrying in 5s"); + drop(socket); + embassy_time::Timer::after(embassy_time::Duration::from_secs(5)).await; + continue; } - let seq = state.next_outbound_seq(); + // Drive the engine until it asks for a socket reset; the engine is + // then in its backoff phase, so re-entering with a fresh socket only + // reconnects once the backoff deadline passes. + drive_connection( + &mut engine, + &mut socket, + gateway_addr, + gateway_port, + command_channel, + &inbound_tx, + ) + .await; + drop(socket); - // Build frames - let cemi = Self::build_group_write_cemi(data_box.group_addr, &data_box.data); - let request = Self::build_tunneling_request(state.channel_id, seq, &cemi); + #[cfg(feature = "defmt")] + defmt::trace!("KNX connection reset, reconnecting after backoff..."); + + // Nothing can be sent until the engine's backoff deadline, so wait it + // out before binding the fresh socket. This also paces the rebind + // cycle when a socket errors persistently (the old client likewise + // slept the full backoff between socket teardowns). + let wait_ms = engine.next_deadline().saturating_sub(now_ms()); + embassy_time::Timer::after(embassy_time::Duration::from_millis(wait_ms)).await; + } +} - // Send to gateway - if let Err(_e) = socket - .send_to(&request, (IpAddress::Ipv4(gateway_addr), gateway_port)) - .await - { - #[cfg(feature = "defmt")] - defmt::error!("Failed to send GroupWrite"); - } else { - // Track pending ACK - state.add_pending_ack(seq); +/// Socket-side glue for [`drain_actions`]: frames ride the `embassy-net` UDP +/// socket, parsed telegrams ride the static inbound channel into [`KnxSource`]. +struct EmbassyIo<'a, 'b> { + socket: &'a UdpSocket<'b>, + gateway: (IpAddress, u16), + inbound_tx: &'a InboundSender, +} +impl TunnelIo for EmbassyIo<'_, '_> { + async fn send(&mut self, frame: &[u8]) -> bool { + // Log-and-continue: a transient send error must not tear down the + // tunnel; a persistently dead send path surfaces through the engine's + // heartbeat-response timeout. + if self.socket.send_to(frame, self.gateway).await.is_err() { #[cfg(feature = "defmt")] - defmt::debug!( - "Sent GroupWrite: {} seq={} ({} bytes)", - data_box.group_addr, // GroupAddress implements Display - seq, - data_box.data.len() - ); + defmt::error!("KNX send failed"); + return false; } + true } - /// Send heartbeat (CONNECTIONSTATE_REQUEST) to gateway - async fn send_heartbeat( - socket: &UdpSocket<'_>, - gateway_addr: Ipv4Address, - gateway_port: u16, - state: &ChannelState, - ) { - if !state.connected { - return; - } + fn forward(&mut self, addr: GroupAddress, payload: Vec) { + let resource_id = addr.to_string(); - let request = Self::build_connectionstate_request(state.channel_id); + #[cfg(feature = "defmt")] + defmt::trace!( + "KNX telegram: {} (len={}) -> routing", + resource_id.as_str(), + payload.len() + ); - if let Err(_e) = socket - .send_to(&request, (IpAddress::Ipv4(gateway_addr), gateway_port)) - .await + if self + .inbound_tx + .try_send((resource_id, Payload::from(payload))) + .is_err() { #[cfg(feature = "defmt")] - defmt::error!("Heartbeat failed"); - } else { - #[cfg(feature = "defmt")] - defmt::trace!("Sent heartbeat"); - } - } - - /// Build a CONNECT_REQUEST frame using knx-pico - fn build_connect_request() -> heapless::Vec { - // Use 0.0.0.0:0 for "any" address - let hpai = Hpai::new([0, 0, 0, 0], 0); - let request = ConnectRequest::new(hpai, hpai); - - let mut buffer = [0u8; 32]; - let len = request - .build(&mut buffer) - .expect("Buffer too small for CONNECT_REQUEST"); - - let mut frame = heapless::Vec::new(); - let _ = frame.extend_from_slice(&buffer[..len]); - frame - } - - /// Parse CONNECT_RESPONSE using knx-pico to extract channel ID - fn parse_connect_response(data: &[u8]) -> Result { - let frame = KnxnetIpFrame::parse(data).map_err(|_| "Failed to parse frame")?; - - if frame.service_type() != ServiceType::ConnectResponse { - return Err("Not a CONNECT_RESPONSE"); - } - - let response = ConnectResponse::parse(frame.body()) - .map_err(|_| "Failed to decode CONNECT_RESPONSE")?; - - if response.status != 0 { - return Err("CONNECT_RESPONSE error status"); - } - - Ok(response.channel_id) - } - - /// Build TUNNELING_ACK frame using knx-pico - fn build_tunneling_ack(channel_id: u8, seq: u8) -> heapless::Vec { - let conn_header = ConnectionHeader::new(channel_id, seq); - let ack = TunnelingAck::new(conn_header, 0); // status = 0 (OK) - - let mut buffer = [0u8; 16]; - let len = ack - .build(&mut buffer) - .expect("Buffer too small for TUNNELING_ACK"); - - let mut frame = heapless::Vec::new(); - let _ = frame.extend_from_slice(&buffer[..len]); - frame - } - - /// Build GroupValueWrite cEMI frame (L_Data.req) - /// - /// Must match knx-pico's exact cEMI structure for proper parsing. - /// Structure: [msg_code, add_info_len, ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, data...] - fn build_group_write_cemi(group_addr: GroupAddress, data: &[u8]) -> heapless::Vec { - let mut frame = heapless::Vec::new(); - - // Message code: L_Data.req (0x11) - let _ = frame.push(0x11); - - // Additional info length: 0 - let _ = frame.push(0x00); - - // Control field 1: 0xBC (Standard frame, no repeat, broadcast, priority low) - // Use 0xBC instead of 0x94 - this is critical for gateway compatibility - let _ = frame.push(0xBC); - - // Control field 2: 0xE0 (Group address, hop count 6) - let _ = frame.push(0xE0); - - // Source address: 0.0.0 (2 bytes, big-endian) - let _ = frame.extend_from_slice(&[0x00, 0x00]); - - // Destination address (group address) - convert to u16 big-endian - let dest_raw: u16 = group_addr.into(); - let dest_bytes = dest_raw.to_be_bytes(); - let _ = frame.extend_from_slice(&dest_bytes); - - // Build NPDU: NPDU_length field + TPCI + APCI + data - // CRITICAL: NPDU length encoding per KNX spec: - // - For short telegram: field = 0x01 (special flag) - // - For long telegram: field = actual_length - 1 (encoded as length-1) - if data.len() == 1 && data[0] < 64 { - // 6-bit encoding: value embedded in APCI byte - // NPDU length = 0x01 (short telegram flag, NOT byte count) - let _ = frame.push(0x01); - - // TPCI (UnnumberedData) - let _ = frame.push(0x00); - - // APCI low byte: GroupValueWrite (0x80) + 6-bit value - let _ = frame.push(0x80 | (data[0] & 0x3F)); - } else { - // Long telegram: APCI + separate data bytes - // NPDU length encoding: field = actual_length - 1 - let npdu_actual = 2 + data.len(); // TPCI + APCI + data - let npdu_len_field = npdu_actual - 1; // Encode as length - 1 - let _ = frame.push(npdu_len_field as u8); - - // TPCI (UnnumberedData) - let _ = frame.push(0x00); - - // APCI: GroupValueWrite - let _ = frame.push(0x80); - - // Data bytes - let _ = frame.extend_from_slice(data); + defmt::warn!("KNX inbound channel full; dropped telegram"); } - - frame } - /// Build TUNNELING_REQUEST containing cEMI frame using knx-pico - fn build_tunneling_request( - channel_id: u8, - seq: u8, - cemi_frame: &[u8], - ) -> heapless::Vec { - let conn_header = ConnectionHeader::new(channel_id, seq); - let request = TunnelingRequest::new(conn_header, cemi_frame); - - let mut buffer = [0u8; 256]; - let len = request - .build(&mut buffer) - .expect("Buffer too small for TUNNELING_REQUEST"); - - let mut frame = heapless::Vec::new(); - let _ = frame.extend_from_slice(&buffer[..len]); - frame - } - - /// Build CONNECTIONSTATE_REQUEST for heartbeat using knx-pico - fn build_connectionstate_request(channel_id: u8) -> heapless::Vec { - // Use 0.0.0.0:0 for "any" address - let hpai = Hpai::new([0, 0, 0, 0], 0); - let request = ConnectionStateRequest::new(channel_id, hpai); - - let mut buffer = [0u8; 32]; - let len = request - .build(&mut buffer) - .expect("Buffer too small for CONNECTIONSTATE_REQUEST"); - - let mut frame = heapless::Vec::new(); - let _ = frame.extend_from_slice(&buffer[..len]); - frame - } - - /// Check if frame is a TUNNELING_REQUEST using knx-pico - fn is_tunneling_request(data: &[u8]) -> bool { - if let Ok(frame) = KnxnetIpFrame::parse(data) { - frame.service_type() == ServiceType::TunnellingRequest - } else { - false - } - } - - /// Check if frame is a TUNNELING_ACK using knx-pico - fn is_tunneling_ack(data: &[u8]) -> bool { - if let Ok(frame) = KnxnetIpFrame::parse(data) { - frame.service_type() == ServiceType::TunnellingAck - } else { - false - } + fn warn_ack_timeout(&mut self, seq: u8) { + let _ = seq; + #[cfg(feature = "defmt")] + defmt::warn!("⚠️ ACK timeout for seq={}", seq); } +} - /// Parse a KNX telegram using knx-pico and extract group address and data - /// - /// Returns (group_address, payload) if this is a valid L_Data.ind telegram - fn parse_telegram(data: &[u8]) -> Option<(GroupAddress, Vec)> { - // Parse KNXnet/IP frame - let frame = KnxnetIpFrame::parse(data).ok()?; - - // Only process TUNNELLING_REQUEST - if frame.service_type() != ServiceType::TunnellingRequest { - return None; - } - - // Parse tunneling request to get cEMI - let tunneling_req = TunnelingRequest::parse(frame.body()).ok()?; - - // Parse cEMI frame - let cemi = CEMIFrame::parse(tunneling_req.cemi_data).ok()?; +/// Drive the engine over one socket lifetime; returns when the engine asks +/// for a socket reset. +async fn drive_connection( + engine: &mut TunnelEngine, + socket: &mut UdpSocket<'_>, + gateway_addr: Ipv4Address, + gateway_port: u16, + command_channel: CommandChannelRef, + inbound_tx: &InboundSender, +) { + use embassy_futures::select::{select3, Either3}; - // Only process L_Data frames - if !cemi.is_ldata() { - return None; - } + let gateway = (IpAddress::Ipv4(gateway_addr), gateway_port); - // Parse LData frame using knx-pico (handles all encoding variants including 6-bit values) - let ldata = match cemi.as_ldata() { - Ok(l) => l, - Err(_e) => { - #[cfg(feature = "defmt")] - defmt::warn!("Failed to parse L_Data frame"); - return None; - } - }; + loop { + engine.poll(now_ms()); - #[cfg(feature = "defmt")] { - let dest_addr = ldata.destination_raw; - let npdu_len = ldata.npdu_length; - defmt::trace!( - "LData parsed: dest={:04X}, npdu_len={}, ldata.data.len()={}", - dest_addr, - npdu_len, - ldata.data.len() - ); - } - - // Only process group write commands - if !ldata.is_group_write() { - return None; + let mut io = EmbassyIo { + socket, + gateway, + inbound_tx, + }; + if drain_actions(engine, &mut io).await { + return; + } } - // Only process group addresses (not individual addresses) - let dest = ldata.destination_group()?; - - // Extract payload (application data) - // For 6-bit encoded values (DPT1 boolean), ldata.data is empty - // and the value is encoded in the APCI byte. We need to extract it manually. - let payload = if ldata.data.is_empty() { - // 6-bit encoding: extract value from APCI byte in raw cEMI data - // cEMI structure: [msg_code, add_info_len, , ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, ...] - // APCI byte position = 2 + add_info_len + 8 - let cemi_data = tunneling_req.cemi_data; - let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; - let apci_pos = 2 + add_info_len + 8; // TPCI is at +7, APCI is at +8 - - if cemi_data.len() > apci_pos { - let apci_byte = cemi_data[apci_pos]; - let value = apci_byte & 0x3F; // Extract 6-bit value - - #[cfg(feature = "defmt")] - defmt::debug!( - "6-bit decoding: apci_byte={:02X}, extracted_value={:02X}", - apci_byte, - value - ); + let sleep_ms = engine.next_deadline().saturating_sub(now_ms()); + let deadline = embassy_time::Timer::after(embassy_time::Duration::from_millis(sleep_ms)); + let mut recv_buf = [0u8; 512]; - vec![value] + // Only drain commands while connected: during connect / backoff the + // arm stays pending, so commands keep queueing in the static channel + // and flush once the handshake completes (same as the previous + // implementation, where the select loop only ran while connected). + let connected = engine.is_connected(); + let cmd_arm = async { + if connected { + command_channel.receive().await } else { - vec![] + core::future::pending().await } - } else { - // Standard encoding: multi-byte data (DPT5, DPT7, DPT9, etc.) - // cEMI L_Data structure (after msg_code and add_info): - // [0] ctrl1, [1] ctrl2, [2-3] src, [4-5] dest, [6] npdu_len, [7] TPCI, [8] APCI_low, [9+] data - // - // According to knx-pico parser: data starts at position 9 in L_Data - // In full cEMI frame: position = 2 + add_info_len + 9 = 11 (when add_info_len=0) - let cemi_data = tunneling_req.cemi_data; - let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; - - // Data starts at: msg_code(0) + add_info_len_field(1) + add_info(variable) + L_Data_header(9) - let ldata_offset = 2 + add_info_len; - let data_start = ldata_offset + 9; // Position 11 when add_info_len=0 - - #[cfg(feature = "defmt")] - { - let npdu_len_pos = ldata_offset + 6; - let tpci_pos = ldata_offset + 7; - let apci_pos = ldata_offset + 8; - - defmt::debug!( - "cEMI: len={}, add_info_len={}, NPDU_len@{}={:02X}, TPCI@{}={:02X}, APCI@{}={:02X}, Data@{}+={=[u8]:02x}", - cemi_data.len(), - add_info_len, - npdu_len_pos, cemi_data[npdu_len_pos], - tpci_pos, cemi_data[tpci_pos], - apci_pos, cemi_data[apci_pos], - data_start, &cemi_data[data_start..] - ); - } - - let extracted = if cemi_data.len() > data_start { - cemi_data[data_start..].to_vec() - } else { - // Fallback to knx-pico's parsed data if extraction fails - ldata.data.to_vec() - }; - - #[cfg(feature = "defmt")] - defmt::debug!( - "Extracted {} bytes: {=[u8]:02x}", - extracted.len(), - extracted - ); - - extracted }; - #[cfg(feature = "defmt")] - defmt::trace!( - "Parsed telegram for {}: {} payload bytes", - dest, - payload.len() - ); - - Some((dest, payload)) - } -} - -// Implement the Connector trait -impl aimdb_core::transport::Connector for KnxConnectorImpl { - fn publish( - &self, - resource_id: &str, - _config: &aimdb_core::transport::ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> - { - use aimdb_core::transport::PublishError; - - // Parse group address from resource_id (format: "1/0/7") using knx-pico's type-safe parser - let group_addr = match resource_id.parse::() { - Ok(addr) => addr, - Err(_) => { - return Box::pin(async move { Err(PublishError::InvalidDestination) }); + match select3(socket.recv_from(&mut recv_buf), cmd_arm, deadline).await { + Either3::First(Ok((len, _peer))) => { + engine.handle_datagram(&recv_buf[..len], now_ms()); } - }; - - // Convert payload to heapless::Vec - let mut vec_data = heapless::Vec::::new(); - if vec_data.extend_from_slice(payload).is_err() { - return Box::pin(async move { Err(PublishError::MessageTooLarge) }); + Either3::First(Err(_)) => { + #[cfg(feature = "defmt")] + defmt::error!("Socket receive error"); + engine.handle_socket_error(now_ms()); + } + Either3::Second(cmd) => { + // The command arm only resolves while connected, so the + // engine's disconnected drop path is unreachable here; its + // `false` return is a defensive contract covered by the + // engine unit tests. + let _ = engine.handle_command(*cmd, now_ms()); + } + // Wake for the engine deadline; `poll` at the loop top fires it. + Either3::Third(()) => {} } - - let cmd = KnxCommand { - kind: KnxCommandKind::GroupWrite(Box::new(GroupWriteData { - group_addr, - data: vec_data, - })), - }; - - let command_channel = self.command_channel; - - Box::pin(async move { - // Send command to background task via channel - command_channel.send(cmd).await; - - Ok(()) - }) } } diff --git a/aimdb-knx-connector/src/lib.rs b/aimdb-knx-connector/src/lib.rs index 0b045109..d42c6f3a 100644 --- a/aimdb-knx-connector/src/lib.rs +++ b/aimdb-knx-connector/src/lib.rs @@ -74,11 +74,11 @@ //! use aimdb_knx_connector::embassy_client::KnxConnectorBuilder; //! use alloc::sync::Arc; //! -//! let runtime = Arc::new(EmbassyAdapter::new_with_network(spawner, stack)); +//! let runtime = Arc::new(EmbassyAdapter::new()); //! //! let db = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(KnxConnectorBuilder::new("knx://192.168.1.19:3671")) +//! .with_connector(KnxConnectorBuilder::new("knx://192.168.1.19:3671", stack)) //! .configure::(|reg| { //! reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) //! .source(sensor_producer) @@ -122,7 +122,7 @@ #![cfg_attr(not(feature = "std"), no_std)] -#[cfg(not(feature = "std"))] +// The shared tunnel engine uses `alloc` types on both runtimes. extern crate alloc; // Re-export knx-pico types for user convenience @@ -152,6 +152,9 @@ pub mod dpt { #[cfg(feature = "std")] pub use knx_pico::dpt::{Dpt1, Dpt5, Dpt9, DptDecode, DptEncode}; +// Runtime-neutral KNX/IP tunneling state machine shared by both transports. +pub mod tunnel; + // Platform-specific implementations #[cfg(feature = "tokio-runtime")] pub mod tokio_client; diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index 7d96043b..aaff6a75 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -1,39 +1,31 @@ -//! KNX/IP client management and lifecycle for Tokio runtime +//! Tokio transport shim for the KNX/IP connector //! -//! This module provides a KNX connector that: -//! - Manages a single KNX/IP gateway connection (with reconnection) -//! - Rides core's `pump_sink` / `pump_source`: a `KnxSink` (outbound -//! `GroupValueWrite`) and a `KnxSource` (inbound telegrams) over the -//! connection task's command / telegram channels +//! This module contributes only socket glue: a UDP socket, the channels +//! between the pumps and the connection task, and a select loop driving the +//! shared sans-io [`TunnelEngine`](crate::tunnel::TunnelEngine). The entire +//! tunneling lifecycle (handshake, ACK bookkeeping, keepalive, reconnect +//! backoff) lives in [`crate::tunnel`]. +//! +//! - Outbound rides core's `pump_sink`: [`KnxSink`] forwards each serialized +//! record as a [`GroupWrite`] command to the connection task. +//! - Inbound rides core's `pump_source`: the connection task pushes parsed +//! `(group-address, payload)` telegrams that [`KnxSource`] yields. +use crate::tunnel::{ + drain_actions, GroupWrite, LocalEndpoint, TunnelConfig, TunnelEngine, TunnelIo, +}; use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; -use knx_pico::protocol::{ - CEMIFrame, ConnectRequest, ConnectResponse, ConnectionHeader, ConnectionStateRequest, Hpai, - KnxnetIpFrame, ServiceType, TunnelingAck, TunnelingRequest, -}; use std::future::Future; -use std::net::SocketAddr; +use std::net::{IpAddr, SocketAddr}; use std::pin::Pin; use std::sync::Arc; use std::time::Duration; use tokio::net::UdpSocket; use tokio::sync::mpsc; -/// Command sent from outbound publishers to connection task -#[derive(Debug)] -enum KnxCommand { - /// Send a GroupValueWrite telegram - GroupWrite { - group_addr: GroupAddress, - data: Vec, - /// Optional response channel for error reporting - response: Option>>, - }, -} - /// KNX connector for a single gateway connection. /// /// Each connector manages ONE KNX/IP gateway connection; inbound telegrams are @@ -98,10 +90,10 @@ impl KnxConnectorBuilder { type BoxFuture = Pin + Send + 'static>>; -impl ConnectorBuilder for KnxConnectorBuilder { +impl ConnectorBuilder for KnxConnectorBuilder { fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { // Build the command channel, the inbound-telegram channel, and the @@ -137,10 +129,7 @@ impl ConnectorBuilder for KnxCon /// Build-time helper aggregating KNX construction logic. /// /// `KnxConnectorBuilder::build()` produces a `Vec` containing one -/// connection-task future plus one publisher future per outbound route. There -/// is no longer a long-lived `KnxConnectorImpl` value — the previous -/// `Connector::publish` direct-publish path was unreachable through the -/// public API (`AimDbBuilder` discarded the `Arc`). +/// connection-task future plus one publisher future per outbound route. pub struct KnxConnectorImpl; impl KnxConnectorImpl { @@ -155,36 +144,40 @@ impl KnxConnectorImpl { command_queue_size: usize, ) -> Result< ( - mpsc::Sender, + mpsc::Sender, mpsc::Receiver<(String, Payload)>, BoxFuture, ), String, > { - // Parse the gateway URL - let mut url = gateway_url.to_string(); - if !url.contains('/') || url.matches('/').count() < 3 { - url = format!("{}/0/0/0", url.trim_end_matches('/')); - } + // Parse the gateway URL (bare `knx://host:port`, like the Embassy shim). let connector_url = - ConnectorUrl::parse(&url).map_err(|e| format!("Invalid KNX URL: {}", e))?; + ConnectorUrl::parse(gateway_url).map_err(|e| format!("Invalid KNX URL: {}", e))?; let gateway_ip = connector_url.host.clone(); let gateway_port = connector_url.port.unwrap_or(3671); + // Validate the gateway address here so a typo'd IP (or a hostname — + // never resolved) surfaces as a build() error instead of a parked + // connection task, matching the Embassy shim (issue #133 contract). + let gateway_addr: SocketAddr = format!("{}:{}", gateway_ip, gateway_port) + .parse() + .map_err(|_| { + format!( + "Invalid KNX gateway address {}:{} (an IP address is required; hostnames are not resolved)", + gateway_ip, gateway_port + ) + })?; + #[cfg(feature = "tracing")] - tracing::info!( - "Creating KNX connector for gateway {}:{}", - gateway_ip, - gateway_port - ); + tracing::info!("Creating KNX connector for gateway {}", gateway_addr); // Outbound commands (publishers → connection task) and inbound telegrams // (connection task → `KnxSource`/`pump_source`). - let (command_tx, command_rx) = mpsc::channel::(command_queue_size); + let (command_tx, command_rx) = mpsc::channel::(command_queue_size); let (telegram_tx, telegram_rx) = mpsc::channel::<(String, Payload)>(command_queue_size); - let connection_future = - build_connection_future(gateway_ip, gateway_port, telegram_tx, command_rx); + let connection_future: BoxFuture = + Box::pin(connection_task(gateway_addr, telegram_tx, command_rx)); Ok((command_tx, telegram_rx, connection_future)) } @@ -197,7 +190,7 @@ impl KnxConnectorImpl { /// parses that address and forwards a fire-and-forget `GroupValueWrite` to the /// connection task over the command channel. struct KnxSink { - command_tx: mpsc::Sender, + command_tx: mpsc::Sender, } impl Connector for KnxSink { @@ -207,19 +200,12 @@ impl Connector for KnxSink { _config: &ConnectorConfig, payload: &[u8], ) -> Pin> + Send + '_>> { - let group_addr_str = destination.to_string(); - let data = payload.to_vec(); + // Validation shared with the Embassy shim (same checks, same order). + let command = GroupWrite::try_new(destination, payload); let command_tx = self.command_tx.clone(); Box::pin(async move { - let group_addr = group_addr_str - .parse::() - .map_err(|_| PublishError::InvalidDestination)?; command_tx - .send(KnxCommand::GroupWrite { - group_addr, - data, - response: None, // fire-and-forget - }) + .send(command?) .await .map_err(|_| PublishError::ConnectionFailed) // connection task gone }) @@ -240,704 +226,308 @@ impl Source for KnxSource { } } -/// Builds the KNX connection-task future with reconnection logic. -/// -/// The connection task handles: -/// - KNXnet/IP connection establishment -/// - Telegram reception and parsing -/// - Forwarding parsed inbound telegrams to the `telegram_tx` channel (`pump_source`) -/// - Outbound command processing -/// - Automatic reconnection on failure +/// The connection task: socket I/O around the shared [`TunnelEngine`]. /// -/// # Arguments -/// * `gateway_ip` - Gateway IP address -/// * `gateway_port` - Gateway port (typically 3671) -/// * `telegram_tx` - Sender for inbound telegrams → `KnxSource`/`pump_source` -/// * `command_rx` - Receiver half of the outbound command channel -fn build_connection_future( - gateway_ip: String, - gateway_port: u16, +/// Each outer iteration binds a fresh UDP socket and drives the engine over +/// its lifetime: fire engine deadlines, apply the engine's actions, and select +/// over inbound datagrams, outbound commands, and the next engine deadline. +/// When the engine asks for a socket reset, the socket is dropped and the +/// engine's backoff deadline is waited out before rebinding. +async fn connection_task( + gateway_addr: SocketAddr, telegram_tx: mpsc::Sender<(String, Payload)>, - mut command_rx: mpsc::Receiver, -) -> BoxFuture { - Box::pin(async move { - #[cfg(feature = "tracing")] - tracing::info!( - "KNX connection task started for {}:{}", - gateway_ip, - gateway_port - ); - - loop { - match connect_and_listen(&gateway_ip, gateway_port, &telegram_tx, &mut command_rx).await - { - Ok(_) => { - #[cfg(feature = "tracing")] - tracing::info!("KNX connection closed gracefully"); - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("KNX connection failed: {:?}, reconnecting in 5s...", _e); - } - } - - // Wait before reconnecting - tokio::time::sleep(Duration::from_secs(5)).await; - } - }) -} - -/// Build CONNECTIONSTATE_REQUEST for heartbeat using knx-pico -fn build_connectionstate_request(channel_id: u8) -> Vec { - // Use 0.0.0.0:0 for "any" address - let hpai = Hpai::new([0, 0, 0, 0], 0); - let request = ConnectionStateRequest::new(channel_id, hpai); - - let mut buffer = [0u8; 32]; - let len = request - .build(&mut buffer) - .expect("Buffer too small for CONNECTIONSTATE_REQUEST"); - buffer[..len].to_vec() -} - -/// Pending ACK for outbound telegram -struct PendingAck { - sent_at: std::time::Instant, - response_tx: Option>>, -} - -/// Connection state shared within the connection task -struct ChannelState { - /// KNXnet/IP channel ID from CONNECT_RESPONSE - channel_id: u8, - - /// Last received sequence counter (inbound telegrams) - inbound_seq: u8, - - /// Next sequence counter to use for outbound telegrams - outbound_seq: u8, - - /// Pending ACKs waiting for confirmation (seq -> PendingAck) - pending_acks: std::collections::HashMap, -} - -impl ChannelState { - fn new(channel_id: u8) -> Self { - Self { - channel_id, - inbound_seq: 0, - outbound_seq: 0, - pending_acks: std::collections::HashMap::new(), - } - } - - fn next_outbound_seq(&mut self) -> u8 { - let seq = self.outbound_seq; - self.outbound_seq = self.outbound_seq.wrapping_add(1); - seq - } - - /// Track a pending ACK for an outbound telegram - fn add_pending_ack( - &mut self, - seq: u8, - response_tx: Option>>, - ) { - self.pending_acks.insert( - seq, - PendingAck { - sent_at: std::time::Instant::now(), - response_tx, - }, - ); - } - - /// Complete a pending ACK (received confirmation) - fn complete_ack(&mut self, seq: u8) -> bool { - if let Some(pending) = self.pending_acks.remove(&seq) { - if let Some(tx) = pending.response_tx { - let _ = tx.send(Ok(())); - } - true - } else { - false - } - } - - /// Check for timed-out ACKs (> 3 seconds) - fn check_ack_timeouts(&mut self) -> Vec { - let now = std::time::Instant::now(); - let mut timed_out = Vec::new(); - - self.pending_acks.retain(|&seq, pending| { - if now.duration_since(pending.sent_at) > Duration::from_secs(3) { - timed_out.push(seq); - if let Some(tx) = pending.response_tx.take() { - let _ = tx.send(Err(format!("ACK timeout for seq={}", seq))); - } - false // Remove from pending - } else { - true // Keep waiting - } - }); - - timed_out - } -} - -/// Connect to KNX gateway and listen for telegrams -/// -/// This function implements the full KNXnet/IP Tunneling lifecycle: -/// 1. Create UDP socket -/// 2. Send CONNECT_REQUEST -/// 3. Receive CONNECT_RESPONSE (get channel_id) -/// 4. Loop: receive TUNNELING_REQUEST, parse, route, send ACK -/// and process outbound commands from the command queue -/// -/// # Arguments -/// * `gateway_ip` - Gateway IP address -/// * `gateway_port` - Gateway port -/// * `telegram_tx` - Sender for parsed inbound telegrams → `pump_source` -/// * `command_rx` - Command receiver for outbound publishing -async fn connect_and_listen( - gateway_ip: &str, - gateway_port: u16, - telegram_tx: &mpsc::Sender<(String, Payload)>, - command_rx: &mut mpsc::Receiver, -) -> Result<(), String> { - // 1. Create UDP socket - let socket = UdpSocket::bind("0.0.0.0:0") - .await - .map_err(|e| format!("Failed to bind UDP socket: {}", e))?; - - let local_addr = socket - .local_addr() - .map_err(|e| format!("Failed to get local address: {}", e))?; - - let gateway_addr: SocketAddr = format!("{}:{}", gateway_ip, gateway_port) - .parse() - .map_err(|e| format!("Invalid gateway address: {}", e))?; - + mut command_rx: mpsc::Receiver, +) { #[cfg(feature = "tracing")] - tracing::debug!("KNX: Connecting from {} to {}", local_addr, gateway_addr); + tracing::info!("KNX connection task started for {}", gateway_addr); - // 2. Send CONNECT_REQUEST (using knx-pico types) - let connect_req = build_connect_request(local_addr)?; - socket - .send_to(&connect_req, gateway_addr) - .await - .map_err(|e| format!("Failed to send CONNECT_REQUEST: {}", e))?; + let epoch = tokio::time::Instant::now(); + let now_ms = || epoch.elapsed().as_millis() as u64; - // 3. Wait for CONNECT_RESPONSE + let mut engine = TunnelEngine::new(TunnelConfig::default(), now_ms()); let mut buf = [0u8; 1024]; - let (len, _) = tokio::time::timeout(Duration::from_secs(5), socket.recv_from(&mut buf)) - .await - .map_err(|_| "Timeout waiting for CONNECT_RESPONSE")? - .map_err(|e| format!("Failed to receive CONNECT_RESPONSE: {}", e))?; - - let (channel_id, status) = parse_connect_response(&buf[..len])?; - - if status != 0 { - return Err(format!( - "Connection rejected by gateway, status: {}", - status - )); - } - - #[cfg(feature = "tracing")] - tracing::info!("✅ KNX connected, channel_id: {}", channel_id); - - // 4. Listen loop with command queue and ACK timeout checking - let mut channel_state = ChannelState::new(channel_id); - let mut heartbeat_interval = tokio::time::interval(Duration::from_secs(55)); - heartbeat_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - - // ACK timeout checker (runs every 500ms) - let mut ack_timeout_interval = tokio::time::interval(Duration::from_millis(500)); - ack_timeout_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // Set to false once every `KnxSink` is gone. With no outbound routes that + // happens right at build time (`pump_sink` drops the unused sink), so a + // closed command channel only disables its select arm — inbound routing + // keeps running. + let mut commands_open = true; loop { - tokio::select! { - // Inbound: Receive telegrams from gateway - result = socket.recv_from(&mut buf) => { - match result { - Ok((len, _)) => { - #[cfg(feature = "tracing")] - tracing::trace!("Received {} bytes from gateway", len); - - // Check if this is a TUNNELING_ACK for our outbound telegram - if is_tunneling_ack(&buf[..len]) { - #[cfg(feature = "tracing")] - tracing::debug!("Received TUNNELING_ACK: {:02X?}", &buf[..len]); - - // Parse ACK - try knx-pico parser first, fallback to manual parsing - // Some gateways send non-standard ACK format (missing status byte) - let ack_seq = if let Ok(frame) = KnxnetIpFrame::parse(&buf[..len]) { - if let Ok(ack) = TunnelingAck::parse(frame.body()) { - // Standard parsing succeeded - ack.connection_header.sequence_counter - } else if frame.body().len() >= 4 { - // Fallback: manually extract sequence from ConnectionHeader - // Body format: [struct_len, channel_id, seq, status] - // Gateway may send 4 bytes instead of 5 (missing final status byte) - let seq = frame.body()[2]; - - #[cfg(feature = "tracing")] - tracing::debug!("Using fallback ACK parsing (non-standard gateway format)"); - - seq - } else { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse TUNNELING_ACK body, raw: {:02X?}", &buf[..len]); - continue; - } - } else { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse frame as TUNNELING_ACK, raw: {:02X?}", &buf[..len]); - continue; - }; - - // Complete the pending ACK - if channel_state.complete_ack(ack_seq) { - #[cfg(feature = "tracing")] - tracing::trace!("✅ Received TUNNELING_ACK for seq={}", ack_seq); - } else { - #[cfg(feature = "tracing")] - tracing::warn!("⚠️ Received unexpected TUNNELING_ACK for seq={}", ack_seq); - } - - continue; // Don't process ACKs as data telegrams - } else { - #[cfg(feature = "tracing")] - tracing::trace!("Frame is not TUNNELING_ACK, checking if telegram..."); - } - - // Parse telegram - if let Some((group_addr, data)) = parse_telegram(&buf[..len]) { - let resource_id = group_addr.to_string(); - - #[cfg(feature = "tracing")] - tracing::debug!("KNX telegram: {} ({} bytes)", resource_id, data.len()); - - // Forward to `pump_source` (which routes to producers). - // `try_send` so a slow/full sink never stalls the - // protocol task (ACKs, keepalive, outbound). - if telegram_tx - .try_send((resource_id, Payload::from(data.as_slice()))) - .is_err() - { - #[cfg(feature = "tracing")] - tracing::warn!( - "KNX inbound: dropping telegram for {} (channel full/closed)", - group_addr - ); - } - } else { - #[cfg(feature = "tracing")] - tracing::trace!("Ignoring non-GroupWrite or invalid telegram"); - } - - // Send ACK if TUNNELING_REQUEST - if is_tunneling_request(&buf[..len]) { - // Extract received sequence from telegram - let recv_seq = if len > 8 { buf[8] } else { 0 }; - channel_state.inbound_seq = recv_seq; - - let ack = build_tunneling_ack(channel_state.channel_id, recv_seq); - let _ = socket.send_to(&ack, gateway_addr).await; - - #[cfg(feature = "tracing")] - tracing::trace!("Sent TUNNELING_ACK with seq={}", recv_seq); - } + // Bind a fresh socket for this connection cycle and advertise its + // real address in the next CONNECT_REQUEST. + let socket = match UdpSocket::bind("0.0.0.0:0").await { + Ok(s) => { + if let Ok(local) = s.local_addr() { + if let IpAddr::V4(ip) = local.ip() { + engine.set_local_endpoint(LocalEndpoint::Explicit { + ip: ip.octets(), + port: local.port(), + }); } - Err(e) => { - return Err(format!("Socket error: {}", e)); - } - } - } - - // Outbound: Process commands from queue - Some(cmd) = command_rx.recv() => { - let KnxCommand::GroupWrite { group_addr, data, response } = cmd; - - // Send the telegram (this increments outbound_seq internally) - let seq_before = channel_state.outbound_seq; - - let result = send_group_write_internal( - &socket, - gateway_addr, - &mut channel_state, - group_addr, - &data, - ).await; - - // If send succeeded, always track pending ACK (even for fire-and-forget) - if result.is_ok() { - channel_state.add_pending_ack(seq_before, response); - } else if let Some(tx) = response { - // Send immediate response if send failed - let _ = tx.send(result); - } else if let Err(_e) = result { #[cfg(feature = "tracing")] - tracing::error!("GroupWrite failed: {}", _e); + tracing::debug!("KNX: Connecting from {} to {}", local, gateway_addr); } + s } - - // Heartbeat: Send CONNECTIONSTATE_REQUEST every 55s - _ = heartbeat_interval.tick() => { + Err(_e) => { #[cfg(feature = "tracing")] - tracing::trace!("Sending heartbeat (CONNECTIONSTATE_REQUEST)"); - - let heartbeat = build_connectionstate_request(channel_state.channel_id); - if let Err(e) = socket.send_to(&heartbeat, gateway_addr).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send heartbeat: {}", e); - return Err(format!("Heartbeat send failed: {}", e)); - } + tracing::error!("Failed to bind UDP socket: {}, retrying in 5s", _e); + tokio::time::sleep(Duration::from_secs(5)).await; + continue; } + }; - // ACK timeout checker: Check for expired ACKs every 500ms - _ = ack_timeout_interval.tick() => { - let timed_out = channel_state.check_ack_timeouts(); - if !timed_out.is_empty() { - #[cfg(feature = "tracing")] - tracing::warn!("⚠️ ACK timeouts for sequences: {:?}", timed_out); - } + // Drive the engine over this socket's lifetime. + loop { + engine.poll(now_ms()); + + let mut io = TokioIo { + socket: &socket, + gateway: gateway_addr, + telegram_tx: &telegram_tx, + }; + if drain_actions(&mut engine, &mut io).await { + break; // engine asked for a socket reset } - } - } -} - -/// Build KNXnet/IP CONNECT_REQUEST frame using knx-pico -fn build_connect_request(local_addr: SocketAddr) -> Result, String> { - use std::net::IpAddr; - // Convert local address to Hpai - let ip_bytes = match local_addr.ip() { - IpAddr::V4(ip) => ip.octets(), - _ => return Err("IPv6 not supported".to_string()), - }; + let sleep_ms = engine.next_deadline().saturating_sub(now_ms()); - let hpai = Hpai::new(ip_bytes, local_addr.port()); - let request = ConnectRequest::new(hpai, hpai); - - let mut buffer = [0u8; 32]; - let len = request - .build(&mut buffer) - .map_err(|e| format!("Failed to build CONNECT_REQUEST: {:?}", e))?; - - Ok(buffer[..len].to_vec()) -} - -/// Parse CONNECT_RESPONSE using knx-pico and extract channel_id and status -fn parse_connect_response(data: &[u8]) -> Result<(u8, u8), String> { - let frame = - KnxnetIpFrame::parse(data).map_err(|e| format!("Failed to parse frame: {:?}", e))?; - - if frame.service_type() != ServiceType::ConnectResponse { - return Err(format!( - "Not a CONNECT_RESPONSE, got: {:?}", - frame.service_type() - )); - } - - let response = ConnectResponse::parse(frame.body()) - .map_err(|e| format!("Failed to decode CONNECT_RESPONSE: {:?}", e))?; - - Ok((response.channel_id, response.status)) -} - -/// Build TUNNELING_ACK frame using knx-pico -fn build_tunneling_ack(channel_id: u8, seq_counter: u8) -> Vec { - let conn_header = ConnectionHeader::new(channel_id, seq_counter); - let ack = TunnelingAck::new(conn_header, 0); // status = 0 (OK) - let mut buffer = [0u8; 16]; - let len = ack - .build(&mut buffer) - .expect("Buffer too small for TUNNELING_ACK"); - buffer[..len].to_vec() -} + tokio::select! { + result = socket.recv_from(&mut buf) => match result { + Ok((len, _)) => { + #[cfg(feature = "tracing")] + tracing::trace!("Received {} bytes from gateway", len); + engine.handle_datagram(&buf[..len], now_ms()); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("Socket error: {}", _e); + engine.handle_socket_error(now_ms()); + } + }, + // Only drained while connected: commands queue up in the channel + // during a reconnect cycle and flush once the handshake completes + // (same as the previous implementation, where the select loop only + // ran while connected). + cmd = command_rx.recv(), if commands_open && engine.is_connected() => match cmd { + Some(cmd) => { + // The arm guard above only admits commands while + // connected, so the engine's disconnected drop path is + // unreachable here; its `false` return is a defensive + // contract covered by the engine unit tests. + let _ = engine.handle_command(cmd, now_ms()); + } + // All `KnxSink`s dropped — no outbound publisher remains. + // Inbound monitoring still has to run, so only disable this + // arm instead of exiting the connection task. + None => commands_open = false, + }, + // Wake for the next engine deadline; `poll` at the loop top fires it. + _ = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {} + } + } -/// Check if frame is a TUNNELING_REQUEST using knx-pico -fn is_tunneling_request(data: &[u8]) -> bool { - if let Ok(frame) = KnxnetIpFrame::parse(data) { - frame.service_type() == ServiceType::TunnellingRequest - } else { - false + #[cfg(feature = "tracing")] + tracing::error!("KNX connection lost, reconnecting after backoff..."); + // The engine is backing off: nothing can be sent until its deadline, + // so wait it out before binding the fresh socket. This also paces the + // rebind cycle when a socket errors persistently (the old client + // likewise slept the full backoff between socket teardowns). + let wait_ms = engine.next_deadline().saturating_sub(now_ms()); + tokio::time::sleep(Duration::from_millis(wait_ms)).await; } } -/// Check if frame is a TUNNELING_ACK using knx-pico -fn is_tunneling_ack(data: &[u8]) -> bool { - if let Ok(frame) = KnxnetIpFrame::parse(data) { - frame.service_type() == ServiceType::TunnellingAck - } else { - false - } +/// Socket-side glue for [`drain_actions`]: frames ride the bound UDP socket, +/// parsed telegrams ride the mpsc channel into [`KnxSource`]. +struct TokioIo<'a> { + socket: &'a UdpSocket, + gateway: SocketAddr, + telegram_tx: &'a mpsc::Sender<(String, Payload)>, } -/// Parse KNX telegram using knx-pico and extract group address and data -/// -/// Returns (group_address, payload) if this is a valid L_Data.ind telegram -fn parse_telegram(data: &[u8]) -> Option<(GroupAddress, Vec)> { - // Parse KNXnet/IP frame - let frame = KnxnetIpFrame::parse(data).ok()?; - - // Only process TUNNELLING_REQUEST - if frame.service_type() != ServiceType::TunnellingRequest { - return None; - } - - // Parse tunneling request to get cEMI - let tunneling_req = TunnelingRequest::parse(frame.body()).ok()?; - - // Parse cEMI frame - let cemi = CEMIFrame::parse(tunneling_req.cemi_data).ok()?; - - // Only process L_Data frames - if !cemi.is_ldata() { - return None; - } - - // Parse LData frame using knx-pico (handles all encoding variants including 6-bit values) - let ldata = match cemi.as_ldata() { - Ok(l) => l, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse L_Data frame: {:?}", _e); - return None; +impl TunnelIo for TokioIo<'_> { + async fn send(&mut self, frame: &[u8]) -> bool { + // Log-and-continue: a transient send error must not tear down the + // tunnel; a persistently dead send path surfaces through the engine's + // heartbeat-response timeout. + match self.socket.send_to(frame, self.gateway).await { + Ok(_) => true, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("KNX send failed: {}", _e); + false + } } - }; - - #[cfg(feature = "tracing")] - { - let dest_addr = ldata.destination_raw; - let npdu_len = ldata.npdu_length; - tracing::trace!( - "LData parsed: dest={:04X}, npdu_len={}, ldata.data.len()={}", - dest_addr, - npdu_len, - ldata.data.len() - ); } - // Only process group write commands - if !ldata.is_group_write() { - return None; - } - - // Only process group addresses (not individual addresses) - let dest = ldata.destination_group()?; - - // Extract payload (application data) - // For 6-bit encoded values (DPT1 boolean), ldata.data is empty - // and the value is encoded in the APCI byte. We need to extract it manually. - // Note: npdu_length can be 1 (combined TPCI+APCI) or 2 (separate TPCI and APCI) - let payload = if ldata.data.is_empty() { - // 6-bit encoding: extract value from APCI byte in raw cEMI data - // cEMI structure: [msg_code, add_info_len, , ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, ...] - // APCI byte position = 2 + add_info_len + 6 (ctrl1, ctrl2, src(2), dest(2), npdu_len) + 1 (tpci) = 2 + add_info_len + 7 + 1 - let cemi_data = tunneling_req.cemi_data; - let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; - let apci_pos = 2 + add_info_len + 8; // TPCI is at +7, APCI is at +8 - - if cemi_data.len() > apci_pos { - let apci_byte = cemi_data[apci_pos]; - let value = apci_byte & 0x3F; // Extract 6-bit value - - #[cfg(feature = "tracing")] - tracing::debug!( - "6-bit decoding: apci_byte={:02X}, extracted_value={:02X}, add_info_len={}, apci_pos={}", - apci_byte, value, add_info_len, apci_pos - ); - - vec![value] - } else { - vec![] - } - } else { - // Standard encoding: multi-byte data (DPT5, DPT7, DPT9, etc.) - // - // cEMI L_Data structure (after msg_code and add_info): - // [0] ctrl1, [1] ctrl2, [2-3] src, [4-5] dest, [6] npdu_len, [7] TPCI, [8] APCI_low, [9+] data - // - // According to knx-pico parser: data starts at position 9 in L_Data - // In full cEMI frame: position = 2 + add_info_len + 9 = 11 (when add_info_len=0) - let cemi_data = tunneling_req.cemi_data; - let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; - - // Data starts at: msg_code(0) + add_info_len_field(1) + add_info(variable) + L_Data_header(9) - let ldata_offset = 2 + add_info_len; - let data_start = ldata_offset + 9; // Position 11 when add_info_len=0 - + fn forward(&mut self, addr: GroupAddress, payload: Vec) { #[cfg(feature = "tracing")] + tracing::debug!("KNX telegram: {} ({} bytes)", addr, payload.len()); + + if self + .telegram_tx + .try_send((addr.to_string(), Payload::from(payload))) + .is_err() { - let npdu_len_pos = ldata_offset + 6; - let tpci_pos = ldata_offset + 7; - let apci_pos = ldata_offset + 8; - - tracing::debug!( - "cEMI: len={}, add_info_len={}, NPDU_len@{}={:02X}, TPCI@{}={:02X}, APCI@{}={:02X}, Data@{}+={:02X?}", - cemi_data.len(), - add_info_len, - npdu_len_pos, cemi_data[npdu_len_pos], - tpci_pos, cemi_data[tpci_pos], - apci_pos, cemi_data[apci_pos], - data_start, &cemi_data[data_start..] + #[cfg(feature = "tracing")] + tracing::warn!( + "KNX inbound: dropping telegram for {} (channel full/closed)", + addr ); } + } - let extracted = if cemi_data.len() > data_start { - cemi_data[data_start..].to_vec() - } else { - // Fallback to knx-pico's parsed data if extraction fails - ldata.data.to_vec() - }; - + fn warn_ack_timeout(&mut self, _seq: u8) { #[cfg(feature = "tracing")] - tracing::debug!("Extracted {} bytes: {:02X?}", extracted.len(), extracted); - - extracted - }; - - #[cfg(feature = "tracing")] - tracing::trace!( - "Parsed telegram for {}: {} payload bytes: {:02X?}", - dest, - payload.len(), - payload - ); - - Some((dest, payload)) -} - -/// Build GroupValueWrite cEMI frame (L_Data.req) -/// -/// Must match knx-pico's exact cEMI structure for proper parsing. -/// Structure: [msg_code, add_info_len, ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, data...] -fn build_group_write_cemi(group_addr: GroupAddress, data: &[u8]) -> Vec { - let mut frame = Vec::with_capacity(16); - - // Message code: L_Data.req (0x11) - frame.push(0x11); - - // Additional info length: 0 - frame.push(0x00); - - // Control field 1: 0xBC (Standard frame, no repeat, broadcast, priority low) - // Use 0xBC instead of 0x94 - this is critical for gateway compatibility - frame.push(0xBC); - - // Control field 2: 0xE0 (Group address, hop count 6) - frame.push(0xE0); - - // Source address: 0.0.0 (2 bytes, big-endian) - frame.extend_from_slice(&[0x00, 0x00]); - - // Destination address (group address) - convert to u16 big-endian - let dest_raw: u16 = group_addr.into(); - let dest_bytes = dest_raw.to_be_bytes(); - frame.extend_from_slice(&dest_bytes); - - // Build NPDU: NPDU_length field + TPCI + APCI + data - // CRITICAL: NPDU length encoding per KNX spec: - // - For short telegram: field = 0x01 (special flag) - // - For long telegram: field = actual_length - 1 (encoded as length-1) - if data.len() == 1 && data[0] < 64 { - // 6-bit encoding: value embedded in APCI byte - // NPDU length = 0x01 (short telegram flag, NOT byte count) - frame.push(0x01); - - // TPCI (UnnumberedData) - frame.push(0x00); - - // APCI low byte: GroupValueWrite (0x80) + 6-bit value - frame.push(0x80 | (data[0] & 0x3F)); - } else { - // Long telegram: APCI + separate data bytes - // NPDU length encoding: field = actual_length - 1 - let npdu_actual = 2 + data.len(); // TPCI + APCI + data - let npdu_len_field = npdu_actual - 1; // Encode as length - 1 - frame.push(npdu_len_field as u8); - - // TPCI (UnnumberedData) - frame.push(0x00); - - // APCI: GroupValueWrite - frame.push(0x80); - - // Data bytes - frame.extend_from_slice(data); + tracing::warn!("⚠️ ACK timeout for seq={}", _seq); } - - frame } -/// Build TUNNELING_REQUEST containing cEMI frame using knx-pico -fn build_tunneling_request(channel_id: u8, seq: u8, cemi: &[u8]) -> Vec { - let conn_header = ConnectionHeader::new(channel_id, seq); - let request = TunnelingRequest::new(conn_header, cemi); - let mut buffer = [0u8; 256]; - let len = request - .build(&mut buffer) - .expect("Buffer too small for TUNNELING_REQUEST"); - buffer[..len].to_vec() -} +#[cfg(test)] +mod tests { + use super::*; + use tokio::time::timeout; -/// Send GroupValueWrite telegram (internal, called from connection task) -async fn send_group_write_internal( - socket: &UdpSocket, - gateway_addr: SocketAddr, - channel_state: &mut ChannelState, - group_addr: GroupAddress, - data: &[u8], -) -> Result<(), String> { - // Build cEMI frame - let cemi = build_group_write_cemi(group_addr, data); + const RECV_TIMEOUT: Duration = Duration::from_secs(5); - #[cfg(feature = "tracing")] - tracing::debug!( - "Built cEMI frame for {} ({} data bytes): {:02X?}", - group_addr, - data.len(), - &cemi - ); + fn service_type_of(frame: &[u8]) -> u16 { + u16::from_be_bytes([frame[2], frame[3]]) + } - // Get next sequence number - let seq = channel_state.next_outbound_seq(); + /// CONNECT_RESPONSE: header + [channel_id, status, HPAI(8), CRD(4)]. + fn connect_response(channel_id: u8, status: u8) -> Vec { + let mut frame = vec![0x06, 0x10, 0x02, 0x06, 0x00, 0x14]; + frame.extend_from_slice(&[channel_id, status]); + frame.extend_from_slice(&[0x08, 0x01, 0, 0, 0, 0, 0, 0]); // HPAI 0.0.0.0:0 + frame.extend_from_slice(&[0x04, 0x04, 0x02, 0x00]); // CRD: tunnel + frame + } - // Build TUNNELING_REQUEST - let telegram = build_tunneling_request(channel_state.channel_id, seq, &cemi); + /// TUNNELING_REQUEST carrying a 6-bit GroupValueWrite to 1/0/7 (value 1). + fn inbound_group_write(channel_id: u8, seq: u8) -> Vec { + let cemi = [ + 0x29, 0x00, 0xBC, 0xE0, // L_Data.ind, no add-info, ctrl1, ctrl2 + 0x00, 0x00, 0x08, 0x07, // src 0.0.0, dest 1/0/7 + 0x01, 0x00, 0x81, // NPDU len, TPCI, APCI | value 1 + ]; + let total = 6 + 4 + cemi.len() as u16; + let mut frame = vec![0x06, 0x10, 0x04, 0x20]; + frame.extend_from_slice(&total.to_be_bytes()); + frame.extend_from_slice(&[0x04, channel_id, seq, 0x00]); // connection header + frame.extend_from_slice(&cemi); + frame + } - #[cfg(feature = "tracing")] - tracing::debug!( - "Built TUNNELING_REQUEST: channel={}, seq={}, total_len={} bytes: {:02X?}", - channel_state.channel_id, - seq, - telegram.len(), - &telegram - ); - - // Send via UDP - socket - .send_to(&telegram, gateway_addr) - .await - .map_err(|e| format!("Send failed: {}", e))?; + /// Full roundtrip against a scripted fake gateway on localhost UDP: + /// handshake, inbound telegram → `KnxSource` channel, outbound command → + /// TUNNELING_REQUEST on the wire (then ACKed). + #[tokio::test] + async fn tunnel_roundtrip_against_fake_gateway() { + let gateway = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let gateway_port = gateway.local_addr().unwrap().port(); - #[cfg(feature = "tracing")] - tracing::debug!( - "Sent GroupWrite: {} seq={} ({} bytes)", - group_addr, // GroupAddress implements Display - seq, - data.len() - ); - - Ok(()) -} + let (command_tx, mut telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&format!("knx://127.0.0.1:{}", gateway_port), 8) + .await + .unwrap(); + let task = tokio::spawn(connection_future); + + // Handshake: CONNECT_REQUEST in, CONNECT_RESPONSE out. + let mut buf = [0u8; 1024]; + let (len, client_addr) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no CONNECT_REQUEST") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0205); + gateway + .send_to(&connect_response(7, 0), client_addr) + .await + .unwrap(); + + // Inbound: gateway pushes a telegram; the client ACKs it and the + // parsed payload reaches the telegram channel. + gateway + .send_to(&inbound_group_write(7, 42), client_addr) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_ACK") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0421); + assert_eq!(buf[8], 42); // sequence echoed + let (topic, payload) = timeout(RECV_TIMEOUT, telegram_rx.recv()) + .await + .expect("no telegram routed") + .unwrap(); + assert_eq!(topic, "1/0/7"); + assert_eq!(&payload[..], &[0x01]); + + // Outbound: a GroupWrite command becomes a TUNNELING_REQUEST. + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + command_tx + .send(GroupWrite { + group_addr: "1/0/8".parse().unwrap(), + data, + }) + .await + .unwrap(); + let (len, _) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no TUNNELING_REQUEST") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0420); + assert_eq!(buf[8], 0); // first outbound sequence + assert_eq!(&buf[16..18], &[0x08, 0x08]); // cEMI destination = 1/0/8 + assert_eq!(buf[len - 1], 0x81); // APCI GroupValueWrite | 6-bit value 1 + + task.abort(); + } -#[cfg(test)] -mod tests { - use super::*; + /// Inbound-only regression: with no outbound routes, `pump_sink` drops the + /// only `KnxSink` (and with it the sole command sender) at build time. The + /// connection task must keep routing inbound telegrams — a closed command + /// channel only disables that select arm. + #[tokio::test] + async fn inbound_routing_survives_dropped_command_sender() { + let gateway = UdpSocket::bind("127.0.0.1:0").await.unwrap(); + let gateway_port = gateway.local_addr().unwrap().port(); + + let (command_tx, mut telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&format!("knx://127.0.0.1:{}", gateway_port), 8) + .await + .unwrap(); + drop(command_tx); // inbound-only configuration + let task = tokio::spawn(connection_future); + + let mut buf = [0u8; 1024]; + let (len, client_addr) = timeout(RECV_TIMEOUT, gateway.recv_from(&mut buf)) + .await + .expect("no CONNECT_REQUEST") + .unwrap(); + assert_eq!(service_type_of(&buf[..len]), 0x0205); + gateway + .send_to(&connect_response(7, 0), client_addr) + .await + .unwrap(); + + // The telegram arrives after the handshake completed — the moment the + // old code observed the closed channel and exited. + gateway + .send_to(&inbound_group_write(7, 1), client_addr) + .await + .unwrap(); + let (topic, payload) = timeout(RECV_TIMEOUT, telegram_rx.recv()) + .await + .expect("connection task died — no telegram routed") + .unwrap(); + assert_eq!(topic, "1/0/7"); + assert_eq!(&payload[..], &[0x01]); + + task.abort(); + } #[tokio::test] async fn test_connector_creation() { @@ -946,9 +536,12 @@ mod tests { } #[tokio::test] - async fn test_connector_with_port() { + async fn test_connector_rejects_hostname_at_build() { + // Hostnames are never resolved (`SocketAddr::parse` only accepts IP + // addresses), so this must fail from build() instead of producing a + // connector whose task can never reach a gateway (issue #133). let connector = KnxConnectorImpl::build_internal("knx://gateway.local:3672", 32).await; - assert!(connector.is_ok()); + assert!(connector.is_err()); } #[test] diff --git a/aimdb-knx-connector/src/tunnel.rs b/aimdb-knx-connector/src/tunnel.rs new file mode 100644 index 00000000..d4115b1a --- /dev/null +++ b/aimdb-knx-connector/src/tunnel.rs @@ -0,0 +1,1228 @@ +//! Sans-io KNX/IP tunneling state machine (issue #135, design doc 034 §3.7) +//! +//! One implementation of the tunneling lifecycle — CONNECT_REQUEST/RESPONSE +//! handshake, TUNNELING_REQUEST/ACK sequence bookkeeping, keepalive +//! (CONNECTIONSTATE_REQUEST) scheduling, ACK-timeout sweeps, and +//! reconnect-with-backoff — shared by the tokio and Embassy transports. +//! +//! The engine owns no I/O and no clock. Transports feed it events +//! ([`TunnelEngine::handle_datagram`], [`TunnelEngine::handle_command`], +//! [`TunnelEngine::handle_socket_error`]), call [`TunnelEngine::poll`] with the +//! current monotonic time to fire elapsed deadlines, drain [`Action`]s via +//! [`TunnelEngine::next_action`], and arm their timer from +//! [`TunnelEngine::next_deadline`]. Time is `u64` milliseconds from an +//! arbitrary epoch chosen by the transport; only differences matter. +//! +//! Mirrors the `framing.rs` precedent in `aimdb-serial-connector`: pure +//! `no_std + alloc` protocol logic, runtime-specific socket glue around it. + +use crate::GroupAddress; +use aimdb_core::transport::PublishError; +use alloc::collections::VecDeque; +use alloc::vec; +use alloc::vec::Vec; +use knx_pico::protocol::{ + CEMIFrame, ConnectRequest, ConnectResponse, ConnectionHeader, ConnectionStateRequest, + ConnectionStateResponse, Hpai, KnxnetIpFrame, ServiceType, TunnelingAck, TunnelingRequest, +}; + +/// Monotonic milliseconds from an arbitrary, transport-chosen epoch. +pub type Millis = u64; + +/// Maximum KNX/IP APDU payload carried in one GroupValueWrite. +pub const MAX_APDU: usize = 254; + +/// cEMI header (msg_code, add_info_len, ctrl1, ctrl2, src, dest, npdu_len, +/// tpci, apci) plus a full APDU. +const MAX_CEMI: usize = 12 + MAX_APDU; + +/// KNX/IP frame header (6) + connection header (4) + a full cEMI frame, +/// rounded up. Every datagram the engine emits fits. +const MAX_FRAME: usize = 12 + MAX_CEMI; + +/// A wire datagram to send to the gateway — stack-allocated. +pub type Frame = heapless::Vec; + +/// Outbound GroupValueWrite command, handed to the engine by the transport's +/// command channel (fed by the `Connector::publish` side). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupWrite { + pub group_addr: GroupAddress, + pub data: heapless::Vec, +} + +impl GroupWrite { + /// Validate one `Connector::publish` call into a tunnel command. + /// + /// Shared by both runtime shims so the checks (and their error + /// precedence) cannot drift: destination first, then payload size. + pub fn try_new(destination: &str, payload: &[u8]) -> Result { + let group_addr = destination + .parse::() + .map_err(|_| PublishError::InvalidDestination)?; + let mut data = heapless::Vec::new(); + data.extend_from_slice(payload) + .map_err(|_| PublishError::MessageTooLarge)?; + Ok(Self { group_addr, data }) + } +} + +/// What the transport must do next; drained via [`TunnelEngine::next_action`]. +// +// `Send(Frame)` dominates the enum size by design: frames stay stack-allocated +// so the per-datagram path never touches the heap, and the action queue only +// ever holds a handful of entries. +#[allow(clippy::large_enum_variant)] +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Send this datagram to the gateway. `await_ack` carries the sequence + /// number of a tracked TUNNELING_REQUEST so a failed send can stop its + /// ACK tracking (see [`TunnelIo::send`]); `None` for everything else. + Send { frame: Frame, await_ack: Option }, + /// Deliver a parsed inbound telegram toward `pump_source` + /// (`try_send`, drop-on-full — never stall the protocol loop). + Telegram { + addr: GroupAddress, + payload: Vec, + }, + /// Tear down (and on the next connect attempt, re-create) the UDP socket. + /// The engine has entered backoff and will emit the next CONNECT_REQUEST + /// as a `Send` action once the backoff deadline passes. + ResetSocket, + /// An outbound telegram was never acknowledged within the ACK timeout. + /// Log-only: the engine keeps the connection (matching both previous + /// implementations, which expired and warned without retransmitting). + AckTimeout { seq: u8 }, +} + +/// Local HPAI advertised in CONNECT_REQUEST. +/// +/// The previous tokio client sent its real bound address, the Embassy client +/// NAT mode — some gateways reject one or the other, so this stays a +/// per-transport choice instead of being unified. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalEndpoint { + /// `0.0.0.0:0` — the gateway replies to the datagram's source address. + Nat, + /// Real local address (required by some gateways). + Explicit { ip: [u8; 4], port: u16 }, +} + +impl LocalEndpoint { + fn hpai(&self) -> Hpai { + match self { + LocalEndpoint::Nat => Hpai::new([0, 0, 0, 0], 0), + LocalEndpoint::Explicit { ip, port } => Hpai::new(*ip, *port), + } + } +} + +/// Timing knobs and the local-endpoint policy. [`Default`] reproduces the +/// constants both previous implementations used. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TunnelConfig { + pub local_endpoint: LocalEndpoint, + /// CONNECT_RESPONSE wait before giving up and backing off. + pub connect_timeout_ms: Millis, + /// Pending ACK lifetime before it is reported via [`Action::AckTimeout`]. + pub ack_timeout_ms: Millis, + /// Cadence of the pending-ACK expiry sweep. + pub ack_sweep_ms: Millis, + /// CONNECTIONSTATE_REQUEST keepalive cadence. + pub heartbeat_ms: Millis, + /// CONNECTIONSTATE_RESPONSE wait before the connection is considered + /// dead (KNX spec CONNECTIONSTATE_REQUEST timeout: 10 s). This is the + /// send-side liveness guard: a silently failing send path or a gateway + /// that expired the tunnel never surfaces through the recv path of an + /// unconnected UDP socket. + pub heartbeat_response_timeout_ms: Millis, + /// Wait between a connection failure and the next CONNECT_REQUEST. + pub reconnect_backoff_ms: Millis, +} + +impl Default for TunnelConfig { + fn default() -> Self { + Self { + local_endpoint: LocalEndpoint::Nat, + connect_timeout_ms: 5_000, + ack_timeout_ms: 3_000, + ack_sweep_ms: 500, + heartbeat_ms: 55_000, + heartbeat_response_timeout_ms: 10_000, + reconnect_backoff_ms: 5_000, + } + } +} + +/// Pending outbound ACKs tracked per connection (seq → sent-at). +const PENDING_ACK_CAPACITY: usize = 16; + +/// Per-connection state; only exists while the handshake has succeeded, so +/// "connected" needs no separate flag and the keepalive cannot fire while +/// disconnected. +#[derive(Debug)] +struct ChannelState { + channel_id: u8, + outbound_seq: u8, + pending_acks: heapless::FnvIndexMap, + next_heartbeat: Millis, + /// Set when a CONNECTIONSTATE_REQUEST goes out; cleared by the gateway's + /// OK response. A pending entry older than + /// `heartbeat_response_timeout_ms` drops the connection. + heartbeat_pending_since: Option, + next_ack_sweep: Millis, +} + +impl ChannelState { + fn new(channel_id: u8, now: Millis, cfg: &TunnelConfig) -> Self { + Self { + channel_id, + outbound_seq: 0, + pending_acks: heapless::FnvIndexMap::new(), + next_heartbeat: now + cfg.heartbeat_ms, + heartbeat_pending_since: None, + next_ack_sweep: now + cfg.ack_sweep_ms, + } + } + + fn next_outbound_seq(&mut self) -> u8 { + let seq = self.outbound_seq; + self.outbound_seq = self.outbound_seq.wrapping_add(1); + seq + } +} + +// One `Phase` exists per engine, so the `Connected` payload size is irrelevant +// and boxing it would only add indirection. +#[allow(clippy::large_enum_variant)] +#[derive(Debug)] +enum Phase { + /// Waiting out the backoff before (re)connecting. The initial phase is + /// `Backoff { until: now }`, so the first `poll` connects immediately. + Backoff { + until: Millis, + }, + /// CONNECT_REQUEST sent, awaiting CONNECT_RESPONSE. + Connecting { + sent_at: Millis, + }, + Connected(ChannelState), +} + +/// The sans-io tunneling engine. See the module docs for the driving contract. +#[derive(Debug)] +pub struct TunnelEngine { + cfg: TunnelConfig, + phase: Phase, + actions: VecDeque, +} + +impl TunnelEngine { + pub fn new(cfg: TunnelConfig, now: Millis) -> Self { + Self { + cfg, + phase: Phase::Backoff { until: now }, + actions: VecDeque::new(), + } + } + + /// Update the HPAI used by future CONNECT_REQUESTs. Transports that + /// advertise their real address call this after every (re)bind, before + /// the backoff deadline fires. + pub fn set_local_endpoint(&mut self, ep: LocalEndpoint) { + self.cfg.local_endpoint = ep; + } + + pub fn is_connected(&self) -> bool { + matches!(self.phase, Phase::Connected(_)) + } + + /// A datagram arrived from the gateway. + pub fn handle_datagram(&mut self, data: &[u8], now: Millis) { + // Set inside the `Connected` arm (where `self.phase` is borrowed) and + // applied after the match. + let mut drop_connection = false; + match &mut self.phase { + // Stale datagram from a previous connection — ignore. + Phase::Backoff { .. } => {} + Phase::Connecting { .. } => match parse_connect_response(data) { + Some((channel_id, 0)) => { + self.phase = Phase::Connected(ChannelState::new(channel_id, now, &self.cfg)); + } + Some((_, _status)) => { + // Gateway refused the connection — back off and retry. + self.actions.push_back(Action::ResetSocket); + self.phase = Phase::Backoff { + until: now + self.cfg.reconnect_backoff_ms, + }; + } + // Not a CONNECT_RESPONSE (or unparseable): keep waiting; + // the connect timeout covers a gateway that never answers. + None => {} + }, + Phase::Connected(state) => { + let Ok(frame) = KnxnetIpFrame::parse(data) else { + return; + }; + match frame.service_type() { + ServiceType::TunnellingAck => { + let body = frame.body(); + let seq = if let Ok(ack) = TunnelingAck::parse(body) { + ack.connection_header.sequence_counter + } else if body.len() >= 4 { + // Some gateways omit the trailing status byte; + // the sequence sits in the connection header: + // [structure_len, channel_id, seq, flags]. + body[2] + } else { + return; + }; + // Unexpected (untracked) ACKs are ignored. + let _ = state.pending_acks.remove(&seq); + } + ServiceType::TunnellingRequest => { + // A parseable connection header is required before + // ACKing — truncated frames are dropped without an ACK + // (the gateway retransmits), never ACKed with a + // fabricated sequence number. + let Ok(request) = TunnelingRequest::parse(frame.body()) else { + return; + }; + let seq = request.connection_header.sequence_counter; + let channel_id = state.channel_id; + self.actions.push_back(Action::Send { + frame: build_tunneling_ack(channel_id, seq), + await_ack: None, + }); + if let Some((addr, payload)) = parse_telegram(request.cemi_data) { + self.actions.push_back(Action::Telegram { addr, payload }); + } + } + ServiceType::ConnectionstateResponse => { + let Ok(response) = ConnectionStateResponse::parse(frame.body()) else { + return; + }; + if response.is_ok() { + state.heartbeat_pending_since = None; + } else { + // The gateway no longer recognizes the channel + // (e.g. it expired during an outage) — reconnect + // with a fresh one. + drop_connection = true; + } + } + // DISCONNECT_RESPONSE, … + _ => {} + } + } + } + if drop_connection { + self.handle_socket_error(now); + } + } + + /// An outbound GroupValueWrite arrived from the command channel. + /// + /// Returns `false` when the command was dropped because no connection is + /// established. This is a defensive contract: both shims gate command + /// intake on `is_connected()` (commands queue in their channel while + /// disconnected), so in production this path is unreachable — it is + /// exercised by the engine unit tests only. + pub fn handle_command(&mut self, cmd: GroupWrite, now: Millis) -> bool { + let Phase::Connected(state) = &mut self.phase else { + return false; + }; + let seq = state.next_outbound_seq(); + let cemi = build_group_write_cemi(cmd.group_addr, &cmd.data); + let frame = build_tunneling_request(state.channel_id, seq, &cemi); + if state.pending_acks.insert(seq, now).is_err() { + // Map full (burst deeper than PENDING_ACK_CAPACITY): evict the + // oldest entry and report it now, so no unacknowledged telegram + // is ever silently untracked. + if let Some((&oldest, _)) = state.pending_acks.iter().next() { + state.pending_acks.remove(&oldest); + self.actions.push_back(Action::AckTimeout { seq: oldest }); + } + let _ = state.pending_acks.insert(seq, now); + } + self.actions.push_back(Action::Send { + frame, + await_ack: Some(seq), + }); + true + } + + /// The transport failed to hand a tracked TUNNELING_REQUEST to the socket + /// (see [`TunnelIo::send`]): stop awaiting its ACK so the sweep doesn't + /// misreport the local send failure as a gateway ACK loss. + fn send_failed(&mut self, seq: u8) { + if let Phase::Connected(state) = &mut self.phase { + let _ = state.pending_acks.remove(&seq); + } + } + + /// The transport's UDP send/recv failed fatally — drop the connection and + /// start a reconnect cycle. + pub fn handle_socket_error(&mut self, now: Millis) { + // Always tear the erroring socket down (the previous implementations + // recreated it on every recv error); an already-running backoff keeps + // its deadline so persistent errors cannot postpone the reconnect. + self.actions.push_back(Action::ResetSocket); + if !matches!(self.phase, Phase::Backoff { .. }) { + self.phase = Phase::Backoff { + until: now + self.cfg.reconnect_backoff_ms, + }; + } + } + + /// Fire any deadlines that have passed. Call at the top of every loop + /// iteration, before draining actions. + pub fn poll(&mut self, now: Millis) { + match &mut self.phase { + Phase::Backoff { until } => { + if now >= *until { + let frame = build_connect_request(&self.cfg.local_endpoint); + self.actions.push_back(Action::Send { + frame, + await_ack: None, + }); + self.phase = Phase::Connecting { sent_at: now }; + } + } + Phase::Connecting { sent_at } => { + if now >= sent_at.saturating_add(self.cfg.connect_timeout_ms) { + self.actions.push_back(Action::ResetSocket); + self.phase = Phase::Backoff { + until: now + self.cfg.reconnect_backoff_ms, + }; + } + } + Phase::Connected(state) => { + // Unanswered keepalive: the gateway (or the send path) is + // gone — drop the connection and re-handshake. + if let Some(sent_at) = state.heartbeat_pending_since { + if now >= sent_at.saturating_add(self.cfg.heartbeat_response_timeout_ms) { + self.handle_socket_error(now); + return; + } + } + if now >= state.next_heartbeat { + let frame = build_connectionstate_request(state.channel_id); + self.actions.push_back(Action::Send { + frame, + await_ack: None, + }); + if state.heartbeat_pending_since.is_none() { + state.heartbeat_pending_since = Some(now); + } + state.next_heartbeat = now + self.cfg.heartbeat_ms; + } + if now >= state.next_ack_sweep { + let mut expired: heapless::Vec = heapless::Vec::new(); + for (&seq, &sent_at) in state.pending_acks.iter() { + if now.saturating_sub(sent_at) > self.cfg.ack_timeout_ms { + let _ = expired.push(seq); + } + } + for seq in &expired { + state.pending_acks.remove(seq); + self.actions.push_back(Action::AckTimeout { seq: *seq }); + } + state.next_ack_sweep = now + self.cfg.ack_sweep_ms; + } + } + } + } + + /// Earliest deadline the transport must wake the engine for. + pub fn next_deadline(&self) -> Millis { + match &self.phase { + Phase::Backoff { until } => *until, + Phase::Connecting { sent_at } => sent_at.saturating_add(self.cfg.connect_timeout_ms), + Phase::Connected(state) => { + let mut next = state.next_heartbeat.min(state.next_ack_sweep); + if let Some(sent_at) = state.heartbeat_pending_since { + next = next.min(sent_at.saturating_add(self.cfg.heartbeat_response_timeout_ms)); + } + next + } + } + } + + /// Drain pending actions (typically 0–2 per event). + pub fn next_action(&mut self) -> Option { + self.actions.pop_front() + } +} + +// --------------------------------------------------------------------------- +// Action drain — the runtime-neutral half of every transport loop. +// --------------------------------------------------------------------------- + +/// Transport glue [`drain_actions`] drives: an async datagram send plus the +/// non-blocking inbound forward. Implemented once per runtime shim; the policy +/// for applying [`Action`]s lives in [`drain_actions`] so it cannot drift +/// between the tokio and Embassy loops. +// Unused only when the crate is built without either runtime shim. +#[cfg_attr( + not(any(feature = "tokio-runtime", feature = "embassy-runtime")), + allow(dead_code) +)] +pub(crate) trait TunnelIo { + /// Send one datagram to the gateway. Returns `false` when the datagram + /// could not be handed to the socket; [`drain_actions`] then stops the + /// ACK tracking of a tracked TUNNELING_REQUEST. Transient errors are + /// logged by the impl and otherwise non-fatal — an established tunnel + /// must survive e.g. ENOBUFS or a route flap. A persistently dead send + /// path surfaces through the heartbeat-response timeout + /// ([`TunnelConfig::heartbeat_response_timeout_ms`]), which drops the + /// connection even when the recv path never errors. + async fn send(&mut self, frame: &[u8]) -> bool; + /// Forward a parsed telegram toward `pump_source`. Non-blocking: + /// drop + log on a full channel rather than stalling the protocol loop. + fn forward(&mut self, addr: GroupAddress, payload: Vec); + /// An outbound telegram's ACK never arrived (log-only, see + /// [`Action::AckTimeout`]). + fn warn_ack_timeout(&mut self, seq: u8); +} + +/// Apply every pending engine action through `io`. Returns `true` when the +/// engine asked for the socket to be torn down ([`Action::ResetSocket`]); the +/// transport reacts once the drain completes. +#[cfg_attr( + not(any(feature = "tokio-runtime", feature = "embassy-runtime")), + allow(dead_code) +)] +pub(crate) async fn drain_actions(engine: &mut TunnelEngine, io: &mut impl TunnelIo) -> bool { + let mut reset = false; + while let Some(action) = engine.next_action() { + match action { + Action::Send { frame, await_ack } => { + if !io.send(&frame).await { + if let Some(seq) = await_ack { + engine.send_failed(seq); + } + } + } + Action::Telegram { addr, payload } => io.forward(addr, payload), + Action::ResetSocket => reset = true, + Action::AckTimeout { seq } => io.warn_ack_timeout(seq), + } + } + reset +} + +// --------------------------------------------------------------------------- +// Frame building / parsing — moved verbatim from the former tokio/embassy +// clients (logging stripped; byte layouts unchanged). +// --------------------------------------------------------------------------- + +fn frame_from(buf: &[u8]) -> Frame { + let mut frame = Frame::new(); + frame + .extend_from_slice(buf) + .expect("frame buffer sized for max KNX/IP datagram"); + frame +} + +/// Build CONNECT_REQUEST advertising the configured local HPAI. +fn build_connect_request(ep: &LocalEndpoint) -> Frame { + let hpai = ep.hpai(); + let request = ConnectRequest::new(hpai, hpai); + let mut buffer = [0u8; 32]; + let len = request + .build(&mut buffer) + .expect("buffer sized for CONNECT_REQUEST"); + frame_from(&buffer[..len]) +} + +/// Extract `(channel_id, status)` if `data` is a CONNECT_RESPONSE. +fn parse_connect_response(data: &[u8]) -> Option<(u8, u8)> { + let frame = KnxnetIpFrame::parse(data).ok()?; + if frame.service_type() != ServiceType::ConnectResponse { + return None; + } + let response = ConnectResponse::parse(frame.body()).ok()?; + Some((response.channel_id, response.status)) +} + +fn build_tunneling_ack(channel_id: u8, seq: u8) -> Frame { + let conn_header = ConnectionHeader::new(channel_id, seq); + let ack = TunnelingAck::new(conn_header, 0); // status = 0 (OK) + let mut buffer = [0u8; 16]; + let len = ack + .build(&mut buffer) + .expect("buffer sized for TUNNELING_ACK"); + frame_from(&buffer[..len]) +} + +fn build_tunneling_request(channel_id: u8, seq: u8, cemi: &[u8]) -> Frame { + let conn_header = ConnectionHeader::new(channel_id, seq); + let request = TunnelingRequest::new(conn_header, cemi); + let mut buffer = [0u8; MAX_FRAME]; + let len = request + .build(&mut buffer) + .expect("buffer sized for max TUNNELING_REQUEST"); + frame_from(&buffer[..len]) +} + +fn build_connectionstate_request(channel_id: u8) -> Frame { + // 0.0.0.0:0 ("any") — matches both previous implementations. + let hpai = Hpai::new([0, 0, 0, 0], 0); + let request = ConnectionStateRequest::new(channel_id, hpai); + let mut buffer = [0u8; 32]; + let len = request + .build(&mut buffer) + .expect("buffer sized for CONNECTIONSTATE_REQUEST"); + frame_from(&buffer[..len]) +} + +/// Build GroupValueWrite cEMI frame (L_Data.req) +/// +/// Must match knx-pico's exact cEMI structure for proper parsing. +/// Structure: [msg_code, add_info_len, ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, data...] +fn build_group_write_cemi(group_addr: GroupAddress, data: &[u8]) -> heapless::Vec { + let mut frame = heapless::Vec::new(); + + // Message code: L_Data.req (0x11) + let _ = frame.push(0x11); + + // Additional info length: 0 + let _ = frame.push(0x00); + + // Control field 1: 0xBC (Standard frame, no repeat, broadcast, priority low) + // Use 0xBC instead of 0x94 - this is critical for gateway compatibility + let _ = frame.push(0xBC); + + // Control field 2: 0xE0 (Group address, hop count 6) + let _ = frame.push(0xE0); + + // Source address: 0.0.0 (2 bytes, big-endian) + let _ = frame.extend_from_slice(&[0x00, 0x00]); + + // Destination address (group address) - convert to u16 big-endian + let dest_raw: u16 = group_addr.into(); + let dest_bytes = dest_raw.to_be_bytes(); + let _ = frame.extend_from_slice(&dest_bytes); + + // Build NPDU: NPDU_length field + TPCI + APCI + data + // CRITICAL: NPDU length encoding per KNX spec: + // - For short telegram: field = 0x01 (special flag) + // - For long telegram: field = actual_length - 1 (encoded as length-1) + if data.len() == 1 && data[0] < 64 { + // 6-bit encoding: value embedded in APCI byte + // NPDU length = 0x01 (short telegram flag, NOT byte count) + let _ = frame.push(0x01); + + // TPCI (UnnumberedData) + let _ = frame.push(0x00); + + // APCI low byte: GroupValueWrite (0x80) + 6-bit value + let _ = frame.push(0x80 | (data[0] & 0x3F)); + } else { + // Long telegram: APCI + separate data bytes + // NPDU length encoding: field = actual_length - 1 + let npdu_actual = 2 + data.len(); // TPCI + APCI + data + let npdu_len_field = npdu_actual - 1; // Encode as length - 1 + let _ = frame.push(npdu_len_field as u8); + + // TPCI (UnnumberedData) + let _ = frame.push(0x00); + + // APCI: GroupValueWrite + let _ = frame.push(0x80); + + // Data bytes + let _ = frame.extend_from_slice(data); + } + + frame +} + +/// Parse a cEMI frame — the body of an already-validated TUNNELING_REQUEST, +/// handed over by `handle_datagram` so the datagram is parsed exactly once. +/// +/// Returns (group_address, payload) if this is a valid L_Data telegram +/// carrying a GroupValueWrite to a group address. +fn parse_telegram(cemi_data: &[u8]) -> Option<(GroupAddress, Vec)> { + // Parse cEMI frame + let cemi = CEMIFrame::parse(cemi_data).ok()?; + + // Only process L_Data frames + if !cemi.is_ldata() { + return None; + } + + // Parse LData frame using knx-pico (handles all encoding variants including 6-bit values) + let ldata = cemi.as_ldata().ok()?; + + // Only process group write commands + if !ldata.is_group_write() { + return None; + } + + // Only process group addresses (not individual addresses) + let dest = ldata.destination_group()?; + + // Extract payload (application data) + // For 6-bit encoded values (DPT1 boolean), ldata.data is empty + // and the value is encoded in the APCI byte. We need to extract it manually. + // Note: npdu_length can be 1 (combined TPCI+APCI) or 2 (separate TPCI and APCI) + let payload = if ldata.data.is_empty() { + // 6-bit encoding: extract value from APCI byte in raw cEMI data + // cEMI structure: [msg_code, add_info_len, , ctrl1, ctrl2, src(2), dest(2), npdu_len, tpci, apci, ...] + // APCI byte position = 2 + add_info_len + 6 (ctrl1, ctrl2, src(2), dest(2), npdu_len) + 1 (tpci) = 2 + add_info_len + 7 + 1 + let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; + let apci_pos = 2 + add_info_len + 8; // TPCI is at +7, APCI is at +8 + + if cemi_data.len() > apci_pos { + let apci_byte = cemi_data[apci_pos]; + let value = apci_byte & 0x3F; // Extract 6-bit value + vec![value] + } else { + vec![] + } + } else { + // Standard encoding: multi-byte data (DPT5, DPT7, DPT9, etc.) + // + // cEMI L_Data structure (after msg_code and add_info): + // [0] ctrl1, [1] ctrl2, [2-3] src, [4-5] dest, [6] npdu_len, [7] TPCI, [8] APCI_low, [9+] data + // + // According to knx-pico parser: data starts at position 9 in L_Data + // In full cEMI frame: position = 2 + add_info_len + 9 = 11 (when add_info_len=0) + let add_info_len = if cemi_data.len() > 1 { cemi_data[1] } else { 0 } as usize; + + // Data starts at: msg_code(0) + add_info_len_field(1) + add_info(variable) + L_Data_header(9) + let ldata_offset = 2 + add_info_len; + let data_start = ldata_offset + 9; // Position 11 when add_info_len=0 + + if cemi_data.len() > data_start { + cemi_data[data_start..].to_vec() + } else { + // Fallback to knx-pico's parsed data if extraction fails + ldata.data.to_vec() + } + }; + + Some((dest, payload)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CFG: TunnelConfig = TunnelConfig { + local_endpoint: LocalEndpoint::Nat, + connect_timeout_ms: 5_000, + ack_timeout_ms: 3_000, + ack_sweep_ms: 500, + heartbeat_ms: 55_000, + heartbeat_response_timeout_ms: 10_000, + reconnect_backoff_ms: 5_000, + }; + + fn drain(engine: &mut TunnelEngine) -> Vec { + let mut actions = Vec::new(); + while let Some(a) = engine.next_action() { + actions.push(a); + } + actions + } + + fn service_type_of(frame: &[u8]) -> u16 { + u16::from_be_bytes([frame[2], frame[3]]) + } + + /// Hand-built CONNECT_RESPONSE: header + [channel_id, status, HPAI(8), CRD(4)]. + fn connect_response(channel_id: u8, status: u8) -> Vec { + let mut body = vec![channel_id, status]; + body.extend_from_slice(&[0x08, 0x01, 0, 0, 0, 0, 0, 0]); // HPAI 0.0.0.0:0 + body.extend_from_slice(&[0x04, 0x04, 0x02, 0x00]); // CRD: tunnel, addr 0.0.0 + let mut frame = vec![0x06, 0x10, 0x02, 0x06]; + frame.extend_from_slice(&(6 + body.len() as u16).to_be_bytes()); + frame.extend_from_slice(&body); + frame + } + + /// A gateway-side TUNNELING_REQUEST carrying a GroupValueWrite. + fn inbound_group_write(channel_id: u8, seq: u8, addr: GroupAddress, data: &[u8]) -> Vec { + let cemi = build_group_write_cemi(addr, data); + build_tunneling_request(channel_id, seq, &cemi).to_vec() + } + + /// Standard 5-byte-body TUNNELING_ACK from the gateway. + fn gateway_ack(channel_id: u8, seq: u8) -> Vec { + build_tunneling_ack(channel_id, seq).to_vec() + } + + /// Drive a fresh engine to Connected; returns it with actions drained. + fn connected_engine(now: Millis) -> TunnelEngine { + let mut engine = TunnelEngine::new(CFG.clone(), now); + engine.poll(now); + let actions = drain(&mut engine); + assert_eq!(actions.len(), 1); + let Action::Send { frame: req, .. } = &actions[0] else { + panic!("expected CONNECT_REQUEST send, got {actions:?}"); + }; + assert_eq!(service_type_of(req), 0x0205); // CONNECT_REQUEST + engine.handle_datagram(&connect_response(7, 0), now); + assert!(engine.is_connected()); + engine + } + + #[test] + fn handshake_connects_on_status_ok() { + let engine = connected_engine(1_000); + assert_eq!(engine.next_deadline(), 1_000 + CFG.ack_sweep_ms); + } + + #[test] + fn handshake_refused_backs_off_and_retries() { + let mut engine = TunnelEngine::new(CFG.clone(), 0); + engine.poll(0); + drain(&mut engine); + engine.handle_datagram(&connect_response(7, 0x24), 10); // E_NO_MORE_CONNECTIONS + assert!(!engine.is_connected()); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + assert_eq!(engine.next_deadline(), 10 + CFG.reconnect_backoff_ms); + + // Backoff expiry emits a fresh CONNECT_REQUEST. + engine.poll(10 + CFG.reconnect_backoff_ms); + let actions = drain(&mut engine); + assert!( + matches!(&actions[..], [Action::Send { frame: f, .. }] if service_type_of(f) == 0x0205) + ); + } + + #[test] + fn connect_timeout_resets_and_backs_off() { + let mut engine = TunnelEngine::new(CFG.clone(), 0); + engine.poll(0); + drain(&mut engine); + assert_eq!(engine.next_deadline(), CFG.connect_timeout_ms); + + // Nothing happens before the deadline… + engine.poll(CFG.connect_timeout_ms - 1); + assert!(drain(&mut engine).is_empty()); + + // …and the timeout tears down the socket and backs off. + engine.poll(CFG.connect_timeout_ms); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + engine.poll(CFG.connect_timeout_ms + CFG.reconnect_backoff_ms); + let actions = drain(&mut engine); + assert!( + matches!(&actions[..], [Action::Send { frame: f, .. }] if service_type_of(f) == 0x0205) + ); + } + + #[test] + fn truncated_tunneling_request_is_not_acked() { + let mut engine = connected_engine(0); + + // Header-only / truncated-connection-header TUNNELING_REQUESTs: no + // sequence number to echo, so no ACK may be fabricated (seq 0 would + // desynchronize the gateway's tunnel sequence tracking). + for frame in [ + vec![0x06, 0x10, 0x04, 0x20, 0x00, 0x06], // header only + vec![0x06, 0x10, 0x04, 0x20, 0x00, 0x08, 0x04, 0x07], // 2-byte body + ] { + engine.handle_datagram(&frame, 100); + } + + assert!(drain(&mut engine).is_empty()); + assert!(engine.is_connected()); + } + + #[test] + fn unparseable_datagram_while_connecting_is_ignored() { + let mut engine = TunnelEngine::new(CFG.clone(), 0); + engine.poll(0); + drain(&mut engine); + engine.handle_datagram(&[0xde, 0xad], 1); + engine.handle_datagram(&gateway_ack(7, 0), 2); // wrong service type + assert!(!engine.is_connected()); + assert!(drain(&mut engine).is_empty()); + // Still waiting — and still connectable. + engine.handle_datagram(&connect_response(3, 0), 3); + assert!(engine.is_connected()); + } + + #[test] + fn inbound_telegram_is_acked_and_routed() { + let addr: GroupAddress = "1/0/7".parse().unwrap(); + let mut engine = connected_engine(0); + + // Multi-byte payload (e.g. DPT9 temperature). + let datagram = inbound_group_write(7, 42, addr, &[0x0C, 0x1A]); + engine.handle_datagram(&datagram, 100); + let actions = drain(&mut engine); + assert_eq!(actions.len(), 2); + let Action::Send { frame: ack, .. } = &actions[0] else { + panic!("expected ACK first, got {actions:?}"); + }; + assert_eq!(service_type_of(ack), 0x0421); // TUNNELING_ACK + assert_eq!(ack[7], 7); // channel_id echoed + assert_eq!(ack[8], 42); // sequence echoed + assert_eq!( + actions[1], + Action::Telegram { + addr, + payload: vec![0x0C, 0x1A] + } + ); + } + + #[test] + fn inbound_6bit_telegram_extracts_apci_value() { + let addr: GroupAddress = "2/1/3".parse().unwrap(); + let mut engine = connected_engine(0); + + // Single-byte value < 64 is encoded in the APCI byte (DPT1 boolean). + let datagram = inbound_group_write(7, 1, addr, &[0x01]); + engine.handle_datagram(&datagram, 100); + let actions = drain(&mut engine); + assert_eq!( + actions[1], + Action::Telegram { + addr, + payload: vec![0x01] + } + ); + } + + #[test] + fn outbound_commands_use_wrapping_sequence_numbers() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + + for expected_seq in [0u8, 1] { + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + assert!(engine.handle_command( + GroupWrite { + group_addr: addr, + data + }, + 10 + )); + let actions = drain(&mut engine); + let Action::Send { frame, .. } = &actions[0] else { + panic!("expected TUNNELING_REQUEST send"); + }; + assert_eq!(service_type_of(frame), 0x0420); + assert_eq!(frame[8], expected_seq); + } + } + + #[test] + fn outbound_seq_wraps_at_255() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + // Pre-wind the counter via the internal state (exercising 256 sends + // would also work but is slow to read). + let Phase::Connected(state) = &mut engine.phase else { + unreachable!() + }; + state.outbound_seq = 255; + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data: data.clone(), + }, + 10, + ); + engine.handle_datagram(&gateway_ack(7, 255), 11); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 12, + ); + let actions = drain(&mut engine); + let seqs: Vec = actions + .iter() + .filter_map(|a| match a { + Action::Send { frame: f, .. } if service_type_of(f) == 0x0420 => Some(f[8]), + _ => None, + }) + .collect(); + assert_eq!(seqs, vec![255, 0]); + } + + #[test] + fn command_while_disconnected_is_dropped() { + let mut engine = TunnelEngine::new(CFG.clone(), 0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + assert!(!engine.handle_command( + GroupWrite { + group_addr: "1/0/8".parse().unwrap(), + data + }, + 0 + )); + assert!(drain(&mut engine).is_empty()); + } + + #[test] + fn standard_and_fallback_ack_bodies_complete_pending() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data: data.clone(), + }, + 10, + ); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + drain(&mut engine); + + // Standard 5-byte body for seq 0. + engine.handle_datagram(&gateway_ack(7, 0), 20); + // Non-standard 4-byte body (status byte missing) for seq 1. + let mut short_ack = gateway_ack(7, 1); + short_ack.truncate(short_ack.len() - 1); + short_ack[4..6].copy_from_slice(&10u16.to_be_bytes()); // fix total length + engine.handle_datagram(&short_ack, 21); + + // Both completed: no AckTimeout at the sweep after the ACK window. + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms + 1); + assert!(drain(&mut engine) + .iter() + .all(|a| !matches!(a, Action::AckTimeout { .. }))); + } + + #[test] + fn unacked_telegram_expires_at_sweep() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + drain(&mut engine); + + // Sweeps inside the ACK window keep the entry pending. + engine.poll(500); + engine.poll(1_000); + assert!(drain(&mut engine).is_empty()); + + // First sweep past sent_at + ack_timeout expires it. + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms); + let actions = drain(&mut engine); + assert_eq!(actions, vec![Action::AckTimeout { seq: 0 }]); + } + + #[test] + fn heartbeat_fires_on_cadence_only_while_connected() { + let mut engine = connected_engine(0); + + engine.poll(CFG.heartbeat_ms - 1); + assert!(drain(&mut engine) + .iter() + .all(|a| !matches!(a, Action::Send { frame: f, .. } if service_type_of(f) == 0x0207))); + + engine.poll(CFG.heartbeat_ms); + let actions = drain(&mut engine); + assert!(actions + .iter() + .any(|a| matches!(a, Action::Send { frame: f, .. } if service_type_of(f) == 0x0207))); + + // After a socket error, no heartbeat fires during backoff. + engine.handle_socket_error(CFG.heartbeat_ms + 10); + drain(&mut engine); + engine.poll(CFG.heartbeat_ms * 2); + assert!(drain(&mut engine) + .iter() + .all(|a| !matches!(a, Action::Send { frame: f, .. } if service_type_of(f) == 0x0207))); + } + + #[test] + fn socket_error_triggers_reset_and_reconnect() { + let mut engine = connected_engine(0); + engine.handle_socket_error(100); + assert!(!engine.is_connected()); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + + // A second error during backoff still tears the erroring socket down, + // but the original backoff deadline stands (no postponement). + engine.handle_socket_error(200); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + + engine.poll(100 + CFG.reconnect_backoff_ms); + let actions = drain(&mut engine); + assert!( + matches!(&actions[..], [Action::Send { frame: f, .. }] if service_type_of(f) == 0x0205) + ); + engine.handle_datagram(&connect_response(9, 0), 100 + CFG.reconnect_backoff_ms + 5); + assert!(engine.is_connected()); + } + + /// Hand-built CONNECTIONSTATE_RESPONSE (0x0208) frame. + fn connectionstate_response(channel_id: u8, status: u8) -> Vec { + let mut frame = vec![0x06, 0x10, 0x02, 0x08]; + frame.extend_from_slice(&8u16.to_be_bytes()); + frame.extend_from_slice(&[channel_id, status]); + frame + } + + #[test] + fn ok_connectionstate_and_disconnect_responses_keep_connection() { + let mut engine = connected_engine(0); + engine.handle_datagram(&connectionstate_response(7, 0), 50); + // DISCONNECT_RESPONSE (0x020A) is ignored. + let mut frame = vec![0x06, 0x10, 0x02, 0x0A]; + frame.extend_from_slice(&8u16.to_be_bytes()); + frame.extend_from_slice(&[7, 0]); + engine.handle_datagram(&frame, 50); + assert!(engine.is_connected()); + assert!(drain(&mut engine).is_empty()); + } + + #[test] + fn answered_heartbeat_keeps_connection_past_response_timeout() { + let mut engine = connected_engine(0); + engine.poll(CFG.heartbeat_ms); + drain(&mut engine); + engine.handle_datagram(&connectionstate_response(7, 0), CFG.heartbeat_ms + 100); + engine.poll(CFG.heartbeat_ms + CFG.heartbeat_response_timeout_ms + 1); + assert!(engine.is_connected()); + assert!(drain(&mut engine).is_empty()); + } + + #[test] + fn unanswered_heartbeat_drops_connection_after_response_timeout() { + let mut engine = connected_engine(0); + engine.poll(CFG.heartbeat_ms); + let actions = drain(&mut engine); + assert!(actions + .iter() + .any(|a| matches!(a, Action::Send { frame: f, .. } if service_type_of(f) == 0x0207))); + + // Inside the response window nothing happens… + engine.poll(CFG.heartbeat_ms + CFG.heartbeat_response_timeout_ms - 1); + assert!(engine.is_connected()); + assert!(drain(&mut engine).is_empty()); + + // …then the silent gateway (or dead send path) drops the connection. + engine.poll(CFG.heartbeat_ms + CFG.heartbeat_response_timeout_ms); + assert!(!engine.is_connected()); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + } + + #[test] + fn refused_connectionstate_response_drops_connection() { + let mut engine = connected_engine(0); + // Gateway answers the keepalive with E_CONNECTION_ID — it no longer + // recognizes the channel (e.g. it expired during an outage). + engine.handle_datagram(&connectionstate_response(7, 0x21), 50); + assert!(!engine.is_connected()); + assert_eq!(drain(&mut engine), vec![Action::ResetSocket]); + // Reconnect after the backoff window. + engine.poll(50 + CFG.reconnect_backoff_ms); + let actions = drain(&mut engine); + assert!( + matches!(&actions[..], [Action::Send { frame: f, .. }] if service_type_of(f) == 0x0205) + ); + } + + #[test] + fn failed_send_stops_ack_tracking() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + engine.handle_command( + GroupWrite { + group_addr: addr, + data, + }, + 10, + ); + let actions = drain(&mut engine); + let Some(Action::Send { + await_ack: Some(seq), + .. + }) = actions.last() + else { + panic!("expected tracked TUNNELING_REQUEST send, got {actions:?}"); + }; + + // The transport reports the send failed: tracking stops, so the sweep + // does not misreport the local failure as a gateway ACK loss. + engine.send_failed(*seq); + engine.poll(10 + CFG.ack_timeout_ms + CFG.ack_sweep_ms + 1); + assert!(drain(&mut engine) + .iter() + .all(|a| !matches!(a, Action::AckTimeout { .. }))); + } + + #[test] + fn pending_ack_overflow_evicts_and_reports_oldest() { + let addr: GroupAddress = "1/0/8".parse().unwrap(); + let mut engine = connected_engine(0); + let mut data = heapless::Vec::new(); + data.push(0x01).unwrap(); + // One more tracked send than the map holds. + for _ in 0..=PENDING_ACK_CAPACITY { + engine.handle_command( + GroupWrite { + group_addr: addr, + data: data.clone(), + }, + 10, + ); + } + let actions = drain(&mut engine); + // The overflowing send evicted the oldest entry and reported it. + assert_eq!( + actions + .iter() + .filter(|a| matches!(a, Action::AckTimeout { seq: 0 })) + .count(), + 1 + ); + // Every send still went out. + assert_eq!( + actions + .iter() + .filter(|a| matches!(a, Action::Send { .. })) + .count(), + PENDING_ACK_CAPACITY + 1 + ); + } + + #[test] + fn explicit_local_endpoint_is_advertised_in_connect_request() { + let mut engine = TunnelEngine::new(CFG.clone(), 0); + engine.set_local_endpoint(LocalEndpoint::Explicit { + ip: [192, 168, 1, 50], + port: 54321, + }); + engine.poll(0); + let actions = drain(&mut engine); + let Action::Send { frame: req, .. } = &actions[0] else { + panic!("expected CONNECT_REQUEST"); + }; + // Control HPAI starts at offset 6: [len, proto, ip(4), port(2)]. + assert_eq!(&req[8..12], &[192, 168, 1, 50]); + assert_eq!(u16::from_be_bytes([req[12], req[13]]), 54321); + } +} diff --git a/aimdb-knx-connector/tests/topic_provider_tests.rs b/aimdb-knx-connector/tests/topic_provider_tests.rs index bfaf44a4..718ffc65 100644 --- a/aimdb-knx-connector/tests/topic_provider_tests.rs +++ b/aimdb-knx-connector/tests/topic_provider_tests.rs @@ -293,7 +293,7 @@ async fn test_knx_topic_provider_registration_api() { builder.configure::("knx.dimmer.living", |reg| { let counter = produced_count_clone.clone(); reg.buffer(BufferCfg::SingleLatest).source( - move |_ctx: RuntimeContext, producer: Producer| { + move |_ctx: RuntimeContext, producer: Producer| { let counter = counter.clone(); async move { let dimmer = DimmerValue::new("living", 200); @@ -324,7 +324,7 @@ async fn test_knx_topic_provider_with_connector_registration() { builder.configure::("knx.dimmer.living", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( - |_ctx: RuntimeContext, producer: Producer| async move { + |_ctx: RuntimeContext, producer: Producer| async move { let dimmer = DimmerValue::new("living", 200); producer.produce(dimmer); }, @@ -380,8 +380,7 @@ async fn test_hvac_zone_routing() { builder.configure::("knx.hvac.setpoint", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( - |_ctx: RuntimeContext, - producer: Producer| async move { + |_ctx: RuntimeContext, producer: Producer| async move { // Different zones get routed to different group addresses for zone in 1..=4 { let setpoint = TemperatureSetpoint::new(zone, 21.0 + zone as f32 * 0.5); diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 8507ee8f..9b837317 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **Issue #131:** the Embassy `MqttConnectorBuilder::new` takes the network stack — `MqttConnectorBuilder::new(broker_url, stack)` — since the deleted `EmbassyNetwork` runtime trait can no longer supply it; both `ConnectorBuilder` impls and the `MqttLinkExt`/`MqttOutboundLinkExt` link-builder ext traits are non-generic over the runtime. + ### Added - **`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. diff --git a/aimdb-mqtt-connector/README.md b/aimdb-mqtt-connector/README.md index d873364c..a29276a7 100644 --- a/aimdb-mqtt-connector/README.md +++ b/aimdb-mqtt-connector/README.md @@ -106,17 +106,18 @@ use alloc::sync::Arc; #[embassy_executor::main] async fn main(spawner: Spawner) { // Initialize network stack - let stack = /* ... */; - - // Create runtime adapter with network stack access - let runtime = Arc::new(EmbassyAdapter::new_with_network(spawner, stack)); - - // Build database with MQTT connector - background tasks spawn automatically + let stack: &'static embassy_net::Stack<'static> = /* ... */; + + // The adapter is a stateless unit type; the connector takes the + // network stack at construction. + 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")); - - builder.configure::(|reg| { + .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack)); + + builder.configure::("sensor-data", |reg| { reg.buffer_sized::<4, 1>(EmbassyBufferType::SingleLatest) .source(sensor_producer) // Outbound: Publish to MQTT diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 94ac4a9a..af2f71ee 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -12,8 +12,10 @@ //! 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. No `unsafe`, no -//! `SendFutureWrapper` — both live in the adapter spine. +//! `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. //! //! # Usage //! @@ -21,13 +23,14 @@ //! 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") +//! MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack) //! .with_client_id("my-unique-device-id"), //! ) -//! .configure::(|reg| { +//! .configure::("temperature", |reg| { //! reg.link_to("mqtt://sensors/temperature").finish(); //! reg.link_from("mqtt://commands/temperature").finish(); //! }) @@ -270,6 +273,7 @@ impl EmbassySourceRaw for MqttSource { pub struct MqttConnectorBuilder { broker_url: String, client_id: String, + stack: aimdb_embassy_adapter::connectors::NetStack, } impl MqttConnectorBuilder { @@ -277,10 +281,17 @@ impl MqttConnectorBuilder { /// /// # Arguments /// * `broker_url` - Broker URL in format `mqtt://host:port` - pub fn new(broker_url: impl Into) -> Self { + /// * `stack` - The device's network stack (the runtime travels as + /// `Arc` since issue #131 and cannot surface it) + pub fn new(broker_url: impl Into, stack: &'static embassy_net::Stack<'static>) -> Self { Self { broker_url: broker_url.into(), client_id: "aimdb-client".to_string(), + // 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) }, } } @@ -291,17 +302,15 @@ impl MqttConnectorBuilder { } } -/// Implement ConnectorBuilder trait for Embassy runtime with network stack access. +/// Implement ConnectorBuilder trait for Embassy. /// -/// Requires the runtime to provide the `EmbassyNetwork` trait so the connector -/// can access the network stack for creating TCP connections. -impl ConnectorBuilder for MqttConnectorBuilder -where - R: aimdb_executor::RuntimeAdapter + aimdb_embassy_adapter::EmbassyNetwork + 'static, -{ +/// 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 { fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { // No `.await` in this body, so the future is `Send` without a wrapper: the @@ -322,7 +331,7 @@ where // Broker manager task + the channel ends for the pumps. let (action_sender, event_receiver, manager_task) = - setup_manager(&self.broker_url, &self.client_id, db.runtime(), topics)?; + setup_manager(&self.broker_url, &self.client_id, self.stack, topics)?; // Outbound publishes + inbound routing ride core's pumps. let mut futures = pump_sink( @@ -355,15 +364,12 @@ where /// the action sender (outbound), the event receiver (inbound), and the manager /// task future (which first queues the topic subscriptions, then runs the broker /// loop). Synchronous — no `.await` — so the caller's `build` future stays `Send`. -fn setup_manager( +fn setup_manager( broker_url: &str, client_id: &str, - runtime: &R, + stack: aimdb_embassy_adapter::connectors::NetStack, topics: Vec, -) -> Result<(ActionSender, EventReceiver, EmbassyBoxFuture), aimdb_core::DbError> -where - R: aimdb_executor::RuntimeAdapter + aimdb_embassy_adapter::EmbassyNetwork + 'static, -{ +) -> Result<(ActionSender, EventReceiver, EmbassyBoxFuture), aimdb_core::DbError> { let build_err = |msg: &str| { #[cfg(feature = "defmt")] defmt::error!("Failed to build MQTT connector: {}", msg); @@ -404,7 +410,7 @@ where let connection_settings = ConnectionSettings::unauthenticated(client_id_static); let settings = Settings::new(broker_addr, port); - let network = runtime.network_stack(); + let network = stack.get(); // Manager task: queue subscriptions, then run the broker loop (never returns). let sub_sender = action_sender; diff --git a/aimdb-mqtt-connector/src/lib.rs b/aimdb-mqtt-connector/src/lib.rs index 69919acf..2cb30ddf 100644 --- a/aimdb-mqtt-connector/src/lib.rs +++ b/aimdb-mqtt-connector/src/lib.rs @@ -51,11 +51,11 @@ //! use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; //! use alloc::sync::Arc; //! -//! let runtime = Arc::new(EmbassyAdapter::new_with_network(spawner, stack)); +//! let runtime = Arc::new(EmbassyAdapter::new()); //! //! let db = AimDbBuilder::new() //! .runtime(runtime) -//! .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883")) +//! .with_connector(MqttConnectorBuilder::new("mqtt://192.168.1.100:1883", stack)) //! .configure::(|reg| { //! reg.buffer_sized::<16, 2>(EmbassyBufferType::SpmcRing) //! .source(sensor_producer) diff --git a/aimdb-mqtt-connector/src/link_ext.rs b/aimdb-mqtt-connector/src/link_ext.rs index 066b83b5..90dd5b13 100644 --- a/aimdb-mqtt-connector/src/link_ext.rs +++ b/aimdb-mqtt-connector/src/link_ext.rs @@ -37,30 +37,27 @@ pub trait MqttOutboundLinkExt: MqttLinkExt { fn with_retain(self, retain: bool) -> Self; } -impl<'r, 'a, T, R> MqttLinkExt for OutboundConnectorBuilder<'r, 'a, T, R> +impl<'r, 'a, T> MqttLinkExt for OutboundConnectorBuilder<'r, 'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { fn with_qos(self, qos: u8) -> Self { self.with_config("qos", &qos.to_string()) } } -impl<'r, 'a, T, R> MqttOutboundLinkExt for OutboundConnectorBuilder<'r, 'a, T, R> +impl<'r, 'a, T> MqttOutboundLinkExt for OutboundConnectorBuilder<'r, 'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { fn with_retain(self, retain: bool) -> Self { self.with_config("retain", if retain { "true" } else { "false" }) } } -impl<'r, 'a, T, R> MqttLinkExt for InboundConnectorBuilder<'r, 'a, T, R> +impl<'r, 'a, T> MqttLinkExt for InboundConnectorBuilder<'r, 'a, T> where T: Send + Sync + 'static + Debug + Clone, - R: aimdb_executor::RuntimeAdapter + 'static, { fn with_qos(self, qos: u8) -> Self { self.with_config("qos", &qos.to_string()) diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 171467bb..cafb1597 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -92,10 +92,10 @@ impl MqttConnectorBuilder { type BoxFuture = Pin + Send + 'static>>; -impl ConnectorBuilder for MqttConnectorBuilder { +impl ConnectorBuilder for MqttConnectorBuilder { fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + 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 diff --git a/aimdb-mqtt-connector/tests/topic_provider_tests.rs b/aimdb-mqtt-connector/tests/topic_provider_tests.rs index 70ae7b4d..3064f71a 100644 --- a/aimdb-mqtt-connector/tests/topic_provider_tests.rs +++ b/aimdb-mqtt-connector/tests/topic_provider_tests.rs @@ -234,7 +234,7 @@ async fn test_topic_provider_registration_api() { builder.configure::("test.sensor.dynamic", |reg| { let counter = produced_count_clone.clone(); reg.buffer(BufferCfg::SingleLatest).source( - move |_ctx: RuntimeContext, producer: Producer| { + move |_ctx: RuntimeContext, producer: Producer| { let counter = counter.clone(); async move { let temp = Temperature::new("test-001", 22.5); @@ -267,7 +267,7 @@ async fn test_topic_provider_with_connector_registration() { builder.configure::("test.sensor.dynamic", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( - |_ctx: RuntimeContext, producer: Producer| async move { + |_ctx: RuntimeContext, producer: Producer| async move { let temp = Temperature::new("kitchen", 22.5); producer.produce(temp); }, @@ -327,7 +327,7 @@ async fn test_mixed_static_and_dynamic_topics() { builder.configure::("test.sensor.static", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( - |_ctx: RuntimeContext, producer: Producer| async move { + |_ctx: RuntimeContext, producer: Producer| async move { producer.produce(Temperature::new("static", 20.0)); }, ) @@ -340,7 +340,7 @@ async fn test_mixed_static_and_dynamic_topics() { builder.configure::("test.sensor.dynamic", |reg| { reg.buffer(BufferCfg::SingleLatest) .source( - |_ctx: RuntimeContext, producer: Producer| async move { + |_ctx: RuntimeContext, producer: Producer| async move { producer.produce(Temperature::new("dynamic", 25.0)); }, ) diff --git a/aimdb-persistence/CHANGELOG.md b/aimdb-persistence/CHANGELOG.md index 869fa364..91c373f9 100644 --- a/aimdb-persistence/CHANGELOG.md +++ b/aimdb-persistence/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **Issue #131:** `RecordRegistrarPersistExt`/`AimDbBuilderPersistExt`/`AimDbQueryExt` are non-generic over the runtime (they extend `RecordRegistrar<'a, T>` / `AimDbBuilder` / `AimDb`); the retention-cleanup `on_start` task receives `RuntimeContext` and sleeps via `ctx.time().sleep_secs(...)`. - **`RecordRegistrarPersistExt::persist` takes a fresh borrow (Issue #130, design 034 Phase 2).** `fn persist(&mut self, …) -> &mut RecordRegistrar<'a, T, R>` (was `&'a mut self -> &'a mut …`), following core's registrar lifetime fix — `persist()` no longer borrows the registrar for its entire remaining lifetime. Call sites compile unchanged. - All `R: Spawn` bounds replaced with `R: RuntimeAdapter` on public traits (`AimDbBuilderPersistExt`, `RecordRegistrarPersistExt`, `AimDbQueryExt`) and internal helpers (Issue #88). No behavioural change — `aimdb-persistence` never called `runtime.spawn` directly, the bound was just propagated. diff --git a/aimdb-persistence/src/builder_ext.rs b/aimdb-persistence/src/builder_ext.rs index 66573048..907d8ab7 100644 --- a/aimdb-persistence/src/builder_ext.rs +++ b/aimdb-persistence/src/builder_ext.rs @@ -4,13 +4,12 @@ use std::sync::Arc; use aimdb_core::builder::AimDbBuilder; use aimdb_core::remote::{QueryHandlerFn, QueryHandlerParams}; -use aimdb_executor::{RuntimeAdapter, TimeOps}; use crate::backend::{PersistenceBackend, QueryParams}; /// State stored in the builder's [`Extensions`](aimdb_core::Extensions) TypeMap. /// -/// Both `.persist()` (on `RecordRegistrar`) and `AimDbQueryExt` (on `AimDb`) +/// Both `.persist()` (on `RecordRegistrar`) and `AimDbQueryExt` (on `AimDb`) /// retrieve this via `extensions().get::()`. pub struct PersistenceState { /// The configured persistence backend. @@ -20,7 +19,7 @@ pub struct PersistenceState { } /// Extension trait that adds `.with_persistence()` to [`AimDbBuilder`]. -pub trait AimDbBuilderPersistExt { +pub trait AimDbBuilderPersistExt { /// Configures a persistence backend with a retention window. /// /// Stores the backend in the builder's `Extensions` TypeMap (accessible to @@ -41,10 +40,7 @@ pub trait AimDbBuilderPersistExt { ) -> Self; } -impl AimDbBuilderPersistExt for AimDbBuilder -where - R: RuntimeAdapter + TimeOps + 'static, -{ +impl AimDbBuilderPersistExt for AimDbBuilder { fn with_persistence( mut self, backend: Arc, @@ -96,7 +92,7 @@ where // Register a startup task for periodic retention cleanup. let backend_task = backend.clone(); - self.on_start(move |runtime: Arc| async move { + self.on_start(move |ctx: aimdb_core::RuntimeContext| async move { loop { // Calculate the cutoff: now minus retention window. let now = u64::try_from( @@ -125,9 +121,8 @@ where } } - // Sleep 24 hours using the runtime's TimeOps. - let day = runtime.secs(24 * 3600); - runtime.sleep(day).await; + // Sleep 24 hours using the runtime's clock. + ctx.time().sleep_secs(24 * 3600).await; } }); diff --git a/aimdb-persistence/src/ext.rs b/aimdb-persistence/src/ext.rs index 71f479e1..c272484a 100644 --- a/aimdb-persistence/src/ext.rs +++ b/aimdb-persistence/src/ext.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use aimdb_core::typed_api::RecordRegistrar; -use aimdb_executor::RuntimeAdapter; use crate::backend::PersistenceBackend; use crate::builder_ext::PersistenceState; @@ -13,25 +12,23 @@ use crate::builder_ext::PersistenceState; /// `T: Serialize` is required so values can be converted to JSON for storage. /// `.with_remote_access()` is **not** required — persistence subscribes to the /// typed buffer directly. -pub trait RecordRegistrarPersistExt<'a, T, R> +pub trait RecordRegistrarPersistExt<'a, T> where T: serde::Serialize + Send + Sync + Clone + core::fmt::Debug + 'static, - R: RuntimeAdapter + 'static, { /// Opt this record into persistence. /// - /// Spawns a background subscriber (via `tap_raw`) that serializes each + /// Spawns a background subscriber (via `.tap()`) that serializes each /// value to JSON and writes it to the configured backend. Retention is /// managed by the cleanup task registered during `with_persistence()`. - fn persist(&mut self, record_name: impl Into) -> &mut RecordRegistrar<'a, T, R>; + fn persist(&mut self, record_name: impl Into) -> &mut RecordRegistrar<'a, T>; } -impl<'a, T, R> RecordRegistrarPersistExt<'a, T, R> for RecordRegistrar<'a, T, R> +impl<'a, T> RecordRegistrarPersistExt<'a, T> for RecordRegistrar<'a, T> where T: serde::Serialize + Send + Sync + Clone + core::fmt::Debug + 'static, - R: RuntimeAdapter + 'static, { - fn persist(&mut self, record_name: impl Into) -> &mut RecordRegistrar<'a, T, R> { + fn persist(&mut self, record_name: impl Into) -> &mut RecordRegistrar<'a, T> { let record_name: String = record_name.into(); // Retrieve the backend from the builder's Extensions TypeMap, if configured. let backend: Option> = self @@ -50,9 +47,8 @@ where return self; }; // Subscribe to the typed buffer as a tap (side-effect observer). - // The second closure argument is the runtime context (Arc), - // which we don't need — persistence is runtime-agnostic. - self.tap_raw(move |consumer, _ctx| async move { + // The runtime context isn't needed — persistence is runtime-agnostic. + self.tap(move |_ctx, consumer| async move { let mut reader = consumer.subscribe(); loop { diff --git a/aimdb-persistence/src/query_ext.rs b/aimdb-persistence/src/query_ext.rs index 416d37b2..05e2c874 100644 --- a/aimdb-persistence/src/query_ext.rs +++ b/aimdb-persistence/src/query_ext.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use aimdb_core::builder::AimDb; -use aimdb_executor::RuntimeAdapter; use serde::de::DeserializeOwned; use crate::backend::{BoxFuture, PersistenceBackend, QueryParams}; @@ -13,7 +12,7 @@ use crate::error::PersistenceError; /// Extension trait that adds persistence query methods to [`AimDb`]. /// /// Import `use aimdb_persistence::AimDbQueryExt;` to call `.query_latest()` / -/// `.query_range()` on a live `AimDb` handle. +/// `.query_range()` on a live `AimDb` handle. pub trait AimDbQueryExt { /// Query the latest N values per matching record. /// @@ -56,16 +55,14 @@ pub trait AimDbQueryExt { } /// Helper: extract the backend from the `AimDb` extensions. -fn get_backend( - db: &AimDb, -) -> Result, PersistenceError> { +fn get_backend(db: &AimDb) -> Result, PersistenceError> { db.extensions() .get::() .map(|s| s.backend.clone()) .ok_or(PersistenceError::NotConfigured) } -impl AimDbQueryExt for AimDb { +impl AimDbQueryExt for AimDb { fn query_latest( &self, record_pattern: &str, diff --git a/aimdb-persistence/tests/query_skip_invalid.rs b/aimdb-persistence/tests/query_skip_invalid.rs index f0af4c83..155b8b9c 100644 --- a/aimdb-persistence/tests/query_skip_invalid.rs +++ b/aimdb-persistence/tests/query_skip_invalid.rs @@ -67,9 +67,9 @@ impl PersistenceBackend for MockBackend { // Helpers // --------------------------------------------------------------------------- -/// Build a minimal `AimDb` backed by `mock` (no records +/// Build a minimal `AimDb` backed by `mock` (no records /// configured — we only need the Extensions TypeMap and the query path). -async fn build_db(mock: Arc) -> aimdb_core::AimDb { +async fn build_db(mock: Arc) -> aimdb_core::AimDb { let adapter = Arc::new(TokioAdapter); let (db, runner) = AimDbBuilder::new() .runtime(adapter) diff --git a/aimdb-serial-connector/Cargo.toml b/aimdb-serial-connector/Cargo.toml index 4fb5ee84..371e6425 100644 --- a/aimdb-serial-connector/Cargo.toml +++ b/aimdb-serial-connector/Cargo.toml @@ -88,6 +88,10 @@ defmt = { workspace = true, optional = true } # keeping it out of dev-deps avoids a std/no_std unification clash with the tokio # tests, where it would otherwise compile against a std `aimdb-core`.) embassy-time-driver = { path = "../_external/embassy/embassy-time-driver" } +# No-op defmt logger stub for the embassy smoke test binary: the engine holds +# the adapter as `Arc` (issue #131), whose vtable references +# the defmt-backed `log` path. +defmt = { workspace = true } tokio = { version = "1", features = [ "rt-multi-thread", "macros", diff --git a/aimdb-serial-connector/src/embassy_transport.rs b/aimdb-serial-connector/src/embassy_transport.rs index 99fea11e..96294ce8 100644 --- a/aimdb-serial-connector/src/embassy_transport.rs +++ b/aimdb-serial-connector/src/embassy_transport.rs @@ -31,7 +31,7 @@ use aimdb_core::connector::ConnectorBuilder; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::session::aimx::{AimxCodec, AimxDispatch}; use aimdb_core::session::{serve, Dispatch, SessionConfig, SessionLimits}; -use aimdb_core::{AimDb, DbResult, RuntimeAdapter}; +use aimdb_core::{AimDb, DbResult}; use crate::framing::{encode_frame, FrameAccumulator}; use crate::DEFAULT_SCHEME; @@ -191,13 +191,12 @@ impl SerialServer { } } -impl ConnectorBuilder for SerialServer +impl ConnectorBuilder for SerialServer where - R: RuntimeAdapter + 'static, Rd: Read + 'static, Wr: Write + 'static, { - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { // Take the moved-in connection out of `&self` (build runs once); the // canonical "already built" error lives on the adapter's cell. let conn = self.conn.take_required(); diff --git a/aimdb-serial-connector/src/lib.rs b/aimdb-serial-connector/src/lib.rs index e83fadeb..6b25218a 100644 --- a/aimdb-serial-connector/src/lib.rs +++ b/aimdb-serial-connector/src/lib.rs @@ -49,10 +49,7 @@ pub const DEFAULT_SCHEME: &str = "serial"; /// `record.list` advertises the `writable` flag (the dispatch also enforces it). /// Shared by both `SerialServer` halves; mirrors the UDS connector. #[cfg(any(feature = "tokio-runtime", feature = "embassy-runtime"))] -pub(crate) fn apply_writable(db: &aimdb_core::AimDb, config: &aimdb_core::remote::AimxConfig) -where - R: aimdb_core::RuntimeAdapter + 'static, -{ +pub(crate) fn apply_writable(db: &aimdb_core::AimDb, config: &aimdb_core::remote::AimxConfig) { for key in config.security_policy.writable_records() { if let Some(id) = db.inner().resolve_str(&key) { if let Some(storage) = db.inner().storage(id) { diff --git a/aimdb-serial-connector/src/tokio_transport.rs b/aimdb-serial-connector/src/tokio_transport.rs index aaa55194..261b3b19 100644 --- a/aimdb-serial-connector/src/tokio_transport.rs +++ b/aimdb-serial-connector/src/tokio_transport.rs @@ -20,7 +20,7 @@ use aimdb_core::session::{ BoxFut, Connection, Dialer, Dispatch, Listener, PeerInfo, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, TransportError, TransportResult, }; -use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; +use aimdb_core::{AimDb, DbError, DbResult}; use crate::framing::{encode_frame, FrameAccumulator}; use crate::DEFAULT_SCHEME; @@ -245,11 +245,8 @@ impl SerialServer { } } -impl ConnectorBuilder for SerialServer -where - R: RuntimeAdapter + 'static, -{ - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { +impl ConnectorBuilder for SerialServer { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { let path = self.path.clone(); let baud = self.baud; let config = self.config.clone(); @@ -271,7 +268,7 @@ where let connector = SessionServerConnector::new( move || open_serial_listener(&path, baud), AimxCodec, - move |db: &AimDb| -> Arc { + move |db: &AimDb| -> Arc { // Apply the security policy's writable marking so `record.list` // reports the `writable` flag (the dispatch also enforces it). crate::apply_writable(db, &dispatch_config); diff --git a/aimdb-serial-connector/tests/embassy_smoke.rs b/aimdb-serial-connector/tests/embassy_smoke.rs index be18e28b..f066cd61 100644 --- a/aimdb-serial-connector/tests/embassy_smoke.rs +++ b/aimdb-serial-connector/tests/embassy_smoke.rs @@ -33,6 +33,24 @@ use aimdb_serial_connector::embassy_transport::CobsFramer; // Trivial host time driver so `embassy_time` links (the happy path never awaits // `clock.sleep`, so the driver is never actually exercised). +// No-op defmt logger so the binary links: the engine holds the adapter as +// `Arc` (issue #131), whose vtable references the `log` path +// (`defmt` on Embassy) even though this smoke never logs. +#[defmt::global_logger] +struct TestLogger; + +unsafe impl defmt::Logger for TestLogger { + 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 TestTimeDriver; impl embassy_time_driver::Driver for TestTimeDriver { fn now(&self) -> u64 { diff --git a/aimdb-sync/CHANGELOG.md b/aimdb-sync/CHANGELOG.md index c5ace53d..a66e8eae 100644 --- a/aimdb-sync/CHANGELOG.md +++ b/aimdb-sync/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **Issue #131:** `AimDbSyncExt` extends the non-generic `aimdb_core::AimDb`; internal handles drop the `TokioAdapter` type parameter. + ### Changed - `attach()` updated to destructure the `(AimDb, AimDbRunner)` tuple returned by `AimDbBuilder::build()` after issue #88, and to drive the runner inside the runtime thread via `tokio::select!` against the shutdown signal. No public API change. diff --git a/aimdb-sync/src/handle.rs b/aimdb-sync/src/handle.rs index 633b9cf9..62335bfb 100644 --- a/aimdb-sync/src/handle.rs +++ b/aimdb-sync/src/handle.rs @@ -1,7 +1,6 @@ //! AimDB handle for managing the sync API runtime thread. use aimdb_core::{AimDb, AimDbBuilder, DbError, DbResult}; -use aimdb_tokio_adapter::TokioAdapter; use std::fmt::Debug; use std::sync::Arc; use std::thread::{self, JoinHandle}; @@ -62,7 +61,7 @@ pub trait AimDbBuilderSyncExt { fn attach(self) -> DbResult; } -impl AimDbBuilderSyncExt for AimDbBuilder { +impl AimDbBuilderSyncExt for AimDbBuilder { fn attach(self) -> DbResult { AimDbHandle::new_from_builder(self) } @@ -102,7 +101,7 @@ pub trait AimDbSyncExt { fn attach(self) -> DbResult; } -impl AimDbSyncExt for AimDb { +impl AimDbSyncExt for AimDb { fn attach(self) -> DbResult { AimDbHandle::new(self) } @@ -135,7 +134,7 @@ pub struct AimDbHandle { runtime_handle: tokio::runtime::Handle, /// Shared reference to the database (protected by Arc for thread safety) - db: Arc>, + db: Arc, } /// Signal to shut down the runtime thread. @@ -144,12 +143,12 @@ struct ShutdownSignal; impl AimDbHandle { /// Create a new handle by spawning the runtime thread and building the database inside it. - pub(crate) fn new_from_builder(builder: AimDbBuilder) -> DbResult { + pub(crate) fn new_from_builder(builder: AimDbBuilder) -> DbResult { // Create shutdown channel let (shutdown_tx, mut shutdown_rx) = mpsc::channel::(1); // Create channels for passing the built database and runtime handle back - let (db_tx, mut db_rx) = mpsc::channel::>>(1); + let (db_tx, mut db_rx) = mpsc::channel::>(1); let (handle_tx, mut handle_rx) = mpsc::channel::(1); // Spawn the runtime thread @@ -226,7 +225,7 @@ impl AimDbHandle { /// Create a new handle from an already-built database (legacy method). #[allow(dead_code)] - pub(crate) fn new(db: AimDb) -> DbResult { + pub(crate) fn new(db: AimDb) -> DbResult { // Create shutdown channel let (shutdown_tx, mut shutdown_rx) = mpsc::channel::(1); diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index 4e61faa3..ef506de1 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **Issue #131 — `TokioRecordRegistrarExt` shrinks to `.buffer(cfg)` only.** `source`/`tap`/`transform` are inherent methods on the non-generic `aimdb_core::RecordRegistrar<'a, T>` (closures keep the `(ctx, producer)` arg order — typically only the import changes); `join_queue.rs` (`TokioJoinQueue`) is deleted with the `JoinFanInRuntime` family (join fan-in lives in core on `async-channel`). + ### Removed (breaking) - **`TokioDatabase` type alias removed (Issue #132, design 034 Phase 1).** It aliased the dead `aimdb_core::Database` wrapper (also removed) and had no users in the workspace. Use `AimDb` via `AimDbBuilder`. diff --git a/aimdb-tokio-adapter/src/join_queue.rs b/aimdb-tokio-adapter/src/join_queue.rs deleted file mode 100644 index 2d018f2e..00000000 --- a/aimdb-tokio-adapter/src/join_queue.rs +++ /dev/null @@ -1,144 +0,0 @@ -use aimdb_executor::{ - ExecutorError, ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender, -}; - -use crate::runtime::TokioAdapter; - -const CAPACITY: usize = 64; - -// ============================================================================ -// TokioJoinQueue -// ============================================================================ - -pub struct TokioJoinQueue { - tx: tokio::sync::mpsc::Sender, - rx: tokio::sync::mpsc::Receiver, -} - -pub struct TokioJoinSender { - tx: tokio::sync::mpsc::Sender, -} - -pub struct TokioJoinReceiver { - rx: tokio::sync::mpsc::Receiver, -} - -// Clone is required by JoinQueue::Sender bound -impl Clone for TokioJoinSender { - fn clone(&self) -> Self { - Self { - tx: self.tx.clone(), - } - } -} - -impl JoinQueue for TokioJoinQueue { - type Sender = TokioJoinSender; - type Receiver = TokioJoinReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - ( - TokioJoinSender { tx: self.tx }, - TokioJoinReceiver { rx: self.rx }, - ) - } -} - -impl JoinSender for TokioJoinSender { - async fn send(&self, item: T) -> ExecutorResult<()> { - self.tx - .send(item) - .await - .map_err(|_| ExecutorError::QueueClosed) - } -} - -impl JoinReceiver for TokioJoinReceiver { - async fn recv(&mut self) -> ExecutorResult { - self.rx.recv().await.ok_or(ExecutorError::QueueClosed) - } -} - -// ============================================================================ -// JoinFanInRuntime for TokioAdapter -// ============================================================================ - -impl JoinFanInRuntime for TokioAdapter { - type JoinQueue = TokioJoinQueue; - - fn create_join_queue(&self) -> ExecutorResult> { - let (tx, rx) = tokio::sync::mpsc::channel(CAPACITY); - Ok(TokioJoinQueue { tx, rx }) - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use aimdb_executor::ExecutorError; - - fn make_queue() -> (TokioJoinSender, TokioJoinReceiver) { - let adapter = TokioAdapter::new().expect("TokioAdapter"); - adapter - .create_join_queue::() - .expect("create_join_queue") - .split() - } - - #[tokio::test] - async fn roundtrip_send_recv() { - let (tx, mut rx) = make_queue(); - tx.send(1).await.unwrap(); - tx.send(2).await.unwrap(); - tx.send(3).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 1); - assert_eq!(rx.recv().await.unwrap(), 2); - assert_eq!(rx.recv().await.unwrap(), 3); - } - - #[tokio::test] - async fn bounded_capacity() { - let adapter = TokioAdapter::new().expect("TokioAdapter"); - let (tx, _rx) = adapter.create_join_queue::().unwrap().split(); - // Fill to capacity without consuming - for i in 0..CAPACITY as u32 { - tx.send(i).await.unwrap(); - } - // The (CAPACITY+1)th send should not complete immediately - let send_fut = tx.send(99); - let result = tokio::time::timeout(std::time::Duration::from_millis(10), send_fut).await; - assert!(result.is_err(), "expected send to block when queue is full"); - } - - #[tokio::test] - async fn send_after_rx_drop_yields_queue_closed() { - let (tx, rx) = make_queue(); - drop(rx); - let err = tx.send(1).await.unwrap_err(); - assert!(matches!(err, ExecutorError::QueueClosed)); - } - - #[tokio::test] - async fn recv_after_all_senders_drop_yields_queue_closed() { - let (tx, mut rx) = make_queue(); - let tx2 = tx.clone(); - drop(tx); - drop(tx2); - let err = rx.recv().await.unwrap_err(); - assert!(matches!(err, ExecutorError::QueueClosed)); - } - - #[tokio::test] - async fn clone_sender_routes_to_same_receiver() { - let (tx, mut rx) = make_queue(); - let tx2 = tx.clone(); - tx.send(10).await.unwrap(); - tx2.send(20).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 10); - assert_eq!(rx.recv().await.unwrap(), 20); - } -} diff --git a/aimdb-tokio-adapter/src/lib.rs b/aimdb-tokio-adapter/src/lib.rs index 9911538a..59bdca59 100644 --- a/aimdb-tokio-adapter/src/lib.rs +++ b/aimdb-tokio-adapter/src/lib.rs @@ -32,8 +32,6 @@ compile_error!("tokio-adapter requires the std feature"); pub mod buffer; pub mod error; -#[cfg(feature = "tokio-runtime")] -pub mod join_queue; pub mod runtime; pub mod time; @@ -43,11 +41,29 @@ pub use error::TokioErrorSupport; #[cfg(feature = "tokio-runtime")] pub use runtime::TokioAdapter; -// Generate extension trait for Tokio adapter using the macro -aimdb_core::impl_record_registrar_ext! { - TokioRecordRegistrarExt, - TokioAdapter, - TokioBuffer, - "tokio-runtime", - |cfg| TokioBuffer::::new(cfg) +/// Buffer-construction extension for [`aimdb_core::RecordRegistrar`]. +/// +/// Buffer construction is the one genuinely adapter-specific registration +/// step left after issue #131 — `source()` / `tap()` / `transform()` are +/// inherent methods on the registrar. This trait adds `.buffer(cfg)` backed +/// by [`TokioBuffer`]. +pub trait TokioRecordRegistrarExt +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + /// Configures a [`TokioBuffer`] from the given configuration. + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self; +} + +impl TokioRecordRegistrarExt for aimdb_core::RecordRegistrar<'_, T> +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self { + use aimdb_core::buffer::Buffer; + let buffer = Box::new(TokioBuffer::::new(&cfg)); + // Record the cfg so buffer_info() reports the real buffer + // type/capacity for the dependency graph. + self.buffer_with_cfg(buffer, cfg) + } } diff --git a/aimdb-tokio-adapter/src/runtime.rs b/aimdb-tokio-adapter/src/runtime.rs index 82484717..c0c69975 100644 --- a/aimdb-tokio-adapter/src/runtime.rs +++ b/aimdb-tokio-adapter/src/runtime.rs @@ -29,7 +29,7 @@ use tracing::{debug, warn}; /// # Example /// ```rust,no_run /// use aimdb_tokio_adapter::TokioAdapter; -/// use aimdb_core::RuntimeAdapter; +/// use aimdb_executor::RuntimeAdapter; /// /// #[tokio::main] /// async fn main() -> aimdb_core::DbResult<()> { @@ -80,7 +80,7 @@ impl TokioAdapter { /// # Example /// ```rust,no_run /// use aimdb_tokio_adapter::TokioAdapter; - /// use aimdb_core::RuntimeAdapter; + /// use aimdb_executor::RuntimeAdapter; /// /// # #[tokio::main] /// # async fn main() -> aimdb_core::DbResult<()> { diff --git a/aimdb-tokio-adapter/tests/drain_integration_tests.rs b/aimdb-tokio-adapter/tests/drain_integration_tests.rs index bcc6d92d..d28149d7 100644 --- a/aimdb-tokio-adapter/tests/drain_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/drain_integration_tests.rs @@ -41,7 +41,7 @@ fn test_socket_path(test_name: &str) -> String { } /// Helper: set up a test AimDB server with remote access -async fn setup_test_server(socket_path: &str) -> aimdb_core::AimDb { +async fn setup_test_server(socket_path: &str) -> aimdb_core::AimDb { let _ = std::fs::remove_file(socket_path); let adapter = Arc::new(TokioAdapter); @@ -72,7 +72,7 @@ async fn setup_test_server(socket_path: &str) -> aimdb_core::AimDb } /// Helper: set up server with a small ring to test overflow -async fn setup_small_ring_server(socket_path: &str) -> aimdb_core::AimDb { +async fn setup_small_ring_server(socket_path: &str) -> aimdb_core::AimDb { let _ = std::fs::remove_file(socket_path); let adapter = Arc::new(TokioAdapter); @@ -97,7 +97,7 @@ async fn setup_small_ring_server(socket_path: &str) -> aimdb_core::AimDb aimdb_core::AimDb { +async fn setup_no_remote_access_server(socket_path: &str) -> aimdb_core::AimDb { let _ = std::fs::remove_file(socket_path); let adapter = Arc::new(TokioAdapter); diff --git a/aimdb-tokio-adapter/tests/stage_profiling.rs b/aimdb-tokio-adapter/tests/stage_profiling.rs index fa33fd0b..f588eedd 100644 --- a/aimdb-tokio-adapter/tests/stage_profiling.rs +++ b/aimdb-tokio-adapter/tests/stage_profiling.rs @@ -34,7 +34,7 @@ async fn source_and_tap_stages_are_timed_and_named() { loop { producer.produce(Reading { value: n }); n += 1; - ctx.time().sleep(ctx.time().millis(10)).await; + ctx.time().sleep_millis(10).await; } }) .with_name("sensor_reader") @@ -43,7 +43,7 @@ async fn source_and_tap_stages_are_timed_and_named() { while let Ok(reading) = reader.recv().await { // Simulate per-value processing work (wall-clock). let _ = reading.value; - ctx.time().sleep(ctx.time().millis(5)).await; + ctx.time().sleep_millis(5).await; } }) .with_name("data_processor"); @@ -57,7 +57,7 @@ async fn source_and_tap_stages_are_timed_and_named() { let rec = db .inner() - .get_typed_record_by_key::(KEY) + .get_typed_record_by_key::(KEY) .expect("typed record"); let prof = rec.profiling(); @@ -112,7 +112,7 @@ async fn with_name_is_a_no_op_friendly_builder() { tokio::spawn(runner.run()); let rec = db .inner() - .get_typed_record_by_key::("profiling::Unused") + .get_typed_record_by_key::("profiling::Unused") .expect("typed record"); assert_eq!( rec.profiling().tap(0).unwrap().name.as_deref(), diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs index 4d6f7ca9..1d9b9c8a 100644 --- a/aimdb-uds-connector/src/lib.rs +++ b/aimdb-uds-connector/src/lib.rs @@ -45,7 +45,7 @@ use aimdb_core::session::aimx::{AimxCodec, AimxDispatch}; use aimdb_core::session::{ Dispatch, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, }; -use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; +use aimdb_core::{AimDb, DbError, DbResult}; type BoxFuture = Pin + Send + 'static>>; type BuildFuture<'a> = Pin>> + Send + 'a>>; @@ -137,11 +137,8 @@ impl UdsServer { } } -impl ConnectorBuilder for UdsServer -where - R: RuntimeAdapter + 'static, -{ - fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { +impl ConnectorBuilder for UdsServer { + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { let config = self.config.clone(); let scheme = self.scheme.clone(); Box::pin(async move { @@ -161,7 +158,7 @@ where let connector = SessionServerConnector::new( move || bind_uds_listener(&bind_config), AimxCodec, - move |db: &AimDb| -> Arc { + move |db: &AimDb| -> Arc { // Apply the security policy's writable marking so `record.list` // reports the `writable` flag (the dispatch also enforces it). apply_writable(db, &dispatch_config); @@ -232,10 +229,7 @@ fn bind_uds_listener(config: &AimxConfig) -> DbResult { /// Mark each record named in the policy's writable set as writable, so /// `record.list` advertises the `writable` flag. -fn apply_writable(db: &AimDb, config: &AimxConfig) -where - R: RuntimeAdapter + 'static, -{ +fn apply_writable(db: &AimDb, config: &AimxConfig) { for key in config.security_policy.writable_records() { if let Some(id) = db.inner().resolve_str(&key) { if let Some(storage) = db.inner().storage(id) { diff --git a/aimdb-wasm-adapter/CHANGELOG.md b/aimdb-wasm-adapter/CHANGELOG.md index a80fdee2..1302b3b3 100644 --- a/aimdb-wasm-adapter/CHANGELOG.md +++ b/aimdb-wasm-adapter/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **Issue #131 — `WasmRecordRegistrarExt` shrinks to `.buffer(cfg)` only** (`source`/`tap`/`transform` are inherent on the non-generic `RecordRegistrar<'a, T>`); `join_queue.rs` (`WasmJoinQueue`) is deleted with the `JoinFanInRuntime` family; bindings/bridge take the non-generic `AimDb`. + ### Added - **`RuntimeOps` implemented for `WasmAdapter` (Issue #130, design 034 Phase 2).** The dyn-safe capability surface from `aimdb-executor`: `now_nanos()` from `Performance.now()` (monotonic ms since page load), `unix_time()` from `Date.now()` (wall clock — the generic `TimeOps::unix_time` default still returns `None`), `sleep` boxes the setTimeout-Promise future, `log` forwards to the console-backed `Logger`. Browser contract test via `wasm_bindgen_test`; native fallback covered by a sync-surface test. diff --git a/aimdb-wasm-adapter/src/bindings.rs b/aimdb-wasm-adapter/src/bindings.rs index 9097c615..4a0e41e4 100644 --- a/aimdb-wasm-adapter/src/bindings.rs +++ b/aimdb-wasm-adapter/src/bindings.rs @@ -111,7 +111,7 @@ pub struct WasmDb { /// Pre-build record configurations. `None` after `build()`. configs: Option>, /// Live database handle. `None` before `build()`. - db: Option>, + db: Option, /// Maps record key → schema type name (always populated). schema_map: BTreeMap, /// Type-erased dispatch registry built via `.register::()` calls. @@ -472,7 +472,7 @@ fn discover_impl(url: String) -> js_sys::Promise { impl WasmDb { /// Resolve a record key to the live DB handle and its type-erased ops. - fn resolve(&self, record_key: &str) -> Result<(&AimDb, &SchemaOps), JsError> { + fn resolve(&self, record_key: &str) -> Result<(&AimDb, &SchemaOps), JsError> { let db = self .db .as_ref() @@ -498,7 +498,7 @@ impl WasmDb { /// Apply a single `RecordConfig` to the builder, dispatching on schema type. fn apply_record_config( registry: &SchemaRegistry, - builder: &mut AimDbBuilder, + builder: &mut AimDbBuilder, config: &RecordConfig, ) -> Result<(), JsError> { let key = StringKey::intern(config.key.clone()); @@ -513,13 +513,13 @@ fn apply_record_config( } /// Read the latest snapshot for record `key` and convert to `JsValue`. -pub(crate) fn get_typed(db: &AimDb, key: &str) -> Result +pub(crate) fn get_typed(db: &AimDb, key: &str) -> Result where T: Send + Sync + 'static + Debug + Clone + Serialize, { let inner = db.inner(); let typed = inner - .get_typed_record_by_key::(key) + .get_typed_record_by_key::(key) .map_err(|e| JsError::new(&format!("{e:?}")))?; match typed.latest() { @@ -534,11 +534,7 @@ where } /// Deserialize `JsValue` → `T` (contract enforcement), then push to buffer. -pub(crate) fn set_typed( - db: &AimDb, - key: &str, - value: JsValue, -) -> Result<(), JsError> +pub(crate) fn set_typed(db: &AimDb, key: &str, value: JsValue) -> Result<(), JsError> where T: Send + Sync + 'static + Debug + Clone + DeserializeOwned, { @@ -559,7 +555,7 @@ where /// future so the unsubscribe closure can break the loop immediately — even /// when `recv()` is blocked waiting for the next push. pub(crate) fn subscribe_typed( - db: &AimDb, + db: &AimDb, key: &str, callback: &js_sys::Function, ) -> Result diff --git a/aimdb-wasm-adapter/src/join_queue.rs b/aimdb-wasm-adapter/src/join_queue.rs deleted file mode 100644 index daaed23a..00000000 --- a/aimdb-wasm-adapter/src/join_queue.rs +++ /dev/null @@ -1,150 +0,0 @@ -use aimdb_executor::{ - ExecutorError, ExecutorResult, JoinFanInRuntime, JoinQueue, JoinReceiver, JoinSender, -}; -use futures_channel::mpsc; -use futures_util::sink::SinkExt as _; -use futures_util::stream::StreamExt as _; - -use crate::runtime::WasmAdapter; - -const CAPACITY: usize = 64; - -// ============================================================================ -// WasmJoinQueue -// ============================================================================ - -pub struct WasmJoinQueue { - tx: mpsc::Sender, - rx: mpsc::Receiver, -} - -pub struct WasmJoinSender { - tx: mpsc::Sender, -} - -pub struct WasmJoinReceiver { - rx: mpsc::Receiver, -} - -impl Clone for WasmJoinSender { - fn clone(&self) -> Self { - Self { - tx: self.tx.clone(), - } - } -} - -impl JoinQueue for WasmJoinQueue { - type Sender = WasmJoinSender; - type Receiver = WasmJoinReceiver; - - fn split(self) -> (Self::Sender, Self::Receiver) { - ( - WasmJoinSender { tx: self.tx }, - WasmJoinReceiver { rx: self.rx }, - ) - } -} - -impl JoinSender for WasmJoinSender { - async fn send(&self, item: T) -> ExecutorResult<()> { - // Clone sender to get a mutable handle (futures-channel requires mut for Sink). - let mut tx = self.tx.clone(); - tx.send(item).await.map_err(|_| ExecutorError::QueueClosed) - } -} - -impl JoinReceiver for WasmJoinReceiver { - async fn recv(&mut self) -> ExecutorResult { - self.rx.next().await.ok_or(ExecutorError::QueueClosed) - } -} - -// ============================================================================ -// JoinFanInRuntime for WasmAdapter -// ============================================================================ - -impl JoinFanInRuntime for WasmAdapter { - type JoinQueue = WasmJoinQueue; - - fn create_join_queue(&self) -> ExecutorResult> { - let (tx, rx) = mpsc::channel(CAPACITY); - Ok(WasmJoinQueue { tx, rx }) - } -} - -// ============================================================================ -// Tests -// ============================================================================ - -#[cfg(test)] -mod tests { - use super::*; - use aimdb_executor::{ExecutorError, JoinQueue as _, JoinReceiver as _}; - use wasm_bindgen_test::wasm_bindgen_test; - - wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); - - fn make_queue() -> (WasmJoinSender, WasmJoinReceiver) { - let (tx, rx) = mpsc::channel::(CAPACITY); - WasmJoinQueue { tx, rx }.split() - } - - #[wasm_bindgen_test] - async fn roundtrip_send_recv() { - let (tx, mut rx) = make_queue(); - tx.send(1).await.unwrap(); - tx.send(2).await.unwrap(); - tx.send(3).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 1); - assert_eq!(rx.recv().await.unwrap(), 2); - assert_eq!(rx.recv().await.unwrap(), 3); - } - - #[wasm_bindgen_test] - async fn bounded_capacity() { - let (tx, _rx) = make_queue(); - // Fill to capacity - for i in 0..CAPACITY as u32 { - tx.send(i).await.unwrap(); - } - // The (CAPACITY+1)th send should return Pending or close — poll once - let mut tx_clone = tx.tx.clone(); - use futures_util::sink::SinkExt as _; - // With a full bounded channel the send future should not resolve immediately; - // we verify by checking that a non-blocking try_send fails. - let result = tx_clone.try_send(99u32); - assert!( - result.is_err(), - "expected try_send to fail when queue is full" - ); - } - - #[wasm_bindgen_test] - async fn send_after_rx_drop_yields_queue_closed() { - let (tx, rx) = make_queue(); - drop(rx); - let err = tx.send(1).await.unwrap_err(); - assert!(matches!(err, ExecutorError::QueueClosed)); - } - - #[wasm_bindgen_test] - async fn recv_after_all_senders_drop_yields_queue_closed() { - let (tx, mut rx) = make_queue(); - let tx2 = tx.clone(); - drop(tx); - drop(tx2); - let err = rx.recv().await.unwrap_err(); - assert!(matches!(err, ExecutorError::QueueClosed)); - } - - #[wasm_bindgen_test] - async fn clone_sender_routes_to_same_receiver() { - let (tx, mut rx) = make_queue(); - let tx2 = tx.clone(); - tx.send(10).await.unwrap(); - tx2.send(20).await.unwrap(); - assert_eq!(rx.recv().await.unwrap(), 10); - assert_eq!(rx.recv().await.unwrap(), 20); - } -} diff --git a/aimdb-wasm-adapter/src/lib.rs b/aimdb-wasm-adapter/src/lib.rs index 7a7a6322..ae16b92d 100644 --- a/aimdb-wasm-adapter/src/lib.rs +++ b/aimdb-wasm-adapter/src/lib.rs @@ -35,7 +35,6 @@ extern crate alloc; pub mod buffer; -pub mod join_queue; pub mod logger; pub mod runtime; pub mod time; @@ -63,11 +62,29 @@ pub use buffer::{WasmBuffer, WasmBufferReader}; // Re-export time types pub use time::{WasmDuration, WasmInstant}; -// Generate the extension trait for convenient record configuration -aimdb_core::impl_record_registrar_ext! { - WasmRecordRegistrarExt, - WasmAdapter, - WasmBuffer, - "wasm-runtime", - |cfg| WasmBuffer::::new(cfg) +/// Buffer-construction extension for [`aimdb_core::RecordRegistrar`]. +/// +/// Buffer construction is the one genuinely adapter-specific registration +/// step left after issue #131 — `source()` / `tap()` / `transform()` are +/// inherent methods on the registrar. This trait adds `.buffer(cfg)` backed +/// by [`WasmBuffer`]. +pub trait WasmRecordRegistrarExt +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + /// Configures a [`WasmBuffer`] from the given configuration. + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self; +} + +impl WasmRecordRegistrarExt for aimdb_core::RecordRegistrar<'_, T> +where + T: Send + Sync + Clone + core::fmt::Debug + 'static, +{ + fn buffer(&mut self, cfg: aimdb_core::buffer::BufferCfg) -> &mut Self { + use aimdb_core::buffer::Buffer; + let buffer = alloc::boxed::Box::new(WasmBuffer::::new(&cfg)); + // Record the cfg so buffer_info() reports the real buffer + // type/capacity for the dependency graph. + self.buffer_with_cfg(buffer, cfg) + } } diff --git a/aimdb-wasm-adapter/src/schema_registry.rs b/aimdb-wasm-adapter/src/schema_registry.rs index d12f748f..bfd2a05c 100644 --- a/aimdb-wasm-adapter/src/schema_registry.rs +++ b/aimdb-wasm-adapter/src/schema_registry.rs @@ -21,17 +21,14 @@ use aimdb_core::record_id::StringKey; use aimdb_data_contracts::Streamable; -use crate::WasmAdapter; - // ─── Type-erased operations ─────────────────────────────────────────────── -type ConfigureFn = Arc, StringKey, BufferCfg) + Send + Sync>; -type GetFn = Arc, &str) -> Result + Send + Sync>; -type SetFn = Arc, &str, JsValue) -> Result<(), JsError> + Send + Sync>; -type SubscribeFn = Arc< - dyn Fn(&AimDb, &str, &js_sys::Function) -> Result + Send + Sync, ->; -type ProduceFromJsonFn = Arc, &str, serde_json::Value) + Send + Sync>; +type ConfigureFn = Arc; +type GetFn = Arc Result + Send + Sync>; +type SetFn = Arc Result<(), JsError> + Send + Sync>; +type SubscribeFn = + Arc Result + Send + Sync>; +type ProduceFromJsonFn = Arc; /// Type-erased operations for a single [`Streamable`] type. #[derive(Clone)] diff --git a/aimdb-wasm-adapter/src/ws_bridge.rs b/aimdb-wasm-adapter/src/ws_bridge.rs index 98635255..d77a1f67 100644 --- a/aimdb-wasm-adapter/src/ws_bridge.rs +++ b/aimdb-wasm-adapter/src/ws_bridge.rs @@ -30,8 +30,6 @@ use wasm_bindgen::prelude::*; use crate::schema_registry::SchemaRegistry; use aimdb_core::builder::AimDb; -use crate::WasmAdapter; - // ─── Connection status ──────────────────────────────────────────────────── /// Observable connection state (matches design doc §7.1). @@ -150,7 +148,7 @@ struct PendingRequest { /// Wrapped in `Rc` so closures can cheaply reference it without cloning /// every field individually (reduces parameter explosion). struct SharedCtx { - db: AimDb, + db: AimDb, schema_map: BTreeMap, registry: SchemaRegistry, state: Rc>, @@ -330,7 +328,7 @@ impl Drop for WsBridge { impl WsBridge { /// Create a new bridge (called from `WasmDb::connect_bridge`). pub(crate) fn new_internal( - db: AimDb, + db: AimDb, schema_map: BTreeMap, registry: SchemaRegistry, url: &str, @@ -748,7 +746,7 @@ fn handle_server_message(ctx: &SharedCtx, msg: ServerMessage) { /// Deserialize `serde_json::Value` → `T` and push to the record buffer. /// /// This is the fast path for incoming server data — no `JsValue` hop. -pub(crate) fn produce_from_json(db: &AimDb, key: &str, json: serde_json::Value) +pub(crate) fn produce_from_json(db: &AimDb, key: &str, json: serde_json::Value) where T: Send + Sync + 'static + Debug + Clone + DeserializeOwned, { diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index 99d725d0..1e304dc7 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -134,17 +134,14 @@ impl WsClientConnectorBuilder { type BoxFuture = Pin + Send + 'static>>; -impl ConnectorBuilder for WsClientConnectorBuilder -where - R: aimdb_executor::TimeOps + 'static, -{ +impl ConnectorBuilder for WsClientConnectorBuilder { fn scheme(&self) -> &str { "ws-client" } fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { @@ -171,12 +168,12 @@ where // Like `UdsClient`: `run_client` owns demux/reconnect/keepalive over // the WS `Dialer` + per-connection `WsCodec`; `pump_client` wires // `link_to`/`link_from` routes to the handle. - // The runtime's `TimeOps` clock drives reconnect backoff/keepalive. + // The runtime's clock drives reconnect backoff/keepalive. let (handle, engine_fut) = run_client( WsDialer::new(self.url.clone()), WsCodec::new(), config, - db.runtime_arc(), + db.runtime_ops(), ); let mut futures = pump_client(db, "ws-client", &handle); futures.push(engine_fut); diff --git a/aimdb-websocket-connector/src/server/builder.rs b/aimdb-websocket-connector/src/server/builder.rs index d821eda1..ddddfdec 100644 --- a/aimdb-websocket-connector/src/server/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -277,17 +277,14 @@ impl WebSocketConnectorBuilder { type BoxFuture = Pin + Send + 'static>>; -impl ConnectorBuilder for WebSocketConnectorBuilder -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ +impl ConnectorBuilder for WebSocketConnectorBuilder { fn scheme(&self) -> &str { "ws" } fn build<'a>( &'a self, - db: &'a aimdb_core::builder::AimDb, + db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { @@ -346,7 +343,7 @@ where known_topics: Arc::new(known_topics), auth: self.auth.clone(), late_join: self.late_join, - runtime_ctx: Some(db.runtime_any()), + runtime_ctx: Some(db.runtime_ctx()), }); // ── Outbound: the shared `pump_sink` drives records → bus ─────── diff --git a/aimdb-websocket-connector/src/server/dispatch.rs b/aimdb-websocket-connector/src/server/dispatch.rs index 7d9f5234..ce7617db 100644 --- a/aimdb-websocket-connector/src/server/dispatch.rs +++ b/aimdb-websocket-connector/src/server/dispatch.rs @@ -10,7 +10,6 @@ //! not here. The `Subscribed` ack and late-join `Snapshot` are engine emissions; //! this session only supplies the snapshot bytes and the subscription stream. -use std::any::Any; use std::sync::Arc; use aimdb_core::session::Session; @@ -34,7 +33,7 @@ pub struct WsDispatch { pub(crate) known_topics: Arc>, pub(crate) auth: Arc, pub(crate) late_join: bool, - pub(crate) runtime_ctx: Option>, + pub(crate) runtime_ctx: Option, } impl Dispatch for WsDispatch { @@ -86,7 +85,7 @@ struct WsSession { known_topics: Arc>, auth: Arc, late_join: bool, - runtime_ctx: Option>, + runtime_ctx: Option, info: Arc, /// Decrements the live-connection count on drop. _conn_guard: ConnectionGuard, diff --git a/aimdb-websocket-connector/tests/e2e.rs b/aimdb-websocket-connector/tests/e2e.rs index e4edd402..339d6fdb 100644 --- a/aimdb-websocket-connector/tests/e2e.rs +++ b/aimdb-websocket-connector/tests/e2e.rs @@ -144,7 +144,7 @@ async fn wait_for_listen(addr: SocketAddr) { /// Stand up a WS server (with the injector record) on an ephemeral port. The /// caller pre-configures `ws` (auth / late-join / …); we add `bind`/`path`. -async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc>) { +async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc) { let addr = free_addr(); let mut sb = AimDbBuilder::new() .runtime(Arc::new(TokioAdapter)) @@ -167,12 +167,12 @@ async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc>) } /// Default allow-all, late-join-on server. -async fn spawn_default() -> (SocketAddr, Arc>) { +async fn spawn_default() -> (SocketAddr, Arc) { spawn(WebSocketConnector::new().with_late_join(true)).await } /// Push `payload` to subscribers of `topic` (one outbound record update). -fn inject(db: &AimDb, topic: &str, payload: Value) { +fn inject(db: &AimDb, topic: &str, payload: Value) { db.set_record_from_json("inject", json!({ "topic": topic, "payload": payload })) .expect("inject"); } diff --git a/aimdb-websocket-connector/tests/ws_roundtrip.rs b/aimdb-websocket-connector/tests/ws_roundtrip.rs index 00888a1e..21c085f9 100644 --- a/aimdb-websocket-connector/tests/ws_roundtrip.rs +++ b/aimdb-websocket-connector/tests/ws_roundtrip.rs @@ -36,7 +36,7 @@ fn free_addr() -> SocketAddr { /// Re-assert `db.` reaches `want`, re-driving `push` each tick so the test is /// robust against subscription-registration timing. async fn mirror_reaches( - db: &Arc>, + db: &Arc, key: &str, want: &serde_json::Value, mut push: impl FnMut(), diff --git a/docs/aimdb-usage-guide.md b/docs/aimdb-usage-guide.md index 27a1fc2b..e1ece3f4 100644 --- a/docs/aimdb-usage-guide.md +++ b/docs/aimdb-usage-guide.md @@ -109,7 +109,7 @@ async fn main() -> Result<(), Box> { }; let _ = producer.produce(data).await; - time.sleep(time.secs(1)).await; + time.sleep_secs(1).await; } }) @@ -211,7 +211,7 @@ struct ButtonPress { /// Button handler that produces events on button press async fn button_handler( - ctx: RuntimeContext, + ctx: RuntimeContext, producer: Producer, mut button: Input<'static>, ) { @@ -249,7 +249,7 @@ async fn main(_spawner: Spawner) { let button = Input::new(p.PIN_15, Pull::Up); // Create Embassy runtime adapter - let runtime = EmbassyAdapter::new(); + let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); // Build database let db = AimDbBuilder::new() diff --git a/docs/design/012-M5-connector-development-guide.md b/docs/design/012-M5-connector-development-guide.md index 841cfdfd..05954656 100644 --- a/docs/design/012-M5-connector-development-guide.md +++ b/docs/design/012-M5-connector-development-guide.md @@ -207,8 +207,10 @@ embassy-runtime = ["aimdb-core/connector-session", "aimdb-embassy-adapter/connec - 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")]`, `R: EmbassyNetwork` for the -network stack. +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/`. diff --git a/docs/design/028-M13-remove-spawn-trait.md b/docs/design/028-M13-remove-spawn-trait.md index d84c2593..73e9c2dc 100644 --- a/docs/design/028-M13-remove-spawn-trait.md +++ b/docs/design/028-M13-remove-spawn-trait.md @@ -610,7 +610,7 @@ Embassy main becomes: ```rust #[embassy_executor::main] async fn main(_spawner: Spawner) { - let adapter = EmbassyAdapter::new().unwrap(); + let adapter = EmbassyAdapter::new(); let db = AimDbBuilder::new() .runtime(Arc::new(adapter)) .configure::(...) diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 6afe0464..49f73b41 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -42,9 +42,7 @@ extern crate alloc; use aimdb_core::remote::SecurityPolicy; use aimdb_core::{AimDbBuilder, RecordKey, RuntimeContext}; -use aimdb_embassy_adapter::{ - EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt, EmbassyRecordRegistrarExtCustom, -}; +use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; use aimdb_knx_connector::dpt::{Dpt1, Dpt9, DptDecode, DptEncode}; use aimdb_knx_connector::embassy_client::KnxConnectorBuilder; use aimdb_serial_connector::embassy_transport::SerialServer; @@ -95,7 +93,7 @@ async fn net_task(mut runner: embassy_net::Runner<'static, Device>) -> ! { /// Button handler that toggles light on button press async fn button_handler( - ctx: RuntimeContext, + ctx: RuntimeContext, producer: aimdb_core::Producer, mut button: ExtiInput<'static, embassy_stm32::mode::Async>, ) { @@ -110,7 +108,7 @@ async fn button_handler( button.wait_for_falling_edge().await; // Debounce - time.sleep(time.millis(50)).await; + time.sleep_millis(50).await; if button.is_high() { continue; } @@ -122,7 +120,7 @@ async fn button_handler( producer.produce(state); button.wait_for_rising_edge().await; - time.sleep(time.millis(50)).await; + time.sleep_millis(50).await; } } @@ -244,7 +242,7 @@ async fn main(spawner: Spawner) { info!("🔌 Initializing KNX client..."); - let runtime = alloc::sync::Arc::new(EmbassyAdapter::new_with_network(stack)); + let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); use alloc::format; let gateway_url = format!("knx://{}:{}", KNX_GATEWAY_IP, KNX_GATEWAY_PORT); @@ -275,7 +273,7 @@ async fn main(spawner: Spawner) { // remote `record.set` is refused — peers can list/get/subscribe, not write. let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector(KnxConnectorBuilder::new(&gateway_url)) + .with_connector(KnxConnectorBuilder::new(&gateway_url, stack)) .with_connector( SerialServer::new(serial_rx, serial_tx).security_policy(SecurityPolicy::read_only()), ); @@ -385,7 +383,7 @@ async fn main(spawner: Spawner) { info!(""); info!("Press USER button to toggle light (1/0/6)"); - static DB_CELL: StaticCell> = StaticCell::new(); + static DB_CELL: StaticCell = StaticCell::new(); let (db, db_runner) = builder.build().await.expect("Failed to build database"); let _db = DB_CELL.init(db); diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index f841f61d..4214fbd5 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -54,9 +54,7 @@ extern crate alloc; use aimdb_core::remote::SecurityPolicy; use aimdb_core::{AimDbBuilder, Producer, RecordKey, RuntimeContext}; -use aimdb_embassy_adapter::{ - EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt, EmbassyRecordRegistrarExtCustom, -}; +use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; use aimdb_serial_connector::embassy_transport::SerialServer; use defmt::*; use embassy_executor::Spawner; @@ -103,10 +101,7 @@ async fn net_task(mut runner: embassy_net::Runner<'static, Device>) -> ! { // ============================================================================ /// Indoor temperature sensor producer -async fn indoor_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn indoor_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); log.info("🏠 Starting INDOOR temperature producer...\n"); @@ -127,10 +122,7 @@ async fn indoor_temp_producer( } /// Outdoor temperature sensor producer -async fn outdoor_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn outdoor_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); log.info("🌳 Starting OUTDOOR temperature producer...\n"); @@ -151,10 +143,7 @@ async fn outdoor_temp_producer( } /// Server room temperature sensor producer -async fn server_room_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn server_room_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); log.info("🖥️ Starting SERVER ROOM temperature producer...\n"); @@ -306,7 +295,7 @@ async fn main(spawner: Spawner) { info!("🔌 Initializing MQTT client..."); // Create AimDB database with Embassy adapter - let runtime = alloc::sync::Arc::new(EmbassyAdapter::new_with_network(stack)); + let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); // Build MQTT broker URL use alloc::format; @@ -340,7 +329,9 @@ async fn main(spawner: Spawner) { // list/drain/subscribe, not write. let mut builder = AimDbBuilder::new() .runtime(runtime.clone()) - .with_connector(MqttConnectorBuilder::new(&broker_url).with_client_id("embassy-demo-001")) + .with_connector( + MqttConnectorBuilder::new(&broker_url, stack).with_client_id("embassy-demo-001"), + ) .with_connector( SerialServer::new(serial_rx, serial_tx).security_policy(SecurityPolicy::read_only()), ); @@ -423,7 +414,7 @@ async fn main(spawner: Spawner) { info!(" -m '{{\"action\":\"read\",\"sensor_id\":\"test\"}}'"); info!(""); - static DB_CELL: StaticCell> = StaticCell::new(); + static DB_CELL: StaticCell = StaticCell::new(); let (db, db_runner) = builder.build().await.expect("Failed to build database"); let _db = DB_CELL.init(db); diff --git a/examples/embassy-serial-connector-demo/src/main.rs b/examples/embassy-serial-connector-demo/src/main.rs index 1e3e06a5..bacc5233 100644 --- a/examples/embassy-serial-connector-demo/src/main.rs +++ b/examples/embassy-serial-connector-demo/src/main.rs @@ -155,7 +155,7 @@ async fn main(spawner: Spawner) { .with_remote_access(); }); - static DB_CELL: StaticCell> = StaticCell::new(); + static DB_CELL: StaticCell = StaticCell::new(); let (db, db_runner) = builder.build().await.expect("build db"); let db = DB_CELL.init(db); diff --git a/examples/hello-single-latest-async/src/main.rs b/examples/hello-single-latest-async/src/main.rs index 2b3b1242..c8a22e1d 100644 --- a/examples/hello-single-latest-async/src/main.rs +++ b/examples/hello-single-latest-async/src/main.rs @@ -32,17 +32,17 @@ async fn main() -> Result<(), Box> { Ok(()) } -async fn rollout_source(ctx: RuntimeContext, producer: Producer) { +async fn rollout_source(ctx: RuntimeContext, producer: Producer) { let time = ctx.time(); - time.sleep(time.millis(50)).await; + time.sleep_millis(50).await; for rollout_percent in [0, 10, 25] { publish_rollout(&producer, rollout_percent).await; } for rollout_percent in [50, 100] { - time.sleep(time.millis(120)).await; + time.sleep_millis(120).await; publish_rollout(&producer, rollout_percent).await; } } @@ -52,7 +52,7 @@ async fn publish_rollout(producer: &Producer, rollout_percent: u8) producer.produce(gate); } -async fn rollout_observer(ctx: RuntimeContext, consumer: Consumer) { +async fn rollout_observer(ctx: RuntimeContext, consumer: Consumer) { let mut reader = consumer.subscribe(); let time = ctx.time(); @@ -71,6 +71,6 @@ async fn rollout_observer(ctx: RuntimeContext, consumer: Consumer< if gate.rollout_percent == 100 { break; } - time.sleep(time.millis(90)).await; + time.sleep_millis(90).await; } } diff --git a/examples/knx-connector-demo-common/src/monitors.rs b/examples/knx-connector-demo-common/src/monitors.rs index 0336a976..cb693c4e 100644 --- a/examples/knx-connector-demo-common/src/monitors.rs +++ b/examples/knx-connector-demo-common/src/monitors.rs @@ -7,7 +7,7 @@ extern crate alloc; use crate::types::{LightState, TemperatureReading}; -use aimdb_core::{Consumer, Logger, Runtime, RuntimeContext}; +use aimdb_core::{Consumer, RuntimeContext}; // ============================================================================ // TEMPERATURE MONITOR @@ -26,10 +26,7 @@ use aimdb_core::{Consumer, Logger, Runtime, RuntimeContext}; /// .finish(); /// }); /// ``` -pub async fn temperature_monitor(ctx: RuntimeContext, consumer: Consumer) -where - R: Runtime + Logger + Send + Sync + 'static, -{ +pub async fn temperature_monitor(ctx: RuntimeContext, consumer: Consumer) { let log = ctx.log(); log.info("🌡️ Temperature monitor started\n"); @@ -53,10 +50,7 @@ where /// Light monitor that logs state changes from the buffer /// /// Uses the group address from the `LightState` data itself. -pub async fn light_monitor(ctx: RuntimeContext, consumer: Consumer) -where - R: Runtime + Logger + Send + Sync + 'static, -{ +pub async fn light_monitor(ctx: RuntimeContext, consumer: Consumer) { let log = ctx.log(); log.info("💡 Light monitor started\n"); diff --git a/examples/mqtt-connector-demo-common/src/monitors.rs b/examples/mqtt-connector-demo-common/src/monitors.rs index e2c81302..f4c0cc9f 100644 --- a/examples/mqtt-connector-demo-common/src/monitors.rs +++ b/examples/mqtt-connector-demo-common/src/monitors.rs @@ -6,7 +6,7 @@ extern crate alloc; use crate::types::{Temperature, TemperatureCommand}; -use aimdb_core::{Consumer, Logger, Runtime, RuntimeContext}; +use aimdb_core::{Consumer, RuntimeContext}; // ============================================================================ // TEMPERATURE LOGGER @@ -25,10 +25,7 @@ use aimdb_core::{Consumer, Logger, Runtime, RuntimeContext}; /// .finish(); /// }); /// ``` -pub async fn temperature_logger(ctx: RuntimeContext, consumer: Consumer) -where - R: Runtime + Logger + Send + Sync + 'static, -{ +pub async fn temperature_logger(ctx: RuntimeContext, consumer: Consumer) { let log = ctx.log(); let mut reader = consumer.subscribe(); @@ -50,10 +47,7 @@ where /// Command consumer that logs received commands /// /// Uses the action and sensor_id from the `TemperatureCommand` data. -pub async fn command_consumer(ctx: RuntimeContext, consumer: Consumer) -where - R: Runtime + Logger + Send + Sync + 'static, -{ +pub async fn command_consumer(ctx: RuntimeContext, consumer: Consumer) { let log = ctx.log(); log.info("📨 Command consumer started\n"); diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs index 3a64ab40..b99cb1fa 100644 --- a/examples/remote-access-demo/src/client.rs +++ b/examples/remote-access-demo/src/client.rs @@ -136,7 +136,10 @@ async fn main() -> Result<(), Box> { // this drain returns only what's accrued since (they share one cursor). println!("📤 Drain #1: history since the (already-open) cursor..."); let d1 = conn.drain_record("server::Temperature").await?; - println!(" Values: {} (cursor shared with the record.get above)\n", d1.count); + println!( + " Values: {} (cursor shared with the record.get above)\n", + d1.count + ); println!("⏳ Waiting 7s for temperature readings to accumulate..."); tokio::time::sleep(Duration::from_secs(7)).await; diff --git a/examples/remote-access-demo/src/server.rs b/examples/remote-access-demo/src/server.rs index 39956632..195ec64a 100644 --- a/examples/remote-access-demo/src/server.rs +++ b/examples/remote-access-demo/src/server.rs @@ -213,10 +213,7 @@ async fn main() -> Result<(), Box> { /// /// Because this runs inside a `.source()` callback, every `produce()` call is /// timed by the `profiling` feature — see `get_stage_profiling`. -async fn temperature_simulator( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn temperature_simulator(ctx: RuntimeContext, temperature: Producer) { let time = ctx.time(); let mut counter = 0u64; loop { @@ -236,13 +233,13 @@ async fn temperature_simulator( temperature.produce(reading); counter += 1; - time.sleep(time.secs(2)).await; + time.sleep_secs(2).await; } } /// Fast tap on Temperature — just logs. Stage profiling will show it as /// substantially faster than `slow_status_processor` on SystemStatus. -async fn temperature_logger(_ctx: RuntimeContext, consumer: Consumer) { +async fn temperature_logger(_ctx: RuntimeContext, consumer: Consumer) { let mut reader = consumer.subscribe(); while let Ok(reading) = reader.recv().await { tracing::debug!( @@ -254,10 +251,7 @@ async fn temperature_logger(_ctx: RuntimeContext, consumer: Consum } /// Periodically produces SystemStatus readings. -async fn system_status_simulator( - ctx: RuntimeContext, - status: Producer, -) { +async fn system_status_simulator(ctx: RuntimeContext, status: Producer) { let time = ctx.time(); let mut uptime = 3600u64; loop { @@ -270,21 +264,18 @@ async fn system_status_simulator( status.produce(snapshot); uptime += 5; - time.sleep(time.secs(5)).await; + time.sleep_secs(5).await; } } /// Intentionally slow tap on SystemStatus — sleeps 100ms per value to simulate /// expensive per-value processing. With profiling enabled, this stage shows up /// as the per-record bottleneck in `get_stage_profiling`. -async fn slow_status_processor( - ctx: RuntimeContext, - consumer: Consumer, -) { +async fn slow_status_processor(ctx: RuntimeContext, consumer: Consumer) { let time = ctx.time(); let mut reader = consumer.subscribe(); while let Ok(snapshot) = reader.recv().await { - time.sleep(time.millis(100)).await; + time.sleep_millis(100).await; tracing::debug!( "💻 Processed status: CPU {:.1}%, MEM {:.1}%", snapshot.cpu_usage, diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index 077c43e7..c3f3ea02 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -41,7 +41,7 @@ use knx_connector_demo_common::{ // ============================================================================ /// Keyboard input handler - toggles light on ENTER -async fn input_handler(ctx: RuntimeContext, producer: Producer) { +async fn input_handler(ctx: RuntimeContext, producer: Producer) { let log = ctx.log(); log.info("\n⌨️ Input handler started. Press ENTER to toggle light on 1/0/6"); log.info(" (This sends GroupValueWrite to the KNX bus)\n"); diff --git a/examples/tokio-mqtt-connector-demo/src/main.rs b/examples/tokio-mqtt-connector-demo/src/main.rs index 34f3258b..6a6312a1 100644 --- a/examples/tokio-mqtt-connector-demo/src/main.rs +++ b/examples/tokio-mqtt-connector-demo/src/main.rs @@ -50,10 +50,7 @@ use mqtt_connector_demo_common::{ // ============================================================================ /// Indoor temperature sensor producer -async fn indoor_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn indoor_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); let time = ctx.time(); @@ -69,17 +66,14 @@ async fn indoor_temp_producer( temperature.produce(temp); - time.sleep(time.secs(2)).await; + time.sleep_secs(2).await; } log.info("✅ Indoor producer finished"); } /// Outdoor temperature sensor producer -async fn outdoor_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn outdoor_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); let time = ctx.time(); @@ -95,17 +89,14 @@ async fn outdoor_temp_producer( temperature.produce(temp); - time.sleep(time.secs(2)).await; + time.sleep_secs(2).await; } log.info("✅ Outdoor producer finished"); } /// Server room temperature sensor producer -async fn server_room_temp_producer( - ctx: RuntimeContext, - temperature: Producer, -) { +async fn server_room_temp_producer(ctx: RuntimeContext, temperature: Producer) { let log = ctx.log(); let time = ctx.time(); @@ -121,7 +112,7 @@ async fn server_room_temp_producer( temperature.produce(temp); - time.sleep(time.secs(2)).await; + time.sleep_secs(2).await; } log.info("✅ Server room producer finished"); 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 2ebb2138..6a4905dd 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -26,9 +26,7 @@ extern crate alloc; use aimdb_core::{AimDbBuilder, Producer, RecordKey, RuntimeContext}; use aimdb_data_contracts::{Simulatable, SimulationConfig, SimulationParams}; -use aimdb_embassy_adapter::{ - EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExt, EmbassyRecordRegistrarExtCustom, -}; +use aimdb_embassy_adapter::{EmbassyAdapter, EmbassyBufferType, EmbassyRecordRegistrarExtCustom}; use aimdb_mqtt_connector::embassy_client::MqttConnectorBuilder; use defmt::*; use embassy_executor::Spawner; @@ -67,10 +65,7 @@ async fn net_task(mut runner: embassy_net::Runner<'static, Device>) -> ! { } /// Temperature producer - generates synthetic data -async fn temperature_producer( - ctx: RuntimeContext, - producer: Producer, -) { +async fn temperature_producer(ctx: RuntimeContext, producer: Producer) { let log = ctx.log(); log.info("🌡️ Starting temperature producer..."); @@ -89,7 +84,7 @@ async fn temperature_producer( let mut prev: Option = None; loop { - let now = ctx.time().now().as_millis(); + let now = ctx.time().now() / 1_000_000; let temp = Temperature::simulate(&config, prev.as_ref(), &mut rng, now); log.info(&alloc::format!("📊 Temp: {:.1}°C", temp.celsius)); @@ -102,7 +97,7 @@ async fn temperature_producer( } /// Humidity producer - generates synthetic data -async fn humidity_producer(ctx: RuntimeContext, producer: Producer) { +async fn humidity_producer(ctx: RuntimeContext, producer: Producer) { let log = ctx.log(); log.info("💧 Starting humidity producer..."); @@ -121,7 +116,7 @@ async fn humidity_producer(ctx: RuntimeContext, producer: Produc let mut prev: Option = None; loop { - let now = ctx.time().now().as_millis(); + let now = ctx.time().now() / 1_000_000; let humidity = Humidity::simulate(&config, prev.as_ref(), &mut rng, now); log.info(&alloc::format!("📊 Humidity: {:.1}%", humidity.percent)); @@ -249,14 +244,14 @@ async fn main(spawner: Spawner) { info!("🔌 Initializing MQTT client..."); // Create AimDB database with Embassy adapter - let runtime = alloc::sync::Arc::new(EmbassyAdapter::new_with_network(stack)); + let runtime = alloc::sync::Arc::new(EmbassyAdapter::new()); // Build MQTT broker URL use alloc::format; 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).with_client_id("weather-station-gamma"), + MqttConnectorBuilder::new(&broker_url, stack).with_client_id("weather-station-gamma"), ); // Configure temperature record @@ -369,7 +364,7 @@ async fn main(spawner: Spawner) { info!(" Broker: {}:{}", MQTT_BROKER_IP, MQTT_BROKER_PORT); info!(""); - static DB_CELL: StaticCell> = StaticCell::new(); + static DB_CELL: StaticCell = StaticCell::new(); let (db, db_runner) = builder.build().await.expect("Failed to build database"); let _db = DB_CELL.init(db);