diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a247512..d34f7f8a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > - [aimdb-mqtt-connector/CHANGELOG.md](aimdb-mqtt-connector/CHANGELOG.md) > - [aimdb-knx-connector/CHANGELOG.md](aimdb-knx-connector/CHANGELOG.md) > - [aimdb-websocket-connector/CHANGELOG.md](aimdb-websocket-connector/CHANGELOG.md) +> - [aimdb-uds-connector/CHANGELOG.md](aimdb-uds-connector/CHANGELOG.md) > - [aimdb-ws-protocol/CHANGELOG.md](aimdb-ws-protocol/CHANGELOG.md) > - [aimdb-wasm-adapter/CHANGELOG.md](aimdb-wasm-adapter/CHANGELOG.md) > - [aimdb-sync/CHANGELOG.md](aimdb-sync/CHANGELOG.md) @@ -29,11 +30,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Remote access via connectors — Phases 0–6: converge four hand-rolled networking stacks onto two shared engines (Issue #39, [design doc](docs/design/remote-access-via-connectors.md)).** AimX remote access (and any future transport) now rides the connector layer instead of a bespoke I/O abstraction. New, runtime-neutral `aimdb-core::session` module (feature `connector-session`, `no_std + alloc`): the three-layer substrate (`Connection`/`Listener`/`Dialer` + `EnvelopeCodec` + `Dispatch`/`Session`), the reactive **server** engine (`serve`/`run_session`) and proactive **client** engine (`run_client`/`pump_client`), the `pump_sink`/`pump_source` data-plane toolkit, and the transport-agnostic `SessionClientConnector`/`SessionServerConnector`. The AimX-v2 NDJSON protocol (`session::aimx`: `AimxCodec` + `AimxDispatch`) and the WebSocket connector are ports onto this substrate, so the AimX server/client and WS server/client stacks collapse onto the two engines. New **`aimdb-uds-connector`** crate carries the UDS transport (`UdsClient`/`UdsServer`). ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-websocket-connector](aimdb-websocket-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md), [aimdb-mqtt-connector](aimdb-mqtt-connector/CHANGELOG.md), [aimdb-knx-connector](aimdb-knx-connector/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) - **M16 — JSON codec extracted behind the `json-serialize` feature; `RecordValue::as_json()` now works on `no_std + alloc`, not just `std` ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** New `aimdb-core::codec` module: `RemoteSerialize` (blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec`, and the zero-sized `SerdeJsonCodec`. `serde_json` runs on `alloc`, so embedded targets can opt in; `std` enables the feature transitively, so std builds are unaffected. ([aimdb-core](aimdb-core/CHANGELOG.md)) - **Embassy buffer + join-queue tests now run in CI (Issue #85).** The join-queue tests previously sat behind `embassy-runtime`, which pulls `embassy-executor`'s cortex-m assembly and can't compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions were never caught. The `join_queue` module is now gated on `embassy-sync`, and `make test` runs the embassy adapter's unit tests + doctests on the host (no executor). Also adds `EmbassyBuffer::peek()` and fixes a stale `EmbassyBuffer` doc example. ([aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md)) ### Changed (breaking) +- **`AimDbBuilder::with_remote_access(config)` removed — remote-access servers are now registered like any other connector (Issue #39).** Replace `.with_remote_access(config)` with `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The AimX wire was reshaped to **v2** (NDJSON tagged frames mapping onto the engine's role-neutral message set) and is **not** backward-compatible with the legacy AimX v1 framing; the bundled `aimdb-client` / CLI / MCP speak v2. The UDS transport types moved out of `aimdb-core` into `aimdb-uds-connector`. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-uds-connector](aimdb-uds-connector/CHANGELOG.md), [aimdb-client](aimdb-client/CHANGELOG.md)) - **M15 — `latest_snapshot` removed; point-in-time reads go through the new buffer-native `DynBuffer::peek()` ([Design 031](docs/design/031-M15-remove-latest-snapshot.md)).** `TypedRecord::latest()` and AimX `record.get` read the buffer directly instead of a per-record snapshot mutex (one lock + clone off the `produce()` hot path). Consequences: a `.with_remote_access()` record with **no buffer** now fails `build()` (was a silent runtime no-op); `record.get` / `latest()` on an `SpmcRing` record returns `not_found` / `None` (rings have no canonical latest — use `record.drain` / `record.subscribe`); `SingleLatest` and `Mailbox` are unaffected. `TypedRecord::produce` is removed — all writes go through `WriteHandle::push`. Adapters implement `peek()` per buffer type. ([aimdb-core](aimdb-core/CHANGELOG.md), [aimdb-tokio-adapter](aimdb-tokio-adapter/CHANGELOG.md), [aimdb-embassy-adapter](aimdb-embassy-adapter/CHANGELOG.md), [aimdb-wasm-adapter](aimdb-wasm-adapter/CHANGELOG.md)) - **M16 — `with_remote_access()` now requires the `json-serialize` feature (transitively enabled by `std`); `with_read_only_serialization()` removed ([Design 032](docs/design/032-M16-aimx-json-codec.md)).** The stored serializer/deserializer closures are replaced by a type-erased `Arc>`. A `Serialize`-only record can no longer be exposed read-only over remote access. ([aimdb-core](aimdb-core/CHANGELOG.md)) diff --git a/Cargo.lock b/Cargo.lock index 650947a4..3ed6c6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,6 +34,7 @@ dependencies = [ "chrono", "clap", "colored", + "futures", "serde", "serde_json", "serde_yaml", @@ -49,7 +50,10 @@ name = "aimdb-client" version = "0.6.0" dependencies = [ "aimdb-core", + "aimdb-tokio-adapter", + "aimdb-uds-connector", "anyhow", + "futures", "serde", "serde_json", "tempfile", @@ -77,8 +81,10 @@ dependencies = [ "aimdb-derive", "aimdb-executor", "anyhow", + "async-channel", "defmt 1.0.1", "futures", + "futures-channel", "futures-core", "futures-util", "hashbrown 0.15.5", @@ -266,6 +272,7 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-executor", + "aimdb-uds-connector", "futures", "serde", "serde_json", @@ -274,6 +281,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "aimdb-uds-connector" +version = "0.1.0" +dependencies = [ + "aimdb-core", + "aimdb-executor", + "aimdb-tokio-adapter", + "tokio", + "tracing", +] + [[package]] name = "aimdb-wasm-adapter" version = "0.2.0" @@ -301,6 +319,7 @@ dependencies = [ "aimdb-core", "aimdb-data-contracts", "aimdb-executor", + "aimdb-tokio-adapter", "aimdb-ws-protocol", "axum", "dashmap", @@ -404,6 +423,18 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + [[package]] name = "async-stream" version = "0.3.6" @@ -675,6 +706,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-default" version = "1.0.0" @@ -1342,6 +1382,26 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -2561,6 +2621,8 @@ dependencies = [ "aimdb-client", "aimdb-core", "aimdb-tokio-adapter", + "aimdb-uds-connector", + "futures", "serde", "serde_json", "tokio", @@ -3035,7 +3097,7 @@ dependencies = [ [[package]] name = "stm32-metapac" version = "21.0.0" -source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-6d108b6d3695cafd30923b033bc82291da5859a7#afce0040f03f376ad34e62e78e201fbfe275a0a1" +source = "git+https://github.com/embassy-rs/stm32-data-generated?tag=stm32-data-7aaa9af0001abcfb01c01e1a9b048697a82b7d57#f4d6b404521840db5ffd97712d29a557a22cbfa4" dependencies = [ "cortex-m", "cortex-m-rt", diff --git a/Cargo.toml b/Cargo.toml index 88a54d70..435d3cd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "aimdb-mqtt-connector", "aimdb-knx-connector", "aimdb-websocket-connector", + "aimdb-uds-connector", "aimdb-ws-protocol", "aimdb-wasm-adapter", "tools/aimdb-cli", diff --git a/Makefile b/Makefile index 91ad9d10..17e4ca81 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,20 @@ # AimDB Makefile # Simple automation for common development tasks -.PHONY: help build test clean fmt fmt-check clippy doc all check test-embedded test-wasm wasm wasm-test examples deny audit security publish publish-check +.PHONY: help build test clean clean-embedded fmt fmt-check clippy doc all check test-embedded test-wasm wasm wasm-test examples deny audit security publish publish-check .DEFAULT_GOAL := help +# Separate target dir for embedded checks so an interrupted example build +# (cargo build --target thumbv7em-none-eabihf) cannot leave corrupted .rmeta +# files that break the next cargo check run (E0786). Clean it with +# `make clean-embedded`. +EMBEDDED_CHECK_TARGET_DIR := target/embedded-check + +# Disable incremental compilation to avoid "Stale file handle" linker errors +# on Docker overlay filesystems when many cargo invocations run in sequence +# with different feature sets (as the test/check targets do). +export CARGO_INCREMENTAL := 0 + # Colors for output GREEN := \033[0;32m YELLOW := \033[0;33m @@ -63,6 +74,10 @@ build: cargo build --package aimdb-core --features "std,tracing,profiling" @printf "$(YELLOW) → Building aimdb-core (no_std + alloc + metrics)$(NC)\n" cargo build --package aimdb-core --no-default-features --features "alloc,metrics" + @printf "$(YELLOW) → Building aimdb-core (no_std + alloc + connector-session contracts)$(NC)\n" + cargo build --package aimdb-core --no-default-features --features "alloc,connector-session" + @printf "$(YELLOW) → Building aimdb-core (std + connector-session engines)$(NC)\n" + cargo build --package aimdb-core --features "std,connector-session" @printf "$(YELLOW) → Building tokio adapter$(NC)\n" cargo build --package aimdb-tokio-adapter --features "tokio-runtime,tracing,metrics" @printf "$(YELLOW) → Building tokio adapter (with profiling)$(NC)\n" @@ -83,8 +98,10 @@ build: cargo build --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Building WS protocol$(NC)\n" cargo build --package aimdb-ws-protocol - @printf "$(YELLOW) → Building WebSocket connector$(NC)\n" - cargo build --package aimdb-websocket-connector --features "tokio-runtime" + @printf "$(YELLOW) → Building WebSocket connector (server + client)$(NC)\n" + cargo build --package aimdb-websocket-connector --features "server,client" + @printf "$(YELLOW) → Building UDS connector$(NC)\n" + cargo build --package aimdb-uds-connector @printf "$(YELLOW) → Building WASM adapter$(NC)\n" cargo build --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" @@ -108,6 +125,12 @@ test: cargo test --package aimdb-core --no-default-features --features "alloc,json-serialize" @printf "$(YELLOW) → Testing aimdb-core remote module$(NC)\n" cargo test --package aimdb-core --lib --features "std" remote:: + @printf "$(YELLOW) → Testing aimdb-core connector-session (contracts object-safety)$(NC)\n" + cargo test --package aimdb-core --lib --features "std,connector-session" session:: + @printf "$(YELLOW) → Testing aimdb-core connector-session engines (session_engine)$(NC)\n" + cargo test --package aimdb-core --features "std,connector-session" --test session_engine + @printf "$(YELLOW) → Testing aimdb-client (engine-based AimX client + UDS round-trip)$(NC)\n" + cargo test --package aimdb-client @printf "$(YELLOW) → Testing tokio adapter$(NC)\n" cargo test --package aimdb-tokio-adapter --features "tokio-runtime,tracing" @printf "$(YELLOW) → Testing tokio adapter (with metrics)$(NC)\n" @@ -134,8 +157,12 @@ test: cargo test --package aimdb-knx-connector --features "std,tokio-runtime" @printf "$(YELLOW) → Testing WS protocol$(NC)\n" cargo test --package aimdb-ws-protocol - @printf "$(YELLOW) → Testing WebSocket connector$(NC)\n" - cargo test --package aimdb-websocket-connector --features "tokio-runtime" + @printf "$(YELLOW) → Testing WebSocket connector (server + client: unit, real-socket e2e, AimDB round-trip)$(NC)\n" + cargo test --package aimdb-websocket-connector --features "server,client" + @printf "$(YELLOW) → Testing WebSocket connector client-only build$(NC)\n" + cargo test --package aimdb-websocket-connector --no-default-features --features "client" --lib + @printf "$(YELLOW) → Testing UDS connector$(NC)\n" + cargo test --package aimdb-uds-connector fmt: @printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n" @@ -206,7 +233,9 @@ clippy: @printf "$(YELLOW) → Clippy on WS protocol$(NC)\n" cargo clippy --package aimdb-ws-protocol --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WebSocket connector$(NC)\n" - cargo clippy --package aimdb-websocket-connector --features "tokio-runtime" --all-targets -- -D warnings + cargo clippy --package aimdb-websocket-connector --features "tokio-runtime,client" --all-targets -- -D warnings + @printf "$(YELLOW) → Clippy on UDS connector$(NC)\n" + cargo clippy --package aimdb-uds-connector --all-targets -- -D warnings @printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n" cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings @@ -245,6 +274,12 @@ doc: clean: @printf "$(GREEN)Cleaning...$(NC)\n" cargo clean + @rm -rf $(EMBEDDED_CHECK_TARGET_DIR) + +clean-embedded: + @printf "$(GREEN)Cleaning embedded check artifacts...$(NC)\n" + @rm -rf $(EMBEDDED_CHECK_TARGET_DIR) + cargo clean --target thumbv7em-none-eabihf ## Testing commands test-wasm: @@ -256,29 +291,33 @@ test-wasm: test-embedded: @printf "$(BLUE)Testing embedded/MCU cross-compilation compatibility...$(NC)\n" @printf "$(YELLOW) → Checking aimdb-data-contracts (no_std + alloc) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-data-contracts --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std minimal) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-core (no_std + alloc + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features "alloc,json-serialize" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,json-serialize" + @printf "$(YELLOW) → Checking aimdb-core session engines (no_std + connector-session) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session" + @printf "$(YELLOW) → Checking aimdb-core AimX codec (no_std + connector-session + json-serialize) on thumbv7em-none-eabihf target$(NC)\n" + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "alloc,connector-session,json-serialize" @printf "$(YELLOW) → Checking aimdb-core (no_std/embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-core --target thumbv7em-none-eabihf --no-default-features --features alloc + cargo check --package aimdb-core --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features alloc @printf "$(YELLOW) → Checking aimdb-embassy-adapter on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with network support on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,embassy-net-support" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,embassy-net-support" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with profiling on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,profiling" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,profiling" @printf "$(YELLOW) → Checking aimdb-embassy-adapter with metrics on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,metrics" + cargo check --package aimdb-embassy-adapter --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,metrics" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-mqtt-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" + cargo check --package aimdb-mqtt-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" @printf "$(YELLOW) → Checking aimdb-knx-connector (Embassy) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime" + cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" @printf "$(YELLOW) → Checking aimdb-knx-connector (Embassy + defmt) on thumbv7em-none-eabihf target$(NC)\n" - cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --no-default-features --features "embassy-runtime,defmt" + cargo check --package aimdb-knx-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" ## Example projects examples: diff --git a/_external/embassy b/_external/embassy index 664d4ead..44729ce1 160000 --- a/_external/embassy +++ b/_external/embassy @@ -1 +1 @@ -Subproject commit 664d4ead36bb24a63955ca649bcec66c6e70bf6d +Subproject commit 44729ce14de1694600d398b836c883e3fd2aff02 diff --git a/aimdb-client/CHANGELOG.md b/aimdb-client/CHANGELOG.md index a786b23a..0bb0b55d 100644 --- a/aimdb-client/CHANGELOG.md +++ b/aimdb-client/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed (breaking) + +- **`AimxClient` → `AimxConnection`, rebuilt on the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The synchronous `connection::AimxClient` is retired; the new `engine::AimxConnection` drives `aimdb-core`'s `run_client` engine over `aimdb-uds-connector`'s `UdsDialer` and speaks the reshaped **AimX-v2** protocol. Both the type and the module are re-exported from the crate root (`aimdb_client::AimxConnection`); the `connection` module is replaced by `engine`. `connect()` performs the `hello` handshake, and the full tool surface (list/get/set/subscribe/drain/graph/query) is available. + - `subscribe(record_name)` now returns a `Stream` of updates directly — the engine routes events back by request id, so there is **no** server-allocated subscription id to track (the old `(subscription_id, queue_size)` handshake is gone). + - New `connect_with_timeout(path, timeout)` bounds the whole dial + handshake (used by discovery probing). + - New dependencies: `aimdb-uds-connector` (the UDS transport), `aimdb-tokio-adapter` (the `TimeOps` clock handed to `run_client`), and `futures`. `aimdb-core` is now pulled in with the `connector-session` feature. + ## [0.6.0] - 2026-05-22 ### Added diff --git a/aimdb-client/Cargo.toml b/aimdb-client/Cargo.toml index cc9cf1d5..4fdd937b 100644 --- a/aimdb-client/Cargo.toml +++ b/aimdb-client/Cargo.toml @@ -14,8 +14,17 @@ metrics = ["aimdb-core/metrics"] profiling = ["aimdb-core/profiling"] [dependencies] -# Core dependencies - protocol types from aimdb-core -aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = ["std"] } +# Core dependencies - protocol types from aimdb-core. `connector-session` +# exposes the shared session engine (`run_client`/`ClientHandle`) plus the AimX +# codec that the engine-based client (`crate::engine`) builds on. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ + "std", + "connector-session", +] } + +# The UDS transport (`UdsDialer`) relocated out of core in Phase 6; the +# engine-based client dials over it. +aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector" } # Serialization serde = { version = "1", features = ["derive"] } @@ -23,6 +32,10 @@ serde_json = "1" # Async runtime (must match AimDB runtime) tokio = { version = "1", features = ["net", "io-util", "macros", "fs"] } +futures = { version = "0.3", default-features = false, features = ["alloc"] } +# Supplies the `TimeOps` clock the engine-based client hands to `run_client` +# (reconnect backoff / keepalive). This client always drives the engine on tokio. +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } # Error handling anyhow = "1" diff --git a/aimdb-client/src/connection.rs b/aimdb-client/src/connection.rs deleted file mode 100644 index 02b7ad93..00000000 --- a/aimdb-client/src/connection.rs +++ /dev/null @@ -1,292 +0,0 @@ -//! AimX Client Connection -//! -//! Async client for connecting to AimDB instances via Unix domain sockets. - -use crate::error::{ClientError, ClientResult}; -use crate::protocol::{ - cli_hello, parse_message, serialize_message, Event, EventMessage, RecordMetadata, Request, - RequestExt, Response, ResponseExt, WelcomeMessage, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::path::{Path, PathBuf}; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; -use tokio::net::UnixStream; - -/// Timeout for connection operations -const CONNECTION_TIMEOUT: Duration = Duration::from_secs(5); - -/// AimX protocol client -pub struct AimxClient { - socket_path: PathBuf, - stream: OwnedWriteHalf, - reader: BufReader, - request_id_counter: u64, - server_info: WelcomeMessage, -} - -impl AimxClient { - /// Connect to an AimDB instance - pub async fn connect(socket_path: impl AsRef) -> ClientResult { - let socket_path = socket_path.as_ref().to_path_buf(); - - // Connect with timeout - let stream = tokio::time::timeout(CONNECTION_TIMEOUT, UnixStream::connect(&socket_path)) - .await - .map_err(|_| { - ClientError::connection_failed( - socket_path.display().to_string(), - "connection timeout", - ) - })? - .map_err(|e| { - ClientError::connection_failed(socket_path.display().to_string(), e.to_string()) - })?; - - // Split into reader and writer - let (reader_stream, writer_stream) = stream.into_split(); - - let reader = BufReader::new(reader_stream); - let mut client = Self { - socket_path, - stream: writer_stream, - reader, - request_id_counter: 0, - server_info: WelcomeMessage { - version: String::new(), - server: String::new(), - permissions: Vec::new(), - writable_records: Vec::new(), - max_subscriptions: None, - authenticated: None, - }, - }; - - // Perform handshake - client.handshake().await?; - - Ok(client) - } - - /// Perform protocol handshake - async fn handshake(&mut self) -> ClientResult<()> { - // Send Hello - let hello = cli_hello(); - self.write_message(&hello).await?; - - // Receive Welcome - let welcome: WelcomeMessage = self.read_message().await?; - self.server_info = welcome; - - Ok(()) - } - - /// Get server information - pub fn server_info(&self) -> &WelcomeMessage { - &self.server_info - } - - /// Send a request and wait for response - async fn send_request( - &mut self, - method: &str, - params: Option, - ) -> ClientResult { - self.request_id_counter += 1; - let id = self.request_id_counter; - - let request = if let Some(params) = params { - Request::with_params(id, method, params) - } else { - Request::new(id, method) - }; - - self.write_message(&request).await?; - - let response: Response = self.read_message().await?; - - match response.into_result() { - Ok(result) => Ok(result), - Err(error) => Err(ClientError::server_error( - error.code, - error.message, - error.details, - )), - } - } - - /// List all registered records - pub async fn list_records(&mut self) -> ClientResult> { - let result = self.send_request("record.list", None).await?; - let records: Vec = serde_json::from_value(result)?; - Ok(records) - } - - /// Reset stage profiling counters for every record on the server. - /// - /// Requires the server to be built with the `profiling` feature and the - /// connection to have write permission. - pub async fn reset_stage_profiling(&mut self) -> ClientResult { - self.send_request("profiling.reset", None).await - } - - /// Reset buffer introspection counters for every record on the server. - /// - /// Requires the server to be built with the `metrics` feature and the - /// connection to have write permission. - pub async fn reset_buffer_metrics(&mut self) -> ClientResult { - self.send_request("buffer_metrics.reset", None).await - } - - /// Get current value of a record - pub async fn get_record(&mut self, name: &str) -> ClientResult { - let params = json!({ "record": name }); - self.send_request("record.get", Some(params)).await - } - - /// Set value of a writable record - pub async fn set_record( - &mut self, - name: &str, - value: serde_json::Value, - ) -> ClientResult { - let params = json!({ - "name": name, - "value": value - }); - self.send_request("record.set", Some(params)).await - } - - /// Subscribe to record updates - pub async fn subscribe(&mut self, name: &str, queue_size: usize) -> ClientResult { - let params = json!({ - "name": name, - "queue_size": queue_size - }); - let result = self.send_request("record.subscribe", Some(params)).await?; - - let subscription_id = result["subscription_id"] - .as_str() - .ok_or_else(|| { - ClientError::Other(anyhow::anyhow!("Missing subscription_id in response")) - })? - .to_string(); - - Ok(subscription_id) - } - - /// Unsubscribe from record updates - pub async fn unsubscribe(&mut self, subscription_id: &str) -> ClientResult<()> { - let params = json!({ "subscription_id": subscription_id }); - self.send_request("record.unsubscribe", Some(params)) - .await?; - Ok(()) - } - - /// Receive next event from subscription - pub async fn receive_event(&mut self) -> ClientResult { - let event_msg: EventMessage = self.read_message().await?; - Ok(event_msg.event) - } - - /// Drain all pending values from a record's drain reader. - /// - /// Returns all values accumulated since the last drain call, - /// in chronological order. This is a destructive read — drained - /// values will not be returned again. - /// - /// The first call for a given record creates the drain reader and - /// returns empty (cold start). Subsequent calls return accumulated values. - pub async fn drain_record(&mut self, name: &str) -> ClientResult { - let params = json!({ "name": name }); - let result = self.send_request("record.drain", Some(params)).await?; - let response: DrainResponse = serde_json::from_value(result)?; - Ok(response) - } - - /// Drain with a limit on the number of values returned. - pub async fn drain_record_with_limit( - &mut self, - name: &str, - limit: u32, - ) -> ClientResult { - let params = json!({ - "name": name, - "limit": limit, - }); - let result = self.send_request("record.drain", Some(params)).await?; - let response: DrainResponse = serde_json::from_value(result)?; - Ok(response) - } - - // ======================================================================== - // Graph Introspection Methods - // ======================================================================== - - /// Get all nodes in the dependency graph. - /// - /// Returns a list of GraphNode objects representing all records - /// and their connections in the database. - pub async fn graph_nodes(&mut self) -> ClientResult> { - let result = self.send_request("graph.nodes", None).await?; - let nodes: Vec = serde_json::from_value(result)?; - Ok(nodes) - } - - /// Get all edges in the dependency graph. - /// - /// Returns a list of GraphEdge objects representing data flow - /// connections between records. - pub async fn graph_edges(&mut self) -> ClientResult> { - let result = self.send_request("graph.edges", None).await?; - let edges: Vec = serde_json::from_value(result)?; - Ok(edges) - } - - /// Get the topological ordering of records. - /// - /// Returns the record keys in topological order, ensuring all - /// dependencies are listed before dependents. Useful for understanding - /// data flow and initialization order. - pub async fn graph_topo_order(&mut self) -> ClientResult> { - let result = self.send_request("graph.topo_order", None).await?; - let order: Vec = serde_json::from_value(result)?; - Ok(order) - } - - /// Write a message to the stream - async fn write_message(&mut self, msg: &T) -> ClientResult<()> { - let data = serialize_message(msg)?; - self.stream.write_all(data.as_bytes()).await?; - self.stream.flush().await?; - Ok(()) - } - - /// Read a message from the stream - async fn read_message serde::Deserialize<'de>>(&mut self) -> ClientResult { - let mut line = String::new(); - self.reader.read_line(&mut line).await?; - - if line.is_empty() { - return Err(ClientError::connection_failed( - self.socket_path.display().to_string(), - "connection closed by server", - )); - } - - parse_message(&line).map_err(|e| e.into()) - } -} - -/// Response from a record.drain call -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DrainResponse { - /// Echo of the queried record name - pub record_name: String, - /// Chronologically ordered values (raw JSON, as written by the producer) - pub values: Vec, - /// Number of values returned - pub count: usize, -} diff --git a/aimdb-client/src/discovery.rs b/aimdb-client/src/discovery.rs index c93ded21..3489b040 100644 --- a/aimdb-client/src/discovery.rs +++ b/aimdb-client/src/discovery.rs @@ -2,7 +2,7 @@ //! //! Scans known directories for running AimDB instances. -use crate::connection::AimxClient; +use crate::engine::AimxConnection; use crate::error::{ClientError, ClientResult}; use crate::protocol::WelcomeMessage; use std::path::PathBuf; @@ -75,17 +75,11 @@ async fn scan_directory(mut entries: tokio::fs::ReadDir) -> Vec { /// Try to connect to a socket and get instance information async fn probe_instance(socket_path: &PathBuf) -> ClientResult { - // Try to connect with a short timeout + // `connect_with_timeout` bounds the whole handshake (dial + hello), so a stale + // socket whose peer accepts but never replies fails fast instead of hanging — + // no need to wrap a second timeout around `connect`. let connect_timeout = Duration::from_millis(500); - - let client = tokio::time::timeout(connect_timeout, AimxClient::connect(socket_path)) - .await - .map_err(|_| { - ClientError::connection_failed( - socket_path.display().to_string(), - "timeout during discovery probe", - ) - })??; + let client = AimxConnection::connect_with_timeout(socket_path, connect_timeout).await?; let welcome = client.server_info().clone(); diff --git a/aimdb-client/src/engine.rs b/aimdb-client/src/engine.rs new file mode 100644 index 00000000..870a84ad --- /dev/null +++ b/aimdb-client/src/engine.rs @@ -0,0 +1,294 @@ +//! Engine-based AimX client. +//! +//! The client rides the shared session engine: a [`UdsDialer`] + the symmetric +//! [`AimxCodec`] drive [`run_client`], which owns the wire, the request-id +//! demux, and (optionally) reconnect. The public surface is the cheap-clone +//! [`ClientHandle`] plus typed convenience wrappers and per-subscription +//! [`futures::Stream`]s. +//! +//! `run_client` is itself spawn-free (it returns a future for a runner to +//! drive); this convenience layer is a *client application*, so it drives the +//! engine on a `tokio::spawn`ed task held by [`AimxConnection`]. Dropping the +//! connection drops the handle, which stops the engine gracefully. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use tokio::task::JoinHandle; +use tokio::time::timeout; + +use aimdb_core::session::aimx::AimxCodec; +use aimdb_core::session::{run_client, BoxStream, ClientConfig, ClientHandle, Payload, RpcError}; +use aimdb_tokio_adapter::TokioAdapter; +use aimdb_uds_connector::UdsDialer; + +use crate::error::{ClientError, ClientResult}; +use crate::protocol::{RecordMetadata, WelcomeMessage}; + +/// Default deadline for the connect handshake (dial + `hello`/Welcome). Bounds +/// the case where a peer accepts the socket but never replies — the engine has +/// no handshake timeout of its own, so the wait would otherwise be unbounded. +pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5); + +/// Response from a `record.drain` call: the values accumulated since the +/// previous drain for this connection's per-record cursor. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DrainResponse { + /// Echo of the queried record name. + pub record_name: String, + /// Chronologically ordered values (raw JSON, as written by the producer). + pub values: Vec, + /// Number of values returned. + pub count: usize, +} + +/// A live connection to an AimDB instance over the shared session engine. +/// +/// Holds the cheap-clone [`ClientHandle`] (use [`handle`](Self::handle) to issue +/// raw `call`/`subscribe`/`write`) and the driven engine task. Typed wrappers +/// cover the common AimX methods. +pub struct AimxConnection { + handle: ClientHandle, + engine: JoinHandle<()>, + server_info: WelcomeMessage, +} + +impl AimxConnection { + /// Dial `socket_path`, start the engine, and complete the `hello` handshake, + /// bounded by [`DEFAULT_CONNECT_TIMEOUT`]. + /// + /// The handshake is a normal RPC (`call("hello", …) -> Welcome`) rather than + /// a privileged frame — the reshaped wire's deliberate simplification. A dial + /// failure surfaces here as the `hello` call failing (the engine runs with + /// reconnect off so connect-time errors are prompt); a peer that accepts but + /// never replies surfaces as a timeout (see [`connect_with_timeout`](Self::connect_with_timeout)). + pub async fn connect(socket_path: impl AsRef) -> ClientResult { + Self::connect_with_timeout(socket_path, DEFAULT_CONNECT_TIMEOUT).await + } + + /// Like [`connect`](Self::connect), but with an explicit handshake deadline. + /// + /// The deadline covers the whole handshake — dial *and* the `hello`/Welcome + /// exchange — so a silent or unresponsive peer cannot block the caller + /// indefinitely. On timeout (or any failure) the engine task is aborted so it + /// does not linger blocked on a stalled connection. + pub async fn connect_with_timeout( + socket_path: impl AsRef, + connect_timeout: Duration, + ) -> ClientResult { + let path = socket_path.as_ref(); + let dialer = UdsDialer::new(path); + let config = ClientConfig { + reconnect: false, + sends_hello: false, + ..ClientConfig::default() + }; + let (handle, engine_fut) = run_client(dialer, AimxCodec, config, Arc::new(TokioAdapter)); + let engine = tokio::spawn(engine_fut); + + // Handshake-as-RPC: the server replies with its Welcome. Bounded so an + // accepted-but-silent peer times out instead of hanging forever. + let server_info = async { + let hello = json!({ "client": "aimdb-client" }); + let reply = timeout(connect_timeout, handle.call("hello", to_payload(&hello)?)) + .await + .map_err(|_| { + ClientError::connection_failed( + path.display().to_string(), + "handshake timed out", + ) + })? + .map_err(|_| { + ClientError::connection_failed( + path.display().to_string(), + "handshake failed (engine could not reach server)", + ) + })?; + from_payload::(&reply) + } + .await; + + match server_info { + Ok(server_info) => Ok(Self { + handle, + engine, + server_info, + }), + Err(e) => { + // Don't leave the engine task blocked on a stalled dial/connection. + engine.abort(); + Err(e) + } + } + } + + /// The raw engine handle — `call` / `subscribe` / `write` for methods the + /// typed wrappers below don't cover. + pub fn handle(&self) -> &ClientHandle { + &self.handle + } + + /// The server's `Welcome` (permissions, writable records) from the handshake. + pub fn server_info(&self) -> &WelcomeMessage { + &self.server_info + } + + /// List all registered records. + pub async fn list_records(&self) -> ClientResult> { + let reply = self.call("record.list", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Get a record's current value. + /// + /// For a `SingleLatest`/state record this is a non-destructive read. A ring + /// (`SpmcRing`) has no canonical latest, so the server returns the most recent + /// value from this connection's drain cursor — which **advances that cursor** + /// (interleaving with [`drain_record`](Self::drain_record)) and is empty until + /// the ring produces a value after the connection first reads it. Prefer + /// [`drain_record`](Self::drain_record) for ring/history records. + pub async fn get_record(&self, name: &str) -> ClientResult { + let reply = self + .call("record.get", to_payload(&json!({ "name": name }))?) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Set a record's value (RPC; awaits the server's reply). + pub async fn set_record( + &self, + name: &str, + value: serde_json::Value, + ) -> ClientResult { + let reply = self + .call( + "record.set", + to_payload(&json!({ "name": name, "value": value }))?, + ) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Subscribe to a record's updates. Returns a stream of decoded JSON values; + /// the engine routes events back by the request id it owns, so there is no + /// `subscription_id` to track. Dropping the stream stops local delivery. + pub fn subscribe(&self, name: &str) -> ClientResult> { + let raw = self.handle.subscribe(name).map_err(rpc_err)?; + // Decode each Payload into a JSON value; drop any that fail to parse. + let decoded = raw.filter_map(|p| async move { serde_json::from_slice(&p).ok() }); + Ok(Box::pin(decoded)) + } + + /// Fire-and-forget write to a record (no reply; routes through the server's + /// producer/arbiter path — single-writer-per-key stays intact). + pub fn write_record(&self, name: &str, value: serde_json::Value) -> ClientResult<()> { + self.handle + .write(name, to_payload(&json!({ "value": value }))?) + .map_err(rpc_err) + } + + /// Drain all values accumulated since the previous drain of `name` (a + /// destructive read against this connection's per-record cursor). + pub async fn drain_record(&self, name: &str) -> ClientResult { + let reply = self + .call("record.drain", to_payload(&json!({ "name": name }))?) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Drain at most `limit` values from `name`. + pub async fn drain_record_with_limit( + &self, + name: &str, + limit: u32, + ) -> ClientResult { + let reply = self + .call( + "record.drain", + to_payload(&json!({ "name": name, "limit": limit }))?, + ) + .await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Run a persistence query (requires the server's `with_persistence()`). + pub async fn query(&self, params: serde_json::Value) -> ClientResult { + let reply = self.call("record.query", to_payload(¶ms)?).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// All nodes in the dependency graph. + pub async fn graph_nodes(&self) -> ClientResult> { + let reply = self.call("graph.nodes", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// All edges in the dependency graph. + pub async fn graph_edges(&self) -> ClientResult> { + let reply = self.call("graph.edges", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Record keys in topological order. + pub async fn graph_topo_order(&self) -> ClientResult> { + let reply = self.call("graph.topo_order", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Reset stage-profiling counters (server built with `profiling`; needs write + /// permission). + pub async fn reset_stage_profiling(&self) -> ClientResult { + let reply = self.call("profiling.reset", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Reset buffer-metrics counters (server built with `metrics`; needs write + /// permission). + pub async fn reset_buffer_metrics(&self) -> ClientResult { + let reply = self.call("buffer_metrics.reset", null_payload()).await?; + Ok(serde_json::from_slice(&reply)?) + } + + /// Issue a raw RPC and map a transport/engine failure to [`ClientError`]. + async fn call(&self, method: &str, params: Payload) -> ClientResult { + self.handle.call(method, params).await.map_err(rpc_err) + } +} + +impl Drop for AimxConnection { + fn drop(&mut self) { + // Dropping `handle` already stops the engine; abort is just promptness. + self.engine.abort(); + } +} + +/// Serialize a value into a record-value [`Payload`]. +fn to_payload(value: &T) -> ClientResult { + Ok(Payload::from(serde_json::to_vec(value)?.as_slice())) +} + +/// The JSON literal `null` as a [`Payload`] — for methods that take no params. +fn null_payload() -> Payload { + Payload::from(&b"null"[..]) +} + +/// Decode a [`Payload`] into a typed value. +fn from_payload(bytes: &[u8]) -> ClientResult { + Ok(serde_json::from_slice(bytes)?) +} + +/// Map an engine [`RpcError`] onto a [`ClientError`]. +fn rpc_err(e: RpcError) -> ClientError { + match e { + RpcError::NotFound => { + ClientError::server_error("not_found", "method or record not found", None) + } + RpcError::Denied => ClientError::server_error("denied", "permission denied", None), + // `Internal` today, plus any future non-exhaustive variant. + _ => ClientError::server_error("internal", "engine/transport failure", None), + } +} diff --git a/aimdb-client/src/lib.rs b/aimdb-client/src/lib.rs index 0592dfdc..7d173115 100644 --- a/aimdb-client/src/lib.rs +++ b/aimdb-client/src/lib.rs @@ -1,46 +1,48 @@ //! AimDB Client Library //! -//! This library provides a client implementation for the AimX v1 remote access protocol, -//! enabling connections to running AimDB instances via Unix domain sockets. +//! This library provides a client implementation for the AimX remote access +//! protocol, enabling connections to running AimDB instances via Unix domain +//! sockets. //! //! ## Overview //! //! The client library offers: -//! - **Connection Management**: Async client for Unix domain socket communication -//! - **Protocol Implementation**: AimX v1 handshake and message handling +//! - **Connection Management**: [`AimxConnection`] over the shared session engine +//! - **Protocol Implementation**: the reshaped AimX-v2 handshake + RPC/streaming //! - **Instance Discovery**: Automatic detection of running AimDB instances -//! - **Record Operations**: List, get, set, subscribe to records +//! - **Record Operations**: list, get, set, subscribe, drain, graph, query //! //! ## Usage //! //! ```no_run -//! use aimdb_client::AimxClient; +//! use aimdb_client::AimxConnection; //! //! #[tokio::main] //! async fn main() -> Result<(), Box> { -//! // Connect to an AimDB instance -//! let mut client = AimxClient::connect("/tmp/aimdb.sock").await?; -//! +//! // Connect to an AimDB instance (performs the `hello` handshake). +//! let conn = AimxConnection::connect("/tmp/aimdb.sock").await?; +//! //! // List all records -//! let records = client.list_records().await?; +//! let records = conn.list_records().await?; //! println!("Found {} records", records.len()); -//! +//! //! // Get a specific record -//! let value = client.get_record("server::Temperature").await?; +//! let value = conn.get_record("server::Temperature").await?; //! println!("Temperature: {:?}", value); -//! +//! //! Ok(()) //! } //! ``` -pub mod connection; pub mod discovery; +pub mod engine; pub mod error; pub mod protocol; -// Re-export main types for convenience -pub use connection::{AimxClient, DrainResponse}; +// Re-export main types for convenience. `AimxConnection` is the engine-based +// client (the synchronous `AimxClient` was retired with the AimX server port). pub use discovery::{discover_instances, find_instance, InstanceInfo}; +pub use engine::{AimxConnection, DrainResponse}; pub use error::{ClientError, ClientResult}; pub use protocol::{ cli_hello, parse_message, serialize_message, Event, EventMessage, RecordMetadata, Request, diff --git a/aimdb-client/tests/aimx_session.rs b/aimdb-client/tests/aimx_session.rs new file mode 100644 index 00000000..e6aa8b24 --- /dev/null +++ b/aimdb-client/tests/aimx_session.rs @@ -0,0 +1,180 @@ +//! The engine-based [`AimxConnection`] round-trips the AimX-v2 wire — `hello` +//! handshake, RPC (`record.get`/`record.set`), a streaming subscription, and a +//! fire-and-forget write — against the **production** server (`UdsServer` → +//! `serve`/`run_session` + `AimxDispatch`) over a real Unix-domain socket, +//! standing up an actual `AimDb` and proving the wire end-to-end through the +//! shared session engine. + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_client::AimxConnection; +use aimdb_core::buffer::BufferCfg; +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +use aimdb_core::AimDbBuilder; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; +use futures::StreamExt; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +/// A writable config-style record (SingleLatest, no producer → remotely settable). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Setting { + level: u64, +} + +/// A streamed record (SpmcRing) fed by a producer in the test. +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Reading { + n: u64, +} + +#[tokio::test] +async fn aimx_roundtrip_over_uds_production_server() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + // ReadWrite policy with `setting` writable; `events` stays read-only. + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("setting"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy) + .max_connections(8) + .max_subs_per_connection(8); + + // Build a real AimDb with two remote-accessible records, served over UDS via + // the production `UdsServer` connector (binds during `build()`). + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); + builder.configure::("setting", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + builder.configure::("events", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 64 }) + .with_remote_access(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let db = Arc::new(db); + // The runner drives both the records and the UDS serve loop (spawn-free). + tokio::spawn(runner.run()); + + // Seed the writable record before connecting so `record.get` has a value. + db.set_record_from_json("setting", json!({ "level": 1 })) + .expect("seed setting"); + + // Connect: performs the `hello` handshake and captures the Welcome. + let conn = AimxConnection::connect(&sock).await.expect("connect"); + assert_eq!(conn.server_info().server, "aimdb"); + assert!(conn + .server_info() + .permissions + .contains(&"write".to_string())); + assert!(conn + .server_info() + .writable_records + .contains(&"setting".to_string())); + + // RPC: record.get on the seeded record. + let got = conn.get_record("setting").await.expect("get setting"); + assert_eq!(got, json!({ "level": 1 })); + + // RPC: record.get on a missing record maps to a server error. + assert!(conn.get_record("missing").await.is_err()); + + // RPC: record.set (permission-checked) echoes the new value. + let set = conn + .set_record("setting", json!({ "level": 7 })) + .await + .expect("set setting"); + assert_eq!(set.get("value").unwrap(), &json!({ "level": 7 })); + assert_eq!( + conn.get_record("setting").await.unwrap(), + json!({ "level": 7 }) + ); + + // Streaming: a producer feeds `events`; the subscription routes updates back. + let producer = db.producer::("events").expect("producer"); + tokio::spawn(async move { + for n in 1..=50 { + producer.produce(Reading { n }); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }); + let mut stream = conn.subscribe("events").expect("subscribe"); + for _ in 0..3 { + let ev = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("event within timeout") + .expect("event"); + assert!(ev.get("n").is_some(), "event carries a Reading: {ev}"); + } + + // Graph introspection wrappers. + let nodes = conn.graph_nodes().await.expect("graph nodes"); + assert!( + nodes.len() >= 2, + "configured records should appear as nodes" + ); + let _edges = conn.graph_edges().await.expect("graph edges"); + let topo = conn.graph_topo_order().await.expect("topo order"); + assert!(!topo.is_empty(), "topo order should list the records"); + + // Drain wrapper: cold-start creates the per-connection cursor; the response + // echoes the record name. + let drained = conn.drain_record("events").await.expect("drain events"); + assert_eq!(drained.record_name, "events"); + + // Fire-and-forget write, then a follow-up RPC. FIFO over the single + // connection guarantees the write is processed before the reply returns. + conn.write_record("setting", json!({ "level": 9 })) + .expect("write"); + let after = conn.get_record("setting").await.expect("get after write"); + assert_eq!(after, json!({ "level": 9 })); + + drop(conn); // stops the client engine +} + +/// `record.get` on a ring (`SpmcRing`) record has no canonical latest, so it +/// falls back to draining the connection's cursor for the most-recent value. +#[tokio::test] +async fn record_get_on_ring_falls_back_to_drain() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + let config = AimxConfig::uds_default().socket_path(&sock); + let mut builder = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); + builder.configure::("stream", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 16 }) + .with_remote_access(); + }); + let (db, runner) = builder.build().await.expect("build db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + let conn = AimxConnection::connect(&sock).await.expect("connect"); + let producer = db.producer::("stream").expect("producer"); + + // First get opens the cursor; a fresh broadcast reader starts at the tail, so + // it sees nothing until a value is produced afterwards. + assert!(conn.get_record("stream").await.is_err()); + + // Produce after the cursor is open; get now returns the most-recent value. + let mut got = None; + for n in 1..=50u64 { + producer.produce(Reading { n }); + tokio::time::sleep(Duration::from_millis(10)).await; + if let Ok(v) = conn.get_record("stream").await { + got = Some(v); + break; + } + } + let got = got.expect("ring get returns a value after producing"); + assert!(got.get("n").is_some(), "ring get yields a Reading: {got}"); + + drop(conn); +} diff --git a/aimdb-client/tests/pump_client.rs b/aimdb-client/tests/pump_client.rs new file mode 100644 index 00000000..8df2319c --- /dev/null +++ b/aimdb-client/tests/pump_client.rs @@ -0,0 +1,133 @@ +//! `pump_client` mirrors a record **both directions** between a local AimDb and +//! a remote AimDb over the shared session engine. +//! +//! Topology: a server `AimDb` (served by `UdsServer`) and a client `AimDb` whose +//! records carry `uds://` connector links. `run_client` opens the connection; +//! `pump_client` wires the client's outbound/inbound routes to the `ClientHandle`: +//! - **client → server**: producing the client's `cfg` record streams it to the +//! server via `ClientHandle::write` → the server's `record.set` path. +//! - **server → client**: updating the server's `tele` record streams it back +//! through a subscription → the client's inbound producer (arbiter path). + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +use aimdb_core::session::ClientConfig; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::{UdsClient, UdsServer}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Msg { + v: u64, +} + +/// Re-assert `db.` reaches `want`, re-driving `push` each tick so the test +/// is robust against subscription-registration timing (a fresh subscriber may +/// only see values produced after it attaches). +async fn mirror_reaches( + db: &Arc>, + key: &str, + want: &serde_json::Value, + mut push: impl FnMut(), +) -> bool { + for _ in 0..100 { + push(); + tokio::time::sleep(Duration::from_millis(20)).await; + if db.try_latest_as_json(key).as_ref() == Some(want) { + return true; + } + } + false +} + +#[tokio::test] +async fn pump_client_mirrors_record_both_directions() { + let dir = tempfile::tempdir().unwrap(); + let sock = dir.path().join("aimdb.sock"); + + // --- server: cfg (writable target) + tele (streamed source) ------------ + let mut policy = SecurityPolicy::read_write(); + policy.allow_write_key("cfg"); + let config = AimxConfig::uds_default() + .socket_path(&sock) + .security_policy(policy); + + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsServer::from_config(config)); + sb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + sb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest).with_remote_access(); + }); + let (server_db, server_runner) = sb.build().await.expect("build server db"); + let server_db = Arc::new(server_db); + // The runner drives both the records and the UDS serve loop. + tokio::spawn(server_runner.run()); + + // --- client: cfg links *to* the server, tele links *from* it ----------- + // UdsClient registers the `uds://` scheme (so the links validate) and, on + // build, dials the server + drives the mirroring pumps. + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(UdsClient::new(&sock).with_config(ClientConfig { + reconnect: true, + reconnect_delay: 50, + max_reconnect_delay: 50, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: false, + })); + cb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("uds://cfg") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + cb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("uds://tele") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + // build() collects the connector's engine + pump futures; the runner drives + // them (spawn-free engine, driven on a task here). + let (client_db, client_runner) = cb.build().await.expect("build client db"); + let client_db = Arc::new(client_db); + tokio::spawn(client_runner.run()); + + // client → server: producing client `cfg` mirrors to server `cfg`. + let want_cfg = json!({ "v": 7 }); + let mirrored_out = mirror_reaches(&server_db, "cfg", &want_cfg, || { + client_db + .set_record_from_json("cfg", json!({ "v": 7 })) + .expect("set client cfg"); + }) + .await; + assert!( + mirrored_out, + "client→server mirror did not reach the server" + ); + + // server → client: updating server `tele` mirrors to client `tele`. + let want_tele = json!({ "v": 9 }); + let mirrored_in = mirror_reaches(&client_db, "tele", &want_tele, || { + server_db + .set_record_from_json("tele", json!({ "v": 9 })) + .expect("set server tele"); + }) + .await; + assert!(mirrored_in, "server→client mirror did not reach the client"); +} diff --git a/aimdb-core/CHANGELOG.md b/aimdb-core/CHANGELOG.md index 16accd3d..7d5541d8 100644 --- a/aimdb-core/CHANGELOG.md +++ b/aimdb-core/CHANGELOG.md @@ -9,11 +9,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`connector-session` feature + `session` module — the shared, runtime-neutral session substrate (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A new `crate::session` module (gated `connector-session`, `no_std + alloc`, also enabled transitively by `std`) carrying the connector-convergence machinery: + - **Substrate traits** — `Connection`/`Listener`/`Dialer` (Layer 1 transport, framing-in-transport), `EnvelopeCodec` (Layer 2, symmetric: `decode`/`encode` + the client-direction `encode_inbound`/`decode_outbound`), and `Dispatch` (shared, `Send + Sync`) + `Session` (per-connection, `&mut`-threaded) for Layer 3. Plus the role-neutral `Inbound`/`Outbound` message set, `Payload = Arc<[u8]>` (raw bytes — one serde pass on hot paths), `PeerInfo`/`SessionCtx` (type-erased auth `ext` slots), `Source`, and the `SessionLimits`/`Transport`/`Codec`/`Rpc`/`Auth` error enums. All `dyn`-safe on `std` and `no_std + alloc`. + - **Server engine** — `serve` (accept loop, honors `SessionLimits::max_connections`) + `run_session` (per-connection biased `select_biased!` loop: RPC + streaming subscriptions funneled through a bounded per-connection event channel + fire-and-forget writes). Spawn-free (one `FuturesUnordered` per engine future); honors `SessionLimits::max_subs_per_connection`, with subscriptions reaped — and their cap slot freed — when a stream ends or on Unsubscribe. `SessionConfig` knobs: `reads_hello`, `acks_subscribe`. + - **Client engine** — `run_client` returns a cheap-clone `ClientHandle` (`call`/`subscribe`/`write`) + the engine future; demuxes replies by `id`, supports id- or topic-routed subscriptions, exponential reconnect backoff, idle keepalive, and a bounded offline command queue (`ClientConfig`). The only runtime dependency is the adapter's `TimeOps` clock; everything else is `futures` channels + `async-channel`. + - **Data-plane toolkit** — `pump_sink` (outbound: consume-serialize-publish via `Connector`) and `pump_source` (inbound: one multiplexed `Source` reader → `Router` fan-out), extracting the boilerplate every data-plane connector hand-rolled. + - **Generic connectors** — `SessionClientConnector` and `SessionServerConnector` wrap the engines onto the `ConnectorBuilder` spine so a transport crate contributes only its `Dialer`/`Listener`/`Connection` triple under a configurable scheme (default `"remote"`). +- **`session::aimx` — the AimX-v2 protocol substrate (gated `connector-session` + `json-serialize`).** `AimxCodec`, the symmetric NDJSON `EnvelopeCodec` (`no_std + alloc`; splices an already-serialized record-value `Payload` into the JSON envelope verbatim via `serde_json`'s `RawValue`, no re-escaping). `AimxDispatch`/`AimxSession` (still `std`-gated — it reaches into core's `record.list`/JSON API), porting the method semantics (`hello`, `record.{list,get,set,drain,query}`, `graph.*`, `*.reset`) off the deleted hand-rolled handler onto `Session`. The drain cursors live in the per-connection session; the AimX subscribe ack stays implicit (events carry the request id back). +- **`AimDb::runtime_arc(&self) -> Arc`.** An owned runtime-adapter handle for connectors that hand the runtime to a `'static` engine future (the session client engine clones it for its `TimeOps` clock). +- **`ConnectorConfig::from_query(&[(String, String)])`.** Builds a per-route `ConnectorConfig` from a link URL's query pairs (`timeout_ms` lifted to the typed field, everything else passed through in `protocol_options`); the seam `pump_sink` uses to thread per-route config to `Connector::publish`. - **`json-serialize` feature + `codec` module (M16, Design 032).** New `crate::codec` module with `RemoteSerialize` (capability trait, blanket-impl'd for every `serde` `Serialize + DeserializeOwned` type), the object-safe `JsonCodec` storage trait, and the zero-sized `SerdeJsonCodec`. All three are re-exported from the crate root. The feature is `no_std + alloc` compatible (`serde_json` runs on `alloc`), so `RecordValue::as_json()` now works on embedded targets, not just `std`. `std` enables `json-serialize` transitively, so existing std builds are unaffected. - **`DynBuffer::peek(&self) -> Option` (M15, Design 031).** Non-destructive, buffer-native point-in-time read; the default impl returns `None` (correct for buffers with no canonical latest, e.g. broadcast/SPMC rings). AimX `record.get` and `TypedRecord::latest()` now route through it. Adapters implement it per buffer type — see the tokio/embassy adapter changelogs. ### Internal refactors +- **AimX server/client ported onto the shared session engine; the hand-rolled loops deleted (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Building on the spawn-free work below, `remote/handler.rs` (the per-connection `select!` loop) and `remote/supervisor.rs` (the accept loop) are **removed** — their behavior is now `run_session` + `serve` in `session`, driven by the AimX-v2 `AimxDispatch`/`AimxCodec`. `remote/stream.rs`'s `stream_record_updates` survives and is reused by `AimxSession::subscribe`. The UDS transport (socket bind/connect, NDJSON framing) relocated out of core into the new `aimdb-uds-connector` crate; core keeps only the protocol (codec + dispatch) and the generic session connectors. The query handler type-erasure moved to `remote/query.rs` (`QueryHandlerFn`/`QueryHandlerParams`). New dependencies: `async-channel` (runtime-neutral mpsc), `futures-channel` (oneshot), `futures-util`'s `async-await-macro` (`select_biased!`), and `serde_json`'s `raw_value` feature — all `no_std + alloc`-compatible, none entering the no_std contracts build. - **AimX remote-access path is now spawn-free (Issue #114, Design 030).** Every remaining `tokio::spawn` in `aimdb-core/src/remote/` was removed; the supervisor's accept loop and each connection handler now own their own `FuturesUnordered` driven by `tokio::select! { biased; }`. Cancellation collapsed to one mechanism — dropping the future. - New `aimdb-core/src/remote/stream.rs` exports a `pub(crate) stream_record_updates` helper that adapts a record's `JsonBufferReader` into a `Stream` via `futures_util::stream::unfold`. No task, no channel — drop the stream to cancel. - `AimDb::subscribe_record_updates` **deleted**. The method had no out-of-tree callers (the only caller was the AimX handler); replaced by `stream_record_updates` above. @@ -22,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed (breaking) +- **`AimDbBuilder::with_remote_access(config)` removed (Issue #39).** Register a session connector instead: `.with_connector(aimdb_uds_connector::UdsServer::from_config(config))`. The connector binds its transport at `build` time (bind errors surface synchronously, as before), applies the security policy's writable-record marking, and drives the shared `serve` engine. The builder's private `remote_config` field is gone; the per-record `TypedRecord::with_remote_access()` is unrelated and unchanged. The reshaped **AimX-v2** wire is not backward-compatible with the legacy v1 framing. - **`latest_snapshot` removed from `TypedRecord`; `latest()` / AimX `record.get` read the buffer via `peek()` (M15, Design 031).** Eliminates one snapshot-mutex lock + `Option` clone per `produce()` on the hot path. Behavioural consequences: - A record configured with `.with_remote_access()` but **no buffer** now fails `build()` with a clear error (previously a silent runtime no-op — reads returned `not_found`, writes were discarded). Add a buffer, e.g. `.buffer(BufferCfg::SingleLatest)`. - `record.get` / `latest()` on an `SpmcRing` record now returns `not_found` / `None` — a ring keeps per-consumer history with no canonical latest. Use `record.drain` (history) or `record.subscribe` (live). `SingleLatest` and `Mailbox` are unaffected. diff --git a/aimdb-core/Cargo.toml b/aimdb-core/Cargo.toml index 5a51d8fa..3793322b 100644 --- a/aimdb-core/Cargo.toml +++ b/aimdb-core/Cargo.toml @@ -26,6 +26,9 @@ std = [ "json-serialize", "tokio", "aimdb-executor/std", + # AimX remote access now rides the shared session engine: `with_remote_access` + # builds the `session::aimx` server, so std pulls in the connector-session module. + "connector-session", ] # Heap allocation in no_std environments @@ -36,6 +39,16 @@ alloc = ["serde"] # Enable heap in no_std # get `record.latest()?.as_json()` without std/AimX. `std` enables it for AimX. json-serialize = ["alloc", "serde_json"] +# The connector-session substrate (`crate::session`): the dyn-safe trait set +# (Connection/Listener/Dialer, Dispatch/EnvelopeCodec, Source + shared types) and +# the runtime-neutral engines built on it — the reactive server (`serve`/ +# `run_session`), the proactive client (`run_client`/`pump_client`), the +# `pump_sink`/`pump_source` data plane, and the generic session connectors. Engine +# logic included; all compiles on `no_std + alloc`. The AimX protocol port +# (`session::aimx`) additionally needs `json-serialize`. See the design doc: +# docs/design/remote-access-via-connectors.md. +connector-session = ["alloc"] + # Observability features (available on both std/no_std) tracing = ["dep:tracing"] # Works in both std and no_std environments defmt = ["dep:defmt"] # Embedded logging via probe (no_std) @@ -64,9 +77,25 @@ aimdb-executor = { version = "0.2.0", path = "../aimdb-executor", default-featur # Stream trait for bidirectional connectors (minimal, no_std compatible) futures-core = { version = "0.3", default-features = false } +# `async-await-macro` enables the `select_biased!` macro the runtime-neutral +# session engines use (Phase 5; it pulls in `futures-macro` + `async-await`); +# `alloc` covers the combinators (`fuse`, `select_next_some`). All no_std- +# compatible — no `std`/`tokio` enters the engine compile path. futures-util = { version = "0.3", default-features = false, features = [ "alloc", + "async-await-macro", +] } +# Runtime-neutral `oneshot` for the session engines (alloc-backed, no_std-ready). +# `futures-channel`'s `mpsc` is std-only, so the engines use `async-channel` for +# that (below); only its `oneshot` is used here. +futures-channel = { version = "0.3", default-features = false, features = [ + "alloc", ] } +# Runtime-neutral mpsc (bounded + unbounded) for the session engines — one +# alloc-backed implementation for every runtime (tokio + Embassy). Owned, +# cloneable `Arc`-based senders (so the `'static` per-connection/-subscription +# pump futures can hold them) and a `Receiver: Stream` with `len()`. no_std + alloc. +async-channel = { version = "2", default-features = false } # Serialization (optional) serde = { workspace = true, optional = true } @@ -74,7 +103,11 @@ serde = { workspace = true, optional = true } # Error handling - only for std environments thiserror = { workspace = true, optional = true } anyhow = { workspace = true, optional = true } -serde_json = { workspace = true, optional = true } +# `raw_value` lets the connector-session `EnvelopeCodec` splice an already- +# serialized record-value `Payload` into a JSON envelope without re-escaping +# (037 Decision 1). Only compiled when `serde_json` is pulled in (std / +# json-serialize); the no_std `connector-session` contracts build never sees it. +serde_json = { workspace = true, optional = true, features = ["raw_value"] } # Async runtime - only for std environments with remote access tokio = { workspace = true, features = [ diff --git a/aimdb-core/src/builder.rs b/aimdb-core/src/builder.rs index c1ddd4e6..20654dcd 100644 --- a/aimdb-core/src/builder.rs +++ b/aimdb-core/src/builder.rs @@ -327,7 +327,7 @@ pub struct AimDbBuilder { spawn_fns: Vec<(StringKey, Box)>, /// Startup tasks registered via on_start() — spawned after build() completes. - /// Stored type-erased (Box) -> BoxFuture<…>>>) to allow + /// Stored type-erased (`Box) -> BoxFuture<…>>>`) to allow /// the field to exist on the unparameterised NoRuntime builder too. start_fns: Vec>, @@ -335,10 +335,6 @@ pub struct AimDbBuilder { /// Moved into AimDbInner during build() so it can be read on the live AimDb handle. extensions: Extensions, - /// Remote access configuration (std only) - #[cfg(feature = "std")] - remote_config: Option, - /// PhantomData to track the runtime type parameter _phantom: PhantomData, } @@ -355,8 +351,6 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: Vec::new(), extensions: Extensions::new(), - #[cfg(feature = "std")] - remote_config: None, _phantom: PhantomData, } } @@ -384,8 +378,8 @@ impl AimDbBuilder { /// .with_connector(connector) // ← Now available /// ``` /// - /// The `records` and `remote_config` are preserved across the transition since they - /// are not parameterized by the runtime type. + /// The `records` are preserved across the transition since they are not + /// parameterized by the runtime type. pub fn runtime(self, rt: Arc) -> AimDbBuilder where R: aimdb_executor::RuntimeAdapter + 'static, @@ -397,8 +391,6 @@ impl AimDbBuilder { spawn_fns: Vec::new(), start_fns: self.start_fns, extensions: self.extensions, - #[cfg(feature = "std")] - remote_config: self.remote_config, _phantom: PhantomData, } } @@ -452,25 +444,44 @@ where self } - /// Registers a connector builder that will be invoked during `build()` + /// Registers a connector builder, invoked during [`build`](Self::build). /// - /// The connector builder will be called after the database is constructed, - /// allowing it to collect routes and initialize the connector properly. + /// This single entry point registers **two kinds** of connector — both + /// implement [`ConnectorBuilder`](crate::connector::ConnectorBuilder) and are + /// driven the same way: /// - /// # Arguments - /// * `builder` - A connector builder that implements `ConnectorBuilder` + /// 1. **Data-plane links** (MQTT / KNX / WebSocket): a record opts in with + /// `link_to("://")` / `link_from(...)`, and the connector + /// mirrors that record to/from the external topic. The connector's + /// [`scheme`](crate::connector::ConnectorBuilder::scheme) is what those + /// links match against. + /// 2. **Remote-access session connectors** (UDS / serial / TCP): these expose + /// AimDB itself over a transport so peers can introspect/subscribe/write. + /// - The **client** half (e.g. `UdsClient`) dials a peer and *does* use + /// `link_to`/`link_from` under its scheme — just like (1), the scheme is + /// `"uds"` by default instead of `"mqtt"`. + /// - The **server** half (e.g. `UdsServer`) *accepts* connections and takes + /// **no links** — registering it is how a server stands up remote access + /// (this replaces the old `with_remote_access(config)`). /// - /// # Example + /// # Examples /// /// ```rust,ignore - /// use aimdb_mqtt_connector::MqttConnector; - /// - /// let db = AimDbBuilder::new() - /// .runtime(runtime) + /// // (1) data-plane link to an MQTT topic + /// AimDbBuilder::new().runtime(rt) /// .with_connector(MqttConnector::new("mqtt://broker.local:1883")) - /// .configure::(|reg| { - /// reg.link_from("mqtt://commands/temp")... - /// }) + /// .configure::(|r| { r.link_from("mqtt://commands/temp"); }) + /// .build().await?; + /// + /// // (2a) remote-access SERVER — no links, just expose this db over UDS + /// AimDbBuilder::new().runtime(rt) + /// .with_connector(UdsServer::from_config(remote_config)) + /// .build().await?; + /// + /// // (2b) remote-access CLIENT — mirror a record to a peer over UDS + /// AimDbBuilder::new().runtime(rt) + /// .with_connector(UdsClient::new("/run/aimdb.sock")) + /// .configure::(|r| { r.with_remote_access().link_to("uds://temp"); }) /// .build().await?; /// ``` pub fn with_connector( @@ -481,35 +492,14 @@ where self } - /// Enables remote access via AimX protocol (std only) - /// - /// Configures the database to accept remote connections over a Unix domain socket, - /// allowing external clients to introspect records, subscribe to updates, and - /// (optionally) write data. - /// - /// The remote access supervisor will be spawned automatically during `build()`. - /// - /// # Arguments - /// * `config` - Remote access configuration (socket path, security policy, etc.) - /// - /// # Example - /// - /// ```rust,ignore - /// use aimdb_core::remote::{AimxConfig, SecurityPolicy}; - /// - /// let config = AimxConfig::new("/tmp/aimdb.sock") - /// .with_security(SecurityPolicy::read_only()); - /// - /// let db = AimDbBuilder::new() - /// .runtime(runtime) - /// .with_remote_access(config) - /// .build()?; - /// ``` - #[cfg(feature = "std")] - pub fn with_remote_access(mut self, config: crate::remote::AimxConfig) -> Self { - self.remote_config = Some(config); - self - } + // NOTE: a remote-access **server** is registered like any other connector — + // there is no dedicated builder method: + // + // .with_connector(aimdb_uds_connector::UdsServer::from_config(config)) + // + // This rides the `with_connector` spine (see its docs) and lets the transport + // be swapped (UDS / serial / TCP) without touching the builder. The per-record + // `TypedRecord::with_remote_access()` is unrelated. /// Configures a record type manually with a unique key /// @@ -876,33 +866,11 @@ where #[cfg(feature = "tracing")] tracing::info!("Record future collection complete"); - // Collect the remote-access supervisor future, if configured (std only). - #[cfg(feature = "std")] - if let Some(remote_cfg) = self.remote_config { - #[cfg(feature = "tracing")] - tracing::info!( - "Building remote access supervisor for socket: {}", - remote_cfg.socket_path.display() - ); - - // Apply security policy to mark writable records - let writable_keys = remote_cfg.security_policy.writable_records(); - for key_str in writable_keys { - if let Some(id) = inner.resolve_str(&key_str) { - #[cfg(feature = "tracing")] - tracing::debug!("Marking record '{}' as writable", key_str); - - inner.storages[id.index()].set_writable_erased(true); - } - } - - let supervisor_future = - crate::remote::supervisor::build_supervisor_future(db.clone(), remote_cfg)?; - futures_acc.push(supervisor_future); - - #[cfg(feature = "tracing")] - tracing::info!("Remote access supervisor future collected"); - } + // AimX remote-access servers are no longer stood up here: register a + // session connector (`UdsServer::from_config(...)`) via `with_connector` + // instead — it collects below like any other connector, binds its + // transport, applies the security policy's writable marking, and drives + // the shared session engine. See `with_connector`'s docs. // Collect connector futures. After issue #88 connector builders return // a `Vec` instead of an `Arc` (which previously @@ -915,10 +883,12 @@ where tracing::debug!("Building connector for scheme: {}", scheme); let connector_futures = builder.build(&db).await?; + #[cfg(feature = "tracing")] + let n_futures = connector_futures.len(); futures_acc.extend(connector_futures); #[cfg(feature = "tracing")] - tracing::info!("Connector '{}' contributed {} future(s)", scheme, "n"); + tracing::info!("Connector '{}' contributed {} future(s)", scheme, n_futures); } // Collect on_start futures (registered by external crates like aimdb-persistence). @@ -1247,6 +1217,15 @@ impl AimDb { &self.runtime } + /// Returns an owned `Arc` handle to the runtime adapter. + /// + /// Connectors that hand the runtime to a `'static` engine future (e.g. the + /// session client engine, which needs the adapter's [`TimeOps`](aimdb_executor::TimeOps) + /// clock for reconnect backoff/keepalive) clone it through here. + pub fn runtime_arc(&self) -> Arc { + self.runtime.clone() + } + /// Returns the runtime as a type-erased `Arc` /// /// Used by connectors to provide `RuntimeContext` to context-aware @@ -1296,7 +1275,12 @@ impl AimDb { /// * `record_name` - The full Rust type name (e.g., "server::Temperature") /// /// # Returns - /// `Some(JsonValue)` with current value, or `None` if unavailable + /// `Some(JsonValue)` with the current value, or `None` if the record has no + /// value yet, no buffer, or a buffer with **no canonical latest** — i.e. + /// [`SpmcRing`](crate::buffer::BufferCfg::SpmcRing). A ring is a stream/backlog + /// with no single "current value"; read it via a subscriber or `record.drain`, + /// not a peek (`record.get`). Use [`SingleLatest`](crate::buffer::BufferCfg::SingleLatest) + /// for state you want to read latest-value style. #[cfg(feature = "std")] pub fn try_latest_as_json(&self, record_name: &str) -> Option { self.inner.try_latest_as_json(record_name) diff --git a/aimdb-core/src/connector.rs b/aimdb-core/src/connector.rs index 68d7dec8..d68ee118 100644 --- a/aimdb-core/src/connector.rs +++ b/aimdb-core/src/connector.rs @@ -473,7 +473,7 @@ pub struct ConnectorLink { /// Consumer factory callback (alloc feature) /// - /// Creates ConsumerTrait from Arc> to enable type-safe subscription. + /// Creates `ConsumerTrait` from `Arc>` to enable type-safe subscription. /// The factory captures the record type T at link_to() configuration time, /// allowing the connector to subscribe without knowing T at compile time. /// @@ -536,7 +536,7 @@ impl ConnectorLink { /// Creates a consumer using the stored factory (alloc feature) /// - /// Takes an Arc (which should contain Arc>) and invokes + /// Takes an `Arc` (which should contain `Arc>`) and invokes /// the consumer factory to create a ConsumerTrait instance. /// /// Returns None if no factory is configured. @@ -587,7 +587,7 @@ pub enum DeserializerKind { /// Type alias for producer factory callback (alloc feature) /// -/// Takes Arc (which contains AimDb) and returns a boxed ProducerTrait. +/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ProducerTrait`. /// This allows capturing the record type T at link_from() time while storing /// the factory in a type-erased InboundConnectorLink. /// @@ -616,7 +616,7 @@ pub type TopicResolverFn = Arc Option + Send + Sync>; /// Type-erased producer trait for MQTT router /// /// Allows the router to call produce() on different record types without knowing -/// the concrete type at compile time. The value is passed as Box and +/// the concrete type at compile time. The value is passed as `Box` and /// downcast to the correct type inside the implementation. /// /// # Implementation Note @@ -627,7 +627,7 @@ pub type TopicResolverFn = Arc Option + Send + Sync>; pub trait ProducerTrait: Send + Sync { /// Produce a value into the record's buffer /// - /// The value must be passed as Box and will be downcast to the correct type. + /// The value must be passed as `Box` and will be downcast to the correct type. /// Returns an error if the downcast fails or if production fails. fn produce_any<'a>( &'a self, @@ -637,7 +637,7 @@ pub trait ProducerTrait: Send + Sync { /// Type alias for consumer factory callback (alloc feature) /// -/// Takes Arc (which contains AimDb) and returns a boxed ConsumerTrait. +/// Takes `Arc` (which contains `AimDb`) and returns a boxed `ConsumerTrait`. /// This allows capturing the record type T at link_to() time while storing /// the factory in a type-erased ConnectorLink. /// @@ -659,7 +659,7 @@ pub type ConsumerFactoryFn = pub trait ConsumerTrait: Send + Sync { /// Subscribe to typed values from this record /// - /// Returns a type-erased reader that can be polled for Box values. + /// Returns a type-erased reader that can be polled for `Box` values. /// The connector will downcast to the expected type after deserialization. fn subscribe_any<'a>(&'a self) -> SubscribeAnyFuture<'a>; } @@ -675,11 +675,11 @@ type RecvAnyFuture<'a> = /// Helper trait for type-erased reading /// /// Allows reading values from a buffer without knowing the concrete type at compile time. -/// The value is returned as Box and must be downcast by the caller. +/// The value is returned as `Box` and must be downcast by the caller. pub trait AnyReader: Send { /// Receive a type-erased value from the buffer /// - /// Returns Box which must be downcast to the concrete type. + /// Returns `Box` which must be downcast to the concrete type. /// Returns an error if the buffer is closed or an I/O error occurs. fn recv_any<'a>(&'a mut self) -> RecvAnyFuture<'a>; } @@ -707,7 +707,7 @@ pub struct InboundConnectorLink { /// Producer creation callback (alloc feature) /// - /// Takes Arc> and returns Box. + /// Takes `Arc>` and returns `Box`. /// Captures the record type T at link_from() call time. /// /// Available in both `std` and `no_std + alloc` environments. diff --git a/aimdb-core/src/extensions.rs b/aimdb-core/src/extensions.rs index 9103adfa..86fbde16 100644 --- a/aimdb-core/src/extensions.rs +++ b/aimdb-core/src/extensions.rs @@ -1,4 +1,4 @@ -//! Generic extension storage for [`AimDbBuilder`] and [`AimDb`]. +//! Generic extension storage for [`AimDbBuilder`](crate::AimDbBuilder) and [`AimDb`](crate::AimDb). //! //! External crates store typed state here during builder configuration //! and retrieve it during record setup or at query time. This is the diff --git a/aimdb-core/src/lib.rs b/aimdb-core/src/lib.rs index 6dcc24d8..89d31324 100644 --- a/aimdb-core/src/lib.rs +++ b/aimdb-core/src/lib.rs @@ -35,6 +35,8 @@ pub mod record_id; #[cfg(feature = "std")] pub mod remote; pub mod router; +#[cfg(feature = "connector-session")] +pub mod session; pub mod time; pub mod transform; pub mod transport; @@ -86,6 +88,15 @@ pub use typed_record::{AnyRecord, AnyRecordExt, TypedRecord}; #[cfg(feature = "json-serialize")] pub use codec::{JsonCodec, RemoteSerialize, SerdeJsonCodec}; +// connector-session contracts (feature `connector-session`, no_std + alloc +// compatible). See docs/design/remote-access-via-connectors.md. +#[cfg(feature = "connector-session")] +pub use session::{ + pump_sink, pump_source, AuthError, BoxFut, BoxStream, CodecError, Connection, Dialer, Dispatch, + EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, SessionCtx, + SessionLimits, Source, TransportError, TransportResult, +}; + // Stage profiling exports (feature-gated) #[cfg(feature = "profiling")] pub use profiling::{RecordProfilingMetrics, StageMetrics, StageProfilingInfo}; diff --git a/aimdb-core/src/remote/handler.rs b/aimdb-core/src/remote/handler.rs deleted file mode 100644 index b9e3ed31..00000000 --- a/aimdb-core/src/remote/handler.rs +++ /dev/null @@ -1,1759 +0,0 @@ -//! Connection handler for AimX protocol -//! -//! Handles individual client connections, including handshake, authentication, -//! and protocol method dispatch. -//! -//! # Architecture: Event Funnel Pattern -//! -//! Subscriptions use a funnel pattern for clean event delivery: -//! - Each `record.subscribe` pushes a future onto a per-connection -//! [`futures_util::stream::FuturesUnordered`] that the connection's -//! outer `select!` loop drives. -//! - Subscription futures send events to a shared mpsc channel (the "funnel"). -//! - The same outer loop drains the funnel and writes events to the -//! `UnixStream`, so NDJSON line integrity is preserved without a -//! dedicated writer task. - -use crate::remote::{ - AimxConfig, Event, HelloMessage, RecordMetadata, Request, Response, WelcomeMessage, -}; -use crate::{AimDb, DbError, DbResult}; - -#[cfg(feature = "std")] -use std::collections::HashMap; -#[cfg(feature = "std")] -use std::sync::Arc; - -#[cfg(feature = "std")] -use futures_core::Stream; -#[cfg(feature = "std")] -use futures_util::stream::{FuturesUnordered, StreamExt}; -#[cfg(feature = "std")] -use serde_json::json; -#[cfg(feature = "std")] -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -#[cfg(feature = "std")] -use tokio::net::UnixStream; -#[cfg(feature = "std")] -use tokio::sync::{mpsc, Notify}; - -#[cfg(feature = "std")] -use crate::builder::BoxFuture; - -/// Connection state for managing subscriptions -/// -/// Tracks all active subscriptions for a single client connection. Each -/// subscription is identified by its `subscription_id` and carries an -/// `Arc` that `record.unsubscribe` fires to wake the -/// per-subscription future, which then exits on its next poll — -/// cancellation is immediate (no need to wait for the next stream -/// event). Connection teardown does not need to fire the notify — -/// dropping the per-connection `FuturesUnordered` (in -/// [`handle_connection`]) drops every subscription future, which is the -/// primary cancellation path. -#[cfg(feature = "std")] -struct ConnectionState { - /// Active subscriptions by subscription_id → cancel notify. - subscriptions: HashMap>, - - /// Counter for generating unique subscription IDs - next_subscription_id: u64, - - /// Event funnel: all subscription futures send events here. - /// This channel feeds the connection's send loop. - event_tx: mpsc::UnboundedSender, - - /// Per-record drain readers, created lazily on first record.drain call. - /// One drain reader per record, per connection. - drain_readers: HashMap>, -} - -#[cfg(feature = "std")] -impl ConnectionState { - /// Creates a new connection state - fn new(event_tx: mpsc::UnboundedSender) -> Self { - Self { - subscriptions: HashMap::new(), - next_subscription_id: 1, - event_tx, - drain_readers: HashMap::new(), - } - } - - /// Generates a unique subscription ID for this connection - fn generate_subscription_id(&mut self) -> String { - let id = format!("sub-{}", self.next_subscription_id); - self.next_subscription_id += 1; - id - } -} - -/// Handles an incoming client connection -/// -/// Processes the AimX protocol handshake and manages the client session. -/// Implements the event funnel pattern for subscription event delivery. -/// -/// # Architecture -/// -/// ```text -/// ┌──────────────────────┐ -/// │ Subscription future 1│───┐ -/// │ (in FuturesUnordered)│ │ -/// └──────────────────────┘ │ -/// ├──► Event Funnel ───► select! loop ───► UnixStream -/// ┌──────────────────────┐ │ (mpsc) (interleaved -/// │ Subscription future 2│───┘ writes) -/// │ (in FuturesUnordered)│ -/// └──────────────────────┘ -/// ``` -/// -/// The main loop uses `tokio::select! { biased; }` to interleave: -/// - Reading requests from the stream -/// - Writing events from subscriptions -/// - Draining completed subscription futures -/// -/// `biased;` polls the request arm first so a chatty subscription -/// cannot starve the request path. Cancellation is by drop: when the -/// outer loop exits, the per-connection `FuturesUnordered` is dropped -/// and every subscription future with it. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `stream` - Unix domain socket stream -/// -/// # Errors -/// Returns error if handshake fails or stream operations error -#[cfg(feature = "std")] -pub async fn handle_connection( - db: Arc>, - config: AimxConfig, - stream: UnixStream, -) -> DbResult<()> -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::info!("New remote access connection established"); - - // Perform protocol handshake - let mut stream = match perform_handshake(stream, &config, &db).await { - Ok(stream) => stream, - Err(e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Handshake failed: {}", e); - return Err(e); - } - }; - - #[cfg(feature = "tracing")] - tracing::info!("Handshake complete, client ready"); - - // Create event funnel: all subscription futures send events here - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - - // Initialize connection state - let mut conn_state = ConnectionState::new(event_tx); - - // Per-connection FuturesUnordered of subscription futures. Each - // `record.subscribe` pushes one future here; cancellation flows - // through `Arc` (Unsubscribe) or by dropping `subs` when the - // connection ends. - let mut subs: FuturesUnordered = FuturesUnordered::new(); - - // Main loop: interleave reading requests, writing events, and draining - // completed subscription futures. `biased;` keeps request reads polled - // first so a chatty subscription cannot starve the request path. - loop { - let mut line = String::new(); - - tokio::select! { - biased; - - // Handle incoming requests - read_result = stream.read_line(&mut line) => { - match read_result { - Ok(0) => { - // Client closed connection - #[cfg(feature = "tracing")] - tracing::info!("Client disconnected gracefully"); - break; - } - Ok(_) => { - #[cfg(feature = "tracing")] - tracing::debug!("Received request: {}", line.trim()); - - // Parse request - let request: Request = match serde_json::from_str(line.trim()) { - Ok(req) => req, - Err(e) => { - #[cfg(feature = "tracing")] - tracing::warn!("Failed to parse request: {}", e); - - // Send error response (use ID 0 if we can't parse the request) - let error_response = - Response::error(0, "parse_error", format!("Invalid JSON: {}", e)); - if let Err(_e) = send_response(&mut stream, &error_response).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send error response: {}", _e); - break; - } - continue; - } - }; - - // Dispatch request to appropriate handler - let response = handle_request( - &db, - &config, - &mut conn_state, - &mut subs, - request, - ) - .await; - - // Send response - if let Err(_e) = send_response(&mut stream, &response).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send response: {}", _e); - break; - } - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Error reading from stream: {}", _e); - break; - } - } - } - - // Handle outgoing events from subscriptions - Some(event) = event_rx.recv() => { - if let Err(_e) = send_event(&mut stream, &event).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to send event: {}", _e); - break; - } - } - - // Drain finished subscription futures so `subs` does not grow - // unboundedly. Using `Some(_) = next()` (rather than - // `select_next_some()`) is the safe form: an empty - // `FuturesUnordered` reports `is_terminated() == true`, and - // `select_next_some` panics in that state. With the pattern - // guard the arm is simply disabled when `next()` resolves to - // `None`, and the always-active `read_line` arm keeps the - // select alive. - Some(_) = subs.next() => {} - } - } - - // Dropping `subs` here cancels every still-running subscription future - // — the connection's `FuturesUnordered` is their sole owner. - drop(subs); - - #[cfg(feature = "tracing")] - tracing::info!("Connection handler terminating"); - - Ok(()) -} - -/// Sends an event to the client -/// -/// Serializes the event to JSON and writes it to the stream with a newline. -/// -/// # Arguments -/// * `stream` - The connection stream -/// * `event` - The event to send -/// -/// # Errors -/// Returns error if serialization or write fails -#[cfg(feature = "std")] -async fn send_event(stream: &mut BufReader, event: &Event) -> DbResult<()> { - // Wrap event in protocol envelope - let event_msg = json!({ "event": event }); - - let event_json = serde_json::to_string(&event_msg).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize event".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(event_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write event".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write event newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::trace!("Sent event for subscription: {}", event.subscription_id); - - Ok(()) -} - -/// Sends a response to the client -/// -/// Serializes the response to JSON and writes it to the stream with a newline. -/// -/// # Arguments -/// * `stream` - The connection stream -/// * `response` - The response to send -/// -/// # Errors -/// Returns error if serialization or write fails -#[cfg(feature = "std")] -async fn send_response(stream: &mut BufReader, response: &Response) -> DbResult<()> { - let response_json = serde_json::to_string(response).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize response".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(response_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write response".to_string(), - source: e, - })?; - - stream - .get_mut() - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write response newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!("Sent response"); - - Ok(()) -} - -/// Performs the AimX protocol handshake -/// -/// Handshake flow: -/// 1. Client sends HelloMessage with protocol version -/// 2. Server validates version compatibility -/// 3. Server sends WelcomeMessage with accepted version -/// 4. Optional: Authenticate with token -/// -/// # Arguments -/// * `stream` - Unix domain socket stream -/// * `config` - Remote access configuration -/// * `db` - Database instance (for querying writable records) -/// -/// # Returns -/// `BufReader` if handshake succeeds -/// -/// # Errors -/// Returns error if: -/// - Protocol version incompatible -/// - Authentication fails -/// - IO error during handshake -#[cfg(feature = "std")] -async fn perform_handshake( - stream: UnixStream, - config: &AimxConfig, - db: &Arc>, -) -> DbResult> -where - R: crate::RuntimeAdapter + 'static, -{ - let (reader, mut writer) = stream.into_split(); - let mut reader = BufReader::new(reader); - - // Read Hello message from client - let mut line = String::new(); - reader - .read_line(&mut line) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to read Hello message".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!("Received handshake: {}", line.trim()); - - // Parse Hello message - let hello: HelloMessage = - serde_json::from_str(line.trim()).map_err(|e| DbError::JsonWithContext { - context: "Failed to parse Hello message".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::debug!( - "Client hello: version={}, client={}", - hello.version, - hello.client - ); - - // Version validation: accept "1.0" or "1" - if hello.version != "1.0" && hello.version != "1" { - let error_msg = format!( - r#"{{"error":"unsupported_version","message":"Server supports version 1.0, client requested {}"}}"#, - hello.version - ); - - #[cfg(feature = "tracing")] - tracing::warn!("Unsupported version: {}", hello.version); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::InvalidOperation { - operation: "handshake".to_string(), - reason: format!("Unsupported version: {}", hello.version), - }); - } - - // Check authentication if required - let authenticated = if let Some(expected_token) = &config.auth_token { - match &hello.auth_token { - Some(provided_token) if provided_token == expected_token => { - #[cfg(feature = "tracing")] - tracing::debug!("Authentication successful"); - true - } - Some(_) => { - let error_msg = - r#"{"error":"authentication_failed","message":"Invalid auth token"}"#; - - #[cfg(feature = "tracing")] - tracing::warn!("Authentication failed: invalid token"); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::PermissionDenied { - operation: "authentication".to_string(), - }); - } - None => { - let error_msg = - r#"{"error":"authentication_required","message":"Auth token required"}"#; - - #[cfg(feature = "tracing")] - tracing::warn!("Authentication failed: no token provided"); - - let _ = writer.write_all(error_msg.as_bytes()).await; - let _ = writer.write_all(b"\n").await; - let _ = writer.shutdown().await; - - return Err(DbError::PermissionDenied { - operation: "authentication".to_string(), - }); - } - } - } else { - false - }; - - // Determine permissions based on security policy - let permissions = match &config.security_policy { - crate::remote::SecurityPolicy::ReadOnly => vec!["read".to_string()], - crate::remote::SecurityPolicy::ReadWrite { .. } => { - vec!["read".to_string(), "write".to_string()] - } - }; - - // Get writable records by querying database for writable record names - let writable_records = match &config.security_policy { - crate::remote::SecurityPolicy::ReadOnly => vec![], - crate::remote::SecurityPolicy::ReadWrite { - writable_records: _writable_type_ids, - } => { - // Get all records from database - let all_records: Vec = db.list_records(); - - // Filter to those that are marked writable - all_records - .into_iter() - .filter(|meta| meta.writable) - .map(|meta| meta.name) - .collect() - } - }; - - // Send Welcome message - let welcome = WelcomeMessage { - version: "1.0".to_string(), - server: "aimdb".to_string(), - permissions, - writable_records, - max_subscriptions: Some(config.max_subs_per_connection), - authenticated: Some(authenticated), - }; - - let welcome_json = serde_json::to_string(&welcome).map_err(|e| DbError::JsonWithContext { - context: "Failed to serialize Welcome message".to_string(), - source: e, - })?; - - writer - .write_all(welcome_json.as_bytes()) - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write Welcome message".to_string(), - source: e, - })?; - - writer - .write_all(b"\n") - .await - .map_err(|e| DbError::IoWithContext { - context: "Failed to write Welcome newline".to_string(), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!("Sent Welcome message to client"); - - // Reunite the stream - let stream = reader - .into_inner() - .reunite(writer) - .map_err(|e| DbError::Io { - source: std::io::Error::other(e.to_string()), - })?; - - Ok(BufReader::new(stream)) -} - -/// Handles a single request and returns a response -/// -/// Dispatches to the appropriate handler based on the request method. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `conn_state` - Connection state (for subscription management) -/// * `request` - The parsed request -/// -/// # Returns -/// Response to send to the client -#[cfg(feature = "std")] -async fn handle_request( - db: &Arc>, - config: &AimxConfig, - conn_state: &mut ConnectionState, - subs: &mut FuturesUnordered, - request: Request, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!( - "Handling request: method={}, id={}", - request.method, - request.id - ); - - match request.method.as_str() { - "record.list" => handle_record_list(db, config, request.id).await, - "record.get" => handle_record_get(db, config, request.id, request.params).await, - "record.set" => handle_record_set(db, config, request.id, request.params).await, - "record.subscribe" => { - handle_record_subscribe(db, config, conn_state, subs, request.id, request.params).await - } - "record.unsubscribe" => { - handle_record_unsubscribe(conn_state, request.id, request.params).await - } - "record.drain" => handle_record_drain(db, conn_state, request.id, request.params).await, - "record.query" => handle_record_query(db, request.id, request.params).await, - "graph.nodes" => handle_graph_nodes(db, request.id).await, - "graph.edges" => handle_graph_edges(db, request.id).await, - "graph.topo_order" => handle_graph_topo_order(db, request.id).await, - #[cfg(feature = "profiling")] - "profiling.reset" => handle_profiling_reset(db, config, request.id).await, - #[cfg(feature = "metrics")] - "buffer_metrics.reset" => handle_buffer_metrics_reset(db, config, request.id).await, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Unknown method: {}", request.method); - - Response::error( - request.id, - "method_not_found", - format!("Unknown method: {}", request.method), - ) - } - } -} - -/// Handles record.list method -/// -/// Returns metadata for all registered records in the database. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of RecordMetadata -#[cfg(feature = "std")] -async fn handle_record_list( - db: &Arc>, - _config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Listing records"); - - // Get all record metadata from database - let records: Vec = db.list_records(); - - #[cfg(feature = "tracing")] - tracing::debug!("Found {} records", records.len()); - - // Convert to JSON and return - Response::success(request_id, json!(records)) -} - -/// Handles profiling.reset method -/// -/// Clears stage profiling counters for every record. Requires write permission. -#[cfg(all(feature = "std", feature = "profiling"))] -async fn handle_profiling_reset( - db: &Arc>, - config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - if matches!( - config.security_policy, - crate::remote::SecurityPolicy::ReadOnly - ) { - return Response::error( - request_id, - "permission_denied", - "profiling.reset requires write permission (ReadOnly security policy)".to_string(), - ); - } - - db.reset_stage_profiling(); - - #[cfg(feature = "tracing")] - tracing::info!("Stage profiling counters reset"); - - Response::success(request_id, json!({ "reset": true })) -} - -/// Handles buffer_metrics.reset method -/// -/// Clears buffer introspection counters for every record. Requires write permission. -#[cfg(all(feature = "std", feature = "metrics"))] -async fn handle_buffer_metrics_reset( - db: &Arc>, - config: &AimxConfig, - request_id: u64, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - if matches!( - config.security_policy, - crate::remote::SecurityPolicy::ReadOnly - ) { - return Response::error( - request_id, - "permission_denied", - "buffer_metrics.reset requires write permission (ReadOnly security policy)".to_string(), - ); - } - - db.reset_buffer_metrics(); - - #[cfg(feature = "tracing")] - tracing::info!("Buffer metrics counters reset"); - - Response::success(request_id, json!({ "reset": true })) -} - -/// Handles record.get method -/// -/// Returns the current value of a record as JSON. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "record" field with record name) -/// -/// # Returns -/// Success response with record value as JSON, or error if: -/// - Missing/invalid "record" parameter -/// - Record not found -/// - Record not configured with `.with_remote_access()` -/// - No value available in atomic snapshot -#[cfg(feature = "std")] -async fn handle_record_get( - db: &Arc>, - _config: &AimxConfig, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(map)) => match map.get("record") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'record' parameter"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'record' parameter".to_string(), - ); - } - }, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Getting value for record: {}", record_name); - - // Try to peek the record's JSON value - match db.try_latest_as_json(&record_name) { - Some(value) => { - #[cfg(feature = "tracing")] - tracing::debug!("Successfully retrieved value for {}", record_name); - - Response::success(request_id, value) - } - None => { - #[cfg(feature = "tracing")] - tracing::warn!("No value available for record: {}", record_name); - - Response::error( - request_id, - "not_found", - format!("No value available for record: {}", record_name), - ) - } - } -} - -/// Handles record.set method -/// -/// Sets a record value from JSON (write operation). -/// -/// **SAFETY:** Enforces the "No Producer Override" rule: -/// - Only allows writes to configuration records (producer_count == 0) -/// - Prevents remote access from interfering with application logic -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration (for permission checks) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" and "value" fields) -/// -/// # Returns -/// Success response, or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Permission denied (not writable or has active producers) -/// - Deserialization failed -#[cfg(feature = "std")] -async fn handle_record_set( - db: &Arc>, - config: &AimxConfig, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - use crate::remote::SecurityPolicy; - - // Check if write operations are allowed - let writable_records = match &config.security_policy { - SecurityPolicy::ReadOnly => { - #[cfg(feature = "tracing")] - tracing::warn!("record.set called but security policy is ReadOnly"); - - return Response::error( - request_id, - "permission_denied", - "Write operations not allowed (ReadOnly security policy)".to_string(), - ); - } - SecurityPolicy::ReadWrite { writable_records } => writable_records, - }; - - // Extract record name and value from params - let (record_name, value) = match params { - Some(serde_json::Value::Object(ref map)) => { - let name = match map.get("name") { - Some(serde_json::Value::String(n)) => n.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'name' parameter in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }; - - let val = match map.get("value") { - Some(v) => v.clone(), - None => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing 'value' parameter in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing 'value' parameter".to_string(), - ); - } - }; - - (name, val) - } - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object in record.set"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Setting value for record: {}", record_name); - - // Check if record is in the writable_records set (using record key) - if !writable_records.contains(&record_name) { - #[cfg(feature = "tracing")] - tracing::warn!("Record '{}' not in writable_records set", record_name); - - return Response::error( - request_id, - "permission_denied", - format!( - "Record '{}' is not writable. \ - Configure with .with_writable_record() to allow writes.", - record_name - ), - ); - } - - // Attempt to set the value - // This will enforce the "no producer override" rule internally - match db.set_record_from_json(&record_name, value) { - Ok(()) => { - #[cfg(feature = "tracing")] - tracing::info!("Successfully set value for record: {}", record_name); - - // Get the updated value to return in response - let result = if let Some(updated_value) = db.try_latest_as_json(&record_name) { - serde_json::json!({ - "status": "success", - "value": updated_value, - }) - } else { - serde_json::json!({ - "status": "success", - }) - }; - - Response::success(request_id, result) - } - Err(e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to set value for record '{}': {}", record_name, e); - - // Map internal errors to appropriate response codes - let (code, message) = match e { - crate::DbError::RecordKeyNotFound { key } => { - ("not_found", format!("Record '{}' not found", key)) - } - crate::DbError::PermissionDenied { operation } => { - // This is the "has active producers" error - ("permission_denied", operation) - } - crate::DbError::JsonWithContext { context, .. } => ( - "validation_error", - format!("JSON validation failed: {}", context), - ), - crate::DbError::RuntimeError { message } => ("internal_error", message), - _ => ("internal_error", format!("Failed to set value: {}", e)), - }; - - Response::error(request_id, code, message) - } - } -} - -/// Handles record.subscribe method -/// -/// Subscribes to live updates for a record. Pushes a per-subscription -/// future onto the connection's [`FuturesUnordered`] (`subs`) — there is -/// no `tokio::spawn`; the connection's outer loop drives the future. -/// -/// # Arguments -/// * `db` - Database instance -/// * `config` - Remote access configuration -/// * `conn_state` - Connection state (for subscription tracking) -/// * `subs` - Per-connection set of subscription futures (this fn pushes one) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" field with record name) -/// -/// # Returns -/// Success response with subscription_id or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Too many subscriptions -#[cfg(feature = "std")] -async fn handle_record_subscribe( - db: &Arc>, - config: &AimxConfig, - conn_state: &mut ConnectionState, - subs: &mut FuturesUnordered, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("name") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing or invalid 'name' parameter in record.subscribe"); - - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }, - _ => { - #[cfg(feature = "tracing")] - tracing::warn!("Missing params object in record.subscribe"); - - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - // Optional: send_initial flag (default true) - let _send_initial = params - .as_ref() - .and_then(|p| p.as_object()) - .and_then(|map| map.get("send_initial")) - .and_then(|v| v.as_bool()) - .unwrap_or(true); - - #[cfg(feature = "tracing")] - tracing::debug!("Subscribing to record: {}", record_name); - - // Check max subscriptions per connection. - if conn_state.subscriptions.len() >= config.max_subs_per_connection { - #[cfg(feature = "tracing")] - tracing::warn!( - "Too many subscriptions: {} (max: {})", - conn_state.subscriptions.len(), - config.max_subs_per_connection - ); - - return Response::error( - request_id, - "too_many_subscriptions", - format!( - "Maximum subscriptions reached: {}", - config.max_subs_per_connection - ), - ); - } - - // Subscribe to the record's JSON event stream - let value_stream = match crate::remote::stream::stream_record_updates(db, &record_name) { - Ok(s) => s, - Err(e) => { - // Map internal errors to appropriate response codes - let (code, message) = match &e { - crate::DbError::RecordKeyNotFound { key } => { - #[cfg(feature = "tracing")] - tracing::warn!("Record not found: {}", key); - ("not_found", format!("Record '{}' not found", key)) - } - _ => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to subscribe to record updates: {}", e); - ("internal_error", format!("Failed to subscribe: {}", e)) - } - }; - - return Response::error(request_id, code, message); - } - }; - - // Generate unique subscription ID and cancel notify - let subscription_id = conn_state.generate_subscription_id(); - let cancel = Arc::new(Notify::new()); - - // Push the subscription future onto the connection's set. The future - // exits — and is therefore dropped — when either the cancel notify - // fires (Unsubscribe, immediate) or the outer connection loop exits - // (drops `subs`, which drops the future). - let event_tx = conn_state.event_tx.clone(); - let sub_id_for_future = subscription_id.clone(); - let cancel_for_future = cancel.clone(); - subs.push(Box::pin(run_subscription( - value_stream, - sub_id_for_future, - event_tx, - cancel_for_future, - ))); - - conn_state - .subscriptions - .insert(subscription_id.clone(), cancel); - - #[cfg(feature = "tracing")] - tracing::info!( - "Created subscription {} for record {}", - subscription_id, - record_name - ); - - // Return success response - Response::success( - request_id, - json!({ - "subscription_id": subscription_id, - }), - ) -} - -/// Per-subscription future: forwards JSON values from the record stream -/// into the connection's event funnel as `Event` messages with sequence -/// numbers and RFC-style "secs.nanos" timestamps. -/// -/// Exits when any of: -/// - the `cancel` notify fires (by `record.unsubscribe`) — wakes the -/// future immediately; the in-flight `stream.next()` is cancelled by -/// `select!` losing its arm, which drops the underlying -/// `JsonBufferReader` even if the record is currently quiet; -/// - the upstream stream ends (e.g. `BufferClosed`); -/// - the event funnel is closed (connection going down). -/// -/// Connection-close cancellation does not rely on the notify — the -/// connection's `FuturesUnordered` is the sole owner of this future and -/// dropping the set drops the future. -#[cfg(feature = "std")] -async fn run_subscription( - stream: S, - subscription_id: String, - event_tx: mpsc::UnboundedSender, - cancel: Arc, -) where - S: Stream + Send + 'static, -{ - futures_util::pin_mut!(stream); - let mut sequence: u64 = 1; - - #[cfg(feature = "tracing")] - tracing::debug!( - "Subscription future started for subscription: {}", - subscription_id - ); - - loop { - // `biased;` polls the cancel arm first so a notify issued while - // a value is also ready terminates the subscription rather than - // emitting one more event. `Notify` stores a permit if no - // waiter is parked, so a notify-before-first-poll is honoured - // on the first iteration. - let json_value = tokio::select! { - biased; - - _ = cancel.notified() => { - #[cfg(feature = "tracing")] - tracing::debug!( - "Subscription {} cancelled via Unsubscribe", - subscription_id - ); - break; - } - - maybe_value = stream.next() => match maybe_value { - Some(v) => v, - None => break, - }, - }; - - // Generate timestamp in "secs.nanosecs" format - let duration = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default(); - let timestamp = format!("{}.{:09}", duration.as_secs(), duration.subsec_nanos()); - - let event = Event { - subscription_id: subscription_id.clone(), - sequence, - data: json_value, - timestamp, - dropped: None, // TODO: Implement dropped event tracking - }; - - if event_tx.send(event).is_err() { - #[cfg(feature = "tracing")] - tracing::debug!( - "Event channel closed, terminating subscription: {}", - subscription_id - ); - break; - } - - sequence += 1; - } - - #[cfg(feature = "tracing")] - tracing::debug!("Subscription future terminated: {}", subscription_id); -} - -/// Handles record.unsubscribe method -/// -/// Cancels an active subscription. -/// -/// # Arguments -/// * `conn_state` - Connection state (for subscription tracking) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "subscription_id" field) -/// -/// # Returns -/// Success response, or error if subscription not found -#[cfg(feature = "std")] -async fn handle_record_unsubscribe( - conn_state: &mut ConnectionState, - request_id: u64, - params: Option, -) -> Response { - // Parse subscription_id parameter - let subscription_id = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("subscription_id") { - Some(serde_json::Value::String(id)) => id.clone(), - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'subscription_id' parameter".to_string(), - ) - } - }, - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing 'subscription_id' parameter".to_string(), - ) - } - }; - - #[cfg(feature = "tracing")] - tracing::debug!("Unsubscribing from subscription_id: {}", subscription_id); - - // Look up and remove the subscription. Firing the notify wakes the - // per-subscription future immediately — the `biased;` cancel arm in - // `run_subscription`'s select! returns next, the stream-poll future - // is dropped (releasing the underlying `JsonBufferReader` even if - // the record is quiet), and the subscription future exits. It is - // then reaped from `subs` by the connection's outer drain loop. - match conn_state.subscriptions.remove(&subscription_id) { - Some(cancel) => { - cancel.notify_one(); - - #[cfg(feature = "tracing")] - tracing::debug!("Cancelled subscription {}", subscription_id); - - Response::success( - request_id, - serde_json::json!({ - "subscription_id": subscription_id, - "status": "cancelled" - }), - ) - } - None => { - #[cfg(feature = "tracing")] - tracing::warn!("Subscription not found: {}", subscription_id); - - Response::error( - request_id, - "not_found", - format!("Subscription '{}' not found", subscription_id), - ) - } - } -} - -/// Handles record.drain method -/// -/// Drains all pending values from a record's drain reader. On the first call for -/// a given record, creates a dedicated drain reader (returns empty). Subsequent -/// calls return all values accumulated since the previous drain. -/// -/// # Arguments -/// * `db` - Database instance -/// * `conn_state` - Connection state (for drain reader management) -/// * `request_id` - Request ID for the response -/// * `params` - Request parameters (must contain "name" field, optional "limit") -/// -/// # Returns -/// Success response with `record_name`, `values` array, and `count`, or error if: -/// - Missing/invalid parameters -/// - Record not found -/// - Record not configured with `.with_remote_access()` -#[cfg(feature = "std")] -async fn handle_record_drain( - db: &Arc>, - conn_state: &mut ConnectionState, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract record name from params - let record_name = match params { - Some(serde_json::Value::Object(ref map)) => match map.get("name") { - Some(serde_json::Value::String(name)) => name.clone(), - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing or invalid 'name' parameter (expected string)".to_string(), - ); - } - }, - _ => { - return Response::error( - request_id, - "invalid_params", - "Missing params object".to_string(), - ); - } - }; - - // Optional: limit parameter - // Use try_from instead of `as` to avoid silent truncation on 32-bit targets - // (values that don't fit in usize are treated as "no limit"). - let limit = params - .as_ref() - .and_then(|p| p.as_object()) - .and_then(|map| map.get("limit")) - .and_then(|v| v.as_u64()) - .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) - .unwrap_or(usize::MAX); - - #[cfg(feature = "tracing")] - tracing::debug!( - "Draining record: {} (limit: {})", - record_name, - if limit == usize::MAX { - "all".to_string() - } else { - limit.to_string() - } - ); - - // Lazily create drain reader on first call for this record - if !conn_state.drain_readers.contains_key(&record_name) { - // Resolve record key → RecordId → AnyRecord → subscribe_json() - let id = match db.inner().resolve_str(&record_name) { - Some(id) => id, - None => { - return Response::error( - request_id, - "not_found", - format!("Record '{}' not found", record_name), - ); - } - }; - - let record = match db.inner().storage(id) { - Some(r) => r, - None => { - return Response::error( - request_id, - "not_found", - format!("Record '{}' storage not found", record_name), - ); - } - }; - - let reader = match record.subscribe_json() { - Ok(r) => r, - Err(e) => { - return Response::error( - request_id, - "remote_access_not_enabled", - format!( - "Record '{}' not configured with .with_remote_access(): {}", - record_name, e - ), - ); - } - }; - - conn_state.drain_readers.insert(record_name.clone(), reader); - } - - // Drain all pending values from the reader - let reader = conn_state.drain_readers.get_mut(&record_name).unwrap(); - let mut values = Vec::new(); - - loop { - if values.len() >= limit { - break; - } - match reader.try_recv_json() { - Ok(val) => values.push(val), - Err(DbError::BufferEmpty) => break, - Err(DbError::BufferLagged { .. }) => { - // Ring overflowed since last drain — cursor resets. - // Log warning, keep draining. - #[cfg(feature = "tracing")] - tracing::warn!( - "Drain reader lagged for record '{}' — some values were lost", - record_name - ); - continue; - } - Err(_) => break, - } - } - - let count = values.len(); - - #[cfg(feature = "tracing")] - tracing::debug!("Drained {} values from record '{}'", count, record_name); - - Response::success( - request_id, - json!({ - "record_name": record_name, - "values": values, - "count": count, - }), - ) -} - -// ============================================================================ -// Persistence Query (record.query) -// ============================================================================ - -/// Type-erased query handler registered by `aimdb-persistence` via Extensions. -/// -/// This keeps `aimdb-core` free of persistence-specific imports. The handler is -/// a boxed async function that accepts query parameters (record pattern, limit, -/// start/end timestamps) and returns a JSON value with the results. -/// -/// Registered by `aimdb_persistence` via the `with_persistence()` builder extension. -pub type QueryHandlerFn = Box< - dyn Fn( - QueryHandlerParams, - ) -> core::pin::Pin< - Box> + Send>, - > + Send - + Sync, ->; - -/// Parameters for the type-erased query handler. -#[derive(Debug, Clone)] -pub struct QueryHandlerParams { - /// Record pattern (supports `*` wildcard). - pub name: String, - /// Maximum results per matching record. - pub limit: Option, - /// Optional start timestamp (Unix ms). - pub start: Option, - /// Optional end timestamp (Unix ms). - pub end: Option, -} - -/// Handles `record.query` method. -/// -/// Delegates to a [`QueryHandlerFn`] stored in the database's `Extensions` -/// TypeMap. If no handler is registered (i.e. persistence is not configured), -/// returns an error. -#[cfg(feature = "std")] -async fn handle_record_query( - db: &Arc>, - request_id: u64, - params: Option, -) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - // Extract the query handler from Extensions. - let handler = match db.extensions().get::() { - Some(h) => h, - None => { - return Response::error( - request_id, - "not_configured", - "Persistence not configured. Call .with_persistence() on the builder.".to_string(), - ); - } - }; - - // Parse parameters - let (name, limit, start, end) = match ¶ms { - Some(serde_json::Value::Object(map)) => { - let name = map - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("*") - .to_string(); - let limit = map - .get("limit") - .and_then(|v| v.as_u64()) - .and_then(|v| usize::try_from(v).ok()); - let start = map.get("start").and_then(|v| v.as_u64()); - let end = map.get("end").and_then(|v| v.as_u64()); - (name, limit, start, end) - } - _ => ("*".to_string(), None, None, None), - }; - - let query_params = QueryHandlerParams { - name, - limit, - start, - end, - }; - - match handler(query_params).await { - Ok(result) => Response::success(request_id, result), - Err(msg) => Response::error(request_id, "query_error", msg), - } -} - -// ============================================================================ -// Graph Introspection Methods -// ============================================================================ - -/// Handles graph.nodes method -/// -/// Returns all nodes in the dependency graph with their metadata. -/// Each node represents a record with its origin, buffer type, and connections. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of GraphNode objects: -/// - `key`: Record key (e.g., "temp.vienna") -/// - `origin`: How the record gets its values (source, link, transform, passive) -/// - `buffer_type`: Buffer type ("spmc_ring", "single_latest", "mailbox", "none") -/// - `buffer_capacity`: Optional buffer capacity -/// - `tap_count`: Number of taps attached -/// - `has_outbound_link`: Whether an outbound connector is configured -#[cfg(feature = "std")] -async fn handle_graph_nodes(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting dependency graph nodes"); - - let graph = db.inner().dependency_graph(); - let nodes = &graph.nodes; - - #[cfg(feature = "tracing")] - tracing::debug!("Returning {} graph nodes", nodes.len()); - - Response::success(request_id, json!(nodes)) -} - -/// Handles graph.edges method -/// -/// Returns all edges in the dependency graph representing data flow between records. -/// Edges are directed from source to target and include the edge type. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of GraphEdge objects: -/// - `from`: Source record key -/// - `to`: Target record key -/// - `edge_type`: Type of connection (TransformInput, TransformJoinInput, etc.) -#[cfg(feature = "std")] -async fn handle_graph_edges(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting dependency graph edges"); - - let graph = db.inner().dependency_graph(); - let edges = &graph.edges; - - #[cfg(feature = "tracing")] - tracing::debug!("Returning {} graph edges", edges.len()); - - Response::success(request_id, json!(edges)) -} - -/// Handles graph.topo_order method -/// -/// Returns the topological ordering of records in the dependency graph. -/// This ordering ensures that all dependencies are processed before dependents. -/// Used for spawn ordering and understanding data flow. -/// -/// # Arguments -/// * `db` - Database instance -/// * `request_id` - Request ID for the response -/// -/// # Returns -/// Success response with array of record keys in topological order: -/// - Sources and passive records first -/// - Transform outputs after their inputs -/// - Respects the DAG structure for proper initialization order -#[cfg(feature = "std")] -async fn handle_graph_topo_order(db: &Arc>, request_id: u64) -> Response -where - R: crate::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::debug!("Getting topological order"); - - let graph = db.inner().dependency_graph(); - let topo_order = graph.topo_order(); - - #[cfg(feature = "tracing")] - tracing::debug!( - "Returning topological order with {} records", - topo_order.len() - ); - - Response::success(request_id, json!(topo_order)) -} - -#[cfg(all(test, feature = "std"))] -mod tests { - use super::*; - use futures_core::Stream; - use std::pin::Pin; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; - use std::task::{Context, Poll}; - - /// A `Stream` that never yields a value but - /// flips a flag when dropped. Used to verify that dropping the - /// per-connection `FuturesUnordered` drops the per-subscription - /// future, which in turn drops its underlying record stream — the - /// invariant the AimX spawn-free refactor depends on for cancellation - /// on connection close. - struct DropTracker { - dropped: Arc, - } - - impl Drop for DropTracker { - fn drop(&mut self) { - self.dropped.store(true, Ordering::SeqCst); - } - } - - impl Stream for DropTracker { - type Item = serde_json::Value; - fn poll_next(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { - // Park forever; we only care that the stream gets dropped. - Poll::Pending - } - } - - #[tokio::test] - async fn dropping_subs_set_drops_subscription_stream() { - let dropped = Arc::new(AtomicBool::new(false)); - let stream = DropTracker { - dropped: dropped.clone(), - }; - - let (event_tx, _event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let mut subs: FuturesUnordered = FuturesUnordered::new(); - subs.push(Box::pin(run_subscription( - stream, - "sub-1".to_string(), - event_tx, - cancel, - ))); - - // Drive the set once so the future is actually pinned/installed. - tokio::task::yield_now().await; - let _ = futures_util::future::poll_fn(|cx| { - let _ = Pin::new(&mut subs).poll_next(cx); - Poll::Ready(()) - }) - .await; - - assert!( - !dropped.load(Ordering::SeqCst), - "drop must not have fired yet" - ); - - // Dropping the set drops every contained future, which in turn - // drops the stream owned by `run_subscription`. - drop(subs); - - assert!( - dropped.load(Ordering::SeqCst), - "dropping the FuturesUnordered must drop the subscription stream" - ); - } - - #[tokio::test] - async fn unsubscribe_terminates_subscription_immediately() { - use futures_util::stream::unfold; - - // Channel-backed stream so the future is parked on `stream.next()` - // with no value pending — the whole point of switching from - // `AtomicBool` to `Notify` is that we no longer need a second - // value to wake the future. The notify itself must wake it. - let (val_tx, val_rx) = mpsc::unbounded_channel::(); - let values = unfold( - val_rx, - |mut rx| async move { rx.recv().await.map(|v| (v, rx)) }, - ); - - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let cancel_for_future = cancel.clone(); - let handle = tokio::spawn(run_subscription( - values, - "sub-1".to_string(), - event_tx, - cancel_for_future, - )); - - // Feed one value and confirm it propagates as an Event. - val_tx.send(serde_json::json!({"v": 1})).unwrap(); - let event = event_rx.recv().await.expect("expected one event"); - assert_eq!(event.subscription_id, "sub-1"); - - // Fire the cancel notify WITHOUT feeding any further values. - // The future is parked on `stream.next()` over an empty - // channel; the notify must wake it via the `biased;` cancel - // arm of `select!`, even though the underlying stream is quiet. - cancel.notify_one(); - - // The future must complete promptly on its own — no abort, - // no further values needed. Timeout caps the test in case - // immediate cancellation is silently broken. - tokio::time::timeout(std::time::Duration::from_secs(1), handle) - .await - .expect("subscription future should exit promptly after notify_one()") - .expect("future panicked"); - - // And no further event should have been produced. - assert!( - event_rx.try_recv().is_err(), - "no further events should be sent after cancel" - ); - } - - #[tokio::test] - async fn dropping_subs_set_drops_inner_stream_state() { - // Stronger integration-style check: a real channel-backed stream - // (the same shape `stream_record_updates` returns via `unfold`) - // is held inside a `run_subscription` future, which is held by a - // `FuturesUnordered`. Dropping the set must drop the channel's - // receiver, which we observe by `val_tx.send(...)` failing. - use futures_util::stream::unfold; - - let (val_tx, val_rx) = mpsc::unbounded_channel::(); - let values = unfold( - val_rx, - |mut rx| async move { rx.recv().await.map(|v| (v, rx)) }, - ); - - let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); - let cancel = Arc::new(Notify::new()); - - let mut subs: FuturesUnordered = FuturesUnordered::new(); - subs.push(Box::pin(run_subscription( - values, - "sub-1".to_string(), - event_tx, - cancel, - ))); - - // Drive the set until the subscription is observably alive. - val_tx.send(serde_json::json!({"v": 1})).unwrap(); - tokio::select! { - event = event_rx.recv() => { - assert_eq!(event.unwrap().subscription_id, "sub-1"); - } - _ = subs.next() => panic!("subscription future ended unexpectedly"), - } - - // Connection going away: drop the whole set. This must drop the - // boxed future, which drops the stream, which drops `val_rx`. - drop(subs); - - assert!( - val_tx.send(serde_json::json!({"v": 2})).is_err(), - "after dropping the FuturesUnordered, the inner stream's \ - receiver must be dropped — `send` is the observable proxy" - ); - } -} diff --git a/aimdb-core/src/remote/mod.rs b/aimdb-core/src/remote/mod.rs index b1ad5bc9..eda763c5 100644 --- a/aimdb-core/src/remote/mod.rs +++ b/aimdb-core/src/remote/mod.rs @@ -18,18 +18,23 @@ //! //! # Usage //! +//! Remote access is registered like any other connector — via `with_connector` +//! using `aimdb_uds_connector::UdsServer` (this replaced the former +//! `AimDbBuilder::with_remote_access(config)`): +//! //! ```rust,ignore //! use aimdb_core::remote::{AimxConfig, SecurityPolicy}; +//! use aimdb_uds_connector::UdsServer; +//! +//! let config = AimxConfig::uds_default() +//! .socket_path("/var/run/aimdb/aimdb.sock") +//! .security_policy(SecurityPolicy::ReadOnly) +//! .max_connections(16) +//! .max_subs_per_connection(32); //! //! let db = AimDbBuilder::new() //! .runtime(tokio_adapter) -//! .with_remote_access( -//! AimxConfig::uds_default() -//! .socket_path("/var/run/aimdb/aimdb.sock") -//! .security_policy(SecurityPolicy::ReadOnly) -//! .max_connections(16) -//! .max_subs_per_connection(32) -//! ) +//! .with_connector(UdsServer::from_config(config)) //! .build()?; //! ``` @@ -37,15 +42,14 @@ mod config; mod error; mod metadata; mod protocol; +mod query; pub use config::{AimxConfig, SecurityPolicy}; pub use error::{RemoteError, RemoteResult}; -pub use handler::{QueryHandlerFn, QueryHandlerParams}; pub use metadata::RecordMetadata; pub use protocol::{ErrorObject, Event, HelloMessage, Request, Response, WelcomeMessage}; +pub use query::{QueryHandlerFn, QueryHandlerParams}; // Internal exports for implementation -pub(crate) mod handler; #[cfg(feature = "std")] pub(crate) mod stream; -pub(crate) mod supervisor; diff --git a/aimdb-core/src/remote/query.rs b/aimdb-core/src/remote/query.rs new file mode 100644 index 00000000..b2595ea8 --- /dev/null +++ b/aimdb-core/src/remote/query.rs @@ -0,0 +1,32 @@ +//! Type-erased persistence query handler for the AimX `record.query` method. +//! +//! Kept free of persistence-specific imports so `aimdb-core` need not depend on +//! `aimdb-persistence`: the handler is a boxed async function registered in the +//! database's `Extensions` TypeMap by `aimdb_persistence::with_persistence()`, +//! and invoked by the AimX server dispatch when a client calls `record.query`. + +/// Type-erased query handler registered by `aimdb-persistence` via Extensions. +/// +/// A boxed async function that accepts query parameters (record pattern, limit, +/// start/end timestamps) and returns a JSON value with the results. +pub type QueryHandlerFn = Box< + dyn Fn( + QueryHandlerParams, + ) -> core::pin::Pin< + Box> + Send>, + > + Send + + Sync, +>; + +/// Parameters for the type-erased query handler. +#[derive(Debug, Clone)] +pub struct QueryHandlerParams { + /// Record pattern (supports `*` wildcard). + pub name: String, + /// Maximum results per matching record. + pub limit: Option, + /// Optional start timestamp (Unix ms). + pub start: Option, + /// Optional end timestamp (Unix ms). + pub end: Option, +} diff --git a/aimdb-core/src/remote/supervisor.rs b/aimdb-core/src/remote/supervisor.rs deleted file mode 100644 index 28b6004a..00000000 --- a/aimdb-core/src/remote/supervisor.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Remote access supervisor -//! -//! Manages the Unix domain socket server and drives per-connection -//! handlers for remote clients connecting via the AimX protocol. Each -//! accepted connection is pushed onto a per-supervisor -//! [`FuturesUnordered`]; there is no `tokio::spawn`. - -use crate::builder::BoxFuture; -use crate::remote::AimxConfig; -use crate::{AimDb, DbError, DbResult}; - -#[cfg(feature = "std")] -use std::sync::Arc; - -#[cfg(feature = "std")] -use std::os::unix::fs::PermissionsExt; - -#[cfg(feature = "std")] -use futures_util::stream::{FuturesUnordered, StreamExt}; -#[cfg(feature = "std")] -use tokio::net::UnixListener; - -/// Builds the remote access supervisor future. -/// -/// Synchronously: binds the Unix domain socket and sets file permissions -/// (so binding errors surface from `build()` rather than at task-start time). -/// -/// The returned [`BoxFuture`] is appended to the `AimDbRunner` accumulator; -/// when driven, it accepts incoming connections in a loop and pushes each -/// per-connection handler onto a [`FuturesUnordered`]. `tokio::select!` -/// with `biased;` keeps `accept` polled ahead of connection drains so a -/// chatty client cannot starve new connects. -/// -/// # Arguments -/// * `db` - Database instance (for introspection and subscriptions) -/// * `config` - Remote access configuration -/// -/// # Errors -/// Returns error if: -/// - Socket path already exists and cannot be removed -/// - Socket binding fails -/// - Permission setting fails -#[cfg(feature = "std")] -pub fn build_supervisor_future(db: Arc>, config: AimxConfig) -> DbResult -where - R: aimdb_executor::RuntimeAdapter + 'static, -{ - #[cfg(feature = "tracing")] - tracing::info!( - "Initializing remote access supervisor on socket: {}", - config.socket_path.display() - ); - - // Remove existing socket file if it exists - if config.socket_path.exists() { - #[cfg(feature = "tracing")] - tracing::debug!( - "Removing existing socket file: {}", - config.socket_path.display() - ); - - std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to remove existing socket file {}", - config.socket_path.display() - ), - source: e, - })?; - } - - // Bind to Unix domain socket - let listener = UnixListener::bind(&config.socket_path).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to bind Unix socket at {}", - config.socket_path.display() - ), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!( - "Unix socket bound successfully: {}", - config.socket_path.display() - ); - - // Set socket file permissions - let mut perms = std::fs::metadata(&config.socket_path) - .map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to read socket metadata for {}", - config.socket_path.display() - ), - source: e, - })? - .permissions(); - - let permissions = config.socket_permissions.unwrap_or(0o600); - perms.set_mode(permissions); - - std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { - context: format!( - "Failed to set socket permissions for {}", - config.socket_path.display() - ), - source: e, - })?; - - #[cfg(feature = "tracing")] - tracing::info!("Socket permissions set to {:o}", permissions); - - // The accept loop is the future the runner drives. Per-connection - // handler futures live in a `FuturesUnordered` owned by this future; - // dropping the supervisor (e.g. when the runner is cancelled) drops - // every active connection in turn. - let supervisor_future: BoxFuture = Box::pin(async move { - #[cfg(feature = "tracing")] - tracing::info!("Remote access supervisor task started"); - - let mut connections: FuturesUnordered = FuturesUnordered::new(); - - loop { - tokio::select! { - biased; - - // Accept the next incoming connection - accept_res = listener.accept() => match accept_res { - Ok((stream, _addr)) => { - // Refuse if we are already at the connection cap. - // The accepted `UnixStream` is dropped, which closes - // the socket; the client sees a closed connection. - // - // `connections.len()` is conservative: a connection - // future that has completed but not yet been yielded - // by `connections.next()` still counts. With - // `biased;` the drain arm only runs once `accept` - // returns Pending, so back-to-back accepts can see - // a transiently inflated count after a disconnect - // burst. Erring toward refusing one extra client - // is fine — the cap is a soft ceiling, not an SLA. - if connections.len() >= config.max_connections { - #[cfg(feature = "tracing")] - tracing::warn!( - "max_connections={} reached, refusing new client", - config.max_connections - ); - drop(stream); - continue; - } - - #[cfg(feature = "tracing")] - tracing::debug!("Accepted new client connection"); - - let db_clone = db.clone(); - let config_clone = config.clone(); - connections.push(Box::pin(async move { - if let Err(_e) = crate::remote::handler::handle_connection( - db_clone, - config_clone, - stream, - ) - .await - { - #[cfg(feature = "tracing")] - tracing::error!("Connection handler error: {}", _e); - } - - #[cfg(feature = "tracing")] - tracing::debug!("Connection handler terminated"); - })); - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to accept connection: {}", _e); - // Continue accepting other connections despite error - } - }, - - // Drain finished connection futures. Using `Some(_) = next()` - // (rather than `select_next_some()`) is the safe form: an - // empty `FuturesUnordered` reports `is_terminated() == true`, - // and `select_next_some` panics in that state. With the - // pattern guard, the arm is simply disabled when `next()` - // resolves to `None`, and the always-active `accept` - // arm keeps the select alive. - Some(_) = connections.next() => {} - } - } - }); - - Ok(supervisor_future) -} diff --git a/aimdb-core/src/router.rs b/aimdb-core/src/router.rs index 0a69ae54..9b590c2b 100644 --- a/aimdb-core/src/router.rs +++ b/aimdb-core/src/router.rs @@ -38,7 +38,7 @@ pub struct Route { /// /// Examples: MQTT topic, Kafka topic, HTTP path, DDS topic, shmem segment /// - /// Uses Arc instead of &'static str to avoid memory leaks from Box::leak(). + /// Uses `Arc` instead of `&'static str` to avoid memory leaks from `Box::leak()`. /// This adds ~8 bytes overhead per route (Arc control block) but enables proper cleanup. pub resource_id: Arc, @@ -262,7 +262,7 @@ impl RouterBuilder { /// /// This is a convenience method for automatic router construction from /// `AimDb::collect_inbound_routes()`. The resource_ids are converted to - /// Arc for proper memory management. + /// `Arc` for proper memory management. /// /// # Arguments /// * `routes` - Vector of (resource_id, producer, deserializer) tuples @@ -286,13 +286,13 @@ impl RouterBuilder { /// Add a route to the router /// /// # Arguments - /// * `resource_id` - Resource identifier to match (as Arc) + /// * `resource_id` - Resource identifier to match (as `Arc`) /// * `producer` - Producer that implements ProducerTrait /// * `deserializer` - Deserializer variant (raw or context-aware) /// /// # Resource ID Memory Management - /// The resource_id is stored as Arc for proper reference counting and cleanup. - /// You can create an Arc from: + /// The resource_id is stored as `Arc` for proper reference counting and cleanup. + /// You can create an `Arc` from: /// - String literal: `Arc::from("sensors/temperature")` /// - Owned String: `Arc::from(string.as_str())` pub fn add_route( diff --git a/aimdb-core/src/session/aimx/codec.rs b/aimdb-core/src/session/aimx/codec.rs new file mode 100644 index 00000000..fd5db4a4 --- /dev/null +++ b/aimdb-core/src/session/aimx/codec.rs @@ -0,0 +1,244 @@ +//! AimX-v2 NDJSON envelope codec (`no_std + alloc`, features `connector-session` +//! + `json-serialize`). +//! +//! One JSON object per line, tagged by a `"t"` field, mapping onto the engine's +//! role-neutral [`Inbound`]/[`Outbound`] message set. This is **not** +//! backward-compatible with the legacy AimX wire: +//! +//! - `record.subscribe` is an [`Inbound::Subscribe`] keyed by the request `id`; +//! there is no `subscription_id` ack — events carry the `id` back as +//! [`Outbound::Event::sub`]. +//! - events carry only `{sub, seq, data}` (no server-side `timestamp`/`dropped`). +//! - the Hello/Welcome handshake is a normal `call("hello", …)`, so +//! `authenticate` stays peer-only. +//! +//! The record-value `Payload` is spliced into / sliced out of the envelope +//! verbatim via [`serde_json::value::RawValue`] — no intermediate `Value` tree, +//! no re-escaping. + +use alloc::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::value::RawValue; + +use crate::session::{CodecError, EnvelopeCodec, Inbound, Outbound, Payload, RpcError}; + +/// The zero-sized AimX-v2 NDJSON codec. +#[derive(Clone, Copy, Default)] +pub struct AimxCodec; + +/// One wire frame. A single flat, all-optional struct (rather than an internally +/// tagged enum, which cannot borrow) so both directions can zero-copy-borrow the +/// `&str` / [`RawValue`] fields out of the frame slice. Each logical message +/// fills the subset of fields its `"t"` tag implies; the rest skip-serialize. +#[derive(Serialize, Deserialize)] +struct Frame<'a> { + t: &'a str, + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + seq: Option, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + method: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + topic: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + sub: Option<&'a str>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + params: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + payload: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + data: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + ok: Option<&'a RawValue>, + #[serde(default, borrow, skip_serializing_if = "Option::is_none")] + err: Option<&'a str>, +} + +impl<'a> Frame<'a> { + /// An empty frame with only the tag set; fill the relevant fields after. + fn tagged(t: &'a str) -> Self { + Self { + t, + id: None, + seq: None, + method: None, + topic: None, + sub: None, + params: None, + payload: None, + data: None, + ok: None, + err: None, + } + } +} + +/// Borrow a [`RawValue`] view over already-serialized payload bytes (validates +/// the bytes are one JSON value, but does not re-serialize the structure). +fn as_raw(bytes: &[u8]) -> Result<&RawValue, CodecError> { + serde_json::from_slice(bytes).map_err(|_| CodecError::Malformed) +} + +/// Slice a [`RawValue`]'s verbatim JSON bytes back out into an owned [`Payload`]; +/// a missing field decodes as the JSON literal `null`. +fn payload_of(raw: Option<&RawValue>) -> Payload { + match raw { + Some(r) => Arc::from(r.get().as_bytes()), + None => Arc::from(&b"null"[..]), + } +} + +fn err_code(e: &RpcError) -> &'static str { + match e { + RpcError::NotFound => "not_found", + RpcError::Denied => "denied", + RpcError::Internal => "internal", + } +} + +fn code_err(s: &str) -> RpcError { + match s { + "not_found" => RpcError::NotFound, + "denied" => RpcError::Denied, + _ => RpcError::Internal, + } +} + +fn write_frame(out: &mut alloc::vec::Vec, frame: &Frame<'_>) -> Result<(), CodecError> { + // `serde_json::to_writer` is gated behind serde_json's `std` feature (the + // workspace builds it on `alloc` only), so serialize via `to_vec` and splice. + let bytes = serde_json::to_vec(frame).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) +} + +impl EnvelopeCodec for AimxCodec { + // --- server direction: read a request, write a reply/event ------------- + fn decode(&self, frame: &[u8]) -> Result { + let f: Frame = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match f.t { + "req" => Ok(Inbound::Request { + id: f.id.ok_or(CodecError::Malformed)?, + method: f.method.ok_or(CodecError::Malformed)?.into(), + params: payload_of(f.params), + }), + "sub" => Ok(Inbound::Subscribe { + id: f.id.ok_or(CodecError::Malformed)?, + topic: f.topic.ok_or(CodecError::Malformed)?.into(), + }), + "unsub" => Ok(Inbound::Unsubscribe { + sub: f.sub.ok_or(CodecError::Malformed)?.into(), + }), + "write" => Ok(Inbound::Write { + topic: f.topic.ok_or(CodecError::Malformed)?.into(), + payload: payload_of(f.payload), + }), + "ping" => Ok(Inbound::Ping), + _ => Err(CodecError::Malformed), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut alloc::vec::Vec) -> Result<(), CodecError> { + match msg { + Outbound::Reply { id, result } => { + let mut frame = Frame::tagged("reply"); + frame.id = Some(id); + match &result { + Ok(data) => { + let raw = as_raw(data)?; + frame.ok = Some(raw); + write_frame(out, &frame) + } + Err(e) => { + frame.err = Some(err_code(e)); + write_frame(out, &frame) + } + } + } + Outbound::Event { sub, seq, data } => { + let raw = as_raw(&data)?; + let mut frame = Frame::tagged("event"); + frame.sub = Some(sub); + frame.seq = Some(seq); + frame.data = Some(raw); + write_frame(out, &frame) + } + Outbound::Snapshot { topic, data } => { + let raw = as_raw(&data)?; + let mut frame = Frame::tagged("snap"); + frame.topic = Some(topic); + frame.data = Some(raw); + write_frame(out, &frame) + } + Outbound::Pong => write_frame(out, &Frame::tagged("pong")), + // AimX has no explicit subscribe ack; `run_session` only emits this + // when `acks_subscribe` is set, which the AimX server leaves off. + Outbound::Subscribed { .. } => Err(CodecError::Malformed), + } + } + + // --- client direction: write a request, read a reply/event ------------- + fn encode_inbound( + &self, + msg: Inbound, + out: &mut alloc::vec::Vec, + ) -> Result<(), CodecError> { + match msg { + Inbound::Request { id, method, params } => { + let raw = as_raw(¶ms)?; + let mut frame = Frame::tagged("req"); + frame.id = Some(id); + frame.method = Some(&method); + frame.params = Some(raw); + write_frame(out, &frame) + } + Inbound::Subscribe { id, topic } => { + let mut frame = Frame::tagged("sub"); + frame.id = Some(id); + frame.topic = Some(&topic); + write_frame(out, &frame) + } + Inbound::Unsubscribe { sub } => { + let mut frame = Frame::tagged("unsub"); + frame.sub = Some(&sub); + write_frame(out, &frame) + } + Inbound::Write { topic, payload } => { + let raw = as_raw(&payload)?; + let mut frame = Frame::tagged("write"); + frame.topic = Some(&topic); + frame.payload = Some(raw); + write_frame(out, &frame) + } + Inbound::Ping => write_frame(out, &Frame::tagged("ping")), + } + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + let f: Frame<'a> = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match f.t { + "reply" => { + let id = f.id.ok_or(CodecError::Malformed)?; + let result = match (f.ok, f.err) { + (Some(_), _) => Ok(payload_of(f.ok)), + (None, Some(code)) => Err(code_err(code)), + (None, None) => return Err(CodecError::Malformed), + }; + Ok(Outbound::Reply { id, result }) + } + "event" => Ok(Outbound::Event { + sub: f.sub.ok_or(CodecError::Malformed)?, + seq: f.seq.ok_or(CodecError::Malformed)?, + data: payload_of(f.data), + }), + "snap" => Ok(Outbound::Snapshot { + topic: f.topic.ok_or(CodecError::Malformed)?, + data: payload_of(f.data), + }), + "pong" => Ok(Outbound::Pong), + _ => Err(CodecError::Malformed), + } + } +} diff --git a/aimdb-core/src/session/aimx/dispatch.rs b/aimdb-core/src/session/aimx/dispatch.rs new file mode 100644 index 00000000..76220bd8 --- /dev/null +++ b/aimdb-core/src/session/aimx/dispatch.rs @@ -0,0 +1,366 @@ +//! AimX server dispatch (`std`-only) — the method semantics of AimX remote +//! access, served on the shared session engine (`serve`/`run_session`). +//! +//! `std`-gated because it reaches into core's `record.list` / JSON API (the +//! `AnyRecord` JSON + metadata methods). A transport pairs this dispatch with the +//! generic [`SessionServerConnector`](crate::session::SessionServerConnector) — +//! see `aimdb-uds-connector`'s `UdsServer`. +//! +//! The role is split in two: +//! - [`AimxDispatch`] — the shared half (one `Arc` per server): peer-only +//! `authenticate` + an `open` factory. +//! - `AimxSession` — the per-connection half the engine owns by value, homing +//! `record.drain`'s lazy per-record cursors (`drain_readers`). +//! +//! Param shapes follow the client ([`aimdb_client::AimxConnection`]): +//! `record.get`/`record.set` take `{name[, value]}`, `write` takes `{value}`. + +use std::collections::HashMap; +use std::sync::Arc; + +use futures_util::StreamExt; +use serde_json::{json, Value}; + +use crate::buffer::JsonBufferReader; +use crate::remote::{AimxConfig, RecordMetadata, SecurityPolicy, WelcomeMessage}; +use crate::session::{ + AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, Session, SessionCtx, +}; +use crate::{AimDb, DbError, RuntimeAdapter}; + +/// The shared AimX dispatch — `authenticate` (peer-only) + the `AimxSession` +/// factory. One `Arc` is shared across every accepted connection. +pub struct AimxDispatch { + db: Arc>, + config: Arc, +} + +impl AimxDispatch { + /// Build a dispatch over `db` with the given remote-access `config`. + pub fn new(db: Arc>, config: AimxConfig) -> Self { + Self { + db, + config: Arc::new(config), + } + } +} + +impl Dispatch for AimxDispatch +where + R: RuntimeAdapter + 'static, +{ + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + // Peer-only: AimX over UDS relies on socket file permissions for access + // control. Permission policy (ReadOnly / writable_records) is enforced + // per-call from the config. + Box::pin(async { Ok(SessionCtx::default()) }) + } + + fn open(&self, _ctx: &SessionCtx) -> Box { + Box::new(AimxSession { + db: self.db.clone(), + config: self.config.clone(), + drain_readers: HashMap::new(), + }) + } +} + +/// One AimX connection's mutable dispatch state. The engine owns this by value +/// and threads `&mut self` into each method, so `drain_readers` (lazy per-record +/// cursors) need no lock. +struct AimxSession { + db: Arc>, + config: Arc, + /// Per-record drain readers, created lazily on first `record.drain`. + drain_readers: HashMap>, +} + +impl Session for AimxSession +where + R: RuntimeAdapter + 'static, +{ + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + Box::pin(async move { + let params: Value = serde_json::from_slice(¶ms).unwrap_or(Value::Null); + self.dispatch_call(method, params) + .await + .map(|v| to_payload(&v)) + }) + } + + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + // The engine owns the subscription lifecycle and the per-connection cap; + // AimX has no async authorization, so this is a trivial wrapper. + Box::pin(async move { + let stream = crate::remote::stream::stream_record_updates(&self.db, topic) + .map_err(map_db_err)?; + Ok(Box::pin(stream.map(|v| to_payload(&v))) as BoxStream<'static, Payload>) + }) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + Box::pin(async move { + self.ensure_writable(topic)?; + // The v2 client wraps the value as `{"value": }`; fall back to the + // whole payload if the wrapper is absent. + let v: Value = serde_json::from_slice(&payload).unwrap_or(Value::Null); + let value = v.get("value").cloned().unwrap_or(v); + // Routes through the producer/arbiter path — single-writer-per-key + // stays enforced inside `set_record_from_json`. + self.db + .set_record_from_json(topic, value) + .map_err(map_db_err) + }) + } +} + +impl AimxSession +where + R: RuntimeAdapter + 'static, +{ + /// Match the method and produce its JSON result (or an [`RpcError`]). + async fn dispatch_call(&mut self, method: &str, params: Value) -> Result { + match method { + "hello" => Ok(self.welcome()), + "record.list" => Ok(json!(self.db.list_records())), + "record.get" => { + let name = str_field(¶ms, "name").ok_or(RpcError::NotFound)?; + self.record_get(&name) + } + "record.set" => self.record_set(params), + "record.drain" => self.record_drain(params), + "record.query" => self + .record_query(params)? + .await + .map_err(|_| RpcError::Internal), + "graph.nodes" => Ok(json!(self.db.inner().dependency_graph().nodes)), + "graph.edges" => Ok(json!(self.db.inner().dependency_graph().edges)), + "graph.topo_order" => Ok(json!(self.db.inner().dependency_graph().topo_order())), + #[cfg(feature = "profiling")] + "profiling.reset" => { + self.ensure_write_permission()?; + self.db.reset_stage_profiling(); + Ok(json!({ "reset": true })) + } + #[cfg(feature = "metrics")] + "buffer_metrics.reset" => { + self.ensure_write_permission()?; + self.db.reset_buffer_metrics(); + Ok(json!({ "reset": true })) + } + _ => Err(RpcError::NotFound), + } + } + + /// `record.set` (RPC): permission-checked write that echoes the new value. + fn record_set(&self, params: Value) -> Result { + let name = str_field(¶ms, "name").ok_or(RpcError::Internal)?; + let value = params.get("value").cloned().ok_or(RpcError::Internal)?; + self.ensure_writable(&name)?; + self.db + .set_record_from_json(&name, value) + .map_err(map_db_err)?; + // Echo the updated value when available (matches the legacy reply shape). + Ok(match self.db.try_latest_as_json(&name) { + Some(updated) => json!({ "status": "success", "value": updated }), + None => json!({ "status": "success" }), + }) + } + + /// `record.get`: the record's current value. + /// + /// A `SingleLatest`/state record exposes a non-destructive canonical latest + /// ([`try_latest_as_json`](crate::AimDb::try_latest_as_json)). A ring + /// ([`SpmcRing`](crate::buffer::BufferCfg::SpmcRing)) has none, so we fall back + /// to the connection's drain cursor and return the **most recent** available + /// value. Two consequences of that fallback: it *advances the shared drain + /// cursor* (so `record.get` and `record.drain` interleave on one connection), + /// and it yields `NotFound` until the ring produces a value *after* the cursor + /// is first opened (a fresh broadcast reader starts at the tail). Use + /// `record.drain` for a ring's full backlog. + fn record_get(&mut self, name: &str) -> Result { + if let Some(v) = self.db.try_latest_as_json(name) { + return Ok(v); + } + // Ring fallback: drain to the newest currently-available value (or NotFound). + self.drain_values(name, usize::MAX)? + .pop() + .ok_or(RpcError::NotFound) + } + + /// `record.drain`: return everything accumulated since the previous drain + /// (capped by an optional `limit`), via the per-connection cursor. + fn record_drain(&mut self, params: Value) -> Result { + let name = str_field(¶ms, "name").ok_or(RpcError::Internal)?; + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .map(|v| usize::try_from(v).unwrap_or(usize::MAX)) + .unwrap_or(usize::MAX); + let values = self.drain_values(&name, limit)?; + let count = values.len(); + Ok(json!({ "record_name": name, "values": values, "count": count })) + } + + /// Lazily open (on first call) the per-record drain cursor and read up to + /// `limit` values accumulated since the previous read (oldest-first). Shared + /// by [`record.drain`](Self::record_drain) and [`record.get`](Self::record_get)'s + /// ring fallback, so both read from the same per-connection cursor. + fn drain_values(&mut self, name: &str, limit: usize) -> Result, RpcError> { + if !self.drain_readers.contains_key(name) { + let id = self + .db + .inner() + .resolve_str(name) + .ok_or(RpcError::NotFound)?; + let record = self.db.inner().storage(id).ok_or(RpcError::NotFound)?; + // `subscribe_json` fails if the record was not configured with + // `.with_remote_access()`. + let reader = record.subscribe_json().map_err(map_db_err)?; + self.drain_readers.insert(name.to_string(), reader); + } + + let reader = self.drain_readers.get_mut(name).expect("inserted above"); + let mut values = Vec::new(); + while values.len() < limit { + match reader.try_recv_json() { + Ok(val) => values.push(val), + Err(DbError::BufferEmpty) => break, + // Ring overflowed since the last read — cursor resets; keep going. + Err(DbError::BufferLagged { .. }) => continue, + Err(_) => break, + } + } + Ok(values) + } + + /// `record.query`: resolve the persistence query handler registered in the + /// db's `Extensions` (absent → not configured) and return its handler + /// future. + /// + /// Deliberately **not** an `async fn`: an `async fn(&self)` future would + /// capture `&self` across its await, forcing `AimxSession: Sync` — which the + /// per-connection `drain_readers` (`Box`, not `Sync`) is not. + /// Returning the (`'static`, `Send`) handler future lets the borrow of + /// `self` end here; the caller awaits the owned future. + #[allow(clippy::type_complexity)] + fn record_query( + &self, + params: Value, + ) -> Result< + core::pin::Pin< + Box> + Send + 'static>, + >, + RpcError, + > { + let handler = self + .db + .extensions() + .get::() + .ok_or(RpcError::Internal)?; + let name = params + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("*") + .to_string(); + let limit = params + .get("limit") + .and_then(|v| v.as_u64()) + .and_then(|v| usize::try_from(v).ok()); + let start = params.get("start").and_then(|v| v.as_u64()); + let end = params.get("end").and_then(|v| v.as_u64()); + Ok(handler(crate::remote::QueryHandlerParams { + name, + limit, + start, + end, + })) + } + + /// Build the `Welcome` from the security policy + writable records. + /// + /// `writable_records` is derived from the policy directly and intersected with + /// the records that actually exist, so the server never advertises a phantom key. + fn welcome(&self) -> Value { + let (permissions, writable_records) = match &self.config.security_policy { + SecurityPolicy::ReadOnly => (vec!["read".to_string()], Vec::new()), + SecurityPolicy::ReadWrite { writable_records } => { + let existing: std::collections::HashSet = self + .db + .list_records() + .into_iter() + .map(|m: RecordMetadata| m.record_key) + .collect(); + let writable = writable_records + .iter() + .filter(|name| existing.contains(name.as_str())) + .cloned() + .collect(); + (vec!["read".to_string(), "write".to_string()], writable) + } + }; + let welcome = WelcomeMessage { + version: "2.0".to_string(), + server: "aimdb".to_string(), + permissions, + writable_records, + max_subscriptions: Some(self.config.max_subs_per_connection), + authenticated: Some(false), + }; + json!(welcome) + } + + /// Deny unless the policy is ReadWrite and `name` is in its writable set. + fn ensure_writable(&self, name: &str) -> Result<(), RpcError> { + match &self.config.security_policy { + SecurityPolicy::ReadWrite { writable_records } if writable_records.contains(name) => { + Ok(()) + } + _ => Err(RpcError::Denied), + } + } + + /// Deny under a ReadOnly policy (used by the `*.reset` admin methods). + #[cfg(any(feature = "profiling", feature = "metrics"))] + fn ensure_write_permission(&self) -> Result<(), RpcError> { + match self.config.security_policy { + SecurityPolicy::ReadOnly => Err(RpcError::Denied), + SecurityPolicy::ReadWrite { .. } => Ok(()), + } + } +} + +/// Serialize a JSON value into an owned record-value [`Payload`] (one serde pass +/// at the reply boundary, per doc 037 Decision 1). +fn to_payload(v: &Value) -> Payload { + Payload::from(serde_json::to_vec(v).unwrap_or_default().as_slice()) +} + +/// Extract a string field from a params object. +fn str_field(params: &Value, key: &str) -> Option { + params.get(key).and_then(|v| v.as_str()).map(String::from) +} + +/// Map a [`DbError`] onto the reshaped wire's coarse [`RpcError`] set. +fn map_db_err(e: DbError) -> RpcError { + match e { + DbError::RecordKeyNotFound { .. } | DbError::InvalidRecordId { .. } => RpcError::NotFound, + DbError::PermissionDenied { .. } => RpcError::Denied, + _ => RpcError::Internal, + } +} diff --git a/aimdb-core/src/session/aimx/mod.rs b/aimdb-core/src/session/aimx/mod.rs new file mode 100644 index 00000000..9a6f5db9 --- /dev/null +++ b/aimdb-core/src/session/aimx/mod.rs @@ -0,0 +1,21 @@ +//! AimX codec + dispatch — the concrete protocol substrate the session engines +//! ride for AimX remote access. +//! +//! - [`AimxCodec`] — the symmetric NDJSON [`EnvelopeCodec`](crate::session::EnvelopeCodec), +//! `no_std + alloc` (features `connector-session` + `json-serialize`); used by +//! both the `run_client` and `serve` engines. +//! - [`AimxDispatch`] — the server method semantics, `std`-only (it reaches into +//! core's `record.list` / JSON API). +//! +//! The transport (UDS) lives in a separate connector crate +//! (`aimdb-uds-connector`); core keeps only the protocol plus the generic +//! [`SessionClientConnector`](crate::session::SessionClientConnector) / +//! [`SessionServerConnector`](crate::session::SessionServerConnector) spine. + +mod codec; +pub use codec::AimxCodec; + +#[cfg(feature = "std")] +mod dispatch; +#[cfg(feature = "std")] +pub use dispatch::AimxDispatch; diff --git a/aimdb-core/src/session/client.rs b/aimdb-core/src/session/client.rs new file mode 100644 index 00000000..11389aa5 --- /dev/null +++ b/aimdb-core/src/session/client.rs @@ -0,0 +1,566 @@ +//! The proactive **client** engine of the session substrate — the dual of the +//! [`server`](super::server): it *dials* a [`Connection`] via a [`Dialer`], +//! *sends* [`Inbound`] / *receives* [`Outbound`], and demultiplexes replies by `id`. +//! +//! [`run_client`] owns the demux core and returns a [`ClientHandle`] for +//! caller-initiated RPC (`call`/`subscribe`/`write`) plus the engine future for +//! the runner to drive (spawn-free). [`pump_client`] is a thin wrapper that +//! mirrors records over the same engine. +//! +//! Runtime-neutral: the only runtime-specific primitive is *time* (reconnect +//! backoff + keepalive), via the adapter's [`TimeOps`] clock; everything else is +//! `futures` channels. The demux loop uses the same **extract-then-act** shape as +//! the server (compute a [`ClientStep`], then act once the arm borrows release). + +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +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}; +use hashbrown::HashMap; + +use super::{ + BoxFut, BoxStream, Connection, Dialer, EnvelopeCodec, Inbound, Outbound, Payload, RpcError, +}; +use crate::connector::SerializerKind; +use crate::router::RouterBuilder; +use crate::{AimDb, RuntimeAdapter}; + +/// Client engine knobs. Durations are in **milliseconds** so the engine stays +/// `no_std`-clean; the adapter's [`TimeOps`] turns them into its native `Duration`. +#[derive(Debug, Clone)] +pub struct ClientConfig { + /// Redial after a dropped/failed connection instead of ending the engine. + /// Replays outbound traffic only: pending calls fail and open subscriptions + /// are not re-issued (so `pump_client` inbound mirroring stops after the first + /// disconnect; outbound survives). + pub reconnect: bool, + /// Base delay (ms) before the first redial; subsequent redials grow + /// exponentially, capped at [`max_reconnect_delay`](Self::max_reconnect_delay). + pub reconnect_delay: u64, + /// Upper bound (ms) for the reconnect backoff. Defaults to + /// [`reconnect_delay`](Self::reconnect_delay) (a fixed delay). + pub max_reconnect_delay: u64, + /// Maximum redial attempts before 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. + pub keepalive_interval: Option, + /// Cap on caller commands buffered while disconnected (oldest dropped past it). + /// Defaults to `usize::MAX` (unbounded). + pub max_offline_queue: usize, + /// Key the subscription demux by **topic** instead of the request `id`. + /// `false` (default): events echo the id. `true`: the wire pushes data keyed + /// by topic, so `decode_outbound` returns the topic as `Event.sub`. + pub topic_routed_subs: bool, + /// Send a Ping handshake on connect and await the Pong before serving caller + /// commands. A real protocol swaps Ping/Pong for its Hello. + pub sends_hello: bool, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + reconnect: true, + reconnect_delay: 200, + max_reconnect_delay: 200, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: false, + } + } +} + +/// Exponential backoff (ms) for the `attempt`-th redial (1-based), capped at +/// [`ClientConfig::max_reconnect_delay`]. +fn backoff_delay(config: &ClientConfig, attempt: usize) -> u64 { + let base = config.reconnect_delay; + let cap = config.max_reconnect_delay.max(base); + let shift = attempt.saturating_sub(1).min(16) as u32; + base.saturating_mul(1u64 << shift).min(cap) +} + +/// Bound the offline backlog: drop the oldest buffered commands beyond `cap`. +fn bound_offline_queue(cmd_rx: &Receiver, cap: usize) { + while cmd_rx.len() > cap && cmd_rx.try_recv().is_ok() {} +} + +/// A cheap-clone handle to a running [`run_client`] engine — the caller-facing +/// RPC surface. Every method funnels a command to the engine, which owns the +/// pending-call map and the wire. +#[derive(Clone)] +pub struct ClientHandle { + cmd_tx: Sender, +} + +/// Commands the [`ClientHandle`] funnels to the engine (the engine assigns the +/// correlation `id`, so it stays the sole owner of the demux map). +enum ClientCmd { + Call { + method: String, + params: Payload, + reply: oneshot::Sender>, + }, + Subscribe { + topic: String, + events: Sender, + }, + Write { + topic: String, + payload: Payload, + }, +} + +impl ClientHandle { + /// Funnel a command to the engine. The channel is unbounded, so `try_send` + /// never blocks and only fails once the engine has stopped (receiver closed). + fn enqueue(&self, cmd: ClientCmd) -> Result<(), RpcError> { + self.cmd_tx.try_send(cmd).map_err(|_| RpcError::Internal) + } + + /// One-shot RPC: send a request and await its single reply. Returns + /// [`RpcError::Internal`] if the engine has stopped or the connection drops + /// before the reply arrives. + pub async fn call( + &self, + method: impl Into, + params: Payload, + ) -> Result { + let (reply, rx) = oneshot::channel(); + self.enqueue(ClientCmd::Call { + method: method.into(), + params, + reply, + })?; + rx.await.map_err(|_| RpcError::Internal)? + } + + /// Open a subscription; returns the stream of updates immediately (the engine + /// sends the `Subscribe` request asynchronously). Dropping the stream stops + /// local delivery. The stream ends on disconnect and is not re-subscribed on + /// reconnect (see [`ClientConfig::reconnect`]) — re-call to resume. + pub fn subscribe( + &self, + topic: impl Into, + ) -> Result, RpcError> { + let (events, rx) = async_channel::unbounded::(); + self.enqueue(ClientCmd::Subscribe { + topic: topic.into(), + events, + })?; + // The receiver is itself a `Stream`. + Ok(Box::pin(rx)) + } + + /// Fire-and-forget write to a remote topic (no reply). + pub fn write(&self, topic: impl Into, payload: Payload) -> Result<(), RpcError> { + self.enqueue(ClientCmd::Write { + topic: topic.into(), + payload, + }) + } +} + +/// Build the client engine: returns a [`ClientHandle`] for issuing RPC and the +/// engine future to drive on the runner (spawn-free). The future runs until all +/// `ClientHandle` clones are dropped (graceful stop) — or, with +/// [`ClientConfig::reconnect`] off, until the first disconnect. +/// +/// `clock` is the adapter's [`TimeOps`] runtime (e.g. `db.runtime_arc()`); the +/// engine uses it for the reconnect backoff and keepalive — the *only* runtime +/// dependency, so the rest of the engine is runtime-neutral. +pub fn run_client( + dialer: D, + codec: C, + config: ClientConfig, + clock: Arc, +) -> (ClientHandle, BoxFut<'static, ()>) +where + D: Dialer + 'static, + C: EnvelopeCodec + 'static, + R: TimeOps + 'static, +{ + let (cmd_tx, cmd_rx) = async_channel::unbounded(); + let handle = ClientHandle { cmd_tx }; + let fut = Box::pin(client_loop(dialer, codec, config, cmd_rx, clock)); + (handle, fut) +} + +/// Why one connection's session ended — decides reconnect vs stop. +enum Ended { + /// The connection dropped/errored; redial if configured. + Disconnected, + /// Every [`ClientHandle`] was dropped — stop the engine. + HandlesDropped, +} + +/// On engine exit, close and drain the command channel so buffered/in-flight +/// commands are dropped — each `ClientCmd::Call` drops its `reply` sender, so a +/// waiting [`ClientHandle::call`] resolves with [`RpcError::Internal`] instead of +/// hanging. +/// +/// Needed because `async-channel` keeps buffered items alive while any `Sender` +/// exists, and dropping the `Receiver` only closes the queue without draining it. +struct DrainOnExit<'a>(&'a Receiver); + +impl Drop for DrainOnExit<'_> { + fn drop(&mut self) { + self.0.close(); + while self.0.try_recv().is_ok() {} + } +} + +/// What [`drive_connection`]'s `select_biased!` decided this iteration — extracted +/// so the work runs after the arm futures' borrow of `conn` releases. +enum ClientStep { + /// A frame (or close/error) arrived from the server. + Inbound(super::TransportResult>>), + /// The keepalive timer fired — send a `Ping`. + Keepalive, + /// A caller command (or `None` = all handles dropped). + Cmd(Option), +} + +async fn client_loop( + dialer: D, + codec: C, + config: ClientConfig, + cmd_rx: Receiver, + 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); + // Consecutive failed attempts; drives backoff and the attempt cap. + let mut attempt: usize = 0; + loop { + let conn = match dialer.connect().await { + Ok(conn) => { + attempt = 0; + conn + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("client dial failed: {:?}", _e); + match reconnect_after(&mut attempt, &config, &cmd_rx, &*clock).await { + true => continue, + false => return, + } + } + }; + + match drive_connection(conn, &codec, &cmd_rx, &config, &*clock).await { + Ended::HandlesDropped => return, + Ended::Disconnected => { + match reconnect_after(&mut attempt, &config, &cmd_rx, &*clock).await { + true => continue, + false => return, + } + } + } + } +} + +/// 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( + attempt: &mut usize, + config: &ClientConfig, + cmd_rx: &Receiver, + clock: &R, +) -> bool { + if !config.reconnect { + return false; + } + *attempt += 1; + if config.max_reconnect_attempts != 0 && *attempt >= config.max_reconnect_attempts { + #[cfg(feature = "tracing")] + tracing::warn!( + "client giving up after {} reconnect attempts", + config.max_reconnect_attempts + ); + return false; + } + bound_offline_queue(cmd_rx, config.max_offline_queue); + clock + .sleep(clock.millis(backoff_delay(config, *attempt))) + .await; + true +} + +/// Drive one dialed [`Connection`]: optional handshake, then `biased` demux of +/// server frames (resolve `Reply` by `id`, route `Event`/`Snapshot` to their +/// subscription channels) interleaved with caller commands. Pending state is +/// per-connection: a disconnect fails outstanding calls (their `oneshot` +/// senders drop → callers see [`RpcError::Internal`]). +async fn drive_connection( + mut conn: Box, + codec: &C, + cmd_rx: &Receiver, + config: &ClientConfig, + clock: &R, +) -> Ended +where + C: EnvelopeCodec + ?Sized, + R: TimeOps, +{ + let mut next_id: u64 = 1; + let mut pending: HashMap>> = HashMap::new(); + // sub-id → event sink. The sub-id is `id.to_string()` of the opening + // request, matching the server's derivation so `Event.sub` routes back. + let mut subs: HashMap> = HashMap::new(); + let mut out = Vec::new(); + let keepalive_ms = config.keepalive_interval; + + // Handshake-as-caller: prove the link with Ping/Pong before serving commands. + if config.sends_hello { + out.clear(); + if codec.encode_inbound(Inbound::Ping, &mut out).is_err() || conn.send(&out).await.is_err() + { + return Ended::Disconnected; + } + match conn.recv().await { + Ok(Some(frame)) => match codec.decode_outbound(&frame) { + Ok(Outbound::Pong) => {} + _ => return Ended::Disconnected, + }, + _ => return Ended::Disconnected, + } + } + + loop { + // 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()); + // `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 => ClientStep::Keepalive, + // ---- caller commands from ClientHandle --------------------- + // `recv()` errors only when every `ClientHandle` is dropped → `None`. + c = cmd => ClientStep::Cmd(c.ok()), + } + }; + + match step { + ClientStep::Inbound(recv) => { + let frame = match recv { + Ok(Some(frame)) => frame, + Ok(None) | Err(_) => return Ended::Disconnected, + }; + match codec.decode_outbound(&frame) { + Ok(Outbound::Reply { id, result }) => { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(result); + } else if result.is_err() { + // A subscribe is acked implicitly by its events; the + // server replies only on failure, carrying the subscribe + // `id` (never a pending call). Drop the event sink so the + // stream ends instead of hanging. + subs.remove(&id.to_string()); + } + } + Ok(Outbound::Event { sub, seq: _, data }) => { + let dead = match subs.get(sub) { + Some(tx) => tx.try_send(data).is_err(), + None => false, // late event for a dropped sub — ignore + }; + if dead { + subs.remove(sub); + } + } + Ok(Outbound::Snapshot { topic, data }) => { + if let Some(tx) = subs.get(topic) { + let _ = tx.try_send(data); + } + } + Ok(Outbound::Pong) => {} + // Explicit subscribe ack — informational; the sink already exists. + Ok(Outbound::Subscribed { .. }) => {} + Err(_e) => continue, // skip a malformed frame, keep the connection + } + } + + ClientStep::Keepalive => { + out.clear(); + if codec.encode_inbound(Inbound::Ping, &mut out).is_ok() + && conn.send(&out).await.is_err() + { + return Ended::Disconnected; + } + } + + ClientStep::Cmd(cmd) => { + let cmd = match cmd { + Some(cmd) => cmd, + None => return Ended::HandlesDropped, // all handles dropped + }; + match cmd { + ClientCmd::Call { + method, + params, + reply, + } => { + let id = next_id; + next_id += 1; + pending.insert(id, reply); + out.clear(); + let sent = codec + .encode_inbound(Inbound::Request { id, method, params }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + if let Some(tx) = pending.remove(&id) { + let _ = tx.send(Err(RpcError::Internal)); + } + return Ended::Disconnected; + } + } + ClientCmd::Subscribe { topic, events } => { + let id = next_id; + next_id += 1; + // Demux key: topic (topic-routed) or the request id. + let key = if config.topic_routed_subs { + topic.clone() + } else { + id.to_string() + }; + subs.insert(key, events); + out.clear(); + let sent = codec + .encode_inbound(Inbound::Subscribe { id, topic }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + return Ended::Disconnected; + } + } + ClientCmd::Write { topic, payload } => { + out.clear(); + let sent = codec + .encode_inbound(Inbound::Write { topic, payload }, &mut out) + .is_ok() + && conn.send(&out).await.is_ok(); + if !sent { + return Ended::Disconnected; + } + } + } + } + } + } +} + +/// Mirror records between a local [`AimDb`] and a remote peer over a running +/// [`run_client`] engine — the connector-link half of the client capability. +/// +/// For the given connector `scheme` (e.g. `"aimx"`): +/// - **outbound** routes (`db.collect_outbound_routes`) stream local record +/// updates to the remote via [`ClientHandle::write`]; +/// - **inbound** routes (`db.collect_inbound_routes`) subscribe to the remote and +/// produce each update into the local record through the producer/arbiter path +/// — single-writer-per-key stays intact (a mirrored-in record is produced +/// through its inbound producer, never a direct co-writer). +/// +/// Returns one spawn-free pump future per route for the runner to drive +/// (mirroring the `ConnectorBuilder::build -> Vec` spine); it drives +/// the **same** engine as [`run_client`], never a second one. +/// +/// 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(); + let mut pumps: Vec> = Vec::new(); + + // --- outbound: local record updates -> remote `write` ------------------ + for (destination, consumer, serializer, _config, topic_provider) in + db.collect_outbound_routes(scheme) + { + let handle = handle.clone(); + let ctx = ctx.clone(); + pumps.push(Box::pin(async move { + let mut reader = match consumer.subscribe_any().await { + Ok(r) => r, + Err(_e) => return, + }; + loop { + let value = match reader.recv_any().await { + Ok(v) => v, + // Lagged (ring overflow) — skip the gap, keep mirroring. + Err(crate::DbError::BufferLagged { .. }) => continue, + // Buffer closed — the record is gone; end this mirror. + Err(_) => break, + }; + // Dynamic destination (topic provider) or the static link target. + let dest = topic_provider + .as_ref() + .and_then(|p| p.topic_any(&*value)) + .unwrap_or_else(|| destination.clone()); + let bytes = match &serializer { + SerializerKind::Raw(ser) => match ser(&*value) { + Ok(b) => b, + Err(_e) => continue, + }, + SerializerKind::Context(ser) => match ser(ctx.clone(), &*value) { + Ok(b) => b, + Err(_e) => continue, + }, + }; + if handle.write(dest, Payload::from(bytes.as_slice())).is_err() { + break; // engine stopped — all handles dropped + } + } + })); + } + + // --- inbound: remote events -> local producer (via the Router) --------- + // The Router applies each route's deserializer and produces the value; one + // subscription per unique remote topic feeds it. + let router = Arc::new(RouterBuilder::from_routes(db.collect_inbound_routes(scheme)).build()); + for id in router.resource_ids() { + let handle = handle.clone(); + let router = router.clone(); + let ctx = ctx.clone(); + pumps.push(Box::pin(async move { + let mut stream = match handle.subscribe(id.as_ref()) { + Ok(s) => s, + Err(_e) => return, + }; + while let Some(payload) = stream.next().await { + let _ = router.route(id.as_ref(), &payload, Some(&ctx)).await; + } + })); + } + + pumps +} diff --git a/aimdb-core/src/session/connector.rs b/aimdb-core/src/session/connector.rs new file mode 100644 index 00000000..8301799f --- /dev/null +++ b/aimdb-core/src/session/connector.rs @@ -0,0 +1,181 @@ +//! Generic, transport-agnostic session connectors — the reusable spine every +//! transport crate (`aimdb-uds-connector`, and later serial/TCP) wraps. +//! +//! A transport contributes only a [`Dialer`]/[`Listener`]/[`Connection`] triple +//! and an [`EnvelopeCodec`]; the engine wiring (reconnect, pumps, accept loop, +//! fan-out) is inherited here, so a new transport is a thin crate and swapping +//! one never ripples into record/link code. +//! +//! - [`SessionClientConnector`] — the dialing half: on `build` it opens +//! [`run_client`] over the injected dialer/codec and drives [`pump_client`] for +//! every route under its **scheme**. +//! - [`SessionServerConnector`] — the accepting half: it binds a [`Listener`] +//! (behind a factory, so bind errors surface synchronously from `build`) and +//! drives [`serve`] with an injected dispatch + codec. +//! +//! The **scheme** is a constructor argument (default `"remote"`) decoupling the +//! logical routing key from the transport, so two transports can coexist under +//! different schemes. + +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; +use core::future::Future; +use core::pin::Pin; + +use aimdb_executor::{RuntimeAdapter, TimeOps}; + +use crate::builder::AimDb; +use crate::connector::ConnectorBuilder; +use crate::session::{ + pump_client, run_client, serve, ClientConfig, Dialer, Dispatch, EnvelopeCodec, Listener, + SessionConfig, +}; +use crate::DbResult; + +/// The default scheme a session connector registers when none is given. +pub const DEFAULT_SCHEME: &str = "remote"; + +type BoxFuture = Pin + Send + 'static>>; +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +// =========================================================================== +// Client — dials a peer, mirrors records under `scheme`. +// =========================================================================== + +/// Mirrors records to/from a peer reached via the dialer `D`, speaking codec `C`, +/// under a logical [`scheme`](ConnectorBuilder::scheme). A transport crate wraps +/// it in a one-line sugar constructor (e.g. `UdsClient`). +pub struct SessionClientConnector { + scheme: String, + dialer: D, + codec: C, + config: ClientConfig, +} + +impl SessionClientConnector { + /// Mirror records over `dialer`, framing messages with `codec`. The scheme + /// defaults to `"remote"`. + pub fn new(dialer: D, codec: C) -> Self { + Self { + scheme: DEFAULT_SCHEME.to_string(), + dialer, + codec, + config: ClientConfig::default(), + } + } + + /// Override the scheme this connector registers (so `://` + /// links validate and route here). + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } + + /// Override the client engine config (reconnect policy, keepalive, etc.). + pub fn with_config(mut self, config: ClientConfig) -> Self { + self.config = config; + self + } +} + +impl ConnectorBuilder for SessionClientConnector +where + R: TimeOps + 'static, + D: Dialer + Clone + Send + Sync + 'static, + C: EnvelopeCodec + Clone + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + Box::pin(async move { + let (handle, engine_fut) = run_client( + self.dialer.clone(), + self.codec.clone(), + self.config.clone(), + db.runtime_arc(), + ); + // One pump future per route; each holds a `ClientHandle` clone, so the + // engine stays alive as long as any mirror runs. `handle` drops here. + let mut futures = pump_client(db, &self.scheme, &handle); + futures.push(engine_fut); + Ok(futures) + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} + +// =========================================================================== +// Server — accepts connections, serves a dispatch under `scheme`. +// =========================================================================== + +/// Accepts connections from a [`Listener`] `L` and serves them with a dispatch, +/// speaking codec `C`, under a logical [`scheme`](ConnectorBuilder::scheme). +/// +/// 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` +/// (e.g. an `AimxDispatch`), so the spine never names a concrete protocol. +pub struct SessionServerConnector { + scheme: String, + listener_factory: LF, + codec: C, + dispatch_factory: DF, + config: SessionConfig, +} + +impl SessionServerConnector { + /// Build a server connector. `listener_factory` binds the listener at + /// `build` time; `dispatch_factory` produces the per-server dispatch from the + /// live db. The scheme defaults to `"remote"`. + pub fn new( + listener_factory: LF, + codec: C, + dispatch_factory: DF, + config: SessionConfig, + ) -> Self { + Self { + scheme: DEFAULT_SCHEME.to_string(), + listener_factory, + codec, + dispatch_factory, + config, + } + } + + /// Override the scheme this connector registers. + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } +} + +impl ConnectorBuilder for SessionServerConnector +where + R: RuntimeAdapter + 'static, + L: Listener + 'static, + C: EnvelopeCodec + Clone + 'static, + LF: Fn() -> DbResult + Send + Sync + 'static, + DF: Fn(&AimDb) -> Arc + Send + Sync + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + // Bind synchronously so a bind error surfaces from `build`. + let listener = (self.listener_factory)(); + let dispatch = (self.dispatch_factory)(db); + let codec = Arc::new(self.codec.clone()); + let config = self.config.clone(); + Box::pin(async move { + let listener = listener?; + let fut: BoxFuture = Box::pin(serve(listener, codec, dispatch, config)); + Ok(vec![fut]) + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} diff --git a/aimdb-core/src/session/mod.rs b/aimdb-core/src/session/mod.rs new file mode 100644 index 00000000..60266ede --- /dev/null +++ b/aimdb-core/src/session/mod.rs @@ -0,0 +1,536 @@ +//! Connector-session substrate — the shared machinery for transport-based +//! remote access. +//! +//! Three layers: transport ([`Connection`]/[`Listener`]/[`Dialer`]), codec +//! ([`EnvelopeCodec`]), and dispatch ([`Dispatch`]/[`Session`]), over a +//! role-neutral [`Inbound`]/[`Outbound`] message set shared by the reactive +//! server engine (`serve`/`run_session`) and the proactive client engine +//! (`run_client`/`pump_client`). Data-plane connectors use `pump_sink`/ +//! `pump_source` over the [`Source`] / [`Connector`](crate::transport::Connector) +//! capabilities. +//! +//! All contracts are `dyn`-safe and compile on `std` and `no_std + alloc`. See +//! `docs/design/remote-access-via-connectors.md` for the design. + +extern crate alloc; + +use alloc::{boxed::Box, string::String, sync::Arc, vec::Vec}; +use core::future::Future; +use core::pin::Pin; + +use futures_core::Stream; + +// The engines are runtime-neutral (`futures` channels + the adapter's `TimeOps` +// clock, no `tokio`/`embassy-*`), so they cross-compile to `no_std + alloc`. +#[cfg(feature = "connector-session")] +mod client; +#[cfg(feature = "connector-session")] +mod connector; +#[cfg(feature = "connector-session")] +mod pump; +#[cfg(feature = "connector-session")] +mod server; + +// Concrete AimX protocol substrate. The codec is `no_std + alloc`; the server +// dispatch is `std`-gated. The transport lives in a separate connector crate +// (`aimdb-uds-connector`) — core keeps the protocol plus the generic +// [`SessionClientConnector`] / [`SessionServerConnector`] spine. +#[cfg(all(feature = "connector-session", feature = "json-serialize"))] +pub mod aimx; + +#[cfg(feature = "connector-session")] +pub use client::{pump_client, run_client, ClientConfig, ClientHandle}; +#[cfg(feature = "connector-session")] +pub use connector::{SessionClientConnector, SessionServerConnector}; +#[cfg(feature = "connector-session")] +pub use pump::{pump_sink, pump_source}; +#[cfg(feature = "connector-session")] +pub use server::{run_session, serve, SessionConfig}; + +// =========================================================================== +// Shared aliases +// =========================================================================== + +/// Boxed, `Send` future — the object-safe async return shape used throughout. +pub type BoxFut<'a, T> = Pin + Send + 'a>>; + +/// Boxed, `Send` stream — the reply shape of a subscription ([`Session::subscribe`]). +pub type BoxStream<'a, T> = Pin + Send + 'a>>; + +/// A serialized record value, carried opaquely through the codec. +/// +/// `Arc<[u8]>` so fan-out is a cheap refcount bump; bytes stay opaque on the hot +/// path, with structured (`serde_json::Value`) conversion only where a handler +/// inspects them. +pub type Payload = Arc<[u8]>; + +/// Result of a transport-layer operation. +pub type TransportResult = Result; + +// =========================================================================== +// Supporting types (stubs — sufficient for the signatures to compile) +// =========================================================================== + +/// Remote-peer metadata carried by a [`Connection`]. +/// +/// A neutral [`peer_addr`](Self::peer_addr) plus a type-erased +/// [`ext`](Self::ext) slot a connector fills with its own resolved identity +/// (e.g. WS attaches `ClientInfo` at the HTTP upgrade), keeping core +/// connector-agnostic. Downcast `ext` with [`ext_as`](Self::ext_as). +#[derive(Clone, Default)] +#[non_exhaustive] +pub struct PeerInfo { + /// Remote address, if the transport exposes one. + pub peer_addr: Option, + /// Connector-resolved identity, type-erased so core need not know the + /// connector's auth types. Downcast with [`ext_as`](Self::ext_as). + pub ext: Option>, +} + +impl PeerInfo { + /// Attach a connector-resolved identity (consumed by [`Dispatch::authenticate`]). + pub fn with_ext(mut self, ext: Arc) -> Self { + self.ext = Some(ext); + self + } + + /// Downcast the [`ext`](Self::ext) identity to a concrete connector type. + pub fn ext_as(&self) -> Option> { + self.ext.clone()?.downcast::().ok() + } +} + +impl core::fmt::Debug for PeerInfo { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PeerInfo") + .field("peer_addr", &self.peer_addr) + .field("ext", &self.ext.as_ref().map(|_| "")) + .finish() + } +} + +/// The authenticated session context produced by [`Dispatch::authenticate`] and +/// threaded into [`Dispatch::open`]. +/// +/// Carries the resolved principal as a type-erased [`ext`](Self::ext) for +/// per-operation authorization in the [`Session`]; downcast with +/// [`ext_as`](Self::ext_as). Connectors that don't authenticate leave it `None`. +#[derive(Clone, Default)] +#[non_exhaustive] +pub struct SessionCtx { + /// The resolved principal, type-erased. Downcast with [`ext_as`](Self::ext_as). + pub ext: Option>, +} + +impl SessionCtx { + /// Build a context carrying a connector-resolved principal. + pub fn with_ext(ext: Arc) -> Self { + Self { ext: Some(ext) } + } + + /// Downcast the [`ext`](Self::ext) principal to a concrete connector type. + pub fn ext_as(&self) -> Option> { + self.ext.clone()?.downcast::().ok() + } +} + +impl core::fmt::Debug for SessionCtx { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("SessionCtx") + .field("ext", &self.ext.as_ref().map(|_| "")) + .finish() + } +} + +/// Per-session resource bounds consumed by the engines. +#[derive(Debug, Clone)] +pub struct SessionLimits { + /// Maximum concurrently served connections. + pub max_connections: usize, + /// Maximum live subscriptions per connection. + pub max_subs_per_connection: usize, +} + +impl Default for SessionLimits { + fn default() -> Self { + Self { + max_connections: 16, + max_subs_per_connection: 32, + } + } +} + +/// Transport-layer failure (Layer 1). +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum TransportError { + /// The connection was closed or reset by the peer. + Closed, + /// An underlying I/O operation failed. + Io, +} + +/// Envelope-codec failure — a frame could not be decoded/encoded. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum CodecError { + /// The frame was not valid for this envelope format. + Malformed, +} + +/// Dispatch-layer (application) failure for `call` / `subscribe` / `write`. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum RpcError { + /// No such method or topic. + NotFound, + /// The caller lacks permission for this operation. + Denied, + /// The handler failed. + Internal, +} + +/// Authentication failure raised by [`Dispatch::authenticate`]. +#[derive(Debug, Clone, PartialEq, Eq)] +#[non_exhaustive] +pub enum AuthError { + /// Credentials were missing or rejected. + Unauthorized, +} + +// =========================================================================== +// Logical message set — role-neutral: the server's `Inbound` is the client's +// outbound and vice versa. +// =========================================================================== + +/// A logical request arriving over a [`Connection`] (what the server receives). +pub enum Inbound { + /// An RPC call expecting a single [`Outbound::Reply`]. + Request { + /// Correlation id, echoed in the reply. + id: u64, + /// Method name (e.g. `"record.set"`, `"query"`). + method: String, + /// Unparsed method parameters. + params: Payload, + }, + /// Open a subscription producing many [`Outbound::Event`]s. + Subscribe { + /// Correlation id for the subscription handshake. + id: u64, + /// Topic to subscribe to. + topic: String, + }, + /// Close a previously opened subscription. + Unsubscribe { + /// Subscription id to cancel. + sub: String, + }, + /// A fire-and-forget write (no reply). + Write { + /// Destination topic. + topic: String, + /// Unparsed record value. + payload: Payload, + }, + /// Keepalive. + Ping, +} + +/// A logical message sent back over a [`Connection`] (what the server emits). +pub enum Outbound<'a> { + /// Reply to an [`Inbound::Request`]. + Reply { + /// Correlation id of the originating request. + id: u64, + /// The result, or an [`RpcError`]. + result: Result, + }, + /// A subscription update. + Event { + /// Subscription id this event belongs to. + sub: &'a str, + /// Monotonic sequence number. + seq: u64, + /// Unparsed record value. + data: Payload, + }, + /// An initial snapshot emitted when a subscription opens (late-join). + Snapshot { + /// Topic the snapshot is for. + topic: &'a str, + /// Unparsed record value. + data: Payload, + }, + /// An explicit acknowledgement that a subscription opened. Emitted by + /// [`run_session`] only when [`SessionConfig::acks_subscribe`] is set. + /// The `sub` is the subscription's routing id — the same value that tags its + /// [`Event`](Outbound::Event)s. + Subscribed { + /// Subscription id that was opened. + sub: &'a str, + }, + /// Keepalive response. + Pong, +} + +// =========================================================================== +// Layer 1 — transport. Framing lives in the transport: `recv` returns one +// logical frame. `Dialer` is the client-side dual of `Listener`. +// =========================================================================== + +/// A framed, bidirectional pipe — role-neutral (yielded by either +/// [`Listener::accept`] or [`Dialer::connect`]). +pub trait Connection: Send { + /// Receive one logical frame. `Ok(None)` signals the peer closed. + fn recv(&mut self) -> BoxFut<'_, TransportResult>>>; + + /// Send one logical frame. + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>>; + + /// Peer metadata (remote addr, headers, pre-resolved auth). + fn peer(&self) -> &PeerInfo; +} + +/// The accepting (server) side — produces [`Connection`]s we did not initiate. +pub trait Listener: Send { + /// Accept the next inbound connection. + fn accept(&mut self) -> BoxFut<'_, TransportResult>>; +} + +/// The initiating (client) side — the dual of [`Listener`]; dials out and +/// produces the same [`Connection`]. +pub trait Dialer: Send { + /// Open a connection to the configured remote. + fn connect(&self) -> BoxFut<'_, TransportResult>>; +} + +// =========================================================================== +// Layer 3 — dispatch. RPC and streaming unify in one per-connection role with +// three reply cardinalities: `call` (one) / `subscribe` (many) / `write` (none). +// Split across two traits: the shared, immutable [`Dispatch`] (one +// `Arc` per server) and the per-connection, `&mut`-threaded +// [`Session`] that can hold mutable state without a lock. +// =========================================================================== + +/// The shared application dispatch: authenticate a connection, then open a +/// per-connection [`Session`]. One `Arc` is shared across every +/// accepted connection, so it stays `Send + Sync` and behind `&self`. +pub trait Dispatch: Send + Sync { + /// Resolve a [`SessionCtx`] from peer metadata and/or the first frame + /// (a pre-resolved identity in [`PeerInfo`], or an in-band Hello in `first`). + fn authenticate<'a>( + &'a self, + peer: &'a PeerInfo, + first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result>; + + /// Open the per-connection [`Session`] once, after [`authenticate`](Self::authenticate). It owns + /// the connection's mutable dispatch state that the shared `Arc` cannot + /// hold behind `&self`; the engine threads `&mut` into it. + fn open(&self, ctx: &SessionCtx) -> Box; +} + +/// The per-connection session: serves calls, subscriptions, and writes for one +/// accepted [`Connection`]. The engine owns the `Box` and threads +/// `&mut self` into each method, so it can hold per-connection state without a +/// lock; the shared, immutable role stays on [`Dispatch`]. +pub trait Session: Send { + /// One-shot RPC: one request → one reply. + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result>; + + /// Open a subscription yielding many payloads. The stream is `'static` (it + /// captures cloned handles) so it outlives the `&mut` borrow and lives in the + /// engine. Async so a connector can await per-operation authorization. + /// Defaulted to [`RpcError::NotFound`] for dispatches with no streaming. + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + let _ = topic; + Box::pin(async { Err(RpcError::NotFound) }) + } + + /// Late-join snapshot: the current value for `topic`, emitted as an + /// [`Outbound::Snapshot`] right after a successful + /// [`subscribe`](Session::subscribe) and before the first event. Defaulted to + /// `None` (no snapshot). + fn snapshot(&mut self, topic: &str) -> Option { + let _ = topic; + None + } + + /// Fire-and-forget write: no reply. Routes through the producer/arbiter path, + /// so single-writer-per-key stays intact. + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>>; +} + +/// The protocol-envelope codec: frame bytes ↔ the logical message set. Layered +/// above (and nesting) the record-value `JsonCodec`, so the wire format stays +/// pluggable; `decode`/`encode` keep the record value as an opaque [`Payload`] +/// sliced from / spliced into the frame. +/// +/// Symmetric, so one codec object serves both engines: `decode`/`encode` are the +/// server direction (read requests / write replies) and +/// [`encode_inbound`](EnvelopeCodec::encode_inbound) / +/// [`decode_outbound`](EnvelopeCodec::decode_outbound) the client direction. +pub trait EnvelopeCodec: Send + Sync { + /// Decode one frame into a logical [`Inbound`] message (server reads a request). + fn decode(&self, frame: &[u8]) -> Result; + + /// Encode a logical [`Outbound`] message, appending its bytes to `out` + /// (server writes a reply/event). + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError>; + + /// Encode a logical [`Inbound`] message, appending its bytes to `out` + /// (client writes a request). The dual of [`decode`](EnvelopeCodec::decode). + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError>; + + /// Decode one frame into a logical [`Outbound`] message (client reads a + /// reply/event). The dual of [`encode`](EnvelopeCodec::encode); the result + /// borrows the frame (`Outbound`'s `sub`/`topic` are `&str` slices into it). + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError>; +} + +// =========================================================================== +// Data-plane capabilities — connectionless (an external library owns any +// session). The outbound `Sink` is the canonical +// [`Connector`](crate::transport::Connector); the inbound `Source` is below. +// =========================================================================== + +/// External → AimDB data-plane: a stream of inbound frames, drained by +/// `pump_source`. +pub trait Source: Send { + /// Yield the next `(topic, payload)`, or `None` when the source is done. + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>>; +} + +// =========================================================================== +// Object-safety: taking each trait as `&dyn Trait` forces the dyn-compatibility +// check on all targets, not just under `cargo test`. +// =========================================================================== + +#[allow(dead_code, clippy::too_many_arguments)] +fn _assert_object_safe( + _connection: &dyn Connection, + _listener: &dyn Listener, + _dialer: &dyn Dialer, + _dispatch: &dyn Dispatch, + _session: &dyn Session, + _codec: &dyn EnvelopeCodec, + _source: &dyn Source, +) { +} + +#[cfg(test)] +mod tests { + use super::*; + + struct MockConnection; + impl Connection for MockConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + unimplemented!() + } + fn send<'a>(&'a mut self, _frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + unimplemented!() + } + fn peer(&self) -> &PeerInfo { + unimplemented!() + } + } + + struct MockListener; + impl Listener for MockListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + unimplemented!() + } + } + + struct MockDialer; + impl Dialer for MockDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + unimplemented!() + } + } + + struct MockDispatch; + impl Dispatch for MockDispatch { + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + unimplemented!() + } + fn open(&self, _ctx: &SessionCtx) -> Box { + unimplemented!() + } + } + + struct MockSession; + impl Session for MockSession { + fn call<'a>( + &'a mut self, + _method: &'a str, + _params: Payload, + ) -> BoxFut<'a, Result> { + unimplemented!() + } + fn subscribe<'a>( + &'a mut self, + _topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + unimplemented!() + } + fn write<'a>( + &'a mut self, + _topic: &'a str, + _payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + unimplemented!() + } + } + + struct MockCodec; + impl EnvelopeCodec for MockCodec { + fn decode(&self, _frame: &[u8]) -> Result { + unimplemented!() + } + fn encode(&self, _msg: Outbound<'_>, _out: &mut Vec) -> Result<(), CodecError> { + unimplemented!() + } + fn encode_inbound(&self, _msg: Inbound, _out: &mut Vec) -> Result<(), CodecError> { + unimplemented!() + } + fn decode_outbound<'a>(&self, _frame: &'a [u8]) -> Result, CodecError> { + unimplemented!() + } + } + + struct MockSource; + impl Source for MockSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + unimplemented!() + } + } + + /// Every trait is `dyn`-usable. + #[test] + fn traits_are_object_safe() { + let _connection: Box = Box::new(MockConnection); + let _listener: Box = Box::new(MockListener); + let _dialer: Box = Box::new(MockDialer); + let _dispatch: Box = Box::new(MockDispatch); + let _session: Box = Box::new(MockSession); + let _codec: Box = Box::new(MockCodec); + let _source: Box = Box::new(MockSource); + } +} diff --git a/aimdb-core/src/session/pump.rs b/aimdb-core/src/session/pump.rs new file mode 100644 index 00000000..aa2dc281 --- /dev/null +++ b/aimdb-core/src/session/pump.rs @@ -0,0 +1,195 @@ +//! Data-plane pump helpers. +//! +//! Two free functions that own the boilerplate a data-plane connector used to +//! hand-roll. The author writes only the pure I/O adapter — a +//! [`Connector`](crate::transport::Connector) (outbound) and a [`Source`] +//! (inbound) — and composes the helpers in `build()`: +//! +//! ```rust,ignore +//! let mut f = pump_sink(db, "redis", self.sink().await?); // outbound +//! f.extend(pump_source(db, "redis", self.subscription().await?)); // inbound +//! Ok(f) +//! ``` +//! +//! Both are `no_std + alloc`-native (boxed futures, no `tokio`). + +extern crate alloc; + +use alloc::boxed::Box; +use alloc::sync::Arc; +use alloc::vec; +use alloc::vec::Vec; + +use super::Source; +use crate::builder::{AimDb, BoxFuture}; +use crate::connector::SerializerKind; +use crate::router::RouterBuilder; +use crate::transport::{Connector, ConnectorConfig}; + +/// Outbound pump: one publisher future per outbound route on `scheme`. +/// +/// Extracts the consume-and-publish loop a data-plane connector used to write by +/// hand. For each route from [`collect_outbound_routes`](AimDb::collect_outbound_routes), +/// the returned future subscribes to the record (type-erased), serializes each +/// value with the route's [`SerializerKind`], resolves the destination via the +/// route's optional topic provider (falling back to the URL-derived default), and +/// publishes through `sink`. Per-route configuration (`qos`/`retain`/…) is built +/// once from the route's URL query via [`ConnectorConfig::from_query`]. +/// +/// The publisher future terminates when its subscription yields an error (e.g. the +/// record buffer closed), matching the legacy hand-rolled loop. +pub fn pump_sink(db: &AimDb, scheme: &str, sink: Arc) -> Vec +where + R: aimdb_executor::RuntimeAdapter + 'static, +{ + let routes = db.collect_outbound_routes(scheme); + let mut futures: Vec = Vec::with_capacity(routes.len()); + + for (default_topic, consumer, serializer, config, topic_provider) in routes { + let sink = sink.clone(); + let runtime_ctx = db.runtime_any(); + let cfg = ConnectorConfig::from_query(&config); + + futures.push(Box::pin(async move { + // Subscribe to typed values (type-erased). + let mut reader = match consumer.subscribe_any().await { + Ok(r) => r, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to subscribe for destination '{}': {:?}", + default_topic, + _e + ); + return; + } + }; + + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher started for destination: {}", + default_topic + ); + + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // SPMC-ring overflow: messages were missed, but the reader + // recovers (cursor resets to the oldest live value). Skip the + // gap and keep pumping — a transient lag must not permanently + // kill the publisher. + Err(crate::DbError::BufferLagged { .. }) => { + #[cfg(feature = "tracing")] + tracing::warn!("pump_sink: consumer lagged for '{}'", default_topic); + continue; + } + // Buffer closed / fatal — the record is gone; end the publisher. + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher stopping for '{}': {:?}", + default_topic, + _e + ); + break; + } + }; + // Resolve destination: dynamic (from provider) or default (from URL). + let dest = topic_provider + .as_ref() + .and_then(|provider| provider.topic_any(&*value_any)) + .unwrap_or_else(|| default_topic.clone()); + + // Serialize the type-erased value. + let bytes = match &serializer { + SerializerKind::Raw(ser) => match ser(&*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to serialize for destination '{}': {:?}", + dest, + _e + ); + continue; + } + }, + SerializerKind::Context(ser) => match ser(runtime_ctx.clone(), &*value_any) { + Ok(b) => b, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_sink: failed to serialize for destination '{}': {:?}", + dest, + _e + ); + continue; + } + }, + }; + + // Publish through the connector's pure I/O adapter. + if let Err(_e) = sink.publish(&dest, &cfg, &bytes).await { + #[cfg(feature = "tracing")] + tracing::error!("pump_sink: failed to publish to '{}': {:?}", dest, _e); + } else { + #[cfg(feature = "tracing")] + tracing::debug!("pump_sink: published to: {}", dest); + } + } + + #[cfg(feature = "tracing")] + tracing::info!( + "pump_sink: publisher stopped for destination: {}", + default_topic + ); + })); + } + + futures +} + +/// Inbound pump: a single multiplexed reader future for `scheme`. +/// +/// Drives one [`Source`] (never one task per topic), fanning each +/// `(topic, payload)` out to the matching producers via a [`Router`] built from +/// [`collect_inbound_routes`](AimDb::collect_inbound_routes). +/// +/// Backpressure: [`Router::route`] drops + logs on a full producer buffer rather +/// than blocking, so one slow record never stalls the shared source. Route errors +/// are non-fatal. +/// +/// [`Router`]: crate::router::Router +/// [`Router::route`]: crate::router::Router::route +pub fn pump_source(db: &AimDb, scheme: &str, mut src: impl Source + 'static) -> Vec +where + R: aimdb_executor::RuntimeAdapter + 'static, +{ + let routes = db.collect_inbound_routes(scheme); + let router = Arc::new(RouterBuilder::from_routes(routes).build()); + let ctx = db.runtime_any(); + + vec![Box::pin(async move { + #[cfg(feature = "tracing")] + tracing::info!( + "pump_source: reader started ({} topics)", + router.resource_ids().len() + ); + + while let Some((topic, payload)) = src.next().await { + // `route` deserializes and fans out to producers; it drops + logs on a + // full producer buffer and never returns a fatal error. + if let Err(_e) = router.route(&topic, &payload, Some(&ctx)).await { + #[cfg(feature = "tracing")] + tracing::error!( + "pump_source: failed to route message on '{}': {}", + topic, + _e + ); + } + } + + #[cfg(feature = "tracing")] + tracing::info!("pump_source: reader stopped"); + })] +} diff --git a/aimdb-core/src/session/server.rs b/aimdb-core/src/session/server.rs new file mode 100644 index 00000000..c16d5999 --- /dev/null +++ b/aimdb-core/src/session/server.rs @@ -0,0 +1,398 @@ +//! The reactive **server** engine of the session substrate. +//! +//! - [`run_session`] drives one accepted [`Connection`]: a biased `select_biased!` +//! loop interleaving inbound requests (RPC + subscribe + write) with outbound +//! subscription events. +//! - [`serve`] is the accept loop over a [`Listener`], honoring +//! [`SessionLimits::max_connections`]. +//! +//! Spawn-free (every per-connection/-subscription task lives in a +//! [`FuturesUnordered`] the runner drives) and runtime-neutral (purely reactive, +//! so no timer and no `tokio`/`embassy-*`). +//! +//! The loops use an **extract-then-act** shape: `select_biased!` only computes a +//! small [`Step`], then the loop acts on it once the arm futures (and their +//! borrows of `conn`/`subs`) drop — `futures`' macro, unlike `tokio::select!`, +//! keeps non-selected futures alive across the handler. + +use alloc::boxed::Box; +use alloc::string::{String, ToString}; +use alloc::sync::Arc; +use alloc::vec::Vec; + +use async_channel::Sender; +use futures_channel::oneshot; +use futures_util::stream::{FuturesUnordered, StreamExt}; +use futures_util::{select_biased, FutureExt}; +use hashbrown::HashMap; + +use super::{ + BoxFut, BoxStream, Connection, Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, + RpcError, SessionLimits, +}; + +/// Per-session engine knobs. +#[derive(Debug, Clone, Default)] +pub struct SessionConfig { + /// Connection cap (consulted by [`serve`]) and per-connection subscription + /// cap (by [`run_session`]). + pub limits: SessionLimits, + /// If `true`, read one frame before authenticating and pass it to + /// [`Dispatch::authenticate`] as an in-band Hello. If `false` (default), + /// authenticate from [`PeerInfo`](super::PeerInfo) alone. + pub reads_hello: bool, + /// If `true`, emit an explicit [`Outbound::Subscribed`] ack before the first + /// event. If `false` (default) the ack is implicit (events carry the + /// subscription id back). + pub acks_subscribe: bool, +} + +/// Bound for the per-connection event funnel: pending outbound updates a +/// connection may buffer before pumps start dropping. +const EVENT_BUFFER: usize = 256; + +/// One subscription update on its way back to the connection's send half. +struct SubEvent { + sub: String, + seq: u64, + data: Payload, +} + +/// What [`run_session`]'s `select_biased!` decided this iteration — extracted so +/// the work runs after the arm futures' borrows of `conn`/`subs` release. +enum Step { + /// A logical frame arrived from the peer (decode + dispatch). + Frame(Vec), + /// Peer closed or the transport errored — end the session. + Closed, + /// A subscription update to encode and forward to the peer. + Event(SubEvent), + /// A subscription pump finished on its own (stream exhausted) — reap it and + /// prune its `cancels` entry by the carried sub id. + SubDrained(String), +} + +/// Drive one accepted [`Connection`] until it closes. +/// +/// Authenticates once, then interleaves (biased toward inbound reads, so a chatty +/// subscription cannot starve the RPC path) incoming requests, outgoing +/// subscription events, and reaping of finished subscription pumps. Dropping the +/// engine cancels every live subscription. +pub async fn run_session( + mut conn: Box, + codec: &C, + dispatch: &D, + config: &SessionConfig, +) where + C: EnvelopeCodec + ?Sized, + D: Dispatch + ?Sized, +{ + // Resolve the session context (Hello-frame or peer-only — see `reads_hello`). + let first = if config.reads_hello { + match conn.recv().await { + Ok(Some(frame)) => Some(frame), + // Peer closed or errored before sending the Hello — nothing to serve. + _ => return, + } + } else { + None + }; + let ctx = match dispatch.authenticate(conn.peer(), first.as_deref()).await { + Ok(ctx) => ctx, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::warn!("session authenticate rejected: {:?}", _e); + return; + } + }; + + // Open the per-connection session once; the loop threads `&mut` into it. + let mut session = dispatch.open(&ctx); + + // Event funnel: every per-subscription pump sends updates here; the main loop + // is the sole writer to the connection. Bounded, so a slow client cannot grow + // it without limit — pumps drop on overflow (events carry a monotonic `seq`). + let (event_tx, event_rx) = async_channel::bounded::(EVENT_BUFFER); + // Per-connection subscription pumps; the engine future is their sole owner. + // Each resolves to its own sub id, so the loop can prune the matching + // `cancels` entry (a no-op if Unsubscribe already removed it). + let mut subs: FuturesUnordered> = FuturesUnordered::new(); + // sub-id → cancel handle; dropping/firing the oneshot cancels the pump. + let mut cancels: HashMap> = HashMap::new(); + // Reused encode scratch buffer. + let mut out = Vec::new(); + + loop { + // Biased toward inbound reads. The select only decides the next step; the + // work happens after, in the `match`, once the arm borrows release. + let step = { + // Per-iteration futures, fused for the select. `event_rx.recv()` is + // `!Unpin`, so pin it; `subs` (a `FusedStream`) parks on the empty set + // while the always-active `recv` arm keeps the select alive. + let mut recv = conn.recv().fuse(); + let mut event = core::pin::pin!(event_rx.recv().fuse()); + select_biased! { + // ---- inbound: one logical frame from the peer -------------- + r = recv => match r { + Ok(Some(frame)) => Step::Frame(frame), + Ok(None) | Err(_) => Step::Closed, // peer closed / transport error + }, + // ---- outbound: a subscription update to forward ------------ + ev = event => match ev { + Ok(ev) => Step::Event(ev), + // Funnel closed — only if every sender dropped, which can't + // happen while the loop holds `event_tx`, so this is + // unreachable; end the session defensively if it ever does. + Err(_) => Step::Closed, + }, + // ---- drain finished subscription pumps --------------------- + sub_id = subs.select_next_some() => Step::SubDrained(sub_id), + } + }; + + match step { + Step::Closed => break, + // A pump finished on its own (stream exhausted), not via Unsubscribe; + // drop its cancel handle so the ended subscription neither leaks nor + // keeps counting against `max_subs_per_connection`. + Step::SubDrained(sub_id) => { + cancels.remove(&sub_id); + } + + Step::Event(ev) => { + out.clear(); + let encoded = codec + .encode( + Outbound::Event { + sub: &ev.sub, + seq: ev.seq, + data: ev.data, + }, + &mut out, + ) + .is_ok(); + if encoded && conn.send(&out).await.is_err() { + break; + } + } + + Step::Frame(frame) => { + let msg = match codec.decode(&frame) { + Ok(msg) => msg, + Err(_e) => continue, // skip a malformed frame, keep the session + }; + match msg { + Inbound::Request { id, method, params } => { + let result = session.call(&method, params).await; + out.clear(); + if codec + .encode(Outbound::Reply { id, result }, &mut out) + .is_err() + { + continue; + } + if conn.send(&out).await.is_err() { + break; + } + } + Inbound::Subscribe { id, topic } => { + // The opening request id is the subscription's routing key; + // events carry it back as `Outbound::Event.sub`. + let sub_id = id.to_string(); + if cancels.len() >= config.limits.max_subs_per_connection { + send_reply_err(&mut conn, codec, &mut out, id, RpcError::Denied).await; + continue; + } + match session.subscribe(&topic).await { + Ok(stream) => { + // Optional explicit ack (see `acks_subscribe`). + if config.acks_subscribe { + out.clear(); + if codec + .encode(Outbound::Subscribed { sub: &sub_id }, &mut out) + .is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } + // Optional late-join snapshot, before the first event. + if let Some(data) = session.snapshot(&topic) { + out.clear(); + if codec + .encode( + Outbound::Snapshot { + topic: &topic, + data, + }, + &mut out, + ) + .is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } + let (cancel_tx, cancel_rx) = oneshot::channel(); + cancels.insert(sub_id.clone(), cancel_tx); + subs.push(Box::pin(pump_subscription( + sub_id, + stream, + event_tx.clone(), + cancel_rx, + ))); + } + Err(e) => { + send_reply_err(&mut conn, codec, &mut out, id, e).await; + } + } + } + Inbound::Unsubscribe { sub } => { + // Dropping the sender resolves the pump's cancel future. + cancels.remove(&sub); + } + Inbound::Write { topic, payload } => { + // Fire-and-forget; single-writer-per-key stays intact. + let _ = session.write(&topic, payload).await; + } + Inbound::Ping => { + out.clear(); + if codec.encode(Outbound::Pong, &mut out).is_ok() + && conn.send(&out).await.is_err() + { + break; + } + } + } + } + } + } + + // Dropping `subs` here cancels every live subscription pump. + drop(subs); +} + +/// Encode + send a `Reply` carrying an [`RpcError`]; best-effort (a send/encode +/// failure just ends this attempt — the caller's loop handles a dead connection +/// on its next `send`). +async fn send_reply_err( + conn: &mut Box, + codec: &C, + out: &mut Vec, + id: u64, + err: RpcError, +) { + out.clear(); + if codec + .encode( + Outbound::Reply { + id, + result: Err(err), + }, + out, + ) + .is_ok() + { + let _ = conn.send(out).await; + } +} + +/// Pump one `Session::subscribe` stream into the connection's event funnel, +/// tagging each update with a monotonic `seq`. Ends when the stream finishes or +/// the cancel handle is dropped/fired (Unsubscribe or connection teardown). +/// +/// Returns its `sub_id` so [`run_session`] can prune the `cancels` entry for a +/// pump that ended on its own; the Unsubscribe path already removed it, so that +/// later prune is a no-op. +async fn pump_subscription( + sub_id: String, + mut stream: BoxStream<'static, Payload>, + tx: Sender, + cancel: oneshot::Receiver<()>, +) -> String { + // Fuse the cancel receiver: a bare `oneshot::Receiver` reports + // `is_terminated()` once its sender drops, and `select_biased!` skips + // terminated arms — so the cancel would never fire. `Fuse` keeps the arm + // polled until it actually resolves. + let mut cancel = cancel.fuse(); + let mut seq: u64 = 0; + loop { + // Independent arms, so a direct `select_biased!` is fine here. + let data = select_biased! { + // Resolves on explicit Unsubscribe (send) or on sender drop. + _ = cancel => break, + // `BoxStream` is not `FusedStream`, so fuse the per-iteration `next`. + next = stream.next().fuse() => match next { + Some(data) => data, + None => break, // stream exhausted + }, + }; + seq += 1; + // Non-blocking: drop on a full funnel (slow-client protection); only a + // disconnected funnel ends the pump. + match tx.try_send(SubEvent { + sub: sub_id.clone(), + seq, + data, + }) { + Ok(()) => {} + Err(e) if e.is_full() => {} // drop on overflow + Err(_) => break, // funnel disconnected — connection gone + } + } + sub_id +} + +/// Accept connections from `listener` and serve each with [`run_session`], +/// bounded by [`SessionLimits::max_connections`]. The accept loop and all +/// per-connection futures share one [`FuturesUnordered`] — spawn-free. +pub async fn serve(mut listener: L, codec: Arc, dispatch: Arc, config: SessionConfig) +where + L: Listener, + C: EnvelopeCodec + 'static, + // `?Sized` so a caller can serve an `Arc`. + D: Dispatch + 'static + ?Sized, +{ + let mut conns: FuturesUnordered> = FuturesUnordered::new(); + + loop { + // Extract the accept result first, then act (keeps the `listener` and + // `conns` borrows apart). + let accept = { + let mut accept = listener.accept().fuse(); + select_biased! { + a = accept => a, + // Parks on the empty set, so the accept arm keeps the select alive. + () = conns.select_next_some() => continue, + } + }; + + match accept { + Ok(conn) => { + // Soft cap; `len()` counts finished-but-not-yet-reaped futures, so + // under an accept flood (the biased `accept` arm starves the reap + // arm) it may read high transiently — acceptable for a soft cap. + if conns.len() >= config.limits.max_connections { + #[cfg(feature = "tracing")] + tracing::warn!( + "max_connections={} reached, refusing client", + config.limits.max_connections + ); + drop(conn); + continue; + } + let codec = codec.clone(); + let dispatch = dispatch.clone(); + let cfg = config.clone(); + conns.push(Box::pin(async move { + run_session(conn, codec.as_ref(), dispatch.as_ref(), &cfg).await; + })); + } + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("accept failed: {:?}", _e); + // Keep serving existing connections despite a transient accept error. + } + } + } +} diff --git a/aimdb-core/src/transform/join.rs b/aimdb-core/src/transform/join.rs index 8c16cdfb..8f6cd5c5 100644 --- a/aimdb-core/src/transform/join.rs +++ b/aimdb-core/src/transform/join.rs @@ -131,7 +131,7 @@ type JoinInputFactory = Box< /// loop) is created by the runtime adapter at database startup — capacity is an /// internal constant chosen per adapter (Tokio: 64, Embassy: 8, WASM: 64). /// -/// Obtain via [`RecordRegistrar::transform_join`]. +/// Obtain via [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). pub struct JoinBuilder { inputs: Vec<(String, JoinInputFactory)>, _phantom: PhantomData<(O, R)>, @@ -241,7 +241,7 @@ where /// Completed multi-input join pipeline, ready to be registered on a record. /// /// Produced by [`JoinBuilder::on_triggers`] and consumed by -/// [`RecordRegistrar::transform_join`]. Not normally constructed directly. +/// [`RecordRegistrar::transform_join`](crate::RecordRegistrar::transform_join). Not normally constructed directly. pub struct JoinPipeline { pub(crate) spawn_factory: Box TransformDescriptor + Send>, } diff --git a/aimdb-core/src/transport.rs b/aimdb-core/src/transport.rs index 87bec876..35ea3697 100644 --- a/aimdb-core/src/transport.rs +++ b/aimdb-core/src/transport.rs @@ -55,6 +55,39 @@ impl Default for ConnectorConfig { } } +impl ConnectorConfig { + /// Build a config from a route's URL-query key/value pairs. + /// + /// This is the shared seam the data-plane `pump_sink` helper uses to thread + /// per-route configuration through to [`Connector::publish`] without changing + /// the `publish` signature. + /// + /// Only the protocol-agnostic `timeout_ms` is lifted into a typed field. The + /// `qos`/`retain` *meaning* differs per protocol (an MQTT QoS level vs. a + /// Kafka `acks` setting vs. an HTTP retry count — see the type docs), and a + /// `u8`/`bool` field cannot represent "unspecified", so these — and every + /// other key — are passed through verbatim in [`protocol_options`] for the + /// connector to interpret with its own defaults. The typed `qos`/`retain` + /// fields therefore keep their [`Default`] values here; they remain available + /// for callers that construct a [`ConnectorConfig`] directly. + /// + /// [`protocol_options`]: ConnectorConfig::protocol_options + pub fn from_query(query: &[(String, String)]) -> ConnectorConfig { + let mut cfg = ConnectorConfig::default(); + for (k, v) in query { + match k.as_str() { + "timeout_ms" => { + if let Ok(n) = v.parse::() { + cfg.timeout_ms = Some(n); + } + } + _ => cfg.protocol_options.push((k.clone(), v.clone())), + } + } + cfg + } +} + /// Error that can occur during connector publishing /// /// Uses an enum instead of String for better performance in `no_std` environments diff --git a/aimdb-core/src/typed_record.rs b/aimdb-core/src/typed_record.rs index 181b2ab2..3196fe5b 100644 --- a/aimdb-core/src/typed_record.rs +++ b/aimdb-core/src/typed_record.rs @@ -540,7 +540,7 @@ pub struct TypedRecord< > { /// Optional producer service - a task that generates data /// This will be auto-spawned during build() if present - /// Stored as FnOnce that takes (Producer, RuntimeContext) and returns a Future + /// Stored as `FnOnce` that takes (`Producer`, `RuntimeContext`) and returns a `Future` /// Wrapped in Mutex for interior mutability (needed to take() during spawning) producer: Mutex>>, @@ -1138,8 +1138,11 @@ impl`, updated atomically on each `produce()`. - /// Non-blocking and buffer-agnostic. + /// Returns the most recent value wrapped in `RecordValue`, or `None` if no + /// value has been produced yet, the record has no buffer, or the buffer has + /// **no canonical latest** — i.e. [`SpmcRing`](crate::buffer::BufferCfg::SpmcRing) + /// (a ring is a stream/backlog; read it via a subscriber/drain instead). + /// Non-blocking. /// /// **Both std and no_std**: Direct access via `Deref`, `.get()`, `.into_inner()` /// diff --git a/aimdb-core/tests/session_engine.rs b/aimdb-core/tests/session_engine.rs new file mode 100644 index 00000000..50829042 --- /dev/null +++ b/aimdb-core/tests/session_engine.rs @@ -0,0 +1,561 @@ +//! A `serve` server and a `run_client` client engine, talking over a throwaway +//! in-memory pipe, round-trip **RPC + a streaming subscription + a +//! fire-and-forget write** in both directions — proving the shared substrate +//! (`Connection` / `EnvelopeCodec` / `Inbound`/`Outbound`) is genuinely +//! role-neutral. +//! +//! The substrate here is deliberately throwaway: a channel-backed `Connection` +//! (framing-in-transport: one `Vec` per logical frame), a `Listener`/ +//! `Dialer` pair over a connect channel, a tiny line-oriented `EnvelopeCodec`, +//! and an echo `Dispatch`. The real UDS/NDJSON/AimX impls live in +//! `aimdb-uds-connector` and `aimdb-core::session::aimx`. + +#![cfg(feature = "connector-session")] + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use futures::StreamExt; + +use aimdb_core::session::{ + run_client, serve, AuthError, BoxFut, BoxStream, ClientConfig, CodecError, Connection, Dialer, + Dispatch, EnvelopeCodec, Inbound, Listener, Outbound, Payload, PeerInfo, RpcError, 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 + } +} + +// =========================================================================== +// Channel-backed transport (Layer 1) +// =========================================================================== + +/// A framed bidirectional pipe: send to the peer, receive from the peer. One +/// `Vec` == one logical frame (framing lives in the transport). +struct ChannelConn { + tx: tokio::sync::mpsc::UnboundedSender>, + rx: tokio::sync::mpsc::UnboundedReceiver>, + peer: PeerInfo, +} + +fn conn_pair() -> (ChannelConn, ChannelConn) { + let (a_tx, a_rx) = tokio::sync::mpsc::unbounded_channel(); + let (b_tx, b_rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ChannelConn { + tx: a_tx, + rx: b_rx, + peer: PeerInfo::default(), + }, + ChannelConn { + tx: b_tx, + rx: a_rx, + peer: PeerInfo::default(), + }, + ) +} + +impl Connection for ChannelConn { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { Ok(self.rx.recv().await) }) // None == peer closed + } + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + let tx = self.tx.clone(); + let bytes = frame.to_vec(); + Box::pin(async move { tx.send(bytes).map_err(|_| TransportError::Closed) }) + } + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +struct ChannelListener { + incoming: tokio::sync::mpsc::UnboundedReceiver>, +} + +impl Listener for ChannelListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { self.incoming.recv().await.ok_or(TransportError::Closed) }) + } +} + +struct ChannelDialer { + connect_tx: tokio::sync::mpsc::UnboundedSender>, +} + +impl Dialer for ChannelDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + let connect_tx = self.connect_tx.clone(); + Box::pin(async move { + let (server_side, client_side) = conn_pair(); + connect_tx + .send(Box::new(server_side) as Box) + .map_err(|_| TransportError::Closed)?; + Ok(Box::new(client_side) as Box) + }) + } +} + +fn transport_pair() -> (ChannelListener, ChannelDialer) { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + ( + ChannelListener { incoming: rx }, + ChannelDialer { connect_tx: tx }, + ) +} + +// =========================================================================== +// Tiny line-oriented EnvelopeCodec (symmetric: both engine directions) +// =========================================================================== + +struct LineCodec; + +fn payload_from(s: &str) -> Payload { + Arc::from(s.as_bytes()) +} + +fn utf8(b: &[u8]) -> Result<&str, CodecError> { + std::str::from_utf8(b).map_err(|_| CodecError::Malformed) +} + +fn rpc_code(e: &RpcError) -> &'static str { + match e { + RpcError::NotFound => "notfound", + RpcError::Denied => "denied", + _ => "internal", + } +} + +fn code_rpc(s: &str) -> RpcError { + match s { + "notfound" => RpcError::NotFound, + "denied" => RpcError::Denied, + _ => RpcError::Internal, + } +} + +impl EnvelopeCodec for LineCodec { + // --- server direction -------------------------------------------------- + fn decode(&self, frame: &[u8]) -> Result { + let s = utf8(frame)?; + let (tag, rest) = s.split_once('\n').unwrap_or((s, "")); + match tag { + "REQ" => { + let (id, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (method, params) = r.split_once('\n').unwrap_or((r, "")); + Ok(Inbound::Request { + id: id.parse().map_err(|_| CodecError::Malformed)?, + method: method.to_string(), + params: payload_from(params), + }) + } + "SUB" => { + let (id, topic) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + Ok(Inbound::Subscribe { + id: id.parse().map_err(|_| CodecError::Malformed)?, + topic: topic.to_string(), + }) + } + "UNSUB" => Ok(Inbound::Unsubscribe { + sub: rest.to_string(), + }), + "WRITE" => { + let (topic, payload) = rest.split_once('\n').unwrap_or((rest, "")); + Ok(Inbound::Write { + topic: topic.to_string(), + payload: payload_from(payload), + }) + } + "PING" => Ok(Inbound::Ping), + _ => Err(CodecError::Malformed), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError> { + let s = match msg { + Outbound::Reply { id, result } => match result { + Ok(data) => format!("REPLY\n{}\nOK\n{}", id, utf8(&data)?), + Err(e) => format!("REPLY\n{}\nERR\n{}", id, rpc_code(&e)), + }, + Outbound::Event { sub, seq, data } => { + format!("EVENT\n{}\n{}\n{}", sub, seq, utf8(&data)?) + } + Outbound::Snapshot { topic, data } => format!("SNAP\n{}\n{}", topic, utf8(&data)?), + Outbound::Pong => "PONG".to_string(), + Outbound::Subscribed { sub } => format!("SUBSCRIBED\n{}", sub), + }; + out.extend_from_slice(s.as_bytes()); + Ok(()) + } + + // --- client direction (dual) ------------------------------------------- + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + let s = match msg { + Inbound::Request { id, method, params } => { + format!("REQ\n{}\n{}\n{}", id, method, utf8(¶ms)?) + } + Inbound::Subscribe { id, topic } => format!("SUB\n{}\n{}", id, topic), + Inbound::Unsubscribe { sub } => format!("UNSUB\n{}", sub), + Inbound::Write { topic, payload } => format!("WRITE\n{}\n{}", topic, utf8(&payload)?), + Inbound::Ping => "PING".to_string(), + }; + out.extend_from_slice(s.as_bytes()); + Ok(()) + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + let s = utf8(frame)?; + let (tag, rest) = s.split_once('\n').unwrap_or((s, "")); + match tag { + "REPLY" => { + let (id, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (kind, tail) = r.split_once('\n').unwrap_or((r, "")); + let result = match kind { + "OK" => Ok(payload_from(tail)), + "ERR" => Err(code_rpc(tail)), + _ => return Err(CodecError::Malformed), + }; + Ok(Outbound::Reply { + id: id.parse().map_err(|_| CodecError::Malformed)?, + result, + }) + } + "EVENT" => { + let (sub, r) = rest.split_once('\n').ok_or(CodecError::Malformed)?; + let (seq, data) = r.split_once('\n').unwrap_or((r, "")); + Ok(Outbound::Event { + sub, + seq: seq.parse().map_err(|_| CodecError::Malformed)?, + data: payload_from(data), + }) + } + "SNAP" => { + let (topic, data) = rest.split_once('\n').unwrap_or((rest, "")); + Ok(Outbound::Snapshot { + topic, + data: payload_from(data), + }) + } + "PONG" => Ok(Outbound::Pong), + _ => Err(CodecError::Malformed), + } + } +} + +// =========================================================================== +// Echo dispatch (Layer 3) +// =========================================================================== + +/// Shared log of `(topic, payload)` writes the server received, for assertion. +type WriteLog = Arc)>>>; + +struct EchoDispatch { + writes: WriteLog, +} + +impl Dispatch for EchoDispatch { + fn authenticate<'a>( + &'a self, + _peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + Box::pin(async { Ok(SessionCtx::default()) }) + } + + fn open(&self, _ctx: &SessionCtx) -> Box { + Box::new(EchoSession { + writes: self.writes.clone(), + }) + } +} + +/// Per-connection echo session — the shared `EchoDispatch` clones its write log +/// into each one at `open` time. +struct EchoSession { + writes: WriteLog, +} + +impl Session for EchoSession { + fn call<'a>( + &'a mut self, + _method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + // Echo the params straight back. + Box::pin(async move { Ok(params) }) + } + + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + let topic = topic.to_string(); + Box::pin(async move { + // Sentinel: let a known topic fail so the subscribe-ack path is testable. + if topic == "bad" { + return Err(RpcError::NotFound); + } + // Three synthetic updates derived from the topic, then end. + let items: Vec = (1..=3) + .map(|i| payload_from(&format!("{topic}#{i}"))) + .collect(); + Ok(Box::pin(futures::stream::iter(items)) as BoxStream<'static, Payload>) + }) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + let writes = self.writes.clone(); + let topic = topic.to_string(); + Box::pin(async move { + writes.lock().unwrap().push((topic, payload.to_vec())); + Ok(()) + }) + } +} + +// =========================================================================== +// The exit-criterion test +// =========================================================================== + +#[tokio::test] +async fn echo_roundtrip_rpc_streaming_and_write() { + let (listener, dialer) = transport_pair(); + let writes = Arc::new(Mutex::new(Vec::new())); + let dispatch = Arc::new(EchoDispatch { + writes: writes.clone(), + }); + + // Server engine on the runner's stand-in (a task — the engine itself is + // spawn-free; the test harness drives the one returned future). + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig::default(), + )); + + // Client engine: handshake-as-caller (Ping/Pong), no reconnect for the test. + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: 10, + max_reconnect_delay: 10, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: true, + }, + Arc::new(TestClock), + ); + let client = tokio::spawn(client_fut); + + // 1) RPC: one request → one reply (echo). + let reply = handle.call("echo", payload_from("hello")).await.unwrap(); + assert_eq!(&*reply, b"hello", "RPC reply should echo the params"); + + // 2) Streaming: subscribe → three events routed back by sub id. + let mut stream = handle.subscribe("temp").unwrap(); + let e1 = stream.next().await.expect("event 1"); + let e2 = stream.next().await.expect("event 2"); + let e3 = stream.next().await.expect("event 3"); + assert_eq!(&*e1, b"temp#1"); + assert_eq!(&*e2, b"temp#2"); + assert_eq!(&*e3, b"temp#3"); + + // 3) Fire-and-forget write, then a follow-up RPC. FIFO on the single + // connection guarantees the write frame is processed before the reply + // returns, so the write is observable by the time the call resolves. + handle.write("room", payload_from("on")).unwrap(); + let _ = handle.call("noop", payload_from("x")).await.unwrap(); + let got = writes.lock().unwrap().clone(); + assert_eq!( + got, + vec![("room".to_string(), b"on".to_vec())], + "server should have received the write" + ); + + // Teardown: dropping the only handle stops the client engine gracefully; + // the server loop is unbounded, so abort it. + drop(handle); + drop(stream); + client + .await + .expect("client engine should stop cleanly when handles drop"); + server.abort(); +} + +/// Subscribe-ack: a subscribe the server rejects must surface as a stream that +/// *ends* (`None`) rather than one that hangs forever (the pre-fix behavior). +#[tokio::test] +async fn failed_subscribe_ends_stream_via_ack() { + let (listener, dialer) = transport_pair(); + let dispatch = Arc::new(EchoDispatch { + writes: Arc::new(Mutex::new(Vec::new())), + }); + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig::default(), + )); + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: 10, + max_reconnect_delay: 10, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: false, + }, + Arc::new(TestClock), + ); + let client = tokio::spawn(client_fut); + + // The "bad" topic is rejected server-side; the failure Reply must close the + // stream. A generous timeout guards against the old hang-forever behavior. + let mut stream = handle.subscribe("bad").unwrap(); + let ended = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("failed subscribe must end the stream, not hang"); + assert!(ended.is_none(), "rejected subscribe should yield no events"); + + drop(handle); + drop(stream); + let _ = client.await; + server.abort(); +} + +/// A subscription whose source stream *ends on its own* (the echo yields three +/// updates, then completes) must free its slot against +/// `max_subs_per_connection` once its pump drains. Regression for the bug where +/// `run_session` pruned `cancels` only on an explicit Unsubscribe, so a +/// naturally-ended subscription lingered in the map — leaking memory and, worse, +/// permanently counting against the per-connection cap until a long-lived +/// connection that churned subscriptions could open no more. +#[tokio::test] +async fn ended_subscription_frees_its_cap_slot() { + let (listener, dialer) = transport_pair(); + let dispatch = Arc::new(EchoDispatch { + writes: Arc::new(Mutex::new(Vec::new())), + }); + // Cap of 2: with the leak, the third subscribe is refused even though the + // first two have already ended. + let server = tokio::spawn(serve( + listener, + Arc::new(LineCodec), + dispatch, + SessionConfig { + limits: SessionLimits { + max_connections: 16, + max_subs_per_connection: 2, + }, + reads_hello: false, + acks_subscribe: false, + }, + )); + let (handle, client_fut) = run_client( + dialer, + LineCodec, + ClientConfig { + reconnect: false, + reconnect_delay: 10, + max_reconnect_delay: 10, + max_reconnect_attempts: 0, + keepalive_interval: None, + max_offline_queue: usize::MAX, + topic_routed_subs: false, + sends_hello: false, + }, + Arc::new(TestClock), + ); + let client = tokio::spawn(client_fut); + + // Drain a subscription's three echo updates; the server-side stream then + // ends, so its pump finishes and is reaped. + async fn drain_three(stream: &mut BoxStream<'static, Payload>, topic: &str) { + for i in 1..=3 { + let ev = tokio::time::timeout(Duration::from_secs(2), stream.next()) + .await + .expect("event should arrive") + .expect("an accepted subscription must yield its events"); + assert_eq!(&*ev, format!("{topic}#{i}").as_bytes()); + } + } + + // Open and fully consume two subscriptions (both fit under the cap of 2). + let mut a = handle.subscribe("a").unwrap(); + drain_three(&mut a, "a").await; + let mut b = handle.subscribe("b").unwrap(); + drain_three(&mut b, "b").await; + + // Let the server forward the last events and reap both finished pumps. + tokio::time::sleep(Duration::from_millis(100)).await; + + // A third subscribe must still be accepted — the two ended subs freed their + // slots. Pre-fix their `cancels` entries lingered, the cap stayed full, and + // this subscribe was refused (surfacing as an immediately-ended stream). + let mut c = handle.subscribe("c").unwrap(); + let first = tokio::time::timeout(Duration::from_secs(2), c.next()) + .await + .expect("third subscribe must not hang"); + assert_eq!( + first.as_deref(), + Some(&b"c#1"[..]), + "an ended subscription must free its cap slot; the third subscribe was refused" + ); + + drop(handle); + drop(a); + drop(b); + drop(c); + let _ = client.await; + server.abort(); +} diff --git a/aimdb-embassy-adapter/CHANGELOG.md b/aimdb-embassy-adapter/CHANGELOG.md index a08e5c71..9d93630a 100644 --- a/aimdb-embassy-adapter/CHANGELOG.md +++ b/aimdb-embassy-adapter/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **`SendFutureWrapper` — shared force-`Send` wrapper for Embassy data-plane connectors (Issue #39).** New `pub` type (`no_std` only): asserts `Send` for a future driven exclusively by a single-core, cooperative Embassy executor, so an Embassy connector's `!Send` data-plane futures (over `NoopRawMutex` channels) satisfy the connector spine's `Send` `BoxFuture` bound. Consolidates the identical wrappers that the KNX and MQTT Embassy clients previously each hand-rolled. +- **Session-engine smoke test on the Embassy clock (Issue #39, Phase 5, [design doc](../docs/design/remote-access-via-connectors.md)).** New `tests/session_smoke.rs` drives `aimdb-core`'s runtime-neutral `run_client` engine using the `EmbassyAdapter`'s `TimeOps` clock for reconnect backoff / keepalive — proving the shared session engines run on Embassy, not just Tokio. Dev-only: pulls in `aimdb-core` with the `connector-session` feature, so the normal `no_std` lib build and the `thumbv7em` cross-checks stay `alloc`-only. - **`EmbassyBuffer::peek()` (M15, Design 031).** Non-destructive buffer-native read matching the Tokio adapter's semantics: `SingleLatest` (`Watch`) via `Watch::try_get()`, `Mailbox` (`Channel<_, T, 1>`) via `Channel::try_peek()`, `SpmcRing` (`PubSubChannel`) returns `None`. Neither path consumes a receiver slot or advances a cursor. - **Embassy buffer + join-queue unit tests now run in CI on the host (Issue #85).** Previously the join-queue tests sat behind `feature = "embassy-runtime"`, which transitively pulls `embassy-executor`'s `platform-cortex-m` ARM assembly and fails to compile under `cargo test` on x86_64 — so ordering / backpressure / clone-routing regressions went uncaught. The `join_queue` module is now gated on `embassy-sync` instead (the `JoinFanInRuntime for EmbassyAdapter` impl keeps its own `embassy-runtime` gate), and `make test` runs `cargo test -p aimdb-embassy-adapter --no-default-features --features "alloc,embassy-sync,embassy-time"` (15 unit tests + doctests). A test-only no-op `#[defmt::global_logger]` / `#[defmt::panic_handler]` and a trivial `embassy-time-driver` satisfy the host link targets that `defmt` + `defmt-timestamp-uptime` would otherwise leave undefined. - **`embassy-time-driver` dev-dependency** — provides the trivial host time driver above (no tick feature, so it unifies with the workspace `tick-hz-32_768` rather than forcing `mock-driver`/`std`'s conflicting rate). diff --git a/aimdb-embassy-adapter/Cargo.toml b/aimdb-embassy-adapter/Cargo.toml index 4d72351b..023db4cf 100644 --- a/aimdb-embassy-adapter/Cargo.toml +++ b/aimdb-embassy-adapter/Cargo.toml @@ -69,6 +69,15 @@ defmt = { workspace = true } tracing = { workspace = true, optional = true, default-features = false } [dev-dependencies] +# Phase 5 session smoke (`tests/session_smoke.rs`) drives the runtime-neutral +# `run_client` engine on the EmbassyAdapter clock — pull in aimdb-core's +# `connector-session` gate. Dev-only, so the normal no_std lib build (and the +# thumbv7em checks) stay `alloc`-only. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false, features = [ + "alloc", + "connector-session", +] } + # For testing on embedded targets heapless = "0.9.1" diff --git a/aimdb-embassy-adapter/src/lib.rs b/aimdb-embassy-adapter/src/lib.rs index 0bdd77e1..2906afe7 100644 --- a/aimdb-embassy-adapter/src/lib.rs +++ b/aimdb-embassy-adapter/src/lib.rs @@ -78,10 +78,17 @@ mod runtime; #[cfg(all(not(feature = "std"), feature = "embassy-time"))] pub mod time; +// Force-`Send` helper for Embassy data-plane connectors (see module docs). +#[cfg(not(feature = "std"))] +pub mod send_wrapper; + // Error handling exports #[cfg(not(feature = "std"))] pub use error::EmbassyErrorSupport; +#[cfg(not(feature = "std"))] +pub use send_wrapper::SendFutureWrapper; + // Runtime exports #[cfg(not(feature = "std"))] pub use runtime::EmbassyAdapter; diff --git a/aimdb-embassy-adapter/src/send_wrapper.rs b/aimdb-embassy-adapter/src/send_wrapper.rs new file mode 100644 index 00000000..6a634a03 --- /dev/null +++ b/aimdb-embassy-adapter/src/send_wrapper.rs @@ -0,0 +1,34 @@ +//! `SendFutureWrapper` — force-`Send` an Embassy future for AimDB's connector spine. +//! +//! AimDB's connector framework requires `Send` futures: `ConnectorBuilder::build` +//! returns `Vec>>` and `AimDbRunner` drives them on a +//! `Send` `BoxFuture`. Embassy's primitives (channels over `NoopRawMutex`, …) are +//! `!Send` *by design* — single-core, cooperative, no preemption or thread +//! migration — so an Embassy connector's data-plane futures must be force-`Send`ed +//! to satisfy that bound. +//! +//! This is also *why* Embassy data-plane connectors hand-roll their outbound / +//! inbound loops instead of riding core's `pump_sink` / `pump_source`: those need a +//! `Send + Sync` `Connector` / `Send` `Source`, which `!Send` Embassy channels +//! cannot be without force-`Send`ing every primitive. + +use core::future::Future; +use core::pin::Pin; +use core::task::{Context, Poll}; + +/// Asserts `Send` for a future driven exclusively by an Embassy executor. +pub struct SendFutureWrapper(pub F); + +// SAFETY: Embassy executors run cooperatively on a single core with no preemption or +// thread migration, so the wrapped value is never actually moved across threads. +// Only wrap futures that are polled solely by an Embassy executor. +unsafe impl Send for SendFutureWrapper {} + +impl Future for SendFutureWrapper { + type Output = F::Output; + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + // SAFETY: structural pin projection onto the single field; never moved out. + unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } + } +} diff --git a/aimdb-embassy-adapter/tests/session_smoke.rs b/aimdb-embassy-adapter/tests/session_smoke.rs new file mode 100644 index 00000000..def5638b --- /dev/null +++ b/aimdb-embassy-adapter/tests/session_smoke.rs @@ -0,0 +1,143 @@ +//! Embassy smoke — the runtime-neutral session **client engine** runs on the +//! Embassy adapter's [`TimeOps`](aimdb_executor::TimeOps) clock. +//! +//! `run_client` is parametrized over the runtime clock (its only runtime +//! dependency, for reconnect backoff / keepalive). This test instantiates it with +//! [`EmbassyAdapter`] — the same monomorphization an MCU build uses — and drives +//! the returned spawn-free engine future over a stub loopback transport, +//! round-tripping one record (an RPC `call` whose reply echoes the params). It +//! validates the Embassy seam (engine future + `EmbassyAdapter::sleep` clock) +//! without needing `embassy-executor`, which does not build on the host; the +//! spawn-free `BoxFut` is driven by `futures::executor::block_on`. +//! +//! Runs under the same host feature set as the other embassy-adapter host tests +//! (`alloc,embassy-sync,embassy-time`); `connector-session` is pulled in for the +//! engine via this crate's dev-dependency on `aimdb-core`. + +#![cfg(feature = "embassy-time")] + +use std::sync::Arc; + +use aimdb_core::session::{ + run_client, BoxFut, ClientConfig, CodecError, Connection, Dialer, EnvelopeCodec, Inbound, + Outbound, Payload, PeerInfo, TransportError, TransportResult, +}; +use aimdb_embassy_adapter::EmbassyAdapter; + +// Trivial host time driver so `embassy_time` links (the happy path never awaits +// `clock.sleep`, so `now`/`schedule_wake` are never actually exercised). +struct TestTimeDriver; +impl embassy_time_driver::Driver for TestTimeDriver { + fn now(&self) -> u64 { + 0 + } + fn schedule_wake(&self, _at: u64, _waker: &core::task::Waker) {} +} +embassy_time_driver::time_driver_impl!(static TEST_TIME_DRIVER: TestTimeDriver = TestTimeDriver); + +/// Minimal echo wire: a `Request` is `[id:8][params]`; the loopback returns those +/// bytes verbatim, which `decode_outbound` reads back as `Reply { id, Ok(params) }`. +struct EchoCodec; + +impl EnvelopeCodec for EchoCodec { + fn decode(&self, _frame: &[u8]) -> Result { + Err(CodecError::Malformed) // server direction unused by this client smoke + } + fn encode(&self, _msg: Outbound<'_>, _out: &mut Vec) -> Result<(), CodecError> { + Err(CodecError::Malformed) + } + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + match msg { + Inbound::Request { + id, + method: _, + params, + } => { + out.extend_from_slice(&id.to_be_bytes()); + out.extend_from_slice(¶ms); + Ok(()) + } + _ => Err(CodecError::Malformed), + } + } + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + if frame.len() < 8 { + return Err(CodecError::Malformed); + } + let id = u64::from_be_bytes(frame[0..8].try_into().unwrap()); + Ok(Outbound::Reply { + id, + result: Ok(Payload::from(&frame[8..])), + }) + } +} + +/// A loopback connection: every `send` echoes the frame straight back to `recv`. +struct Loopback { + tx: futures::channel::mpsc::UnboundedSender>, + rx: futures::channel::mpsc::UnboundedReceiver>, + peer: PeerInfo, +} + +impl Connection for Loopback { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { + use futures::StreamExt; + Ok(self.rx.next().await) // `None` once every sender drops + }) + } + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + let tx = self.tx.clone(); + let bytes = frame.to_vec(); + Box::pin(async move { tx.unbounded_send(bytes).map_err(|_| TransportError::Closed) }) + } + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// Dials a fresh loopback connection. +struct StubDialer; + +impl Dialer for StubDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + Box::pin(async { + let (tx, rx) = futures::channel::mpsc::unbounded(); + Ok(Box::new(Loopback { + tx, + rx, + peer: PeerInfo::default(), + }) as Box) + }) + } +} + +#[test] +fn embassy_clock_drives_client_engine_rpc() { + use futures::executor::block_on; + use futures::future::{select, Either}; + + // The exact `run_client<_, _, EmbassyAdapter>` monomorphization an MCU uses. + let clock = Arc::new(EmbassyAdapter::default()); + let config = ClientConfig { + reconnect: false, + sends_hello: false, + ..ClientConfig::default() + }; + let (handle, engine_fut) = run_client(StubDialer, EchoCodec, config, clock); + + block_on(async move { + futures::pin_mut!(engine_fut); + let call = handle.call("echo", Payload::from(&b"ping"[..])); + futures::pin_mut!(call); + + // Drive the engine concurrently with the call; the engine must reach the + // reply, not end first. + match select(call, engine_fut).await { + Either::Left((reply, _engine)) => { + assert_eq!(&*reply.expect("call should resolve"), b"ping"); + } + Either::Right(_) => panic!("engine ended before the reply arrived"), + } + }); +} diff --git a/aimdb-knx-connector/CHANGELOG.md b/aimdb-knx-connector/CHANGELOG.md index 65ece963..5e909676 100644 --- a/aimdb-knx-connector/CHANGELOG.md +++ b/aimdb-knx-connector/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and telegram read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers: the connector now writes only a `KnxSink` (`Connector`, parses the destination group address and forwards a fire-and-forget `GroupValueWrite`) and a `KnxSource` (`Source`, yields each inbound `(group_address, payload)`) and composes the pumps in `build()`. The routing `Router` is (re)built inside `pump_source`. `std` enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. +- **Outbound publishers survive a consumer lag (Tokio + Embassy).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. +- **`SendFutureWrapper` moved to `aimdb-embassy-adapter`.** The Embassy client's local force-`Send` wrapper is gone in favour of the shared `aimdb_embassy_adapter::SendFutureWrapper` (single definition, no behavior change). + ### Changed (breaking) - **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. diff --git a/aimdb-knx-connector/src/embassy_client.rs b/aimdb-knx-connector/src/embassy_client.rs index ddcb4921..38effa09 100644 --- a/aimdb-knx-connector/src/embassy_client.rs +++ b/aimdb-knx-connector/src/embassy_client.rs @@ -34,6 +34,7 @@ use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; use aimdb_core::ConnectorBuilder; +use aimdb_embassy_adapter::SendFutureWrapper; use alloc::boxed::Box; use alloc::string::ToString; use alloc::sync::Arc; @@ -1072,7 +1073,21 @@ impl KnxConnectorImpl { default_group_addr_clone.as_str() ); - while let Ok(value_any) = reader.recv_any().await { + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // SPMC-ring overflow — skip the gap, keep publishing. + Err(aimdb_core::DbError::BufferLagged { .. }) => { + #[cfg(feature = "defmt")] + defmt::warn!( + "KNX outbound: consumer lagged for '{}'", + default_group_addr_clone.as_str() + ); + continue; + } + // Buffer closed — stop the publisher. + Err(_) => break, + }; // Determine group address: dynamic (from provider) or default (from URL) let group_addr_str = topic_provider .as_ref() @@ -1204,22 +1219,3 @@ impl aimdb_core::transport::Connector for KnxConnectorImpl { }) } } - -// SAFETY: Embassy is single-threaded, so we can safely implement Send -// even though some Embassy types don't implement it. Embassy executors run -// cooperatively on a single core with no preemption or thread migration. -struct SendFutureWrapper(F); - -unsafe impl Send for SendFutureWrapper {} - -impl core::future::Future for SendFutureWrapper { - type Output = F::Output; - - fn poll( - self: core::pin::Pin<&mut Self>, - cx: &mut core::task::Context<'_>, - ) -> core::task::Poll { - // SAFETY: We're just forwarding the poll call - unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } - } -} diff --git a/aimdb-knx-connector/src/tokio_client.rs b/aimdb-knx-connector/src/tokio_client.rs index dda000a8..d4b4e1cd 100644 --- a/aimdb-knx-connector/src/tokio_client.rs +++ b/aimdb-knx-connector/src/tokio_client.rs @@ -1,15 +1,15 @@ //! KNX/IP client management and lifecycle for Tokio runtime //! //! This module provides a KNX connector that: -//! - Manages a single KNX/IP gateway connection -//! - Automatic event loop spawning with reconnection -//! - Thread-safe access from multiple consumers -//! - Router-based dispatch for inbound telegrams +//! - Manages a single KNX/IP gateway connection (with reconnection) +//! - Rides core's `pump_sink` / `pump_source`: a `KnxSink` (outbound +//! `GroupValueWrite`) and a `KnxSource` (inbound telegrams) over the +//! connection task's command / telegram channels use crate::GroupAddress; use aimdb_core::connector::ConnectorUrl; -use aimdb_core::router::{Router, RouterBuilder}; -use aimdb_core::ConnectorBuilder; +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; +use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; use knx_pico::protocol::{ CEMIFrame, ConnectRequest, ConnectResponse, ConnectionHeader, ConnectionStateRequest, Hpai, KnxnetIpFrame, ServiceType, TunnelingAck, TunnelingRequest, @@ -34,10 +34,11 @@ enum KnxCommand { }, } -/// KNX connector for a single gateway connection with router-based dispatch +/// KNX connector for a single gateway connection. /// -/// Each connector manages ONE KNX/IP gateway connection. The router determines -/// how incoming telegrams are dispatched to AimDB producers. +/// Each connector manages ONE KNX/IP gateway connection; inbound telegrams are +/// dispatched to AimDB producers by `pump_source`, outbound records published by +/// `pump_sink`. /// /// # Usage Pattern /// @@ -103,67 +104,32 @@ impl ConnectorBuilder for KnxCon db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // Collect inbound routes from database - let inbound_routes = db.collect_inbound_routes("knx"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} inbound routes for KNX connector", - inbound_routes.len() - ); - - // Convert routes to Router - let router = RouterBuilder::from_routes(inbound_routes).build(); - - #[cfg(feature = "tracing")] - tracing::info!( - "KNX router has {} group addresses", - router.resource_ids().len() - ); - - // Build the command channel + connection-task future. - // - // Channel ownership ordering (design 028 §"KNX channel ownership"): - // 1. mpsc::channel created here. - // 2. Receiver captured by `connection_future`. - // 3. Sender cloned into each outbound publisher future below. - let runtime_ctx = db.runtime_any(); - let (command_tx, connection_future) = KnxConnectorImpl::build_internal( - &self.gateway_url, - router, - Some(runtime_ctx), - self.command_queue_size, - ) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build KNX connector: {}", e), - } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; - - let outbound_routes = db.collect_outbound_routes("knx"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} outbound routes for KNX connector", - outbound_routes.len() - ); + // Build the command channel, the inbound-telegram channel, and the + // connection task. Inbound flows connection-task → `KnxSource` → + // `pump_source`; outbound flows `pump_sink` → `KnxSink` → the command + // channel → connection task. The routing `Router` is (re)built inside + // `pump_source` from `collect_inbound_routes`. + let (command_tx, telegram_rx, connection_future) = + KnxConnectorImpl::build_internal(&self.gateway_url, self.command_queue_size) + .await + .map_err(|_e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build KNX connector: {}", _e), + } + } + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(1 + outbound_routes.len()); - futures.push(connection_future); - futures.extend(KnxConnectorImpl::collect_outbound_futures( - command_tx, - runtime_ctx, - outbound_routes, - )); + let mut futures: Vec = vec![connection_future]; + // Inbound: the KNX bus source, fanned out to producers by `pump_source`. + futures.extend(pump_source(db, "knx", KnxSource { telegram_rx })); + // Outbound: `pump_sink` serializes each record and hands it to `KnxSink`. + futures.extend(pump_sink(db, "knx", Arc::new(KnxSink { command_tx }))); Ok(futures) }) @@ -184,29 +150,30 @@ impl ConnectorBuilder for KnxCon pub struct KnxConnectorImpl; impl KnxConnectorImpl { - /// Builds the KNX connection-task future and returns it along with the - /// command sender for use by outbound publishers. + /// Builds the KNX connection-task future, returning the outbound command + /// sender and the inbound-telegram receiver for `KnxSink` / `KnxSource`. /// /// # Arguments /// * `gateway_url` - Gateway URL (knx://host:port) - /// * `router` - Pre-configured router with all routes + /// * `command_queue_size` - Capacity of both the command and telegram channels async fn build_internal( gateway_url: &str, - router: Router, - runtime_ctx: Option>, command_queue_size: usize, - ) -> Result<(mpsc::Sender, BoxFuture), String> { + ) -> Result< + ( + mpsc::Sender, + mpsc::Receiver<(String, Payload)>, + BoxFuture, + ), + String, + > { // Parse the gateway URL let mut url = gateway_url.to_string(); - - // If no group address is provided, add a dummy one for parsing if !url.contains('/') || url.matches('/').count() < 3 { url = format!("{}/0/0/0", url.trim_end_matches('/')); } - let connector_url = ConnectorUrl::parse(&url).map_err(|e| format!("Invalid KNX URL: {}", e))?; - let gateway_ip = connector_url.host.clone(); let gateway_port = connector_url.port.unwrap_or(3671); @@ -217,163 +184,65 @@ impl KnxConnectorImpl { gateway_port ); - let router_arc = Arc::new(router); - - // 1. Create command channel; receiver goes to the connection future, - // sender is returned for the publisher futures to clone. + // Outbound commands (publishers → connection task) and inbound telegrams + // (connection task → `KnxSource`/`pump_source`). let (command_tx, command_rx) = mpsc::channel::(command_queue_size); + let (telegram_tx, telegram_rx) = mpsc::channel::<(String, Payload)>(command_queue_size); - // 2. Build the connection-task future (captures the receiver). - let connection_future = build_connection_future( - gateway_ip, - gateway_port, - router_arc, - runtime_ctx, - command_rx, - ); + let connection_future = + build_connection_future(gateway_ip, gateway_port, telegram_tx, command_rx); - Ok((command_tx, connection_future)) + Ok((command_tx, telegram_rx, connection_future)) } +} - /// Collects outbound publisher futures for all configured routes (internal). - /// - /// Each route's future subscribes to its typed record, serializes values, and - /// sends them as `KnxCommand::GroupWrite` to the connection task via - /// `command_tx`. Returned futures are appended to the runner's accumulator. - fn collect_outbound_futures( - command_tx: mpsc::Sender, - runtime_ctx: Arc, - routes: Vec, - ) -> Vec { - let mut futures: Vec = Vec::with_capacity(routes.len()); - - for (default_group_addr_str, consumer, serializer, _config, topic_provider) in routes { - let command_tx = command_tx.clone(); - let default_group_addr_clone = default_group_addr_str.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - // Parse default group address using knx-pico's type-safe parser - let default_group_addr = match default_group_addr_clone.parse::() { - Ok(addr) => Some(addr), - Err(_e) => { - // If no topic provider, this is an error - if topic_provider.is_none() { - #[cfg(feature = "tracing")] - tracing::error!( - "Invalid group address for outbound: '{}'", - default_group_addr_clone - ); - return; - } - // With topic provider, the default can be invalid (will be overridden) - None - } - }; - - // Subscribe to typed values (type-erased) - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to subscribe for outbound: '{}'", - default_group_addr_clone - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "KNX outbound publisher started for: {}", - default_group_addr_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Determine group address: dynamic (from provider) or default (from URL) - let group_addr_str = topic_provider - .as_ref() - .and_then(|provider| provider.topic_any(&*value_any)) - .unwrap_or_else(|| default_group_addr_clone.clone()); - - // Parse group address (may be dynamic) - let group_addr = match group_addr_str.parse::() { - Ok(addr) => addr, - Err(_e) => { - // Try to use cached default if available - if let Some(addr) = default_group_addr { - addr - } else { - #[cfg(feature = "tracing")] - tracing::error!( - "Invalid dynamic group address: '{}'", - group_addr_str - ); - continue; - } - } - }; - - // Serialize the type-erased value - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for group address '{}': {:?}", - group_addr_str, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for group address '{}': {:?}", - group_addr_str, - _e - ); - continue; - } - } - } - }; - - // Send command to connection task - let cmd = KnxCommand::GroupWrite { - group_addr, - data: bytes, - response: None, // Fire-and-forget - }; - - if let Err(_e) = command_tx.send(cmd).await { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to send command for group address '{}': channel closed", - group_addr_str - ); - break; // Connection task died, stop publishing - } +/// Outbound publish adapter driven by `pump_sink`. +/// +/// `pump_sink` resolves each record's destination group address (dynamic via a +/// topic provider, or the link's default) and serializes the value; `publish` +/// parses that address and forwards a fire-and-forget `GroupValueWrite` to the +/// connection task over the command channel. +struct KnxSink { + command_tx: mpsc::Sender, +} - #[cfg(feature = "tracing")] - tracing::debug!("Published to KNX: {}", group_addr_str); - } +impl Connector for KnxSink { + fn publish( + &self, + destination: &str, + _config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { + let group_addr_str = destination.to_string(); + let data = payload.to_vec(); + let command_tx = self.command_tx.clone(); + Box::pin(async move { + let group_addr = group_addr_str + .parse::() + .map_err(|_| PublishError::InvalidDestination)?; + command_tx + .send(KnxCommand::GroupWrite { + group_addr, + data, + response: None, // fire-and-forget + }) + .await + .map_err(|_| PublishError::ConnectionFailed) // connection task gone + }) + } +} - #[cfg(feature = "tracing")] - tracing::info!( - "KNX outbound publisher stopped for: {}", - default_group_addr_clone - ); - })); - } +/// Inbound telegram source driven by `pump_source`. +/// +/// Yields each `(group_address, payload)` the connection task parsed off the KNX +/// bus; `pump_source` deserializes and fans it out to the matching producers. +struct KnxSource { + telegram_rx: mpsc::Receiver<(String, Payload)>, +} - futures +impl Source for KnxSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { self.telegram_rx.recv().await }) } } @@ -382,21 +251,19 @@ impl KnxConnectorImpl { /// The connection task handles: /// - KNXnet/IP connection establishment /// - Telegram reception and parsing -/// - Router-based dispatch to producers +/// - Forwarding parsed inbound telegrams to the `telegram_tx` channel (`pump_source`) /// - Outbound command processing /// - Automatic reconnection on failure /// /// # Arguments /// * `gateway_ip` - Gateway IP address /// * `gateway_port` - Gateway port (typically 3671) -/// * `router` - Router for dispatching telegrams to producers -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers +/// * `telegram_tx` - Sender for inbound telegrams → `KnxSource`/`pump_source` /// * `command_rx` - Receiver half of the outbound command channel fn build_connection_future( gateway_ip: String, gateway_port: u16, - router: Arc, - runtime_ctx: Option>, + telegram_tx: mpsc::Sender<(String, Payload)>, mut command_rx: mpsc::Receiver, ) -> BoxFuture { Box::pin(async move { @@ -408,14 +275,7 @@ fn build_connection_future( ); loop { - match connect_and_listen( - &gateway_ip, - gateway_port, - router.clone(), - &mut command_rx, - runtime_ctx.as_ref(), - ) - .await + match connect_and_listen(&gateway_ip, gateway_port, &telegram_tx, &mut command_rx).await { Ok(_) => { #[cfg(feature = "tracing")] @@ -543,15 +403,13 @@ impl ChannelState { /// # Arguments /// * `gateway_ip` - Gateway IP address /// * `gateway_port` - Gateway port -/// * `router` - Router for dispatching messages +/// * `telegram_tx` - Sender for parsed inbound telegrams → `pump_source` /// * `command_rx` - Command receiver for outbound publishing -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers async fn connect_and_listen( gateway_ip: &str, gateway_port: u16, - router: Arc, + telegram_tx: &mpsc::Sender<(String, Payload)>, command_rx: &mut mpsc::Receiver, - runtime_ctx: Option<&Arc>, ) -> Result<(), String> { // 1. Create UDP socket let socket = UdpSocket::bind("0.0.0.0:0") @@ -667,10 +525,18 @@ async fn connect_and_listen( #[cfg(feature = "tracing")] tracing::debug!("KNX telegram: {} ({} bytes)", resource_id, data.len()); - // Dispatch via router - if let Err(_e) = router.route(&resource_id, &data, runtime_ctx).await { + // Forward to `pump_source` (which routes to producers). + // `try_send` so a slow/full sink never stalls the + // protocol task (ACKs, keepalive, outbound). + if telegram_tx + .try_send((resource_id, Payload::from(data.as_slice()))) + .is_err() + { #[cfg(feature = "tracing")] - tracing::warn!("Router dispatch failed for {}: {:?}", resource_id, _e); + tracing::warn!( + "KNX inbound: dropping telegram for {} (channel full/closed)", + group_addr + ); } } else { #[cfg(feature = "tracing")] @@ -1078,21 +944,16 @@ async fn send_group_write_internal( #[cfg(test)] mod tests { use super::*; - use aimdb_core::router::RouterBuilder; #[tokio::test] - async fn test_connector_creation_with_router() { - let router = RouterBuilder::new().build(); - let connector = - KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", router, None, 32).await; + async fn test_connector_creation() { + let connector = KnxConnectorImpl::build_internal("knx://192.168.1.19:3671", 32).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_connector_with_port() { - let router = RouterBuilder::new().build(); - let connector = - KnxConnectorImpl::build_internal("knx://gateway.local:3672", router, None, 32).await; + let connector = KnxConnectorImpl::build_internal("knx://gateway.local:3672", 32).await; assert!(connector.is_ok()); } diff --git a/aimdb-mqtt-connector/CHANGELOG.md b/aimdb-mqtt-connector/CHANGELOG.md index 3f4863c9..06441db9 100644 --- a/aimdb-mqtt-connector/CHANGELOG.md +++ b/aimdb-mqtt-connector/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Tokio client rebuilt on the shared data-plane toolkit (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** The hand-rolled consume-serialize-publish and read-route loops are replaced by `aimdb-core`'s `pump_sink` / `pump_source` helpers (the connector now writes only its `Connector`/`Source` I/O adapters and composes the pumps in `build()`). Per-route configuration (`qos` / `retain` / `timeout_ms` / …) is threaded from each link URL's query via `ConnectorConfig::from_query`. `std` now enables `aimdb-core/connector-session` (where the pump helpers live; `std` implies it transitively). No public API change. +- **Outbound publisher survives a consumer lag; `SendFutureWrapper` relocated (Embassy client, Issue #39).** A `BufferLagged` (SPMC-ring overflow) on the outbound reader now skips the gap and keeps publishing instead of terminating the publisher; only a closed buffer stops it. The Embassy client's local force-`Send` wrapper is gone in favour of the shared `aimdb_embassy_adapter::SendFutureWrapper` (single definition, no behavior change). + ### Changed (breaking) - **`ConnectorBuilder::build()` now returns `Vec>` instead of `Arc` (Issue #88).** Both Tokio and Embassy implementations updated. The MQTT event-loop, the Embassy event-router, and every outbound publisher are returned as futures that the `AimDbRunner` drives — no more `runtime.spawn` / `tokio::spawn` inside the connector. `R: Spawn` bounds dropped throughout in favour of `R: RuntimeAdapter`. diff --git a/aimdb-mqtt-connector/Cargo.toml b/aimdb-mqtt-connector/Cargo.toml index a37b7041..fa2e8e35 100644 --- a/aimdb-mqtt-connector/Cargo.toml +++ b/aimdb-mqtt-connector/Cargo.toml @@ -12,7 +12,9 @@ categories = ["network-programming", "embedded", "asynchronous"] [features] default = ["aimdb-core/alloc"] -std = ["aimdb-core/std", "aimdb-core/alloc", "thiserror"] +# `aimdb-core/connector-session` provides the data-plane `pump_sink`/`pump_source` +# helpers the tokio client builds on (re-exported there; `std` implies it too). +std = ["aimdb-core/std", "aimdb-core/alloc", "aimdb-core/connector-session", "thiserror"] tokio-runtime = [ "std", "tokio", diff --git a/aimdb-mqtt-connector/src/embassy_client.rs b/aimdb-mqtt-connector/src/embassy_client.rs index 886190ce..7d405d04 100644 --- a/aimdb-mqtt-connector/src/embassy_client.rs +++ b/aimdb-mqtt-connector/src/embassy_client.rs @@ -53,6 +53,7 @@ use core::net::Ipv4Addr; use core::pin::Pin; use core::str::FromStr; +use aimdb_embassy_adapter::SendFutureWrapper; use embassy_net::Ipv4Address; use embassy_sync::blocking_mutex::raw::NoopRawMutex; use embassy_sync::channel::{Channel, Sender}; @@ -594,7 +595,22 @@ impl MqttConnectorImpl { default_topic_clone.as_str() ); - while let Ok(value_any) = reader.recv_any().await { + loop { + let value_any = match reader.recv_any().await { + Ok(v) => v, + // Lagged (ring overflow) — skip the gap, keep publishing + // rather than letting a transient overrun kill the publisher. + Err(aimdb_core::DbError::BufferLagged { .. }) => { + #[cfg(feature = "defmt")] + defmt::warn!( + "MQTT outbound: consumer lagged for '{}'", + default_topic_clone.as_str() + ); + continue; + } + // Buffer closed — stop the publisher. + Err(_e) => break, + }; // Determine topic: dynamic (from provider) or default (from URL) let topic = topic_provider .as_ref() @@ -664,27 +680,6 @@ impl MqttConnectorImpl { } } -// Helper wrapper to make futures Send for Embassy's single-threaded environment -// -// SAFETY: Embassy is single-threaded, so we can safely implement Send -// even though some Embassy types don't implement it. Embassy executors run -// cooperatively on a single core with no preemption or thread migration. -struct SendFutureWrapper(F); - -unsafe impl Send for SendFutureWrapper {} - -impl core::future::Future for SendFutureWrapper { - type Output = F::Output; - - fn poll( - self: core::pin::Pin<&mut Self>, - cx: &mut core::task::Context<'_>, - ) -> core::task::Poll { - // SAFETY: We're just forwarding the poll call - unsafe { self.map_unchecked_mut(|s| &mut s.0).poll(cx) } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/aimdb-mqtt-connector/src/tokio_client.rs b/aimdb-mqtt-connector/src/tokio_client.rs index 42863f29..9f5af65e 100644 --- a/aimdb-mqtt-connector/src/tokio_client.rs +++ b/aimdb-mqtt-connector/src/tokio_client.rs @@ -8,8 +8,9 @@ use aimdb_core::connector::ConnectorUrl; use aimdb_core::router::{Router, RouterBuilder}; -use aimdb_core::ConnectorBuilder; -use rumqttc::{AsyncClient, EventLoop, MqttOptions, Packet}; +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; +use aimdb_core::{pump_sink, pump_source, BoxFut, ConnectorBuilder, Payload, Source}; +use rumqttc::{AsyncClient, Event, EventLoop, MqttOptions, Packet}; use std::future::Future; use std::pin::Pin; use std::sync::Arc; @@ -97,60 +98,49 @@ impl ConnectorBuilder for MqttCo db: &'a aimdb_core::builder::AimDb, ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // Collect inbound routes from database + // Build a router from the inbound routes purely to drive the MQTT + // subscriptions + channel-capacity sizing in `build_internal`. The + // routing `Router` that fans incoming frames out to producers is + // (re)built by `pump_source` from the same `collect_inbound_routes`. let inbound_routes = db.collect_inbound_routes("mqtt"); - - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} inbound routes for MQTT connector", - inbound_routes.len() - ); - - // Convert routes to Router let router = RouterBuilder::from_routes(inbound_routes).build(); #[cfg(feature = "tracing")] - tracing::info!("MQTT router has {} topics", router.resource_ids().len()); - - // Build the client + event-loop future - let runtime_ctx = db.runtime_any(); - let (client, event_loop_future) = MqttConnectorImpl::build_internal( - &self.broker_url, - self.client_id.clone(), - router, - Some(runtime_ctx), - ) - .await - .map_err(|e| { - #[cfg(feature = "std")] - { - aimdb_core::DbError::RuntimeError { - message: format!("Failed to build MQTT connector: {}", e).into(), - } - } - #[cfg(not(feature = "std"))] - { - aimdb_core::DbError::RuntimeError { _message: () } - } - })?; + tracing::info!("MQTT subscribing to {} topics", router.resource_ids().len()); + + // Connect, subscribe, and hand back the raw event loop. + let (client, event_loop) = + MqttConnectorImpl::build_internal(&self.broker_url, self.client_id.clone(), router) + .await + .map_err(|_e| { + #[cfg(feature = "std")] + { + aimdb_core::DbError::RuntimeError { + message: format!("Failed to build MQTT connector: {}", _e), + } + } + #[cfg(not(feature = "std"))] + { + aimdb_core::DbError::RuntimeError { _message: () } + } + })?; - let outbound_routes = db.collect_outbound_routes("mqtt"); + let mut futures: Vec = Vec::new(); - #[cfg(feature = "tracing")] - tracing::info!( - "Collected {} outbound routes for MQTT connector", - outbound_routes.len() - ); - - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(1 + outbound_routes.len()); - futures.push(event_loop_future); - futures.extend(MqttConnectorImpl::collect_outbound_futures( - client, - runtime_ctx, - outbound_routes, + // Inbound: one multiplexed reader future fanning publishes out to producers. + futures.extend(pump_source( + db, + "mqtt", + MqttEventLoopSource { + event_loop, + #[cfg(feature = "tracing")] + broker_key: self.broker_url.clone(), + }, )); + // Outbound: one publisher future per outbound route. + futures.extend(pump_sink(db, "mqtt", Arc::new(MqttSink { client }))); + Ok(futures) }) } @@ -160,31 +150,31 @@ impl ConnectorBuilder for MqttCo } } -/// Internal MQTT connector implementation +/// Internal MQTT connector build helpers. /// -/// This is the actual connector created after collecting routes from the database. -pub struct MqttConnectorImpl { - client: Arc, - router: Arc, -} +/// A namespace for the broker-connection setup invoked from +/// [`MqttConnectorBuilder::build`]; the data-plane loops themselves live in the +/// reusable `pump_sink` / `pump_source` helpers + the [`MqttSink`] / +/// [`MqttEventLoopSource`] adapters below. +pub struct MqttConnectorImpl; impl MqttConnectorImpl { - /// Create a new MQTT connector with pre-configured router (internal) + /// Connect to the broker and subscribe to all configured topics (internal). /// - /// Creates a connection to the MQTT broker and subscribes to all topics - /// defined in the router. The event loop is spawned automatically. + /// Creates the MQTT client, sizes the send-channel from the route count, and + /// subscribes to every topic in `router`. Returns the shared client (for the + /// outbound `pump_sink`) plus the raw event loop (handed to a + /// [`MqttEventLoopSource`] for the inbound `pump_source`). /// /// # Arguments /// * `broker_url` - Broker URL (mqtt://host:port or mqtts://host:port) /// * `client_id` - Optional client ID (if None, generates UUID-based ID) - /// * `router` - Pre-configured router with all routes - /// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers + /// * `router` - Routes used only for the subscription list + capacity sizing async fn build_internal( broker_url: &str, client_id: Option, router: Router, - runtime_ctx: Option>, - ) -> Result<(Arc, BoxFuture), String> { + ) -> Result<(Arc, EventLoop), String> { // Parse the broker URL - we accept it with or without a topic let mut url = broker_url.to_string(); @@ -205,10 +195,8 @@ impl MqttConnectorImpl { } }); - let broker_key = format!("{}:{}", host, port); - #[cfg(feature = "tracing")] - tracing::info!("Creating MQTT client for {}", broker_key); + tracing::info!("Creating MQTT client for {}:{}", host, port); // Use provided client_id or generate a UUID-based one let client_id = client_id.unwrap_or_else(|| format!("aimdb-{}", uuid::Uuid::new_v4())); @@ -251,12 +239,6 @@ impl MqttConnectorImpl { let (client, event_loop) = AsyncClient::new(mqtt_opts, channel_capacity); let client_arc = Arc::new(client); - // Build the event-loop future (returned to the caller for the runner to - // drive). Per design 028 §"Connector futures", the event loop runs - // concurrently with outbound publishers under one `FuturesUnordered`. - let event_loop_future = - build_event_loop_future(event_loop, broker_key, router_arc.clone(), runtime_ctx); - let topics = router_arc.resource_ids(); #[cfg(feature = "tracing")] @@ -275,173 +257,48 @@ impl MqttConnectorImpl { #[cfg(feature = "tracing")] tracing::info!("MQTT subscriptions complete"); - Ok((client_arc, event_loop_future)) + Ok((client_arc, event_loop)) } +} - /// Get list of all MQTT topics this connector is subscribed to - /// - /// Returns the unique topics from the router configuration. - /// Useful for debugging and monitoring. - pub fn topics(&self) -> Vec> { - self.router.resource_ids() - } - - /// Get the number of routes configured in this connector - /// - /// Each route represents a (topic, type) mapping. - /// Multiple routes can exist for the same topic if different types subscribe to it. - pub fn route_count(&self) -> usize { - self.router.route_count() - } - - /// Collects outbound publisher futures for all configured routes (internal). - /// - /// Called automatically during `build()` to construct the per-route - /// publisher futures. Each subscribes to its record (type-erased), serializes - /// values, and publishes them to the MQTT broker. Returned futures are - /// appended to the `AimDbRunner` accumulator. - fn collect_outbound_futures( - client: Arc, - runtime_ctx: Arc, - routes: Vec, - ) -> Vec { - let mut futures: Vec = Vec::with_capacity(routes.len()); - - for (default_topic, consumer, serializer, config, topic_provider) in routes { - let client = client.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - // Parse config options - let mut qos = rumqttc::QoS::AtLeastOnce; // Default - let mut retain = false; - - for (key, value) in &config { - match key.as_str() { - "qos" => { - if let Ok(qos_val) = value.parse::() { - qos = match qos_val { - 0 => rumqttc::QoS::AtMostOnce, - 1 => rumqttc::QoS::AtLeastOnce, - 2 => rumqttc::QoS::ExactlyOnce, - _ => rumqttc::QoS::AtLeastOnce, - }; - } - } - "retain" => { - if let Ok(retain_val) = value.parse::() { - retain = retain_val; - } - } - _ => {} - } - } - - futures.push(Box::pin(async move { - // Subscribe to typed values (type-erased) - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to subscribe for outbound topic '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "MQTT outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Determine topic: dynamic (from provider) or default (from URL) - let topic = topic_provider - .as_ref() - .and_then(|provider| provider.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize the type-erased value - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for topic '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize for topic '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Publish to MQTT with protocol-specific config - if let Err(_e) = client.publish(&topic, qos, retain, bytes).await { - #[cfg(feature = "tracing")] - tracing::error!("Failed to publish to MQTT topic '{}': {:?}", topic, _e); - } else { - #[cfg(feature = "tracing")] - tracing::debug!("Published to MQTT topic: {}", topic); - } - } - - #[cfg(feature = "tracing")] - tracing::info!( - "MQTT outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } +/// Pure outbound publish adapter driven by `pump_sink`. +/// +/// Wraps the shared rumqttc client. `qos`/`retain` come from the route's protocol +/// options (threaded through by `pump_sink` via [`ConnectorConfig::from_query`]), +/// interpreted with MQTT's legacy defaults — **QoS 1 (`AtLeastOnce`)** when +/// unspecified, no retain — so the wire stays byte-identical to the old loop. +struct MqttSink { + client: Arc, +} - futures +impl MqttSink { + /// Look up a protocol option by key and parse it. + fn opt(config: &ConnectorConfig, key: &str) -> Option { + config + .protocol_options + .iter() + .find(|(k, _)| k == key) + .and_then(|(_, v)| v.parse().ok()) } } -// Implement the connector trait from aimdb-core -impl aimdb_core::transport::Connector for MqttConnectorImpl { +impl Connector for MqttSink { fn publish( &self, destination: &str, - config: &aimdb_core::transport::ConnectorConfig, + config: &ConnectorConfig, payload: &[u8], - ) -> core::pin::Pin< - Box< - dyn core::future::Future> - + Send - + '_, - >, - > { - use aimdb_core::transport::PublishError; - - // Destination is already the MQTT topic (from ConnectorUrl::resource_id()) + ) -> Pin> + Send + '_>> { + // Legacy defaults: QoS 1 when no `qos` query option, no retain. + let qos = Self::opt::(config, "qos").unwrap_or(1); + let retain = Self::opt::(config, "retain").unwrap_or(false); + + // Destination is already the MQTT topic (from ConnectorUrl::resource_id()). let topic = destination.to_string(); let payload_owned = payload.to_vec(); - let qos = config.qos; - let retain = config.retain; let client = self.client.clone(); Box::pin(async move { - // Determine QoS let qos_level = match qos { 0 => rumqttc::QoS::AtMostOnce, 1 => rumqttc::QoS::AtLeastOnce, @@ -449,7 +306,6 @@ impl aimdb_core::transport::Connector for MqttConnectorImpl { _ => return Err(PublishError::UnsupportedQoS), }; - // Publish the message #[cfg(feature = "tracing")] let topic_for_log = topic.clone(); @@ -468,43 +324,29 @@ impl aimdb_core::transport::Connector for MqttConnectorImpl { Ok(()) }) } - - // Note: subscribe() method removed in v0.2.0 - // Inbound routing now uses the MqttRouter passed to new() } -/// Builds the MQTT event-loop future with router-based dispatch. -/// -/// The event loop is required by rumqttc to handle: -/// - Network I/O (reading/writing packets) -/// - Reconnection logic -/// - QoS handshakes -/// - Routing incoming publishes to AimDB producers -/// -/// Returns a `BoxFuture` that is appended to the `AimDbRunner` accumulator. +/// Inbound frame source driven by `pump_source`. /// -/// # Arguments -/// * `event_loop` - The rumqttc EventLoop to run -/// * `_broker_key` - Broker identifier for logging (unused in release builds) -/// * `router` - Router for dispatching messages to producers -/// * `runtime_ctx` - Optional type-erased runtime for context-aware deserializers -fn build_event_loop_future( - mut event_loop: EventLoop, - _broker_key: String, - router: Arc, - runtime_ctx: Option>, -) -> BoxFuture { - Box::pin(async move { - #[cfg(feature = "tracing")] - tracing::debug!("MQTT event loop started for {}", _broker_key); +/// Yields `(topic, payload)` for each incoming MQTT publish. The inner poll loop +/// discards non-publish packets — keeping QoS handshakes and keepalive flowing — +/// and backs off 5s on a connection error before retrying, reproducing the old +/// hand-rolled event-loop future exactly. It never yields `None`: the reader runs +/// for the lifetime of the connector. +struct MqttEventLoopSource { + event_loop: EventLoop, + #[cfg(feature = "tracing")] + broker_key: String, +} - loop { - match event_loop.poll().await { - Ok(notification) => { - // Route incoming publishes via the router - if let rumqttc::Event::Incoming(Packet::Publish(publish)) = notification { +impl Source for MqttEventLoopSource { + fn next(&mut self) -> BoxFut<'_, Option<(String, Payload)>> { + Box::pin(async move { + loop { + match self.event_loop.poll().await { + Ok(Event::Incoming(Packet::Publish(publish))) => { let topic = publish.topic.clone(); - let payload = publish.payload.to_vec(); + let payload: Payload = Arc::from(publish.payload.as_ref()); #[cfg(feature = "tracing")] tracing::debug!( @@ -513,24 +355,21 @@ fn build_event_loop_future( payload.len() ); - // Route to appropriate producer(s) - if let Err(_e) = router.route(&topic, &payload, runtime_ctx.as_ref()).await - { - #[cfg(feature = "tracing")] - tracing::error!("Failed to route message on topic '{}': {}", topic, _e); - } + return Some((topic, payload)); } - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("MQTT event loop error for {}: {:?}", _broker_key, _e); + // Non-publish packets (PUBACK/PINGRESP/…) keep driving the protocol. + Ok(_) => continue, + Err(_e) => { + #[cfg(feature = "tracing")] + tracing::error!("MQTT event loop error for {}: {:?}", self.broker_key, _e); - // Wait before reconnecting - tokio::time::sleep(Duration::from_secs(5)).await; + // Wait before reconnecting. + tokio::time::sleep(Duration::from_secs(5)).await; + } } } - } - }) + }) + } } #[cfg(test)] @@ -542,7 +381,7 @@ mod tests { async fn test_connector_creation_with_router() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router, None).await; + MqttConnectorImpl::build_internal("mqtt://localhost:1883", None, router).await; assert!(connector.is_ok()); } @@ -550,15 +389,14 @@ mod tests { async fn test_connector_with_port() { let router = RouterBuilder::new().build(); let connector = - MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router, None).await; + MqttConnectorImpl::build_internal("mqtt://broker.local:9999", None, router).await; assert!(connector.is_ok()); } #[tokio::test] async fn test_invalid_url() { let router = RouterBuilder::new().build(); - let connector = - MqttConnectorImpl::build_internal("not-a-valid-url", None, router, None).await; + let connector = MqttConnectorImpl::build_internal("not-a-valid-url", None, router).await; assert!(connector.is_err()); } } diff --git a/aimdb-tokio-adapter/CHANGELOG.md b/aimdb-tokio-adapter/CHANGELOG.md index e1fa6518..aa40c5ea 100644 --- a/aimdb-tokio-adapter/CHANGELOG.md +++ b/aimdb-tokio-adapter/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Notes +- **Drain integration tests now stand up the AimX server via `aimdb-uds-connector::UdsServer`** (new dev-dependency) instead of the removed `AimDbBuilder::with_remote_access(config)`, and connect with the engine-based `aimdb-client::AimxConnection` (Issue #39). Test-only; no production change. - `BufferOps::spawn_dispatcher` (a test-only utility) is unchanged — it calls `tokio::spawn` directly and does not depend on the deleted `Spawn` trait. - `tests/stage_profiling.rs` dropped the `avg == total / call_count` assertion: it is a tautology (`avg_time_ns()` is *defined* as that quotient) and racy while the source task is still producing. No coverage lost. diff --git a/aimdb-tokio-adapter/Cargo.toml b/aimdb-tokio-adapter/Cargo.toml index 7827782e..4d2f1a81 100644 --- a/aimdb-tokio-adapter/Cargo.toml +++ b/aimdb-tokio-adapter/Cargo.toml @@ -57,5 +57,7 @@ futures = { workspace = true } # For drain integration tests aimdb-client = { version = "0.6.0", path = "../aimdb-client" } +# Stands up the AimX UDS server (`UdsServer`) the drain tests connect to. +aimdb-uds-connector = { version = "0.1.0", path = "../aimdb-uds-connector" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/aimdb-tokio-adapter/tests/drain_integration_tests.rs b/aimdb-tokio-adapter/tests/drain_integration_tests.rs index 347de22b..bcc6d92d 100644 --- a/aimdb-tokio-adapter/tests/drain_integration_tests.rs +++ b/aimdb-tokio-adapter/tests/drain_integration_tests.rs @@ -2,17 +2,19 @@ //! //! Tests the full record history drain pipeline: //! - AimDB server with remote access enabled -//! - AimxClient connecting via Unix domain socket +//! - AimxConnection connecting via Unix domain socket //! - record.drain protocol method (cold start, accumulation, limits, overflow) //! //! These tests exercise: handler.rs dispatch → ConnectionState.drain_readers → //! JsonReaderAdapter.try_recv_json() → TokioBufferReader.try_recv() -use aimdb_client::{AimxClient, DrainResponse}; +use aimdb_client::AimxConnection; +use aimdb_client::DrainResponse; use aimdb_core::buffer::BufferCfg; use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::AimDbBuilder; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -51,7 +53,7 @@ async fn setup_test_server(socket_path: &str) -> aimdb_core::AimDb let mut builder = AimDbBuilder::new() .runtime(adapter) - .with_remote_access(remote_config); + .with_connector(UdsServer::from_config(remote_config)); // SpmcRing for drain testing (capacity 20) builder.configure::("test::Temperature", |reg| { @@ -82,7 +84,7 @@ async fn setup_small_ring_server(socket_path: &str) -> aimdb_core::AimDb("test::Counter", |reg| { reg.buffer(BufferCfg::SpmcRing { capacity: 4 }) @@ -107,7 +109,7 @@ async fn setup_no_remote_access_server(socket_path: &str) -> aimdb_core::AimDb("test::Counter", |reg| { @@ -142,7 +144,7 @@ async fn test_drain_cold_start_returns_empty() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; // Connect client - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // First drain creates the reader — returns empty (cold start) let response = client.drain_record("test::Temperature").await.unwrap(); @@ -169,7 +171,7 @@ async fn test_drain_returns_accumulated_values() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // First drain — cold start let response = client.drain_record("test::Temperature").await.unwrap(); @@ -223,7 +225,7 @@ async fn test_drain_sequential_only_new_values() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -293,7 +295,7 @@ async fn test_drain_with_limit() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -358,7 +360,7 @@ async fn test_drain_single_latest_at_most_one() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Counter").await.unwrap(); @@ -399,7 +401,7 @@ async fn test_drain_nonexistent_record_error() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Drain a record that doesn't exist let result = client.drain_record("test::DoesNotExist").await; @@ -421,7 +423,7 @@ async fn test_drain_requires_remote_access() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Drain a record that exists but lacks .with_remote_access() let result = client.drain_record("test::Counter").await; @@ -446,7 +448,7 @@ async fn test_drain_with_ring_overflow() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start — creates the drain reader let _ = client.drain_record("test::Counter").await.unwrap(); @@ -494,7 +496,7 @@ async fn test_drain_multiple_records_independent() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start both records let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -545,7 +547,7 @@ async fn test_drain_response_structure() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -590,7 +592,7 @@ async fn test_drain_with_zero_limit() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start let _ = client.drain_record("test::Temperature").await.unwrap(); @@ -635,7 +637,7 @@ async fn test_drain_independent_of_other_consumers() { tokio::time::sleep(std::time::Duration::from_millis(100)).await; - let mut client = AimxClient::connect(&socket_path).await.unwrap(); + let client = AimxConnection::connect(&socket_path).await.unwrap(); // Cold start the drain reader let _ = client.drain_record("test::Temperature").await.unwrap(); diff --git a/aimdb-uds-connector/CHANGELOG.md b/aimdb-uds-connector/CHANGELOG.md new file mode 100644 index 00000000..b48f7f7e --- /dev/null +++ b/aimdb-uds-connector/CHANGELOG.md @@ -0,0 +1,14 @@ +# Changelog - aimdb-uds-connector + +All notable changes to the `aimdb-uds-connector` crate will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- **New crate — the Unix-domain-socket transport for AimDB remote access (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** A thin, swappable transport that rides the shared session engine in `aimdb-core::session`: it contributes only the `Dialer`/`Listener`/`Connection` triple (`UdsDialer` / `UdsListener` / `UdsConnection`, NDJSON framing in the transport — one line per logical frame); the AimX codec + dispatch and the engine wiring are reused verbatim from core. Two ergonomic constructors: + - **`UdsServer`** — accepts connections and serves the full AimX toolset over a socket. Register it via `with_connector` to stand up remote access (this replaces `aimdb-core`'s removed `AimDbBuilder::with_remote_access(config)`). Sugar over `SessionServerConnector`; `UdsServer::from_config(config)` is the one-line migration, plus `new`/`max_connections`/`max_subs_per_connection`/`socket_permissions`/`scheme` builders. Binds synchronously (remove-stale → `bind` → `set_permissions`) so bind errors surface from `build()`, and applies the security policy's writable-record marking. + - **`UdsClient`** — dials a peer over UDS and mirrors records under a scheme (default `"uds"`), using `link_to`/`link_from` like any data-plane connector. Sugar over `SessionClientConnector`; chain `.scheme(...)` / `.with_config(...)`. diff --git a/aimdb-uds-connector/Cargo.toml b/aimdb-uds-connector/Cargo.toml new file mode 100644 index 00000000..d6d9fe07 --- /dev/null +++ b/aimdb-uds-connector/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "aimdb-uds-connector" +version = "0.1.0" +edition = "2021" +license.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Unix-domain-socket transport connector for AimDB: record mirroring and remote access (AimX over UDS)" +keywords = ["aimdb", "connector", "uds", "unix-socket", "remote"] +categories = ["network-programming", "database"] + +[features] +default = [] +tracing = ["dep:tracing", "aimdb-core/tracing"] + +[dependencies] +# AimX protocol (codec + dispatch) and the generic session connectors live in +# core; this crate contributes only the UDS transport triple + sugar. UDS is +# Linux/std-only, so there is no Embassy half. +aimdb-core = { version = "1.1.0", path = "../aimdb-core", features = [ + "std", + "connector-session", +] } +aimdb-executor = { version = "0.2.0", path = "../aimdb-executor", default-features = false } + +tokio = { version = "1", features = ["net", "io-util"] } + +tracing = { version = "0.1", optional = true } + +[dev-dependencies] +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/aimdb-uds-connector/src/lib.rs b/aimdb-uds-connector/src/lib.rs new file mode 100644 index 00000000..cfdc613b --- /dev/null +++ b/aimdb-uds-connector/src/lib.rs @@ -0,0 +1,260 @@ +//! Unix-domain-socket transport connector for AimDB — record mirroring and +//! remote access over a local socket. +//! +//! A thin, swappable transport crate: it contributes only the +//! `Dialer`/`Listener`/`Connection` triple ([`UdsConnection`] / +//! [`UdsDialer`] / [`UdsListener`]); the AimX codec + dispatch and the engine +//! wiring are reused from `aimdb-core`. Two ergonomic constructors wrap the +//! generic core connectors: +//! +//! - [`UdsClient`] — dials a peer over UDS and mirrors records under a scheme +//! (`"uds"` by default), using `link_to`/`link_from` like any data-plane +//! connector. Sugar over [`SessionClientConnector`]``. +//! - [`UdsServer`] — accepts connections and serves the AimX toolset over UDS; +//! register it with `with_connector` to stand up remote access. Sugar over +//! [`SessionServerConnector`]. +//! +//! ```rust,ignore +//! use aimdb_uds_connector::{UdsClient, UdsServer}; +//! +//! // server: expose this db over a socket (no links) +//! AimDbBuilder::new().runtime(rt) +//! .with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) +//! .build().await?; +//! +//! // client: mirror a record to a peer over the socket +//! AimDbBuilder::new().runtime(rt) +//! .with_connector(UdsClient::new("/run/aimdb.sock")) +//! .configure::("temp", |r| { r.with_remote_access().link_to("uds://temp")...; }) +//! .build().await?; +//! ``` + +mod transport; + +pub use transport::{UdsConnection, UdsDialer, UdsListener}; + +use std::future::Future; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use aimdb_core::connector::ConnectorBuilder; +use aimdb_core::remote::AimxConfig; +use aimdb_core::session::aimx::{AimxCodec, AimxDispatch}; +use aimdb_core::session::{ + Dispatch, SessionClientConnector, SessionConfig, SessionLimits, SessionServerConnector, +}; +use aimdb_core::{AimDb, DbError, DbResult, RuntimeAdapter}; + +type BoxFuture = Pin + Send + 'static>>; +type BuildFuture<'a> = Pin>> + Send + 'a>>; + +/// The default scheme `UdsClient`/`UdsServer` register when none is given. +/// +/// Transport-matched (like MQTT's `"mqtt"`), so `link_to("uds://")` reads +/// at the call site. Override with `.scheme(...)` when running more than one +/// remote connector. +pub const DEFAULT_SCHEME: &str = "uds"; + +// =========================================================================== +// Client sugar +// =========================================================================== + +/// Constructs a [`SessionClientConnector`] that dials an AimX peer over a +/// Unix-domain socket. `UdsClient::new(path)` is sugar; chain `.scheme(...)` / +/// `.with_config(...)` on the returned connector. +pub struct UdsClient; + +impl UdsClient { + /// Mirror records to/from the AimX peer listening at `socket_path` (scheme + /// defaults to [`DEFAULT_SCHEME`]). + // Sugar constructor: intentionally returns the generic connector, not `Self`. + #[allow(clippy::new_ret_no_self)] + pub fn new(socket_path: impl Into) -> SessionClientConnector { + SessionClientConnector::new(UdsDialer::new(socket_path), AimxCodec).scheme(DEFAULT_SCHEME) + } +} + +// =========================================================================== +// Server sugar +// =========================================================================== + +/// Accepts AimX connections over a Unix-domain socket and serves the full AimX +/// toolset. Register it via `with_connector` to stand up remote access: +/// +/// ```rust,ignore +/// builder.with_connector(UdsServer::new("/run/aimdb.sock").max_connections(32)) +/// ``` +/// +/// Unlike a data-plane connector, a server takes **no** `link_to`/`link_from` — +/// it answers introspection/subscribe/write for whatever records exist. +pub struct UdsServer { + config: AimxConfig, + scheme: String, +} + +impl UdsServer { + /// Serve AimX over the socket at `socket_path`, with default limits/policy. + pub fn new(socket_path: impl Into) -> Self { + Self { + config: AimxConfig::uds_default().socket_path(socket_path), + scheme: DEFAULT_SCHEME.to_string(), + } + } + + /// Build from a full [`AimxConfig`] (the one-line migration from the former + /// `AimDbBuilder::with_remote_access`). + pub fn from_config(config: AimxConfig) -> Self { + Self { + config, + scheme: DEFAULT_SCHEME.to_string(), + } + } + + /// Maximum concurrently served connections. + pub fn max_connections(mut self, max: usize) -> Self { + self.config = self.config.max_connections(max); + self + } + + /// Maximum live subscriptions per connection. + pub fn max_subs_per_connection(mut self, max: usize) -> Self { + self.config = self.config.max_subs_per_connection(max); + self + } + + /// Socket file permissions (octal mode, e.g. `0o600`). + pub fn socket_permissions(mut self, mode: u32) -> Self { + self.config = self.config.socket_permissions(mode); + self + } + + /// Override the scheme this connector registers. + pub fn scheme(mut self, scheme: impl Into) -> Self { + self.scheme = scheme.into(); + self + } +} + +impl ConnectorBuilder for UdsServer +where + R: RuntimeAdapter + 'static, +{ + fn build<'a>(&'a self, db: &'a AimDb) -> BuildFuture<'a> { + let config = self.config.clone(); + let scheme = self.scheme.clone(); + Box::pin(async move { + let session_config = SessionConfig { + limits: SessionLimits { + max_connections: config.max_connections, + max_subs_per_connection: config.max_subs_per_connection, + }, + reads_hello: false, + // AimX's subscribe ack stays implicit (events flow); no ack frame. + acks_subscribe: false, + }; + let bind_config = config.clone(); + let dispatch_config = config; + // Reuse the generic spine: bind (errors surface synchronously) + AimX + // dispatch over the AimX codec. + let connector = SessionServerConnector::new( + move || bind_uds_listener(&bind_config), + AimxCodec, + move |db: &AimDb| -> Arc { + // Apply the security policy's writable marking so `record.list` + // reports the `writable` flag (the dispatch also enforces it). + apply_writable(db, &dispatch_config); + Arc::new(AimxDispatch::new( + Arc::new(db.clone()), + dispatch_config.clone(), + )) + }, + session_config, + ) + .scheme(scheme); + connector.build(db).await + }) + } + + fn scheme(&self) -> &str { + &self.scheme + } +} + +// =========================================================================== +// Shared bind / writable helpers +// =========================================================================== + +/// Bind the Unix-domain socket synchronously (remove a stale socket file, +/// `bind`, `set_permissions`) so bind errors surface from `build`. +fn bind_uds_listener(config: &AimxConfig) -> DbResult { + #[cfg(feature = "tracing")] + tracing::info!( + "Initializing AimX UDS server on socket: {}", + config.socket_path.display() + ); + + if config.socket_path.exists() { + std::fs::remove_file(&config.socket_path).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to remove existing socket file {}", + config.socket_path.display() + ), + source: e, + })?; + } + + let listener = tokio::net::UnixListener::bind(&config.socket_path).map_err(|e| { + DbError::IoWithContext { + context: format!( + "Failed to bind Unix socket at {}", + config.socket_path.display() + ), + source: e, + } + })?; + + let permissions = config.socket_permissions.unwrap_or(0o600); + let mut perms = std::fs::metadata(&config.socket_path) + .map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to read socket metadata for {}", + config.socket_path.display() + ), + source: e, + })? + .permissions(); + perms.set_mode(permissions); + std::fs::set_permissions(&config.socket_path, perms).map_err(|e| DbError::IoWithContext { + context: format!( + "Failed to set socket permissions for {}", + config.socket_path.display() + ), + source: e, + })?; + + #[cfg(feature = "tracing")] + tracing::info!( + "AimX socket bound at {} (mode {:o})", + config.socket_path.display(), + permissions + ); + + Ok(UdsListener::new(listener)) +} + +/// Mark each record named in the policy's writable set as writable, so +/// `record.list` advertises the `writable` flag. +fn apply_writable(db: &AimDb, config: &AimxConfig) +where + R: RuntimeAdapter + 'static, +{ + for key in config.security_policy.writable_records() { + if let Some(id) = db.inner().resolve_str(&key) { + if let Some(storage) = db.inner().storage(id) { + storage.set_writable_erased(true); + } + } + } +} diff --git a/aimdb-uds-connector/src/transport.rs b/aimdb-uds-connector/src/transport.rs new file mode 100644 index 00000000..cdb832c3 --- /dev/null +++ b/aimdb-uds-connector/src/transport.rs @@ -0,0 +1,132 @@ +//! AimX UDS transport — a [`Connection`] over a Unix-domain socket with NDJSON +//! framing in the transport: one line == one logical frame. +//! +//! Both roles ride the same role-neutral [`UdsConnection`]: the dialing half +//! ([`UdsDialer`], driven by `run_client`) and the accepting half +//! ([`UdsListener`], driven by `serve`). + +use std::path::PathBuf; + +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf}; +use tokio::net::{UnixListener, UnixStream}; + +use aimdb_core::session::{ + BoxFut, Connection, Dialer, Listener, PeerInfo, TransportError, TransportResult, +}; + +/// A framed bidirectional pipe over a Unix-domain socket. Framing lives in the +/// transport: [`recv`](Connection::recv) returns one newline-delimited frame +/// (newline stripped); [`send`](Connection::send) appends the newline. +pub struct UdsConnection { + reader: BufReader, + writer: OwnedWriteHalf, + peer: PeerInfo, +} + +impl UdsConnection { + /// Wrap an already-connected [`UnixStream`] (used by both the dialer and the + /// server-side listener). + pub fn new(stream: UnixStream) -> Self { + let (read_half, write_half) = stream.into_split(); + Self { + reader: BufReader::new(read_half), + writer: write_half, + peer: PeerInfo::default(), + } + } +} + +impl Connection for UdsConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(async move { + let mut line = String::new(); + match self.reader.read_line(&mut line).await { + Ok(0) => Ok(None), // EOF — peer closed + Ok(_) => { + // Strip the trailing '\n' (and a stray '\r' if present); the + // frame is the line content, the codec owns the rest. + while matches!(line.as_bytes().last(), Some(b'\n' | b'\r')) { + line.pop(); + } + Ok(Some(line.into_bytes())) + } + Err(_) => Err(TransportError::Io), + } + }) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + Box::pin(async move { + self.writer + .write_all(frame) + .await + .map_err(|_| TransportError::Closed)?; + self.writer + .write_all(b"\n") + .await + .map_err(|_| TransportError::Closed)?; + self.writer + .flush() + .await + .map_err(|_| TransportError::Closed) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// The initiating (client) side: dials a Unix-domain socket and yields a +/// [`UdsConnection`]. Cheap to clone (just the path), so `run_client` can redial +/// on reconnect and the generic `SessionClientConnector` can hold it. +#[derive(Clone)] +pub struct UdsDialer { + socket_path: PathBuf, +} + +impl UdsDialer { + /// Dial the socket at `socket_path` on each [`connect`](Dialer::connect). + pub fn new(socket_path: impl Into) -> Self { + Self { + socket_path: socket_path.into(), + } + } +} + +impl Dialer for UdsDialer { + fn connect(&self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let stream = UnixStream::connect(&self.socket_path) + .await + .map_err(|_| TransportError::Io)?; + Ok(Box::new(UdsConnection::new(stream)) as Box) + }) + } +} + +/// The accepting (server) side: wraps an already-bound [`UnixListener`] and +/// yields a [`UdsConnection`] per accepted client. The dual of [`UdsDialer`]; +/// `serve` drives it. Socket setup (remove-stale / `bind` / `set_permissions`) +/// happens once in [`UdsServer`](crate::UdsServer)'s bind step before the +/// listener is handed here. +pub struct UdsListener { + inner: UnixListener, +} + +impl UdsListener { + /// Wrap an already-bound [`UnixListener`]. + pub fn new(inner: UnixListener) -> Self { + Self { inner } + } +} + +impl Listener for UdsListener { + fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + Box::pin(async move { + let (stream, _addr) = self.inner.accept().await.map_err(|_| TransportError::Io)?; + Ok(Box::new(UdsConnection::new(stream)) as Box) + }) + } +} diff --git a/aimdb-websocket-connector/CHANGELOG.md b/aimdb-websocket-connector/CHANGELOG.md index 806a5c5b..33bfe309 100644 --- a/aimdb-websocket-connector/CHANGELOG.md +++ b/aimdb-websocket-connector/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Internal refactors +- **WebSocket server + client ported onto the shared session engine (Issue #39, [design doc](../docs/design/remote-access-via-connectors.md)).** Behavior-preserving (wire-identical, gated by a round-trip test): the WS server now runs on `aimdb-core`'s `serve`/`run_session` and the client on `run_client`, so the two hand-rolled WS stacks collapse onto the same engines as AimX. New modules: `codec` (`WsCodec`, the per-connection WS-JSON `EnvelopeCodec` — id↔topic bookkeeping, O(1) fan-out by writing the bus-pre-serialized `Data` frame verbatim, zero-copy `decode_outbound` replacing the old `&'static` topic interner), `transport` (`WsServerConnection`/`WsClientConnection`/`WsDialer` over axum / tokio-tungstenite, including the multi-topic `Subscribe`/`Unsubscribe` split), and `dispatch` (`WsDispatch`/`WsSession` homing the `ClientManager` bus + auth + query/snapshot). The hand-rolled `client/connector.rs` loop is removed; `client_manager`/`session` slim down to a fan-out bus + snapshot/query providers. Public `WebSocketConnectorBuilder` / `WsClientConnectorBuilder` surfaces are unchanged (the client builder now bounds `R: TimeOps` for the engine clock). Added `examples/ws_server.rs`, `tests/ws_roundtrip.rs`, and a dev-dep on `aimdb-tokio-adapter`. - **WS client connector is now spawn-free (Issue #114, Design 030).** All six `tokio::spawn` call sites in the client connector (initial write/read/keepalive/reconnect-watcher plus the watcher's per-reconnect read/write loops) collapsed into one infrastructure future that owns a `FuturesUnordered` driven by `tokio::select! { biased; }`. The reconnect watcher no longer spawns; on a successful reconnect it sends a `NewLoops { write_sink, read_stream, write_rx }` over an mpsc to the outer future, which pushes fresh read- and write-loop futures onto the set. - `WsClientConnectorImpl::connect()` return type changed from `Result` to `Result<(Self, BoxFuture), String>` — the second element is the infrastructure future; the builder prepends it to the outbound publisher futures before returning to `AimDbBuilder`. - Internal-only API change; no impact on the public `WsClientConnectorBuilder` or `ConnectorBuilder` surfaces. diff --git a/aimdb-websocket-connector/Cargo.toml b/aimdb-websocket-connector/Cargo.toml index b18add5e..ac34be4e 100644 --- a/aimdb-websocket-connector/Cargo.toml +++ b/aimdb-websocket-connector/Cargo.toml @@ -71,3 +71,13 @@ tracing = { version = "0.1", optional = true } [dev-dependencies] tokio = { version = "1", features = ["full", "test-util"] } tokio-tungstenite = "0.26" +aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter" } + +# Runnable demos — see their headers for the two-terminal workflow. +[[example]] +name = "ws_server" +required-features = ["server"] + +[[example]] +name = "ws_client" +required-features = ["client"] diff --git a/aimdb-websocket-connector/examples/ws_client.rs b/aimdb-websocket-connector/examples/ws_client.rs new file mode 100644 index 00000000..408b3819 --- /dev/null +++ b/aimdb-websocket-connector/examples/ws_client.rs @@ -0,0 +1,89 @@ +//! Minimal runnable WebSocket **client** demo — pairs with the `ws_server` example +//! to show AimDB ↔ AimDB mirroring over a real socket. +//! +//! Two terminals: +//! ```text +//! cargo run -p aimdb-websocket-connector --example ws_server +//! cargo run -p aimdb-websocket-connector --example ws_client --features client +//! ``` +//! +//! The client dials the server at `ws://127.0.0.1:8080/ws` and: +//! - mirrors the server's ticking `counter` into a local record (printing each +//! update) — `link_from("ws-client://counter")` ← the server's `link_to("ws://counter")`; +//! - writes an `echo` record back to the server — `link_to("ws-client://echo")` +//! → the server's `link_from("ws://echo")`, which the server prints. + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::{buffer::BufferCfg, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::WsClientConnector; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Counter { + n: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Echo { + msg: String, +} + +#[tokio::main] +async fn main() { + let url = "ws://127.0.0.1:8080/ws"; + println!("ws_client: dialing {url} (start `--example ws_server` first)"); + + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WsClientConnector::new(url)); + + // Subscribe to the server's `counter` and mirror it into a local record. + cb.configure::("counter", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws-client://counter") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + + // Produce an `echo` locally; the client connector writes it to the server. + cb.configure::("echo", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws-client://echo") + .with_serializer_raw(|e: &Echo| Ok(serde_json::to_vec(e).unwrap())) + .finish(); + }); + + let (db, runner) = cb.build().await.expect("build client db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + // Each tick: write an echo (the server prints it) and print the mirrored + // counter whenever it advances. + let mut last: Option = None; + let mut tick = 0u64; + loop { + tick += 1; + db.set_record_from_json( + "echo", + json!({ "msg": format!("hello #{tick} from ws_client") }), + ) + .ok(); + + if let Some(v) = db.try_latest_as_json("counter") { + if last.as_ref() != Some(&v) { + println!("← counter from server: {v}"); + last = Some(v); + } + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } +} diff --git a/aimdb-websocket-connector/examples/ws_server.rs b/aimdb-websocket-connector/examples/ws_server.rs new file mode 100644 index 00000000..94913f9b --- /dev/null +++ b/aimdb-websocket-connector/examples/ws_server.rs @@ -0,0 +1,89 @@ +//! Minimal runnable WebSocket **server** demo (doc 039-validation Layer 5) — the +//! first real consumer of the connector and a manual-smoke vehicle. +//! +//! Run: +//! ```text +//! cargo run -p aimdb-websocket-connector --example ws_server +//! ``` +//! Then connect a client and subscribe to the ticking `counter` record: +//! ```text +//! wscat -c ws://127.0.0.1:8080/ws +//! > {"type":"subscribe","topics":["counter"]} +//! < {"type":"subscribed","topics":["counter"]} +//! < {"type":"data","topic":"counter","payload":{"n":1},"ts":...} +//! ``` +//! Or write to the inbound `echo` record: +//! ```text +//! > {"type":"write","topic":"echo","payload":{"msg":"hi"}} +//! ``` + +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::AimDbBuilder; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::WebSocketConnector; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Counter { + n: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Echo { + msg: String, +} + +#[tokio::main] +async fn main() { + let addr = "127.0.0.1:8080"; + + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector( + WebSocketConnector::new() + .bind(addr) + .path("/ws") + .with_late_join(true), + ); + + // Outbound: pushed to every subscribed client. + sb.configure::("counter", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://counter") + .with_serializer_raw(|c: &Counter| Ok(serde_json::to_vec(c).unwrap())) + .finish(); + }); + // Inbound: clients may `write` to it. + sb.configure::("echo", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws://echo") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + + let (db, runner) = sb.build().await.expect("build db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + + println!("WS server listening on ws://{addr}/ws"); + println!(" subscribe: wscat -c ws://{addr}/ws → {{\"type\":\"subscribe\",\"topics\":[\"counter\"]}}"); + + let mut n = 0u64; + loop { + n += 1; + db.set_record_from_json("counter", json!({ "n": n })) + .expect("set counter"); + if let Some(echo) = db.try_latest_as_json("echo") { + println!("echo record = {echo}"); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } +} diff --git a/aimdb-websocket-connector/src/client/builder.rs b/aimdb-websocket-connector/src/client/builder.rs index d8bccba5..99d725d0 100644 --- a/aimdb-websocket-connector/src/client/builder.rs +++ b/aimdb-websocket-connector/src/client/builder.rs @@ -17,11 +17,13 @@ //! └─ return Vec (drained by AimDbRunner) //! ``` -use std::{pin::Pin, sync::Arc, time::Duration}; +use std::pin::Pin; -use aimdb_core::{router::RouterBuilder, ConnectorBuilder}; +use aimdb_core::session::{pump_client, run_client, ClientConfig}; +use aimdb_core::ConnectorBuilder; -use super::connector::WsClientConnectorImpl; +use crate::codec::WsCodec; +use crate::transport::WsDialer; // ════════════════════════════════════════════════════════════════════ // Builder @@ -134,7 +136,7 @@ type BoxFuture = Pin + Send + 'static> impl ConnectorBuilder for WsClientConnectorBuilder where - R: aimdb_executor::RuntimeAdapter + 'static, + R: aimdb_executor::TimeOps + 'static, { fn scheme(&self) -> &str { "ws-client" @@ -146,68 +148,38 @@ where ) -> Pin>> + Send + 'a>> { Box::pin(async move { - // ── Inbound routes ────────────────────────────────────── - let inbound_routes = db.collect_inbound_routes("ws-client"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: {} inbound routes collected", - inbound_routes.len() - ); - - let router = Arc::new(RouterBuilder::from_routes(inbound_routes).build()); - - // ── Outbound routes ────────────────────────────────────── - let outbound_routes = db.collect_outbound_routes("ws-client"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: {} outbound routes collected", - outbound_routes.len() - ); - - // ── Resolve subscribe topics ───────────────────────────── - // Merge explicit subscribe_topics with topics derived from inbound routes - let mut topics: Vec = self.subscribe_topics.clone(); - for resource_id in router.resource_ids() { - let topic = resource_id.to_string(); - if !topics.contains(&topic) { - topics.push(topic); - } - } - - // ── Build client config ───────────────────────────────── - let config = super::connector::WsClientConfig { - url: self.url.clone(), - auto_reconnect: self.auto_reconnect, + // ── Engine config from the WS-specific knobs (doc 039 § 5) ── + // Reconnect/keepalive/offline-queue are now `ClientConfig`/engine + // concerns; `topic_routed_subs` keys the demux by topic (the WS wire + // pushes `Data{topic}` with no id). + let config = ClientConfig { + reconnect: self.auto_reconnect, + reconnect_delay: 200, + max_reconnect_delay: 30_000, max_reconnect_attempts: self.max_reconnect_attempts, keepalive_interval: if self.keepalive_ms > 0 { - Some(Duration::from_millis(self.keepalive_ms)) + Some(self.keepalive_ms) } else { None }, max_offline_queue: self.max_offline_queue, - subscribe_topics: topics, + topic_routed_subs: true, + sends_hello: false, }; - // ── Build the connector and collect its infrastructure future ── - // The connector future owns a `FuturesUnordered` driving the - // write/read/keepalive loops and reconnect watcher; no - // `tokio::spawn` is involved. - let (connector, connector_future) = WsClientConnectorImpl::connect(config, router, db) - .await - .map_err(|e| aimdb_core::DbError::RuntimeError { - message: format!("WS client connect failed: {}", e), - })?; - - // ── Collect outbound publisher futures ─────────────────── - let mut futures = connector.collect_outbound_futures(db, outbound_routes); - - // Prepend the connector's infrastructure future so it gets - // driven alongside the per-route publishers. Order does not - // matter to `FuturesUnordered`, but front-loading the long- - // running infra future keeps logs readable. - futures.insert(0, connector_future); + // ── Drive the shared client engine + record-mirroring pumps ── + // Like `UdsClient`: `run_client` owns demux/reconnect/keepalive over + // the WS `Dialer` + per-connection `WsCodec`; `pump_client` wires + // `link_to`/`link_from` routes to the handle. + // The runtime's `TimeOps` clock drives reconnect backoff/keepalive. + let (handle, engine_fut) = run_client( + WsDialer::new(self.url.clone()), + WsCodec::new(), + config, + db.runtime_arc(), + ); + let mut futures = pump_client(db, "ws-client", &handle); + futures.push(engine_fut); Ok(futures) }) } diff --git a/aimdb-websocket-connector/src/client/connector.rs b/aimdb-websocket-connector/src/client/connector.rs deleted file mode 100644 index 09eb2a82..00000000 --- a/aimdb-websocket-connector/src/client/connector.rs +++ /dev/null @@ -1,704 +0,0 @@ -//! WebSocket client connector implementation. -//! -//! [`WsClientConnectorImpl`] manages a `tokio-tungstenite` WebSocket connection -//! to a remote AimDB server, with: -//! -//! - **Inbound routing**: `ServerMessage::Data/Snapshot` → `Router::route()` -//! - **Outbound publishing**: `subscribe_any() → recv_any() → Write` message -//! - **Reconnection**: exponential backoff with configurable limits -//! - **Keepalive**: periodic `Ping` messages -//! - **Offline queue**: queued writes during disconnection - -use std::{collections::VecDeque, pin::Pin, sync::Arc, time::Duration}; - -use aimdb_core::{ - router::Router, - transport::{ConnectorConfig, PublishError}, - OutboundRoute, -}; -use aimdb_ws_protocol::{ClientMessage, ServerMessage}; -use futures_util::stream::FuturesUnordered; -use futures_util::{SinkExt, StreamExt}; -use tokio::sync::{mpsc, Mutex}; - -/// Boxed `()`-yielding future used for the connector's nested -/// `FuturesUnordered`. Identical in shape to `aimdb_core::builder::BoxFuture`. -type BoxFuture = Pin + Send + 'static>>; - -/// Aliases for the split halves of the underlying WebSocket stream. -/// Defined once so the reconnect-watcher's `NewLoops` payload type -/// stays readable. -type WsStream = - tokio_tungstenite::WebSocketStream>; -type WsWriteSink = - futures_util::stream::SplitSink; -type WsReadStream = futures_util::stream::SplitStream; - -/// Sent from the reconnect watcher to the outer connector future after a -/// successful reconnect. The outer loop pushes one write-loop future and -/// one read-loop future built from these halves into its -/// `FuturesUnordered`. -struct NewLoops { - write_sink: WsWriteSink, - read_stream: WsReadStream, - write_rx: mpsc::UnboundedReceiver, -} - -// ════════════════════════════════════════════════════════════════════ -// Configuration -// ════════════════════════════════════════════════════════════════════ - -/// Internal configuration for the WS client connector. -pub(crate) struct WsClientConfig { - pub url: String, - pub auto_reconnect: bool, - pub max_reconnect_attempts: usize, - pub keepalive_interval: Option, - pub max_offline_queue: usize, - pub subscribe_topics: Vec, -} - -// ════════════════════════════════════════════════════════════════════ -// Connection status -// ════════════════════════════════════════════════════════════════════ - -/// Connection state of the WS client. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConnectionStatus { - Connecting, - Connected, - Disconnected, - Reconnecting, -} - -// ════════════════════════════════════════════════════════════════════ -// Shared state -// ════════════════════════════════════════════════════════════════════ - -/// Shared mutable state protected by a Mutex. -struct SharedState { - status: ConnectionStatus, - pending_writes: VecDeque, - max_offline_queue: usize, - /// The current write channel sender. Swapped atomically on reconnect so - /// that all producers (outbound publishers, publish(), keepalive) always - /// send through the live connection. - write_tx: mpsc::UnboundedSender, -} - -// ════════════════════════════════════════════════════════════════════ -// Connector implementation -// ════════════════════════════════════════════════════════════════════ - -/// Live WebSocket client connector. -/// -/// Created by [`WsClientConnectorBuilder::build()`]. Manages the connection -/// lifecycle and spawns background tasks for: -/// -/// - Receiving server messages and routing them via `Router` -/// - Sending outbound data from local record changes -/// - Keepalive pings -/// - Automatic reconnection -pub struct WsClientConnectorImpl { - /// Shared state for status, offline queue, and the current write channel. - state: Arc>, - /// Router for inbound data (server → local buffers). - #[allow(dead_code)] - router: Arc, -} - -impl WsClientConnectorImpl { - /// Connect to the remote WebSocket server and return a handle plus - /// the infrastructure future that drives the read/write/keepalive - /// loops and the reconnect watcher. - /// - /// The returned [`BoxFuture`] owns a [`FuturesUnordered`] holding all - /// background loops; dropping it (via the runner being cancelled) - /// terminates every loop in one step. On successful reconnect the - /// watcher sends a [`NewLoops`] message that the outer future - /// translates into two fresh futures pushed onto the set. - pub(crate) async fn connect( - config: WsClientConfig, - router: Arc, - db: &aimdb_core::builder::AimDb, - ) -> Result<(Self, BoxFuture), String> - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - // Connect to the remote server - let (ws_stream, _response) = tokio_tungstenite::connect_async(&config.url) - .await - .map_err(|e| format!("WebSocket connection failed: {e}"))?; - - #[cfg(feature = "tracing")] - tracing::info!("WS client: connected to {}", config.url); - - let (ws_write, ws_read) = ws_stream.split(); - - // Channel for sending text frames from any task to the write loop - let (write_tx, write_rx) = mpsc::unbounded_channel::(); - - let state = Arc::new(Mutex::new(SharedState { - status: ConnectionStatus::Connected, - pending_writes: VecDeque::new(), - max_offline_queue: config.max_offline_queue, - write_tx, - })); - - // ── Send subscribe message ────────────────────────────────── - // The mpsc buffers this until the write loop is first polled by - // the runner; the message is delivered as soon as the outer - // future starts. - if !config.subscribe_topics.is_empty() { - let sub_msg = ClientMessage::Subscribe { - topics: config.subscribe_topics.clone(), - }; - if let Ok(json) = serde_json::to_string(&sub_msg) { - let _ = state.lock().await.write_tx.send(json); - } - } - - let reconnect_url = config.url.clone(); - let reconnect_topics = config.subscribe_topics.clone(); - let auto_reconnect = config.auto_reconnect; - let max_reconnect_attempts = config.max_reconnect_attempts; - let keepalive_interval = config.keepalive_interval; - let runtime_ctx: Arc = db.runtime_any(); - - // Channel from the reconnect watcher to the outer future. The - // watcher sends a `NewLoops` on each successful reconnect; the - // outer future pushes a fresh write+read future onto its set. - let (new_loops_tx, mut new_loops_rx) = mpsc::unbounded_channel::(); - - let state_for_future = state.clone(); - let router_for_future = router.clone(); - let runtime_ctx_for_future = runtime_ctx.clone(); - - let connector_future: BoxFuture = Box::pin(async move { - let mut tasks: FuturesUnordered = FuturesUnordered::new(); - - // Initial write loop. On exit, mark the connection as - // disconnected so the reconnect watcher notices. - { - let state_for_write = state_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_write_loop(ws_write, write_rx).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: write loop ended"); - - state_for_write.lock().await.status = ConnectionStatus::Disconnected; - })); - } - - // Initial read loop. - { - let router_for_read = router_for_future.clone(); - let ctx_for_read = runtime_ctx_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_read_loop(ws_read, &router_for_read, Some(&ctx_for_read)).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: read loop ended"); - })); - } - - // Keepalive. - if let Some(interval) = keepalive_interval { - let ka_state = state_for_future.clone(); - tasks.push(Box::pin(Self::run_keepalive(ka_state, interval))); - } - - // Reconnect watcher. - if auto_reconnect { - let watcher_state = state_for_future.clone(); - let watcher_tx = new_loops_tx.clone(); - tasks.push(Box::pin(Self::run_reconnect_watcher( - watcher_state, - reconnect_url, - reconnect_topics, - max_reconnect_attempts, - watcher_tx, - ))); - } - // Drop the watcher's sender clone we still hold so the - // `new_loops_rx.recv()` returns `None` once the watcher - // task ends, breaking the outer loop cleanly. - drop(new_loops_tx); - - // Drive the set. `biased;` keeps reconnect handling - // (which churns rarely) polled ahead of the drain arm. - loop { - tokio::select! { - biased; - - // Reconnect produced fresh halves — push new read + - // write futures into the set. - maybe_new = new_loops_rx.recv() => match maybe_new { - Some(NewLoops { write_sink, read_stream, write_rx }) => { - let router_for_read = router_for_future.clone(); - let ctx_for_read = runtime_ctx_for_future.clone(); - let state_for_write = state_for_future.clone(); - tasks.push(Box::pin(async move { - Self::run_write_loop(write_sink, write_rx).await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: (reconnect) write loop ended"); - - state_for_write.lock().await.status = ConnectionStatus::Disconnected; - })); - tasks.push(Box::pin(async move { - Self::run_read_loop( - read_stream, - &router_for_read, - Some(&ctx_for_read), - ) - .await; - - #[cfg(feature = "tracing")] - tracing::warn!("WS client: (reconnect) read loop ended"); - })); - } - None => { - // Watcher gone (auto_reconnect disabled or - // it gave up after max_attempts). Stop - // listening for new loops; tasks continue - // draining until empty. - break; - } - }, - - // Drain finished child futures. `Some(_) = next()` - // (rather than `select_next_some()`) is the safe form: - // an empty `FuturesUnordered` reports - // `is_terminated() == true`, and `select_next_some` - // panics in that state. With the pattern guard, the - // arm is simply disabled when `next()` resolves to - // `None`; the always-active reconnect arm keeps the - // select alive. - Some(_) = tasks.next() => {} - } - } - - // After the watcher exited: drain remaining children to - // completion so resources release cleanly. - while tasks.next().await.is_some() {} - }); - - Ok((Self { state, router }, connector_future)) - } - - /// Collect one outbound publisher future per route. - /// - /// Each future subscribes to a local record, serializes values, and sends - /// `ClientMessage::Write` to the remote server. Returned futures are appended - /// to the `AimDbRunner` accumulator. - pub(crate) fn collect_outbound_futures( - &self, - db: &aimdb_core::builder::AimDb, - outbound_routes: Vec, - ) -> Vec + Send + 'static>>> - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec + Send + 'static>>> = - Vec::with_capacity(outbound_routes.len()); - - for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { - let state = self.state.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: subscribe failed for '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Resolve topic (dynamic or static) - let topic = topic_provider - .as_ref() - .and_then(|p| p.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Build Write message - let payload: serde_json::Value = match serde_json::from_slice(&bytes) { - Ok(v) => v, - Err(_e) => { - // Fallback: wrap raw bytes as a JSON string - serde_json::Value::String(String::from_utf8_lossy(&bytes).into_owned()) - } - }; - - let msg = ClientMessage::Write { - topic: topic.clone(), - payload, - }; - - if let Ok(json) = serde_json::to_string(&msg) { - let mut s = state.lock().await; - if s.status == ConnectionStatus::Connected { - let _ = s.write_tx.send(json); - } else if s.pending_writes.len() < s.max_offline_queue { - s.pending_writes.push_back(json); - } - // else: drop (overflow policy) - } - } - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } - - futures - } - - // ════════════════════════════════════════════════════════════════ - // Background task implementations - // ════════════════════════════════════════════════════════════════ - - /// Write loop: drains the mpsc channel and sends text frames. - async fn run_write_loop( - mut ws_write: futures_util::stream::SplitSink< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - tokio_tungstenite::tungstenite::Message, - >, - mut write_rx: mpsc::UnboundedReceiver, - ) { - while let Some(text) = write_rx.recv().await { - let msg = tokio_tungstenite::tungstenite::Message::Text(text.into()); - if ws_write.send(msg).await.is_err() { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: write failed, closing write loop"); - break; - } - } - } - - /// Read loop: receives server messages and routes them via the Router. - async fn run_read_loop( - mut ws_read: futures_util::stream::SplitStream< - tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, - >, - >, - router: &Router, - runtime_ctx: Option<&Arc>, - ) { - while let Some(Ok(msg)) = ws_read.next().await { - let text = match msg { - tokio_tungstenite::tungstenite::Message::Text(t) => t.to_string(), - tokio_tungstenite::tungstenite::Message::Close(_) => { - #[cfg(feature = "tracing")] - tracing::info!("WS client: received close frame"); - break; - } - _ => continue, - }; - - let server_msg: ServerMessage = match serde_json::from_str(&text) { - Ok(m) => m, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: failed to parse server message: {}", _e); - continue; - } - }; - - match server_msg { - ServerMessage::Data { topic, payload, .. } - | ServerMessage::Snapshot { topic, payload } => { - if let Some(payload) = payload { - let bytes = match serde_json::to_vec(&payload) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: failed to serialize payload for '{}': {}", - topic, - _e - ); - continue; - } - }; - if let Err(_e) = router.route(&topic, &bytes, runtime_ctx).await { - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: route failed for topic '{}': {:?}", - topic, - _e - ); - } - } - } - ServerMessage::Subscribed { .. } => { - #[cfg(feature = "tracing")] - tracing::debug!("WS client: subscription acknowledged"); - } - ServerMessage::Error { message, topic, .. } => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client: server error{}: {}", - topic - .as_ref() - .map(|t| format!(" on '{}'", t)) - .unwrap_or_default(), - message - ); - let _ = (&message, &topic); - } - ServerMessage::Pong => { - // Keepalive ACK — nothing to do. - } - ServerMessage::QueryResult { .. } => { - // Query results are handled by the WASM bridge; the native - // client connector does not issue queries (yet). - } - ServerMessage::TopicList { .. } => { - // Topic list responses are not used by the native client connector. - } - } - } - } - - /// Keepalive loop: sends periodic Ping messages via the shared state sender. - async fn run_keepalive(state: Arc>, interval: Duration) { - let mut ticker = tokio::time::interval(interval); - ticker.tick().await; // skip first immediate tick - - loop { - ticker.tick().await; - let ping = ClientMessage::Ping; - if let Ok(json) = serde_json::to_string(&ping) { - let s = state.lock().await; - if s.status != ConnectionStatus::Connected { - continue; - } - if s.write_tx.send(json).is_err() { - break; // channel closed, connection gone - } - } - } - } - - /// Reconnect watcher: monitors connection status and reconnects when needed. - /// - /// Uses exponential backoff: 500ms, 1s, 2s, 4s, 8s (capped). On a - /// successful reconnect it sends a [`NewLoops`] to the outer - /// connector future, which translates it into a fresh write- and - /// read-loop future pushed onto the connector's `FuturesUnordered`. - /// The watcher itself never calls `tokio::spawn`. - async fn run_reconnect_watcher( - state: Arc>, - url: String, - subscribe_topics: Vec, - max_attempts: usize, - new_loops_tx: mpsc::UnboundedSender, - ) { - let backoff = [500u64, 1_000, 2_000, 4_000, 8_000]; - let mut attempt = 0usize; - - loop { - // Wait a bit before checking - tokio::time::sleep(Duration::from_millis(1_000)).await; - - let status = state.lock().await.status; - if status == ConnectionStatus::Connected || status == ConnectionStatus::Connecting { - attempt = 0; - continue; - } - - // Disconnected — try to reconnect - if max_attempts > 0 && attempt >= max_attempts { - #[cfg(feature = "tracing")] - tracing::error!( - "WS client: max reconnect attempts ({}) reached, giving up", - max_attempts - ); - break; - } - - let delay_ms = backoff.get(attempt).copied().unwrap_or(8_000); - attempt += 1; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS client: reconnecting in {}ms (attempt {})", - delay_ms, - attempt - ); - - state.lock().await.status = ConnectionStatus::Reconnecting; - tokio::time::sleep(Duration::from_millis(delay_ms)).await; - - // Guard: status may have changed during sleep - if state.lock().await.status != ConnectionStatus::Reconnecting { - continue; - } - - match tokio_tungstenite::connect_async(&url).await { - Ok((ws_stream, _)) => { - #[cfg(feature = "tracing")] - tracing::info!("WS client: reconnected to {}", url); - - let (ws_write, ws_read) = ws_stream.split(); - - // Create new channel; sender will be swapped into - // shared state; receiver travels to the outer future - // inside `NewLoops`. - let (new_write_tx, new_write_rx) = mpsc::unbounded_channel::(); - - // Re-subscribe before swapping — the new sender is - // still local and other producers cannot reach it - // yet, so this `Subscribe` is guaranteed first in - // the new write channel. - if !subscribe_topics.is_empty() { - let sub = ClientMessage::Subscribe { - topics: subscribe_topics.clone(), - }; - if let Ok(json) = serde_json::to_string(&sub) { - let _ = new_write_tx.send(json); - } - } - - // Swap write_tx and flush pending writes in one - // critical section. All producers (outbound - // publishers, publish(), keepalive) pick up the new - // sender on their next lock acquisition. - { - let mut s = state.lock().await; - s.write_tx = new_write_tx; - while let Some(msg) = s.pending_writes.pop_front() { - let _ = s.write_tx.send(msg); - } - s.status = ConnectionStatus::Connected; - } - - // Hand the new halves to the outer connector future, - // which will push fresh read+write loop futures onto - // its `FuturesUnordered`. - if new_loops_tx - .send(NewLoops { - write_sink: ws_write, - read_stream: ws_read, - write_rx: new_write_rx, - }) - .is_err() - { - // Outer future has gone away — nothing left to - // drive the loops; give up. - #[cfg(feature = "tracing")] - tracing::warn!( - "WS client: outer future dropped, stopping reconnect watcher" - ); - break; - } - - attempt = 0; - } - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("WS client: reconnect failed: {}", _e); - state.lock().await.status = ConnectionStatus::Disconnected; - } - } - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Connector trait -// ════════════════════════════════════════════════════════════════════ - -impl aimdb_core::transport::Connector for WsClientConnectorImpl { - /// Send a payload to the remote server as a `Write` message. - /// - /// This is the on-demand publish path used by the `ConnectorConfig` system. - /// Most data flow happens via the outbound publisher tasks instead. - fn publish( - &self, - destination: &str, - _config: &ConnectorConfig, - payload: &[u8], - ) -> Pin> + Send + '_>> { - let destination = destination.to_string(); - let payload_owned = payload.to_vec(); - - Box::pin(async move { - let json_payload: serde_json::Value = serde_json::from_slice(&payload_owned) - .map_err(|_| PublishError::MessageTooLarge)?; - - let msg = ClientMessage::Write { - topic: destination, - payload: json_payload, - }; - - let json = serde_json::to_string(&msg).map_err(|_| PublishError::MessageTooLarge)?; - - let mut s = self.state.lock().await; - if s.status == ConnectionStatus::Connected { - s.write_tx - .send(json) - .map_err(|_| PublishError::ConnectionFailed)?; - } else if s.pending_writes.len() < s.max_offline_queue { - s.pending_writes.push_back(json); - } else { - return Err(PublishError::BufferFull); - } - - Ok(()) - }) - } -} diff --git a/aimdb-websocket-connector/src/client/mod.rs b/aimdb-websocket-connector/src/client/mod.rs index c05e00e0..05814c8f 100644 --- a/aimdb-websocket-connector/src/client/mod.rs +++ b/aimdb-websocket-connector/src/client/mod.rs @@ -23,7 +23,5 @@ //! ``` mod builder; -mod connector; pub use builder::WsClientConnectorBuilder; -pub use connector::WsClientConnectorImpl; diff --git a/aimdb-websocket-connector/src/client_manager.rs b/aimdb-websocket-connector/src/client_manager.rs deleted file mode 100644 index ce47b97c..00000000 --- a/aimdb-websocket-connector/src/client_manager.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! Shared client registry and topic-based fan-out. -//! -//! [`ClientManager`] tracks all connected WebSocket clients and their topic -//! subscriptions. When an outbound publisher task receives a new value it calls -//! [`ClientManager::broadcast`] which serializes the payload once and delivers it -//! to every client that has a matching subscription pattern. - -use std::{ - collections::HashMap, - sync::{ - atomic::{AtomicU64, Ordering}, - Arc, - }, -}; - -use axum::extract::ws::Message; -use dashmap::DashMap; -use serde_json::Value; -use tokio::sync::mpsc; - -use crate::{ - auth::{ClientId, ClientInfo}, - protocol::{topic_matches, ErrorCode, ServerMessage}, -}; - -// ════════════════════════════════════════════════════════════════════ -// Per-client state -// ════════════════════════════════════════════════════════════════════ - -/// State tracked for each connected WebSocket client. -pub(crate) struct ClientState { - pub info: ClientInfo, - /// Channel used to push messages to the client's send loop. - pub sender: mpsc::Sender, - /// Topic patterns this client has subscribed to. - pub subscriptions: Vec, -} - -// ════════════════════════════════════════════════════════════════════ -// ClientManager -// ════════════════════════════════════════════════════════════════════ - -/// Shared registry of connected clients with subscription-based fan-out. -/// -/// Cloning this type is cheap — all instances share the same underlying data. -#[derive(Clone)] -pub struct ClientManager { - /// Map from ClientId → per-client state. - /// - /// `DashMap` is used instead of `RwLock` to minimise lock contention - /// when many publisher tasks are broadcasting concurrently. - clients: Arc>, - /// Monotonically-increasing counter for generating unique `ClientId`s. - next_id: Arc, -} - -impl ClientManager { - /// Create a new, empty client registry. - pub fn new() -> Self { - Self { - clients: Arc::new(DashMap::new()), - next_id: Arc::new(AtomicU64::new(1)), - } - } - - /// Register a new client and return its id together with the message receiver. - /// - /// The caller (session task) owns the `mpsc::Receiver` and drives the - /// WebSocket send loop. - pub fn register( - &self, - info: ClientInfo, - channel_capacity: usize, - ) -> (ClientId, mpsc::Receiver) { - let (tx, rx) = mpsc::channel(channel_capacity); - let state = ClientState { - info, - sender: tx, - subscriptions: Vec::new(), - }; - let raw_id = state.info.id.0; - self.clients.insert(raw_id, state); - (ClientId(raw_id), rx) - } - - /// Remove a client from the registry (called when the connection closes). - pub fn unregister(&self, id: ClientId) { - self.clients.remove(&id.0); - } - - /// Return the number of currently connected clients. - pub fn client_count(&self) -> usize { - self.clients.len() - } - - /// Allocate a new unique `ClientId`. - pub fn next_client_id(&self) -> ClientId { - ClientId(self.next_id.fetch_add(1, Ordering::Relaxed)) - } - - // ──────────────────────────────────────────────────────────────── - // Subscription management (called from session recv loop) - // ──────────────────────────────────────────────────────────────── - - /// Add subscription patterns for the given client. - /// - /// Returns only the patterns that were actually new (duplicates are skipped). - pub fn subscribe(&self, id: ClientId, patterns: &[String]) -> Vec { - let mut added = Vec::new(); - if let Some(mut entry) = self.clients.get_mut(&id.0) { - for pat in patterns { - if !entry.subscriptions.contains(pat) { - entry.subscriptions.push(pat.clone()); - added.push(pat.clone()); - } - } - } - added - } - - /// Remove subscription patterns for the given client. - pub fn unsubscribe(&self, id: ClientId, patterns: &[String]) { - if let Some(mut entry) = self.clients.get_mut(&id.0) { - entry.subscriptions.retain(|s| !patterns.contains(s)); - } - } - - /// Returns `true` if the client has at least one matching subscription for `topic`. - pub fn is_subscribed(&self, id: ClientId, topic: &str) -> bool { - self.clients - .get(&id.0) - .map(|e| e.subscriptions.iter().any(|p| topic_matches(p, topic))) - .unwrap_or(false) - } - - // ──────────────────────────────────────────────────────────────── - // Fan-out - // ──────────────────────────────────────────────────────────────── - - /// Broadcast a serialized `data` payload to all clients subscribed to `topic`. - /// - /// The payload bytes (from the record serializer) are parsed as JSON once; - /// if parsing fails the raw bytes are embedded as a JSON string. - pub async fn broadcast(&self, topic: &str, payload_bytes: &[u8]) { - let payload = parse_payload(payload_bytes); - let ts = crate::protocol::now_ms(); - - let msg = ServerMessage::Data { - topic: topic.to_string(), - payload: Some(payload), - ts, - }; - - let text = match serde_json::to_string(&msg) { - Ok(t) => t, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "Failed to serialize data message for topic '{}': {}", - topic, - _e - ); - return; - } - }; - - let ws_msg = Message::Text(text.into()); - - // Iterate clients without holding a write lock - let ids: Vec = self - .clients - .iter() - .filter_map(|entry| { - if entry.subscriptions.iter().any(|p| topic_matches(p, topic)) { - Some(*entry.key()) - } else { - None - } - }) - .collect(); - - for raw_id in ids { - if let Some(entry) = self.clients.get(&raw_id) { - let _ = entry.sender.try_send(ws_msg.clone()); - } - } - } - - /// Broadcast raw payload bytes directly to all subscribed clients as a - /// WebSocket text frame — **no `ServerMessage` envelope**. - /// - /// Use this (with `raw_payload = true` on the connector builder) when the - /// serializer already produces the complete JSON the client expects. - pub async fn broadcast_raw(&self, topic: &str, payload_bytes: &[u8]) { - let text = match std::str::from_utf8(payload_bytes) { - Ok(s) => s.to_string(), - Err(_) => { - #[cfg(feature = "tracing")] - tracing::error!("broadcast_raw: payload for '{}' is not valid UTF-8", topic); - return; - } - }; - - let ws_msg = Message::Text(text.into()); - - let ids: Vec = self - .clients - .iter() - .filter_map(|entry| { - if entry.subscriptions.iter().any(|p| topic_matches(p, topic)) { - Some(*entry.key()) - } else { - None - } - }) - .collect(); - - for raw_id in ids { - if let Some(entry) = self.clients.get(&raw_id) { - let _ = entry.sender.try_send(ws_msg.clone()); - } - } - } - - /// Send a snapshot (late-join current value) to a single client. - pub async fn send_snapshot(&self, id: ClientId, topic: &str, payload_bytes: &[u8]) { - let payload = parse_payload(payload_bytes); - let msg = ServerMessage::Snapshot { - topic: topic.to_string(), - payload: Some(payload), - }; - - self.send_to(id, &msg).await; - } - - /// Send an error message to a single client. - pub async fn send_error( - &self, - id: ClientId, - code: ErrorCode, - topic: Option, - message: impl Into, - ) { - let msg = ServerMessage::Error { - code, - topic, - message: message.into(), - }; - self.send_to(id, &msg).await; - } - - /// Send a `subscribed` acknowledgement to a single client. - pub async fn send_subscribed(&self, id: ClientId, topics: Vec) { - let msg = ServerMessage::Subscribed { topics }; - self.send_to(id, &msg).await; - } - - /// Send a `pong` to a single client. - pub async fn send_pong(&self, id: ClientId) { - self.send_to(id, &ServerMessage::Pong).await; - } - - /// Send an arbitrary [`ServerMessage`] to a single client. - /// - /// Used by the query handler to deliver `QueryResult` responses. - pub async fn send_to_client(&self, id: ClientId, msg: &ServerMessage) { - self.send_to(id, msg).await; - } - - // ──────────────────────────────────────────────────────────────── - // Helpers - // ──────────────────────────────────────────────────────────────── - - async fn send_to(&self, id: ClientId, msg: &ServerMessage) { - let text = match serde_json::to_string(msg) { - Ok(t) => t, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!("Failed to serialize message: {}", _e); - return; - } - }; - - if let Some(entry) = self.clients.get(&id.0) { - let _ = entry.sender.try_send(Message::Text(text.into())); - } - } - - /// Return the `ClientInfo` for the given id, if still connected. - pub fn client_info(&self, id: ClientId) -> Option { - self.clients.get(&id.0).map(|e| e.info.clone()) - } - - /// Returns a snapshot of (topic, subscribed-client-count) pairs for monitoring. - pub fn subscription_stats(&self) -> HashMap { - let mut stats: HashMap = HashMap::new(); - for entry in self.clients.iter() { - for pat in &entry.subscriptions { - *stats.entry(pat.clone()).or_insert(0) += 1; - } - } - stats - } -} - -impl Default for ClientManager { - fn default() -> Self { - Self::new() - } -} - -// ════════════════════════════════════════════════════════════════════ -// Helpers -// ════════════════════════════════════════════════════════════════════ - -/// Parse raw bytes as JSON, falling back to a JSON string if parsing fails. -fn parse_payload(bytes: &[u8]) -> Value { - serde_json::from_slice(bytes) - .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned())) -} - -// ════════════════════════════════════════════════════════════════════ -// Tests -// ════════════════════════════════════════════════════════════════════ - -#[cfg(test)] -mod tests { - use super::*; - use crate::auth::Permissions; - use std::net::{IpAddr, Ipv4Addr, SocketAddr}; - - fn dummy_info(id: u64) -> ClientInfo { - ClientInfo { - id: ClientId(id), - remote_addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 1234), - permissions: Permissions::allow_all(), - } - } - - #[tokio::test] - async fn register_and_unregister() { - let mgr = ClientManager::new(); - let info = dummy_info(1); - let (id, _rx) = mgr.register(info, 16); - assert_eq!(mgr.client_count(), 1); - mgr.unregister(id); - assert_eq!(mgr.client_count(), 0); - } - - #[tokio::test] - async fn subscribe_and_broadcast() { - let mgr = ClientManager::new(); - let info = dummy_info(42); - let (id, mut rx) = mgr.register(info, 16); - mgr.subscribe(id, &["sensors/#".to_string()]); - - mgr.broadcast("sensors/temperature/vienna", b"22.5").await; - - let msg = rx.recv().await.expect("should receive message"); - if let Message::Text(text) = msg { - let v: serde_json::Value = serde_json::from_str(&text).unwrap(); - assert_eq!(v["type"], "data"); - assert_eq!(v["topic"], "sensors/temperature/vienna"); - } else { - panic!("expected text message"); - } - } - - #[tokio::test] - async fn no_broadcast_when_not_subscribed() { - let mgr = ClientManager::new(); - let info = dummy_info(7); - let (id, mut rx) = mgr.register(info, 16); - mgr.subscribe(id, &["commands/#".to_string()]); - - // Broadcast to a topic the client is NOT subscribed to - mgr.broadcast("sensors/temperature/vienna", b"22.5").await; - - // Channel should be empty - assert!(rx.try_recv().is_err()); - } -} diff --git a/aimdb-websocket-connector/src/codec.rs b/aimdb-websocket-connector/src/codec.rs new file mode 100644 index 00000000..c6a4ebda --- /dev/null +++ b/aimdb-websocket-connector/src/codec.rs @@ -0,0 +1,507 @@ +//! Per-connection WS-JSON `EnvelopeCodec`. +//! +//! Maps the WS wire ([`ClientMessage`]/[`ServerMessage`]) onto the engine's +//! `Inbound`/`Outbound` so `run_session` drives a WebSocket exactly as it +//! drives AimX. +//! +//! Per-connection (not the shared `Arc` that `serve` uses) because the codec +//! holds `id↔topic` bookkeeping: `decode` is 1→1, so the transport splits a +//! multi-topic `Subscribe` (see [`crate::transport`]) and the codec synthesizes a +//! `u64` id per topic that the `Subscribed` ack and `Unsubscribe` map back. The +//! maps sit behind a `Mutex` so the codec stays `Send + Sync` with `&self` methods. +//! +//! The hot fan-out path skips the maps: [`ClientManager::broadcast`](crate::server::client_manager) +//! serializes the complete `Data` frame once (it owns the topic) and the codec +//! writes it verbatim — O(1) in subscribers. The `Subscribed` ack and late-join +//! `Snapshot` are engine emissions the codec maps to wire frames. + +use std::collections::HashMap; +use std::sync::Mutex; + +use aimdb_core::{CodecError, Inbound, Outbound, Payload, RpcError}; +use serde_json::Value; + +use crate::protocol::{ClientMessage, ErrorCode, ServerMessage}; + +/// Per-connection id bookkeeping, behind a `Mutex` so the `&self` codec methods +/// can mutate it. +#[derive(Default)] +struct WsCodecState { + /// Monotonic id allocator for engine `Subscribe`/`Request` correlation. The + /// WS client never sees these ids — they exist only for the engine's demux. + next_id: u64, + /// WS topic → engine `Subscribe.id` (synthesized on subscribe; consulted by + /// `Unsubscribe`). + topic_to_id: HashMap, + /// Engine `sub` id → wire topic (consulted when encoding `Event`→`Data` and + /// the `Subscribed` ack). + id_to_topic: HashMap, +} + +impl WsCodecState { + /// Allocate (or reuse) the engine subscribe id for `topic`. + fn alloc_sub(&mut self, topic: &str) -> u64 { + if let Some(id) = self.topic_to_id.get(topic) { + return *id; + } + self.next_id += 1; + let id = self.next_id; + self.topic_to_id.insert(topic.to_string(), id); + self.id_to_topic.insert(id, topic.to_string()); + id + } + + /// Allocate a bare correlation id (for `Query`/`ListTopics` requests). + fn alloc_req(&mut self) -> u64 { + self.next_id += 1; + self.next_id + } + + /// Drop the mapping for `topic`, returning its engine id if known. + fn remove_topic(&mut self, topic: &str) -> Option { + let id = self.topic_to_id.remove(topic)?; + self.id_to_topic.remove(&id); + Some(id) + } + + /// Resolve an engine `sub` id (as the engine's `&str` form) back to its topic. + fn topic_of(&self, sub: &str) -> Option { + let id: u64 = sub.parse().ok()?; + self.id_to_topic.get(&id).cloned() + } +} + +/// A per-connection WS-JSON codec. Construct one per accepted connection (server) +/// or per dialed connection (client). +pub struct WsCodec { + state: Mutex, +} + +impl WsCodec { + /// Build a fresh codec with empty id maps. + pub fn new() -> Self { + Self { + state: Mutex::new(WsCodecState::default()), + } + } +} + +impl Default for WsCodec { + fn default() -> Self { + Self::new() + } +} + +/// Parse record-value bytes as JSON, falling back to a JSON string (mirrors the +/// legacy `ClientManager` behavior so the wire is byte-identical). +pub(crate) fn parse_payload(bytes: &[u8]) -> Value { + serde_json::from_slice(bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(bytes).into_owned())) +} + +/// Map an engine [`RpcError`] to a wire [`ErrorCode`]. +fn rpc_to_code(err: &RpcError) -> ErrorCode { + match err { + RpcError::NotFound => ErrorCode::UnknownTopic, + RpcError::Denied => ErrorCode::Forbidden, + _ => ErrorCode::ServerError, + } +} + +/// Serialize a [`ServerMessage`] into `out` as one WS-JSON text frame. +fn write_server(out: &mut Vec, msg: &ServerMessage) -> Result<(), CodecError> { + let bytes = serde_json::to_vec(msg).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) +} + +impl aimdb_core::EnvelopeCodec for WsCodec { + // ---- server direction: read a ClientMessage, write a ServerMessage ------ + + fn decode(&self, frame: &[u8]) -> Result { + let msg: ClientMessage = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + let mut st = self.state.lock().unwrap(); + match msg { + // The transport splits multi-topic frames, so exactly one topic here. + ClientMessage::Subscribe { topics } => { + let topic = topics.into_iter().next().ok_or(CodecError::Malformed)?; + let id = st.alloc_sub(&topic); + Ok(Inbound::Subscribe { id, topic }) + } + ClientMessage::Unsubscribe { topics } => { + let topic = topics.into_iter().next().ok_or(CodecError::Malformed)?; + let id = st.remove_topic(&topic).ok_or(CodecError::Malformed)?; + Ok(Inbound::Unsubscribe { + sub: id.to_string(), + }) + } + ClientMessage::Write { topic, payload } => { + let bytes = serde_json::to_vec(&payload).map_err(|_| CodecError::Malformed)?; + Ok(Inbound::Write { + topic, + payload: Payload::from(bytes.as_slice()), + }) + } + ClientMessage::Ping => Ok(Inbound::Ping), + // The whole frame (incl. the WS `String` correlation id) rides as + // `params`; the dispatch parses it and the id round-trips in the + // response, so no per-request id map is needed. + ClientMessage::Query { .. } => Ok(Inbound::Request { + id: st.alloc_req(), + method: "query".to_string(), + params: Payload::from(frame), + }), + ClientMessage::ListTopics { .. } => Ok(Inbound::Request { + id: st.alloc_req(), + method: "list_topics".to_string(), + params: Payload::from(frame), + }), + } + } + + fn encode(&self, msg: Outbound<'_>, out: &mut Vec) -> Result<(), CodecError> { + match msg { + // The bus pre-serializes the complete `Data` frame once per broadcast, + // so fan-out is O(1) — the codec writes it verbatim. + Outbound::Event { data, .. } => { + out.extend_from_slice(&data); + Ok(()) + } + Outbound::Snapshot { topic, data } => write_server( + out, + &ServerMessage::Snapshot { + topic: topic.to_string(), + payload: Some(parse_payload(&data)), + }, + ), + Outbound::Subscribed { sub } => { + let topic = self + .state + .lock() + .unwrap() + .topic_of(sub) + .ok_or(CodecError::Malformed)?; + write_server( + out, + &ServerMessage::Subscribed { + topics: vec![topic], + }, + ) + } + Outbound::Pong => write_server(out, &ServerMessage::Pong), + // `Reply::Ok` payloads are already a complete `ServerMessage` JSON + // (`QueryResult`/`TopicList`/`Error`) built by the dispatch with the + // client's `String` id spliced in — write them verbatim. + Outbound::Reply { id, result } => match result { + Ok(payload) => { + out.extend_from_slice(&payload); + Ok(()) + } + Err(e) => { + // Restore the topic for subscribe/cap denials from the id↔topic + // map; Query/ListTopics use a bare id, so it stays `None`. + let topic = self.state.lock().unwrap().id_to_topic.get(&id).cloned(); + write_server( + out, + &ServerMessage::Error { + code: rpc_to_code(&e), + topic, + message: format!("{e:?}"), + }, + ) + } + }, + } + } + + // ---- client direction: write a ClientMessage, read a ServerMessage ------ + // Used by the WS client port; the client engine is topic-routed, so + // `Event.sub` carries the topic. + + fn encode_inbound(&self, msg: Inbound, out: &mut Vec) -> Result<(), CodecError> { + let client_msg = match msg { + Inbound::Subscribe { id, topic } => { + let mut st = self.state.lock().unwrap(); + st.topic_to_id.insert(topic.clone(), id); + st.id_to_topic.insert(id, topic.clone()); + ClientMessage::Subscribe { + topics: vec![topic], + } + } + Inbound::Unsubscribe { sub } => { + let topic = { + let mut st = self.state.lock().unwrap(); + st.topic_of(&sub) + .inspect(|t| { + st.remove_topic(t); + }) + .ok_or(CodecError::Malformed)? + }; + ClientMessage::Unsubscribe { + topics: vec![topic], + } + } + Inbound::Write { topic, payload } => ClientMessage::Write { + topic, + payload: parse_payload(&payload), + }, + Inbound::Ping => ClientMessage::Ping, + // `params` is already a complete `ClientMessage` JSON (`Query`/ + // `ListTopics`) — write it verbatim. + Inbound::Request { params, .. } => { + out.extend_from_slice(¶ms); + return Ok(()); + } + }; + let bytes = serde_json::to_vec(&client_msg).map_err(|_| CodecError::Malformed)?; + out.extend_from_slice(&bytes); + Ok(()) + } + + fn decode_outbound<'a>(&self, frame: &'a [u8]) -> Result, CodecError> { + // `Event`/`Snapshot` borrow `topic` zero-copy from the frame. serde's + // internally-tagged enums can't borrow, so peek the `type` tag, then + // deserialize a struct that borrows the topic slice. + let tag: TagOnly = serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + match tag.ty { + "data" => { + let d: TopicValueRef = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + Ok(Outbound::Event { + sub: d.topic, + seq: 0, + data: value_to_payload(d.payload), + }) + } + "snapshot" => { + let d: TopicValueRef = + serde_json::from_slice(frame).map_err(|_| CodecError::Malformed)?; + Ok(Outbound::Snapshot { + topic: d.topic, + data: value_to_payload(d.payload), + }) + } + // Informational; the engine ignores `Subscribed`, so `sub` is irrelevant. + "subscribed" => Ok(Outbound::Subscribed { sub: "" }), + "pong" => Ok(Outbound::Pong), + // Query/list/error replies aren't wired on the WS client (records + // mirror via Data/Snapshot), so map them to a benign Pong. + _ => Ok(Outbound::Pong), + } + } +} + +/// Zero-copy peek at the `"type"` discriminant (the tag is short ASCII — no escapes). +#[derive(serde::Deserialize)] +struct TagOnly<'a> { + #[serde(rename = "type", borrow)] + ty: &'a str, +} + +/// Zero-copy view of a `Data`/`Snapshot` frame: the `topic` borrows the frame. +#[derive(serde::Deserialize)] +struct TopicValueRef<'a> { + #[serde(borrow)] + topic: &'a str, + payload: Option, +} + +/// Serialize a WS payload `Value` back to record-value bytes. +fn value_to_payload(payload: Option) -> Payload { + let bytes = payload + .as_ref() + .and_then(|v| serde_json::to_vec(v).ok()) + .unwrap_or_default(); + Payload::from(bytes.as_slice()) +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_core::EnvelopeCodec; + + fn sub(codec: &WsCodec, topic: &str) -> u64 { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec![topic.to_string()], + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Subscribe { id, topic: t } => { + assert_eq!(t, topic); + id + } + _ => panic!("expected Subscribe"), + } + } + + #[test] + fn event_is_written_verbatim() { + // The bus pre-serializes the complete Data frame; encode passes it through + // (O(1) fan-out). `sub`/`seq` are irrelevant on this path. + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: "sensors/temp/vienna".into(), + payload: Some(serde_json::json!(22.5)), + ts: 123, + }) + .unwrap(); + let mut out = Vec::new(); + codec + .encode( + Outbound::Event { + sub: "ignored", + seq: 7, + data: Payload::from(frame.as_slice()), + }, + &mut out, + ) + .unwrap(); + assert_eq!(out, frame); + } + + #[test] + fn decode_outbound_borrows_topic_without_leaking() { + // Client direction: a Data frame decodes to a topic-routed Event whose + // `sub` is the topic, borrowed zero-copy from the frame. + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: "weather/vienna".into(), + payload: Some(serde_json::json!("sunny")), + ts: 0, + }) + .unwrap(); + match codec.decode_outbound(&frame).unwrap() { + Outbound::Event { sub, data, .. } => { + assert_eq!(sub, "weather/vienna"); + assert_eq!(&data[..], b"\"sunny\""); + } + _ => panic!("expected Event"), + } + } + + #[test] + fn subscribed_ack_maps_to_topic() { + let codec = WsCodec::new(); + let id = sub(&codec, "a/b"); + let mut out = Vec::new(); + codec + .encode( + Outbound::Subscribed { + sub: &id.to_string(), + }, + &mut out, + ) + .unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert_eq!( + v, + ServerMessage::Subscribed { + topics: vec!["a/b".into()] + } + ); + } + + #[test] + fn unsubscribe_resolves_topic_to_id() { + let codec = WsCodec::new(); + let id = sub(&codec, "x/y"); + let frame = serde_json::to_vec(&ClientMessage::Unsubscribe { + topics: vec!["x/y".to_string()], + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Unsubscribe { sub } => assert_eq!(sub, id.to_string()), + _ => panic!("expected Unsubscribe"), + } + } + + // Decoding many distinct-topic Data frames must not accumulate any + // process-lifetime state; the borrow is zero-copy. + #[test] + fn decode_outbound_high_cardinality_no_static_growth() { + let codec = WsCodec::new(); + for i in 0..10_000 { + let topic = format!("sensors/dev-{i}"); + let frame = serde_json::to_vec(&ServerMessage::Data { + topic: topic.clone(), + payload: Some(serde_json::json!(i)), + ts: 0, + }) + .unwrap(); + match codec.decode_outbound(&frame).unwrap() { + Outbound::Event { sub, .. } => assert_eq!(sub, topic), + _ => panic!("expected Event"), + } + } + } + + #[test] + fn write_carries_payload() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Write { + topic: "cmd/set".to_string(), + payload: serde_json::json!({"v": 1}), + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Write { topic, payload } => { + assert_eq!(topic, "cmd/set"); + let v: Value = serde_json::from_slice(&payload).unwrap(); + assert_eq!(v, serde_json::json!({"v": 1})); + } + _ => panic!("expected Write"), + } + } + + #[test] + fn query_passes_frame_as_params_and_reply_writes_verbatim() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Query { + id: "q1".to_string(), + pattern: "#".to_string(), + from: None, + to: None, + limit: None, + }) + .unwrap(); + match codec.decode(&frame).unwrap() { + Inbound::Request { method, params, .. } => { + assert_eq!(method, "query"); + assert_eq!(¶ms[..], &frame[..]); + } + _ => panic!("expected Request"), + } + + // A dispatch reply (already a full ServerMessage) is written verbatim. + let reply = serde_json::to_vec(&ServerMessage::QueryResult { + id: "q1".to_string(), + records: vec![], + total: 0, + }) + .unwrap(); + let mut out = Vec::new(); + codec + .encode( + Outbound::Reply { + id: 7, + result: Ok(Payload::from(reply.as_slice())), + }, + &mut out, + ) + .unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert!(matches!(v, ServerMessage::QueryResult { id, .. } if id == "q1")); + } + + #[test] + fn ping_pong() { + let codec = WsCodec::new(); + let frame = serde_json::to_vec(&ClientMessage::Ping).unwrap(); + assert!(matches!(codec.decode(&frame).unwrap(), Inbound::Ping)); + let mut out = Vec::new(); + codec.encode(Outbound::Pong, &mut out).unwrap(); + let v: ServerMessage = serde_json::from_slice(&out).unwrap(); + assert_eq!(v, ServerMessage::Pong); + } +} diff --git a/aimdb-websocket-connector/src/connector.rs b/aimdb-websocket-connector/src/connector.rs deleted file mode 100644 index 91890958..00000000 --- a/aimdb-websocket-connector/src/connector.rs +++ /dev/null @@ -1,152 +0,0 @@ -//! WebSocket connector implementation (`Connector` trait). -//! -//! [`WebSocketConnectorImpl`] is the live connector instance built by -//! [`crate::builder::WebSocketConnectorBuilder`]. -//! -//! # Outbound publishing -//! -//! Each outbound route (`link_to("ws://…")`) gets a dedicated Tokio task: -//! -//! ```text -//! consumer.subscribe_any() → recv_any() → serializer() → ClientManager::broadcast() -//! ``` -//! -//! # Inbound routing -//! -//! Inbound writes from WebSocket clients go through the shared [`Router`] -//! (same infrastructure as MQTT). The `Connector::publish()` impl is a -//! no-op because WebSocket inbound happens via the session receive loop instead -//! of the standard publish path. - -use std::{collections::HashMap, pin::Pin, sync::Arc}; - -use aimdb_core::OutboundRoute; - -use crate::client_manager::ClientManager; - -type BoxFuture = Pin + Send + 'static>>; - -/// Live WebSocket connector returned by `build()`. -pub struct WebSocketConnectorImpl { - pub(crate) client_mgr: ClientManager, - /// When `true`, outbound data bypasses the `ServerMessage::Data` envelope - /// and sends the serializer bytes directly as a WebSocket text frame. - pub(crate) raw_payload: bool, -} - -impl WebSocketConnectorImpl { - pub(crate) fn new(client_mgr: ClientManager, raw_payload: bool) -> Self { - Self { - client_mgr, - raw_payload, - } - } - - /// Collects one outbound publisher future per route. - /// - /// Each future: - /// 1. Calls `consumer.subscribe_any()` to get a type-erased reader. - /// 2. Loops calling `reader.recv_any()`. - /// 3. Runs the serializer. - /// 4. Broadcasts the bytes via `ClientManager::broadcast()`. - pub(crate) fn collect_outbound_futures( - &self, - db: &aimdb_core::builder::AimDb, - outbound_routes: Vec, - snapshot_map: Arc>>>, - ) -> Vec - where - R: aimdb_executor::RuntimeAdapter + 'static, - { - let raw_payload = self.raw_payload; - let runtime_ctx: Arc = db.runtime_any(); - let mut futures: Vec = Vec::with_capacity(outbound_routes.len()); - - for (default_topic, consumer, serializer, _config, topic_provider) in outbound_routes { - let client_mgr = self.client_mgr.clone(); - let snap = snapshot_map.clone(); - let default_topic_clone = default_topic.clone(); - let runtime_ctx = runtime_ctx.clone(); - - futures.push(Box::pin(async move { - let mut reader = match consumer.subscribe_any().await { - Ok(r) => r, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: failed to subscribe for '{}': {:?}", - default_topic_clone, - _e - ); - return; - } - }; - - #[cfg(feature = "tracing")] - tracing::info!( - "WS outbound publisher started for topic: {}", - default_topic_clone - ); - - while let Ok(value_any) = reader.recv_any().await { - // Resolve topic (dynamic or static) - let topic = topic_provider - .as_ref() - .and_then(|p| p.topic_any(&*value_any)) - .unwrap_or_else(|| default_topic_clone.clone()); - - // Serialize - let bytes = match &serializer { - aimdb_core::connector::SerializerKind::Raw(ser) => match ser(&*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - }, - aimdb_core::connector::SerializerKind::Context(ser) => { - match ser(runtime_ctx.clone(), &*value_any) { - Ok(b) => b, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::error!( - "WS outbound: serialize error for '{}': {:?}", - topic, - _e - ); - continue; - } - } - } - }; - - // Update snapshot cache for late-join - { - let mut map = snap.lock().unwrap(); - map.insert(topic.clone(), bytes.clone()); - } - - // Fan-out to subscribed clients - if raw_payload { - client_mgr.broadcast_raw(&topic, &bytes).await; - } else { - client_mgr.broadcast(&topic, &bytes).await; - } - } - - #[cfg(feature = "tracing")] - tracing::info!( - "WS outbound publisher stopped for topic: {}", - default_topic_clone - ); - })); - } - - futures - } -} diff --git a/aimdb-websocket-connector/src/lib.rs b/aimdb-websocket-connector/src/lib.rs index 1561313e..ab2a9ade 100644 --- a/aimdb-websocket-connector/src/lib.rs +++ b/aimdb-websocket-connector/src/lib.rs @@ -67,26 +67,14 @@ //! //! ## Authentication (server only) //! -//! See [`auth`] for the [`AuthHandler`][auth::AuthHandler] trait. +//! See [`auth`] for the [`AuthHandler`] trait. // ════════════════════════════════════════════════════════════════════ // Server modules (feature = "server") // ════════════════════════════════════════════════════════════════════ #[cfg(feature = "server")] -pub mod auth; -#[cfg(feature = "server")] -pub mod builder; -#[cfg(feature = "server")] -pub mod client_manager; -#[cfg(feature = "server")] -pub mod connector; -#[cfg(feature = "server")] -pub(crate) mod registry; -#[cfg(feature = "server")] -pub(crate) mod server; -#[cfg(feature = "server")] -pub(crate) mod session; +pub mod server; // ════════════════════════════════════════════════════════════════════ // Client module (feature = "client") @@ -95,6 +83,21 @@ pub(crate) mod session; #[cfg(feature = "client")] pub mod client; +// ════════════════════════════════════════════════════════════════════ +// Shared session-engine glue (server and/or client) +// ════════════════════════════════════════════════════════════════════ + +/// Per-connection WS-JSON `EnvelopeCodec` shared by the server (`run_session`) +/// and client (`run_client`) ports. +#[cfg(any(feature = "server", feature = "client"))] +pub mod codec; + +/// WS transport adapters (`Connection`/`Dialer`) over a real WebSocket. +#[cfg(any(feature = "server", feature = "client"))] +pub mod transport; + +// Real-socket integration tests live in `tests/e2e.rs` (black-box, public API). + // ════════════════════════════════════════════════════════════════════ // Protocol (always available) // ════════════════════════════════════════════════════════════════════ @@ -107,14 +110,16 @@ pub mod protocol; /// The primary entry point for a WebSocket **server** connector. /// -/// This is a type alias for [`builder::WebSocketConnectorBuilder`]. +/// This is a type alias for [`server::builder::WebSocketConnectorBuilder`]. #[cfg(feature = "server")] -pub type WebSocketConnector = builder::WebSocketConnectorBuilder; +pub type WebSocketConnector = server::builder::WebSocketConnectorBuilder; #[cfg(feature = "server")] -pub use auth::{AuthError, AuthHandler, AuthRequest, ClientId, ClientInfo, NoAuth, Permissions}; +pub use server::auth::{ + AuthError, AuthHandler, AuthRequest, ClientId, ClientInfo, NoAuth, Permissions, +}; #[cfg(feature = "server")] -pub use client_manager::ClientManager; +pub use server::client_manager::ClientManager; /// The primary entry point for a WebSocket **client** connector. /// @@ -125,4 +130,4 @@ pub type WsClientConnector = client::WsClientConnectorBuilder; pub use protocol::{ClientMessage, ErrorCode, QueryRecord, ServerMessage}; #[cfg(feature = "server")] -pub use session::{NoQuery, QueryHandler}; +pub use server::session::{NoQuery, QueryFuture, QueryHandler}; diff --git a/aimdb-websocket-connector/src/registry.rs b/aimdb-websocket-connector/src/registry.rs deleted file mode 100644 index 607b8ad8..00000000 --- a/aimdb-websocket-connector/src/registry.rs +++ /dev/null @@ -1,256 +0,0 @@ -//! Type-erased dispatch registry for [`Streamable`] types. -//! -//! Built incrementally via [`StreamableRegistry::register::()`] at -//! connector construction time. Each entry stores monomorphized closures -//! that capture the concrete type `T` — runtime downcasts are limited to -//! a `TypeId`-guarded path inside the serializer closure. - -use std::any::TypeId; -use std::collections::HashMap; - -use aimdb_data_contracts::Streamable; - -// ─── Type-erased operations ─────────────────────────────────────── - -/// Type-erased serialization closure: takes `&dyn Any`, downcasts to `T`, -/// and serializes to JSON bytes. -type SerializeFn = Box Result, String> + Send + Sync>; - -/// Type-erased deserialization closure: takes JSON bytes and produces a -/// boxed `Any` value of the concrete type `T`. -type DeserializeFn = - Box Result, String> + Send + Sync>; - -/// Type-erased operations for a single [`Streamable`] type. -/// -/// Each field is a monomorphized closure that captures `T` at compile time -/// through generic instantiation. The serializer performs a `downcast_ref` -/// on `&dyn Any` to recover the concrete type. -#[allow(dead_code)] -pub(crate) struct StreamableOps { - /// The `TypeId` of the concrete type. - pub type_id: TypeId, - /// The schema name (`T::NAME`). - pub name: &'static str, - /// Serialize a `&dyn Any` (known to be `&T`) to JSON bytes. - pub serialize: SerializeFn, - /// Deserialize JSON bytes into a `Box` (actually `Box`). - pub deserialize: DeserializeFn, -} - -// ─── Registry ───────────────────────────────────────────────────── - -/// Maps schema names and type IDs to type-erased operations. -/// -/// Built incrementally via [`register::()`](StreamableRegistry::register) -/// before the connector is started. -pub(crate) struct StreamableRegistry { - /// Schema name → operations. - pub name_to_ops: HashMap<&'static str, StreamableOps>, - /// TypeId → schema name (for outbound topic resolution). - pub type_id_to_name: HashMap, -} - -impl StreamableRegistry { - /// Create an empty registry. - pub fn new() -> Self { - Self { - name_to_ops: HashMap::new(), - type_id_to_name: HashMap::new(), - } - } - - /// Register a [`Streamable`] type. - /// - /// Each call monomorphizes closures for `T`'s serialization and - /// deserialization. Re-registering the same type is idempotent. - /// - /// # Errors - /// - /// Returns an error if a *different* type has already been registered - /// under the same schema name (`T::NAME`). - pub fn register(&mut self) -> Result<(), String> { - let type_id = TypeId::of::(); - let name = T::NAME; - - // Same type re-registered — idempotent, nothing to do. - if let Some(existing) = self.name_to_ops.get(name) { - if existing.type_id == type_id { - return Ok(()); - } - return Err(format!( - "schema name collision: \"{name}\" is already registered by a different type" - )); - } - - let ops = StreamableOps { - type_id, - name, - serialize: Box::new(|any_ref| { - let value = any_ref - .downcast_ref::() - .expect("type mismatch: registry is internally consistent"); - serde_json::to_vec(value).map_err(|e| e.to_string()) - }), - deserialize: Box::new(|bytes| { - let value: T = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; - Ok(Box::new(value)) - }), - }; - - self.name_to_ops.insert(name, ops); - self.type_id_to_name.insert(type_id, name); - Ok(()) - } - - /// Look up operations by schema name. - #[allow(dead_code)] - pub fn get_by_name(&self, name: &str) -> Option<&StreamableOps> { - self.name_to_ops.get(name) - } - - /// Resolve a `TypeId` to its schema name. - #[allow(dead_code)] - pub fn resolve_name(&self, type_id: &TypeId) -> Option<&'static str> { - self.type_id_to_name.get(type_id).copied() - } - - /// Returns all registered schema names. - #[allow(dead_code)] - pub fn known_names(&self) -> Vec<&'static str> { - self.name_to_ops.keys().copied().collect() - } - - /// Returns `true` if no types have been registered. - #[allow(dead_code)] - pub fn is_empty(&self) -> bool { - self.name_to_ops.is_empty() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use aimdb_data_contracts::SchemaType; - use serde::{Deserialize, Serialize}; - - #[derive(Clone, Debug, Serialize, Deserialize)] - struct TestSensor { - value: f32, - timestamp: u64, - } - - impl SchemaType for TestSensor { - const NAME: &'static str = "test_sensor"; - } - - impl Streamable for TestSensor {} - - #[derive(Clone, Debug, Serialize, Deserialize)] - struct TestActuator { - command: String, - } - - impl SchemaType for TestActuator { - const NAME: &'static str = "test_actuator"; - } - - impl Streamable for TestActuator {} - - #[test] - fn register_and_lookup_by_name() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - let ops = reg.get_by_name("test_sensor").unwrap(); - assert_eq!(ops.name, "test_sensor"); - assert_eq!(ops.type_id, TypeId::of::()); - } - - #[test] - fn register_and_resolve_type_id() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - assert_eq!( - reg.resolve_name(&TypeId::of::()), - Some("test_sensor") - ); - assert_eq!(reg.resolve_name(&TypeId::of::()), None); - } - - #[test] - fn serialize_roundtrip() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - let sensor = TestSensor { - value: 42.5, - timestamp: 1000, - }; - - let ops = reg.get_by_name("test_sensor").unwrap(); - let bytes = (ops.serialize)(&sensor).unwrap(); - let restored = (ops.deserialize)(&bytes).unwrap(); - let restored_sensor = restored.downcast_ref::().unwrap(); - - assert_eq!(restored_sensor.value, 42.5); - assert_eq!(restored_sensor.timestamp, 1000); - } - - #[test] - fn duplicate_registration_is_idempotent() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - reg.register::().unwrap(); - - assert_eq!(reg.known_names().len(), 1); - } - - #[test] - fn name_collision_from_different_type_is_rejected() { - #[derive(Clone, Debug, Serialize, Deserialize)] - struct FakeSensor { - fake: bool, - } - - impl SchemaType for FakeSensor { - const NAME: &'static str = "test_sensor"; // same name as TestSensor - } - - impl Streamable for FakeSensor {} - - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - let err = reg.register::().unwrap_err(); - assert!(err.contains("test_sensor")); - assert!(err.contains("collision")); - } - - #[test] - fn multiple_types_registered() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - reg.register::().unwrap(); - - assert_eq!(reg.known_names().len(), 2); - assert!(reg.get_by_name("test_sensor").is_some()); - assert!(reg.get_by_name("test_actuator").is_some()); - } - - #[test] - fn empty_registry() { - let reg = StreamableRegistry::new(); - assert!(reg.is_empty()); - assert!(reg.get_by_name("anything").is_none()); - } - - #[test] - fn unknown_schema_returns_none() { - let mut reg = StreamableRegistry::new(); - reg.register::().unwrap(); - - assert!(reg.get_by_name("unknown_schema").is_none()); - } -} diff --git a/aimdb-websocket-connector/src/auth.rs b/aimdb-websocket-connector/src/server/auth.rs similarity index 97% rename from aimdb-websocket-connector/src/auth.rs rename to aimdb-websocket-connector/src/server/auth.rs index df93a96e..9a56911b 100644 --- a/aimdb-websocket-connector/src/auth.rs +++ b/aimdb-websocket-connector/src/server/auth.rs @@ -140,8 +140,8 @@ impl AuthError { pub trait AuthHandler: Send + Sync + 'static { /// Called during WebSocket upgrade to authenticate the client. /// - /// Return [`Ok(Permissions)`] to accept the connection with the assigned - /// permissions, or [`Err(AuthError)`] to reject it (HTTP 401). + /// Return `Ok(Permissions)` to accept the connection with the assigned + /// permissions, or `Err(AuthError)` to reject it (HTTP 401). fn authenticate<'a>( &'a self, request: &'a AuthRequest, diff --git a/aimdb-websocket-connector/src/builder.rs b/aimdb-websocket-connector/src/server/builder.rs similarity index 81% rename from aimdb-websocket-connector/src/builder.rs rename to aimdb-websocket-connector/src/server/builder.rs index faab78d3..d821eda1 100644 --- a/aimdb-websocket-connector/src/builder.rs +++ b/aimdb-websocket-connector/src/server/builder.rs @@ -8,10 +8,10 @@ //! ```text //! AimDbBuilder::build() //! └─ WebSocketConnectorBuilder::build(&db) -//! ├─ db.collect_inbound_routes("ws") → Router -//! ├─ db.collect_outbound_routes("ws") → outbound tasks -//! ├─ start Axum / WebSocket server -//! └─ return Arc +//! ├─ inbound Router (client writes → producers, via the session Dispatch) +//! ├─ outbound `pump_sink` over the `WsBusSink` (records → broadcast bus) +//! ├─ start Axum / WebSocket server (per-connection `run_session`) +//! └─ return the server + pump futures //! ``` use std::{ @@ -19,20 +19,22 @@ use std::{ net::{SocketAddr, ToSocketAddrs}, pin::Pin, sync::{Arc, Mutex}, + time::Instant, }; use aimdb_data_contracts::Streamable; -use aimdb_core::{router::RouterBuilder, ConnectorBuilder}; +use aimdb_core::{pump_sink, router::RouterBuilder, ConnectorBuilder, Dispatch}; use axum::Router as AxumRouter; -use crate::{ +use super::{ auth::{AuthHandler, DynAuthHandler, NoAuth}, client_manager::ClientManager, - connector::WebSocketConnectorImpl, + connector::{SnapshotCache, WsBusSink}, + dispatch::WsDispatch, + http::{build_server_future, ServerState}, registry::StreamableRegistry, - server::build_server_future, - session::{NoQuery, NoSnapshot, QueryHandler, SessionContext, SnapshotProvider}, + session::{NoQuery, NoSnapshot, QueryHandler, SnapshotProvider}, }; use aimdb_ws_protocol::TopicInfo; @@ -161,9 +163,11 @@ impl WebSocketConnectorBuilder { self } - /// Set the maximum number of concurrent WebSocket clients (default: 1 024). + /// Set the per-connection subscription ceiling (default: 1 024). /// - /// Currently informational — used for pre-allocating the client map. + /// Despite the name, this bounds live subscriptions per connection + /// (`max_subs_per_connection`), not the client count — connection count is the + /// axum accept loop's concern, not enforced here. pub fn with_max_clients(mut self, max: usize) -> Self { self.max_clients = max; self @@ -298,39 +302,29 @@ where let router = Arc::new(RouterBuilder::from_routes(inbound_routes).build()); - // ── Outbound routes ────────────────────────────────────── - let outbound_routes = db.collect_outbound_routes("ws"); - - #[cfg(feature = "tracing")] - tracing::info!( - "WS connector: {} outbound routes collected", - outbound_routes.len() - ); - - // ── Shared snapshot cache (for late-join) ───────────── - let snapshot_map: Arc>>> = - Arc::new(Mutex::new(HashMap::new())); + // ── Late-join snapshot cache (only when enabled) ────── + let snapshot_map: Option = + self.late_join.then(|| Arc::new(Mutex::new(HashMap::new()))); // ── Client manager ──────────────────────────────────── - let client_mgr = ClientManager::new(); + let client_mgr = ClientManager::new(self.raw_payload, self.channel_capacity.max(1)); // ── Build snapshot provider ────────────────────────── - let snapshot_provider: Arc = if self.late_join { - let snap = snapshot_map.clone(); - Arc::new(DynMapSnapshot(snap)) - } else { - Arc::new(NoSnapshot) + let snapshot_provider: Arc = match &snapshot_map { + Some(map) => Arc::new(DynMapSnapshot(map.clone())), + None => Arc::new(NoSnapshot), }; // ── Known topics (for list_topics responses) ────────── // Use the registered streamable types to resolve TypeId → schema name. - let type_id_map = &self.streamable_registry.type_id_to_name; - let topic_type_ids = db.collect_outbound_topic_type_ids("ws"); let known_topics: Vec = topic_type_ids .into_iter() .map(|(topic, type_id)| { - let schema_type = type_id_map.get(&type_id).map(|s| s.to_string()); + let schema_type = self + .streamable_registry + .resolve_name(&type_id) + .map(|s| s.to_string()); // Extract entity from topic name: "temp.vienna" → "vienna". // The server owns the naming convention — clients receive // the entity as a first-class field and never parse topics. @@ -343,33 +337,43 @@ where }) .collect(); - // ── Session context ─────────────────────────────────── - let session_ctx = SessionContext { + // ── Shared dispatch (one Arc per server) ─── + let dispatch: Arc = Arc::new(WsDispatch { client_mgr: client_mgr.clone(), + snapshot_provider, + query_handler: self.query_handler.clone(), router: router.clone(), + known_topics: Arc::new(known_topics), auth: self.auth.clone(), - channel_capacity: self.channel_capacity, late_join: self.late_join, - snapshot_provider, - auto_subscribe_topics: self.auto_subscribe_topics.clone(), - query_handler: self.query_handler.clone(), - known_topics, runtime_ctx: Some(db.runtime_any()), - }; - - // ── Build connector & collect outbound publishers ─────────────── - let connector = WebSocketConnectorImpl::new(client_mgr, self.raw_payload); - let outbound_futures = - connector.collect_outbound_futures(db, outbound_routes, snapshot_map); + }); + + // ── Outbound: the shared `pump_sink` drives records → bus ─────── + // (same helper MQTT uses; the `WsBusSink` just broadcasts + caches). + let outbound_futures = pump_sink( + db, + "ws", + Arc::new(WsBusSink { + client_mgr: client_mgr.clone(), + snapshot: snapshot_map, + }), + ); // ── Build Axum server future ────────────────────────── + let state = ServerState { + dispatch, + auth: self.auth.clone(), + client_mgr, + auto_subscribe: Arc::new(self.auto_subscribe_topics.clone()), + // `max_clients` now supplies the per-connection subscription cap; + // connection count stays axum's concern (see `with_max_clients`). + max_subs_per_connection: self.max_clients.max(1), + started_at: Instant::now(), + }; let additional = self.additional_routes.clone(); - let server_future = build_server_future( - self.bind_addr, - self.ws_path.clone(), - session_ctx, - additional, - ); + let server_future = + build_server_future(self.bind_addr, self.ws_path.clone(), state, additional); let mut futures: Vec = Vec::with_capacity(1 + outbound_futures.len()); futures.push(server_future); @@ -383,7 +387,7 @@ where // Dynamic snapshot provider backed by the shared Mutex // ════════════════════════════════════════════════════════════════════ -struct DynMapSnapshot(Arc>>>); +struct DynMapSnapshot(SnapshotCache); impl SnapshotProvider for DynMapSnapshot { fn snapshot(&self, topic: &str) -> Option> { diff --git a/aimdb-websocket-connector/src/server/client_manager.rs b/aimdb-websocket-connector/src/server/client_manager.rs new file mode 100644 index 00000000..45c0ef00 --- /dev/null +++ b/aimdb-websocket-connector/src/server/client_manager.rs @@ -0,0 +1,239 @@ +//! Shared per-topic broadcast bus. +//! +//! [`ClientManager`] is the **fan-out bridge** behind `Dispatch::subscribe`: one +//! record update reaches every matching subscription. Each `WsSession::subscribe` +//! registers a per-subscription channel and gets back a [`BoxStream`] of raw +//! record-value [`Payload`]s; the per-connection [`WsCodec`](crate::codec) wraps +//! each into a `ServerMessage::Data` on encode. The outbound record→broadcast +//! tasks ([`super::connector`]) feed [`broadcast`](ClientManager::broadcast). +//! +//! Frame formatting lives in the codec; the per-connection send half is owned by +//! `run_session`. + +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; + +use aimdb_core::{BoxStream, Payload}; +use dashmap::DashMap; +use tokio::sync::mpsc; + +use crate::{ + codec::parse_payload, + protocol::{now_ms, topic_matches, ServerMessage}, +}; + +use super::auth::ClientId; + +/// One live subscription: a wildcard pattern + the channel feeding its stream. +struct SubEntry { + pattern: String, + /// Bounded; `broadcast` drops on a full channel (slow-client protection). + tx: mpsc::Sender, +} + +/// Shared per-topic broadcast bus. Cloning is cheap (all clones share state). +#[derive(Clone)] +pub struct ClientManager { + /// sub-id → subscription entry. + subs: Arc>, + /// Allocator for subscription ids. + next_sub: Arc, + /// Allocator for client ids (assigned at the HTTP upgrade). + next_client: Arc, + /// Live connection count (for the health endpoint). + connections: Arc, + /// Per-subscription channel bound (the builder's `with_channel_capacity`). + sub_capacity: usize, + /// Mirrors the builder's `with_raw_payload`: when set, `broadcast` ships the + /// serializer bytes verbatim instead of wrapping them in a `Data` envelope. + raw_payload: bool, +} + +impl ClientManager { + /// Create a new, empty bus. `raw_payload` mirrors the builder flag; + /// `sub_capacity` bounds each subscription's queue. + pub fn new(raw_payload: bool, sub_capacity: usize) -> Self { + Self { + subs: Arc::new(DashMap::new()), + next_sub: Arc::new(AtomicU64::new(1)), + next_client: Arc::new(AtomicU64::new(1)), + connections: Arc::new(AtomicU64::new(0)), + sub_capacity: sub_capacity.max(1), + raw_payload, + } + } + + /// Allocate a new unique [`ClientId`] (called at the HTTP upgrade). + pub fn next_client_id(&self) -> ClientId { + ClientId(self.next_client.fetch_add(1, Ordering::Relaxed)) + } + + /// Number of live connections (informational, for `/health`). + pub fn client_count(&self) -> usize { + self.connections.load(Ordering::Relaxed) as usize + } + + /// RAII guard: increments the connection count now, decrements on drop. + pub(crate) fn connection_guard(&self) -> ConnectionGuard { + self.connections.fetch_add(1, Ordering::Relaxed); + ConnectionGuard { + connections: self.connections.clone(), + } + } + + /// Register a subscription for `pattern`; returns its id and the stream of + /// matching record-value payloads. Dropping the stream ends the subscription; + /// the next matching [`broadcast`](Self::broadcast) lazily prunes the entry. + pub fn subscribe(&self, pattern: &str) -> (u64, BoxStream<'static, Payload>) { + let id = self.next_sub.fetch_add(1, Ordering::Relaxed); + let (tx, rx) = mpsc::channel::(self.sub_capacity); + self.subs.insert( + id, + SubEntry { + pattern: pattern.to_string(), + tx, + }, + ); + let stream = futures_util::stream::unfold(rx, |mut rx| async move { + rx.recv().await.map(|item| (item, rx)) + }); + (id, Box::pin(stream)) + } + + /// Fan a serialized record-value out to every subscription whose pattern + /// matches `topic`. Dead subscriptions (dropped streams) are pruned. + pub async fn broadcast(&self, topic: &str, payload_bytes: &[u8]) { + // Serialize the complete wire frame **once** here — the bus is the only + // place the real topic + value meet, and doing it once (vs once per + // subscriber in the codec) keeps fan-out O(1). The codec writes the + // result verbatim. The same finished bytes are `Arc`-shared to every + // matching subscription (a refcount bump, no per-subscriber copy). + let frame = if self.raw_payload { + payload_bytes.to_vec() + } else { + match serde_json::to_vec(&ServerMessage::Data { + topic: topic.to_string(), + payload: Some(parse_payload(payload_bytes)), + ts: now_ms(), + }) { + Ok(f) => f, + Err(_) => return, + } + }; + let payload = Payload::from(frame.as_slice()); + let mut dead: Vec = Vec::new(); + for entry in self.subs.iter() { + if !topic_matches(&entry.pattern, topic) { + continue; + } + // Bounded: drop on a full queue (slow-client protection), prune only + // when the receiver is gone (stream dropped). + if let Err(mpsc::error::TrySendError::Closed(_)) = entry.tx.try_send(payload.clone()) { + dead.push(*entry.key()); + } + } + for id in dead { + self.subs.remove(&id); + } + } + + /// Number of live subscriptions (for monitoring/tests). + pub fn subscription_count(&self) -> usize { + self.subs.len() + } +} + +impl Default for ClientManager { + fn default() -> Self { + Self::new(false, 256) + } +} + +/// Decrements the connection count when dropped (held by `WsSession`). +pub(crate) struct ConnectionGuard { + connections: Arc, +} + +impl Drop for ConnectionGuard { + fn drop(&mut self) { + self.connections.fetch_sub(1, Ordering::Relaxed); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures_util::StreamExt; + + #[tokio::test] + async fn broadcast_reaches_matching_subscriptions() { + let mgr = ClientManager::new(false, 256); + let (_id, mut stream) = mgr.subscribe("sensors/#"); + + mgr.broadcast("sensors/temp/vienna", b"22.5").await; + + // Delivery is the complete, pre-serialized `Data` frame (built once in + // broadcast) carrying the real topic — even for the wildcard sub. + let payload = stream.next().await.expect("should receive"); + match serde_json::from_slice::(&payload).unwrap() { + ServerMessage::Data { topic, payload, .. } => { + assert_eq!(topic, "sensors/temp/vienna"); + assert_eq!(payload, Some(serde_json::json!(22.5))); + } + _ => panic!("expected Data"), + } + } + + #[tokio::test] + async fn non_matching_topic_is_not_delivered() { + use futures_util::FutureExt; + let mgr = ClientManager::new(false, 256); + let (_id, mut stream) = mgr.subscribe("commands/#"); + mgr.broadcast("sensors/temp", b"22.5").await; + // Nothing queued: the next() future is not ready. + assert!(stream.next().now_or_never().is_none()); + } + + #[tokio::test] + async fn fan_out_to_n_subscribers() { + let mgr = ClientManager::new(false, 256); + let mut streams: Vec<_> = (0..5).map(|_| mgr.subscribe("#").1).collect(); + mgr.broadcast("any/topic", b"\"v\"").await; + for s in &mut streams { + let frame = s.next().await.unwrap(); + assert!(matches!( + serde_json::from_slice::(&frame).unwrap(), + ServerMessage::Data { topic, .. } if topic == "any/topic" + )); + } + } + + #[tokio::test] + async fn dropped_stream_is_pruned() { + let mgr = ClientManager::new(false, 256); + let (_id, stream) = mgr.subscribe("#"); + assert_eq!(mgr.subscription_count(), 1); + drop(stream); + mgr.broadcast("t", b"v").await; + assert_eq!(mgr.subscription_count(), 0); + } + + // Layer 2.2 (#2): one broadcast → N subscribers all receive the *same* + // pre-serialized bytes (a shared `Arc`), evidencing a single serialization + // regardless of subscriber count (O(1) fan-out, not O(N)). + #[tokio::test] + async fn broadcast_serializes_once_and_shares_to_all() { + let mgr = ClientManager::new(false, 256); + let mut streams: Vec<_> = (0..8).map(|_| mgr.subscribe("#").1).collect(); + mgr.broadcast("t", b"123").await; + let mut frames = Vec::new(); + for s in &mut streams { + frames.push(s.next().await.unwrap()); + } + // Every subscriber got byte-identical content (the one serialized frame). + let first = &frames[0]; + assert!(frames.iter().all(|f| f.as_ref() == first.as_ref())); + } +} diff --git a/aimdb-websocket-connector/src/server/connector.rs b/aimdb-websocket-connector/src/server/connector.rs new file mode 100644 index 00000000..0a4d7774 --- /dev/null +++ b/aimdb-websocket-connector/src/server/connector.rs @@ -0,0 +1,54 @@ +//! WebSocket outbound sink — the [`Connector`] adapter that `pump_sink` drives. +//! +//! Outbound record updates (`link_to("ws://…")`) fan out to subscribed clients +//! through the [`ClientManager`] bus. The shared +//! [`pump_sink`](aimdb_core::pump_sink) helper owns the consume → serialize → +//! publish loop (the same one MQTT uses); this adapter just routes each +//! serialized value to [`broadcast`](ClientManager::broadcast) and, when +//! late-join is enabled, caches it for snapshots. +//! +//! Inbound writes from WebSocket clients do **not** go through here — they ride +//! the session `Dispatch` (`WsSession::write` → the shared `Router`). + +use std::collections::HashMap; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use aimdb_core::transport::{Connector, ConnectorConfig, PublishError}; + +use super::client_manager::ClientManager; + +/// Shared late-join cache: topic → last serialized bytes. +pub(crate) type SnapshotCache = Arc>>>; + +/// Outbound sink: feeds each serialized record value into the broadcast bus. +pub(crate) struct WsBusSink { + pub(crate) client_mgr: ClientManager, + /// Late-join cache — `Some` only when late-join is on, so a disabled + /// late-join does zero per-message snapshot work. + pub(crate) snapshot: Option, +} + +impl Connector for WsBusSink { + fn publish( + &self, + destination: &str, + _config: &ConnectorConfig, + payload: &[u8], + ) -> Pin> + Send + '_>> { + // Own the args so the returned future borrows only `&self` (the trait + // binds the future's lifetime to the receiver, not the arguments). + let dest = destination.to_string(); + let bytes = payload.to_vec(); + Box::pin(async move { + if let Some(map) = &self.snapshot { + map.lock().unwrap().insert(dest.clone(), bytes.clone()); + } + // The per-connection `WsCodec` applies the `Data` envelope (or, in raw + // mode, sends the bytes verbatim) — so the bus carries raw bytes. + self.client_mgr.broadcast(&dest, &bytes).await; + Ok(()) + }) + } +} diff --git a/aimdb-websocket-connector/src/server/dispatch.rs b/aimdb-websocket-connector/src/server/dispatch.rs new file mode 100644 index 00000000..7d9f5234 --- /dev/null +++ b/aimdb-websocket-connector/src/server/dispatch.rs @@ -0,0 +1,177 @@ +//! WS server [`Dispatch`] + [`Session`]. +//! +//! [`WsDispatch`] is the shared half (one `Arc` per server): +//! `authenticate` reads the identity pre-resolved at the HTTP upgrade (in +//! [`PeerInfo`]`::ext`), `open` mints a per-connection [`WsSession`] homing the +//! application surface (the [`ClientManager`] bus handle, auth principal, query +//! handler). +//! +//! The id↔topic bookkeeping lives in the per-connection [`WsCodec`](crate::codec), +//! not here. The `Subscribed` ack and late-join `Snapshot` are engine emissions; +//! this session only supplies the snapshot bytes and the subscription stream. + +use std::any::Any; +use std::sync::Arc; + +use aimdb_core::session::Session; +use aimdb_core::{AuthError, BoxFut, BoxStream, Dispatch, Payload, PeerInfo, RpcError, SessionCtx}; +use aimdb_ws_protocol::TopicInfo; + +use crate::protocol::{ClientMessage, ErrorCode, ServerMessage}; + +use super::{ + auth::{AuthHandler, ClientId, ClientInfo, Permissions}, + client_manager::{ClientManager, ConnectionGuard}, + session::{QueryHandler, Router, SnapshotProvider}, +}; + +/// The shared WS dispatch — one `Arc` per server. +pub struct WsDispatch { + pub(crate) client_mgr: ClientManager, + pub(crate) snapshot_provider: Arc, + pub(crate) query_handler: Arc, + pub(crate) router: Arc, + pub(crate) known_topics: Arc>, + pub(crate) auth: Arc, + pub(crate) late_join: bool, + pub(crate) runtime_ctx: Option>, +} + +impl Dispatch for WsDispatch { + fn authenticate<'a>( + &'a self, + peer: &'a PeerInfo, + _first: Option<&'a [u8]>, + ) -> BoxFut<'a, Result> { + // Identity is pre-resolved at the HTTP upgrade and carried in `PeerInfo`. + let info = peer.ext_as::(); + Box::pin(async move { + match info { + Some(info) => Ok(SessionCtx::with_ext(info)), + None => Err(AuthError::Unauthorized), + } + }) + } + + fn open(&self, ctx: &SessionCtx) -> Box { + let info = ctx.ext_as::().unwrap_or_else(|| { + // Should not happen (authenticate populates it); deny-all fallback. + Arc::new(ClientInfo { + id: ClientId(0), + remote_addr: ([0, 0, 0, 0], 0).into(), + permissions: Permissions::default(), + }) + }); + Box::new(WsSession { + client_mgr: self.client_mgr.clone(), + snapshot_provider: self.snapshot_provider.clone(), + query_handler: self.query_handler.clone(), + router: self.router.clone(), + known_topics: self.known_topics.clone(), + auth: self.auth.clone(), + late_join: self.late_join, + runtime_ctx: self.runtime_ctx.clone(), + info, + _conn_guard: self.client_mgr.connection_guard(), + }) + } +} + +/// One connection's per-session state (owned by the engine, `&mut`-threaded). +struct WsSession { + client_mgr: ClientManager, + snapshot_provider: Arc, + query_handler: Arc, + router: Arc, + known_topics: Arc>, + auth: Arc, + late_join: bool, + runtime_ctx: Option>, + info: Arc, + /// Decrements the live-connection count on drop. + _conn_guard: ConnectionGuard, +} + +impl Session for WsSession { + fn call<'a>( + &'a mut self, + method: &'a str, + params: Payload, + ) -> BoxFut<'a, Result> { + Box::pin(async move { + let msg: ClientMessage = + serde_json::from_slice(¶ms).map_err(|_| RpcError::Internal)?; + let response = match (method, msg) { + ( + "query", + ClientMessage::Query { + id, + pattern, + from, + to, + limit, + }, + ) => match self + .query_handler + .handle_query(&pattern, from, to, limit) + .await + { + Ok((records, total)) => ServerMessage::QueryResult { id, records, total }, + Err(message) => ServerMessage::Error { + code: ErrorCode::ServerError, + topic: None, + message, + }, + }, + ("list_topics", ClientMessage::ListTopics { id }) => ServerMessage::TopicList { + id, + topics: (*self.known_topics).clone(), + }, + _ => return Err(RpcError::NotFound), + }; + // The codec writes this complete `ServerMessage` verbatim. + let bytes = serde_json::to_vec(&response).map_err(|_| RpcError::Internal)?; + Ok(Payload::from(bytes.as_slice())) + }) + } + + fn subscribe<'a>( + &'a mut self, + topic: &'a str, + ) -> BoxFut<'a, Result, RpcError>> { + Box::pin(async move { + // Per-operation authorization via the async `AuthHandler` hook. + if !self.auth.authorize_subscribe(&self.info, topic).await { + return Err(RpcError::Denied); + } + // Register on the shared bus; the engine owns and drops the stream. + let (_sub_id, stream) = self.client_mgr.subscribe(topic); + Ok(stream) + }) + } + + fn snapshot(&mut self, topic: &str) -> Option { + if !self.late_join { + return None; + } + self.snapshot_provider + .snapshot(topic) + .map(|bytes| Payload::from(bytes.as_slice())) + } + + fn write<'a>( + &'a mut self, + topic: &'a str, + payload: Payload, + ) -> BoxFut<'a, Result<(), RpcError>> { + Box::pin(async move { + if !self.auth.authorize_write(&self.info, topic).await { + return Err(RpcError::Denied); + } + self.router + .route(topic, &payload, self.runtime_ctx.as_ref()) + .await + .map_err(|_| RpcError::Internal) + }) + } +} diff --git a/aimdb-websocket-connector/src/server.rs b/aimdb-websocket-connector/src/server/http.rs similarity index 61% rename from aimdb-websocket-connector/src/server.rs rename to aimdb-websocket-connector/src/server/http.rs index fa3062ea..a73b0749 100644 --- a/aimdb-websocket-connector/src/server.rs +++ b/aimdb-websocket-connector/src/server/http.rs @@ -11,8 +11,12 @@ //! { "status": "ok", "clients": 3, "uptime_secs": 120 } //! ``` -use std::{collections::HashMap, net::SocketAddr, time::Instant}; +use std::{collections::HashMap, net::SocketAddr, sync::Arc, time::Instant}; +use aimdb_core::{ + session::{run_session, SessionConfig}, + Connection, Dispatch, PeerInfo, SessionLimits, +}; use axum::{ extract::{ ws::{WebSocket, WebSocketUpgrade}, @@ -25,18 +29,32 @@ use axum::{ }; use tower_http::cors::CorsLayer; -use crate::{ - auth::{AuthError, AuthRequest, ClientInfo}, - session::{run_session, SessionContext}, +use crate::{codec::WsCodec, transport::WsServerConnection}; + +use super::{ + auth::{AuthError, AuthRequest, ClientInfo, DynAuthHandler}, + client_manager::ClientManager, }; // ════════════════════════════════════════════════════════════════════ // Shared server state // ════════════════════════════════════════════════════════════════════ +/// State shared across upgrade/health handlers. The per-connection session engine +/// (`run_session`) is driven from [`ws_upgrade_handler`]; only the *accept* loop +/// stays axum's (Option A, doc 039 § 6). #[derive(Clone)] pub(crate) struct ServerState { - pub session_ctx: SessionContext, + /// Shared application dispatch (one `Arc` per server). + pub dispatch: Arc, + /// HTTP-upgrade authenticator (resolves identity before the engine runs). + pub auth: DynAuthHandler, + /// Bus + connection counter (for client-id allocation and `/health`). + pub client_mgr: ClientManager, + /// Patterns to auto-subscribe each client to on connect. + pub auto_subscribe: Arc>, + /// Per-connection subscription cap. + pub max_subs_per_connection: usize, pub started_at: Instant, } @@ -46,45 +64,33 @@ pub(crate) struct ServerState { type BoxFuture = std::pin::Pin + Send + 'static>>; -/// Builds the WebSocket Axum server future. -/// -/// Returns a `BoxFuture` containing the `axum::serve()` accept loop. The -/// future is appended to the `AimDbRunner` accumulator (design 028 §"Connector -/// futures"). Per-connection handlers spawned by Axum internally continue to -/// use `tokio::spawn` — outside the scope of issue #88. -/// -/// # Arguments -/// -/// * `bind_addr` — TCP address to listen on. -/// * `ws_path` — URL path for the WebSocket endpoint (e.g., `"/ws"`). -/// * `session_ctx` — Shared session context (auth, router, client manager, …). -/// * `additional_routes` — Optional user-supplied Axum `Router` that is merged -/// into the server (useful for REST + WebSocket on the same port). -pub(crate) fn build_server_future( - bind_addr: SocketAddr, - ws_path: String, - session_ctx: SessionContext, - additional_routes: Option, -) -> BoxFuture { - let state = ServerState { - session_ctx, - started_at: Instant::now(), - }; - +/// Assemble the axum `Router`: the WS endpoint + `/health`, with the shared +/// [`ServerState`] and any user-supplied extra routes merged in. +fn build_app(ws_path: &str, state: ServerState, additional_routes: Option) -> Router { // Apply state first so the router becomes `Router<()>`, which can then be // merged with user-supplied `additional_routes: Router<()>` without a // type-parameter mismatch. let ws_app = Router::new() - .route(&ws_path, get(ws_upgrade_handler)) + .route(ws_path, get(ws_upgrade_handler)) .route("/health", get(health_handler)) .with_state(state) .layer(CorsLayer::permissive()); - let app = if let Some(extra) = additional_routes { - ws_app.merge(extra) - } else { - ws_app - }; + match additional_routes { + Some(extra) => ws_app.merge(extra), + None => ws_app, + } +} + +/// Bind `bind_addr` and serve [`build_app`] as the connector's runner future +/// (the axum accept loop). Each upgraded socket is driven by `run_session`. +pub(crate) fn build_server_future( + bind_addr: SocketAddr, + ws_path: String, + state: ServerState, + additional_routes: Option, +) -> BoxFuture { + let app = build_app(&ws_path, state, additional_routes); Box::pin(async move { let listener = match tokio::net::TcpListener::bind(bind_addr).await { @@ -132,8 +138,8 @@ async fn ws_upgrade_handler( remote_addr, }; - // Authenticate — returns permissions or rejects - let permissions = match state.session_ctx.auth.authenticate(&auth_req).await { + // Authenticate at the HTTP upgrade — returns permissions or rejects (401). + let permissions = match state.auth.authenticate(&auth_req).await { Ok(p) => p, Err(AuthError { message }) => { #[cfg(feature = "tracing")] @@ -142,8 +148,9 @@ async fn ws_upgrade_handler( } }; - // Allocate a client id before upgrading so it's available synchronously - let id = state.session_ctx.client_mgr.next_client_id(); + // Resolve identity synchronously, before the upgrade, and carry it into the + // engine via `PeerInfo::ext` (WS-style `reads_hello:false`, doc 039 § 4). + let id = state.client_mgr.next_client_id(); let info = ClientInfo { id, remote_addr, @@ -157,16 +164,32 @@ async fn ws_upgrade_handler( remote_addr ); - let ctx = state.session_ctx.clone(); + let dispatch = state.dispatch.clone(); + let auto_subscribe = state.auto_subscribe.clone(); + let config = SessionConfig { + limits: SessionLimits { + max_connections: usize::MAX, // axum owns the accept loop (Option A) + max_subs_per_connection: state.max_subs_per_connection, + }, + reads_hello: false, + acks_subscribe: true, + }; - ws.on_upgrade(move |socket: WebSocket| run_session(socket, info, ctx)) - .into_response() + ws.on_upgrade(move |socket: WebSocket| async move { + let peer = PeerInfo::default().with_ext(Arc::new(info)); + let conn: Box = + Box::new(WsServerConnection::new(socket, peer, &auto_subscribe)); + let codec = WsCodec::new(); + // Per-connection codec + run_session drive this socket (doc 039 § 6). + run_session(conn, &codec, dispatch.as_ref(), &config).await; + }) + .into_response() } /// Health check endpoint. async fn health_handler(State(state): State) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); - let clients = state.session_ctx.client_mgr.client_count(); + let clients = state.client_mgr.client_count(); Json(serde_json::json!({ "status": "ok", diff --git a/aimdb-websocket-connector/src/server/mod.rs b/aimdb-websocket-connector/src/server/mod.rs new file mode 100644 index 00000000..d26b122c --- /dev/null +++ b/aimdb-websocket-connector/src/server/mod.rs @@ -0,0 +1,18 @@ +//! Server-side WebSocket connector: accepts browser/client connections and +//! bridges records to them. +//! +//! The HTTP/WS accept loop is axum's ([`http`]); each upgraded socket is driven +//! by the shared `run_session` engine over the root [`codec`](crate::codec) / +//! [`transport`](crate::transport) substrate, with [`dispatch`] supplying the +//! subscribe/write/query semantics and [`client_manager`] the cross-connection +//! fan-out bus. The outbound data plane rides [`connector`]'s `WsBusSink` through +//! the core `pump_sink`. + +pub mod auth; +pub mod builder; +pub mod client_manager; +pub(crate) mod connector; +pub(crate) mod dispatch; +pub(crate) mod http; +pub(crate) mod registry; +pub(crate) mod session; diff --git a/aimdb-websocket-connector/src/server/registry.rs b/aimdb-websocket-connector/src/server/registry.rs new file mode 100644 index 00000000..165918cd --- /dev/null +++ b/aimdb-websocket-connector/src/server/registry.rs @@ -0,0 +1,147 @@ +//! Schema-name registry for [`Streamable`] types. +//! +//! Built incrementally via [`StreamableRegistry::register::()`] at connector +//! construction time. It exists only to answer `list_topics` (resolve an +//! outbound topic's `TypeId` → schema name) and to reject schema-name +//! collisions. The actual record (de)serialization lives in the link routes' +//! serializer/deserializer, not here. + +use std::any::TypeId; +use std::collections::HashMap; + +use aimdb_data_contracts::Streamable; + +/// Maps registered [`Streamable`] types to their schema names. +pub(crate) struct StreamableRegistry { + /// Schema name → `TypeId` (collision detection on `register`). + name_to_type_id: HashMap<&'static str, TypeId>, + /// `TypeId` → schema name (outbound topic → schema for `list_topics`). + type_id_to_name: HashMap, +} + +impl StreamableRegistry { + /// Create an empty registry. + pub fn new() -> Self { + Self { + name_to_type_id: HashMap::new(), + type_id_to_name: HashMap::new(), + } + } + + /// Register a [`Streamable`] type. Idempotent for the same type; errors if a + /// *different* type already claims the same schema name (`T::NAME`). + pub fn register(&mut self) -> Result<(), String> { + let type_id = TypeId::of::(); + let name = T::NAME; + match self.name_to_type_id.get(name) { + Some(existing) if *existing == type_id => return Ok(()), + Some(_) => { + return Err(format!( + "schema name collision: \"{name}\" is already registered by a different type" + )) + } + None => {} + } + self.name_to_type_id.insert(name, type_id); + self.type_id_to_name.insert(type_id, name); + Ok(()) + } + + /// Resolve a `TypeId` to its registered schema name. + pub fn resolve_name(&self, type_id: &TypeId) -> Option<&'static str> { + self.type_id_to_name.get(type_id).copied() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aimdb_data_contracts::SchemaType; + use serde::{Deserialize, Serialize}; + + #[derive(Clone, Debug, Serialize, Deserialize)] + struct TestSensor { + value: f32, + timestamp: u64, + } + + impl SchemaType for TestSensor { + const NAME: &'static str = "test_sensor"; + } + + impl Streamable for TestSensor {} + + #[derive(Clone, Debug, Serialize, Deserialize)] + struct TestActuator { + command: String, + } + + impl SchemaType for TestActuator { + const NAME: &'static str = "test_actuator"; + } + + impl Streamable for TestActuator {} + + #[test] + fn register_and_resolve_name() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); + } + + #[test] + fn unknown_type_resolves_to_none() { + let reg = StreamableRegistry::new(); + assert_eq!(reg.resolve_name(&TypeId::of::()), None); + } + + #[test] + fn duplicate_registration_is_idempotent() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + reg.register::().unwrap(); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); + } + + #[test] + fn name_collision_from_different_type_is_rejected() { + #[derive(Clone, Debug, Serialize, Deserialize)] + struct FakeSensor { + fake: bool, + } + + impl SchemaType for FakeSensor { + const NAME: &'static str = "test_sensor"; // same name as TestSensor + } + + impl Streamable for FakeSensor {} + + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + + let err = reg.register::().unwrap_err(); + assert!(err.contains("test_sensor")); + assert!(err.contains("collision")); + } + + #[test] + fn multiple_types_registered() { + let mut reg = StreamableRegistry::new(); + reg.register::().unwrap(); + reg.register::().unwrap(); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_sensor") + ); + assert_eq!( + reg.resolve_name(&TypeId::of::()), + Some("test_actuator") + ); + } +} diff --git a/aimdb-websocket-connector/src/server/session.rs b/aimdb-websocket-connector/src/server/session.rs new file mode 100644 index 00000000..9af7948b --- /dev/null +++ b/aimdb-websocket-connector/src/server/session.rs @@ -0,0 +1,80 @@ +//! Reusable handler traits for the WebSocket dispatch. +//! +//! The WS server rides `run_session` ([`aimdb_core::session::run_session`]) via +//! [`super::dispatch`]; what lives here is the pluggable application surface the +//! dispatch consumes: +//! +//! - [`QueryHandler`] — answers client `query` messages from a persistence backend; +//! - [`SnapshotProvider`] — supplies the late-join current value for a topic. + +use core::future::Future; +use core::pin::Pin; + +use crate::protocol::QueryRecord; + +// Re-export so the builder/dispatch can use it easily. +pub use aimdb_core::router::Router; + +// ════════════════════════════════════════════════════════════════════ +// Query handler +// ════════════════════════════════════════════════════════════════════ + +/// Boxed future returned by [`QueryHandler::handle_query`]. +pub type QueryFuture<'a> = + Pin, usize), String>> + Send + 'a>>; + +/// Trait for handling `Query` messages from WebSocket clients. +/// +/// Implementations typically query a persistence backend and return matching +/// records. The trait is async to support database I/O. +pub trait QueryHandler: Send + Sync + 'static { + /// Execute a history query and return `(records, total_count)`. + /// + /// - `pattern` — topic pattern (MQTT wildcards, `"*"` for all) + /// - `from` / `to` — time range in **milliseconds** since Unix epoch (inclusive) + /// - `limit` — max records per matching topic + fn handle_query<'a>( + &'a self, + pattern: &'a str, + from: Option, + to: Option, + limit: Option, + ) -> QueryFuture<'a>; +} + +/// A query handler that always returns an error (used when no persistence +/// backend is configured). +pub struct NoQuery; + +impl QueryHandler for NoQuery { + fn handle_query<'a>( + &'a self, + _pattern: &'a str, + _from: Option, + _to: Option, + _limit: Option, + ) -> QueryFuture<'a> { + Box::pin( + async move { Err("Query not supported — no persistence backend configured".into()) }, + ) + } +} + +// ════════════════════════════════════════════════════════════════════ +// Snapshot provider (late-join) +// ════════════════════════════════════════════════════════════════════ + +/// Provides the current serialized value of a record for late-join snapshots. +pub trait SnapshotProvider: Send + Sync + 'static { + /// Return the latest serialized value for the given topic, if available. + fn snapshot(&self, topic: &str) -> Option>; +} + +/// A snapshot provider that always returns `None` (late-join disabled or no data). +pub struct NoSnapshot; + +impl SnapshotProvider for NoSnapshot { + fn snapshot(&self, _topic: &str) -> Option> { + None + } +} diff --git a/aimdb-websocket-connector/src/session.rs b/aimdb-websocket-connector/src/session.rs deleted file mode 100644 index 74c8cbdc..00000000 --- a/aimdb-websocket-connector/src/session.rs +++ /dev/null @@ -1,443 +0,0 @@ -//! Per-client WebSocket session management. -//! -//! Each accepted connection spawns three cooperating tasks: -//! -//! 1. **Send loop** — drains the per-client `mpsc` channel and writes frames to -//! the WebSocket. -//! 2. **Recv loop** — reads frames from the WebSocket and dispatches -//! `subscribe`, `unsubscribe`, `write`, `ping`, and `query` messages. -//! 3. A **cleanup** fence — unregisters the client from the [`ClientManager`] -//! when either loop finishes. -//! -//! The session receives an already-authenticated [`ClientInfo`] and the shared -//! [`ClientManager`] / inbound [`Router`] from the server. - -use std::sync::Arc; - -use core::future::Future; -use core::pin::Pin; - -use axum::extract::ws::{Message, WebSocket}; -use futures_util::{SinkExt, StreamExt}; -use tokio::sync::mpsc; - -use crate::{ - auth::{AuthHandler, ClientId, ClientInfo}, - client_manager::ClientManager, - protocol::{ClientMessage, ErrorCode, QueryRecord, TopicInfo}, -}; - -// Re-export so server.rs can use it easily. -pub use aimdb_core::router::Router; - -// ════════════════════════════════════════════════════════════════════ -// Query handler -// ════════════════════════════════════════════════════════════════════ - -/// Boxed future returned by [`QueryHandler::handle_query`]. -pub type QueryFuture<'a> = - Pin, usize), String>> + Send + 'a>>; - -/// Trait for handling `Query` messages from WebSocket clients. -/// -/// Implementations typically query a persistence backend and return matching -/// records. The trait is async to support database I/O. -/// -/// # Example -/// -/// ```rust,ignore -/// struct MyQueryHandler { db: Arc } -/// -/// impl QueryHandler for MyQueryHandler { -/// fn handle_query<'a>( -/// &'a self, -/// pattern: &'a str, -/// from: Option, -/// to: Option, -/// limit: Option, -/// ) -> QueryFuture<'a> { -/// Box::pin(async move { -/// // query your persistence layer … -/// Ok((records, total)) -/// }) -/// } -/// } -/// ``` -pub trait QueryHandler: Send + Sync + 'static { - /// Execute a history query and return `(records, total_count)`. - /// - /// - `pattern` — topic pattern (MQTT wildcards, `"*"` for all) - /// - `from` / `to` — time range in **milliseconds** since Unix epoch (inclusive) - /// - `limit` — max records per matching topic - fn handle_query<'a>( - &'a self, - pattern: &'a str, - from: Option, - to: Option, - limit: Option, - ) -> QueryFuture<'a>; -} - -/// A query handler that always returns an error (used when no persistence -/// backend is configured). -pub struct NoQuery; - -impl QueryHandler for NoQuery { - fn handle_query<'a>( - &'a self, - _pattern: &'a str, - _from: Option, - _to: Option, - _limit: Option, - ) -> QueryFuture<'a> { - Box::pin( - async move { Err("Query not supported — no persistence backend configured".into()) }, - ) - } -} - -// ════════════════════════════════════════════════════════════════════ -// Session context -// ════════════════════════════════════════════════════════════════════ - -/// Shared context injected into every session. -#[derive(Clone)] -pub(crate) struct SessionContext { - pub client_mgr: ClientManager, - /// Inbound router: maps WebSocket topics → AimDB producers. - pub router: Arc, - pub auth: Arc, - /// Channel capacity used when registering a new client. - pub channel_capacity: usize, - /// Whether to send current values on subscribe (late-join). - pub late_join: bool, - /// Snapshot provider: topic → serialized current value. - /// - /// Set by the connector builder after collecting outbound routes. - pub snapshot_provider: Arc, - /// Topics to subscribe every new client to automatically on connect. - /// - /// Use `["#"]` to push all data to all clients without requiring an - /// explicit `{"type":"subscribe"}` message from the client. - pub auto_subscribe_topics: Vec, - /// Handler for `Query` messages (historical record retrieval). - pub query_handler: Arc, - /// All outbound topics served by this endpoint, returned on `list_topics`. - pub known_topics: Vec, - /// Type-erased runtime for context-aware deserializers. - pub runtime_ctx: Option>, -} - -/// Provides the current serialized value of a record for late-join snapshots. -pub(crate) trait SnapshotProvider: Send + Sync + 'static { - /// Return the latest serialized value for the given topic, if available. - fn snapshot(&self, topic: &str) -> Option>; -} - -/// A snapshot provider that always returns `None` (used when late-join is disabled -/// or no snapshot data is available). -pub(crate) struct NoSnapshot; - -impl SnapshotProvider for NoSnapshot { - fn snapshot(&self, _topic: &str) -> Option> { - None - } -} - -/// A snapshot provider backed by a `HashMap`. -#[cfg(test)] -#[allow(dead_code)] -pub(crate) struct MapSnapshot(pub std::collections::HashMap>); - -#[cfg(test)] -impl SnapshotProvider for MapSnapshot { - fn snapshot(&self, topic: &str) -> Option> { - self.0.get(topic).cloned() - } -} - -// ════════════════════════════════════════════════════════════════════ -// Session entry point -// ════════════════════════════════════════════════════════════════════ - -/// Drive a single WebSocket connection to completion. -/// -/// This function is `await`ed inside `tokio::spawn` by the Axum upgrade handler. -pub(crate) async fn run_session(socket: WebSocket, info: ClientInfo, ctx: SessionContext) { - let id = info.id; - - // Register client and obtain the per-client receiver - let (_, rx) = ctx.client_mgr.register(info, ctx.channel_capacity); - - // Auto-subscribe: subscribe all clients to the configured topics immediately - // on connect, without requiring a Subscribe message from the client. - if !ctx.auto_subscribe_topics.is_empty() { - ctx.client_mgr.subscribe(id, &ctx.auto_subscribe_topics); - } - - #[cfg(feature = "tracing")] - tracing::debug!("{}: session started", id); - - let (ws_sender, ws_receiver) = socket.split(); - - // Spawn the send loop (mpsc receiver → WebSocket sender) - let mgr_send = ctx.client_mgr.clone(); - let send_handle = tokio::spawn(send_loop(ws_sender, rx, id)); - - // Run the receive loop in-place (WebSocket receiver → router/subscriptions) - recv_loop(ws_receiver, id, ctx).await; - - // Receiving finished; abort sender and unregister - send_handle.abort(); - mgr_send.unregister(id); - - #[cfg(feature = "tracing")] - tracing::debug!("{}: session ended", id); -} - -// ════════════════════════════════════════════════════════════════════ -// Send loop -// ════════════════════════════════════════════════════════════════════ - -async fn send_loop( - mut ws_sender: futures_util::stream::SplitSink, - mut rx: mpsc::Receiver, - #[allow(unused_variables)] id: ClientId, -) { - while let Some(msg) = rx.recv().await { - if ws_sender.send(msg).await.is_err() { - #[cfg(feature = "tracing")] - tracing::debug!("{}: send failed — closing", id); - break; - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Receive loop -// ════════════════════════════════════════════════════════════════════ - -async fn recv_loop( - mut ws_receiver: futures_util::stream::SplitStream, - id: ClientId, - ctx: SessionContext, -) { - while let Some(result) = ws_receiver.next().await { - let raw = match result { - Ok(msg) => msg, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::debug!("{}: recv error: {}", id, _e); - break; - } - }; - - match raw { - Message::Text(text) => { - handle_text(id, text.as_str(), &ctx).await; - } - Message::Binary(bytes) => { - handle_text(id, &String::from_utf8_lossy(&bytes), &ctx).await; - } - Message::Close(_) => { - #[cfg(feature = "tracing")] - tracing::debug!("{}: received close frame", id); - break; - } - // WebSocket ping/pong frames are handled transparently by axum. - _ => {} - } - } -} - -// ════════════════════════════════════════════════════════════════════ -// Message dispatch -// ════════════════════════════════════════════════════════════════════ - -async fn handle_text(id: ClientId, text: &str, ctx: &SessionContext) { - let msg: ClientMessage = match serde_json::from_str(text) { - Ok(m) => m, - Err(_e) => { - #[cfg(feature = "tracing")] - tracing::warn!("{}: invalid JSON from client: {}", id, _e); - ctx.client_mgr - .send_error( - id, - ErrorCode::SerializationError, - None, - "Invalid JSON message", - ) - .await; - return; - } - }; - - match msg { - ClientMessage::Subscribe { topics } => handle_subscribe(id, topics, ctx).await, - ClientMessage::Unsubscribe { topics } => { - ctx.client_mgr.unsubscribe(id, &topics); - } - ClientMessage::Write { topic, payload } => handle_write(id, topic, payload, ctx).await, - ClientMessage::Ping => { - ctx.client_mgr.send_pong(id).await; - } - ClientMessage::Query { - id: query_id, - pattern, - from, - to, - limit, - } => { - handle_query(id, query_id, pattern, from, to, limit, ctx).await; - } - ClientMessage::ListTopics { id: req_id } => { - handle_list_topics(id, req_id, ctx).await; - } - } -} - -async fn handle_subscribe(id: ClientId, topics: Vec, ctx: &SessionContext) { - // Authorise each requested pattern - let client_info = match ctx.client_mgr.client_info(id) { - Some(i) => i, - None => return, - }; - - let mut allowed = Vec::new(); - - for topic in &topics { - if ctx.auth.authorize_subscribe(&client_info, topic).await { - allowed.push(topic.clone()); - } else { - ctx.client_mgr - .send_error( - id, - ErrorCode::Forbidden, - Some(topic.clone()), - "Not authorised to subscribe to this topic", - ) - .await; - } - } - - if allowed.is_empty() { - return; - } - - // Register subscriptions - let confirmed = ctx.client_mgr.subscribe(id, &allowed); - - // Send acknowledgement - ctx.client_mgr.send_subscribed(id, confirmed.clone()).await; - - // Late-join: send current values for each exact topic pattern that resolves - if ctx.late_join { - for pattern in confirmed { - if let Some(bytes) = ctx.snapshot_provider.snapshot(&pattern) { - ctx.client_mgr.send_snapshot(id, &pattern, &bytes).await; - } - } - } -} - -async fn handle_write( - id: ClientId, - topic: String, - payload: serde_json::Value, - ctx: &SessionContext, -) { - // Authorise - let client_info = match ctx.client_mgr.client_info(id) { - Some(i) => i, - None => return, - }; - - if !ctx.auth.authorize_write(&client_info, &topic).await { - ctx.client_mgr - .send_error( - id, - ErrorCode::Forbidden, - Some(topic.clone()), - "Write permission denied", - ) - .await; - return; - } - - // Serialize payload back to bytes for the router - let bytes = match serde_json::to_vec(&payload) { - Ok(b) => b, - Err(_e) => { - ctx.client_mgr - .send_error( - id, - ErrorCode::SerializationError, - Some(topic.clone()), - "Failed to re-serialize payload", - ) - .await; - return; - } - }; - - // Dispatch through the inbound router - if let Err(_e) = ctx - .router - .route(&topic, &bytes, ctx.runtime_ctx.as_ref()) - .await - { - #[cfg(feature = "tracing")] - tracing::warn!("{}: write routing failed for '{}': {}", id, topic, _e); - - ctx.client_mgr - .send_error( - id, - ErrorCode::UnknownTopic, - Some(topic), - "No inbound route for this topic", - ) - .await; - } -} - -async fn handle_list_topics(id: ClientId, req_id: String, ctx: &SessionContext) { - use crate::protocol::ServerMessage; - - let result = ServerMessage::TopicList { - id: req_id, - topics: ctx.known_topics.clone(), - }; - ctx.client_mgr.send_to_client(id, &result).await; -} - -async fn handle_query( - id: ClientId, - query_id: String, - pattern: String, - from: Option, - to: Option, - limit: Option, - ctx: &SessionContext, -) { - use crate::protocol::ServerMessage; - - match ctx - .query_handler - .handle_query(&pattern, from, to, limit) - .await - { - Ok((records, total)) => { - let result = ServerMessage::QueryResult { - id: query_id, - records, - total, - }; - ctx.client_mgr.send_to_client(id, &result).await; - } - Err(msg) => { - ctx.client_mgr - .send_error(id, ErrorCode::ServerError, None, &msg) - .await; - } - } -} diff --git a/aimdb-websocket-connector/src/transport.rs b/aimdb-websocket-connector/src/transport.rs new file mode 100644 index 00000000..82a9f73f --- /dev/null +++ b/aimdb-websocket-connector/src/transport.rs @@ -0,0 +1,235 @@ +//! WS transport adapters — `Connection` (and, for the client, `Dialer`) over a +//! real WebSocket, so the shared session engines drive it. +//! +//! The **server** side (`WsServerConnection`) wraps axum's upgraded `WebSocket` +//! and performs the **multi-topic split**: a `Subscribe`/`Unsubscribe` carrying N +//! topics is yielded as N single-topic frames, so the codec's `decode` stays 1→1. + +#[cfg(feature = "server")] +use std::collections::VecDeque; + +#[cfg(any(feature = "server", feature = "client"))] +use aimdb_core::{BoxFut, Connection, PeerInfo, TransportError, TransportResult}; +#[cfg(feature = "server")] +use axum::extract::ws::{Message, WebSocket}; + +#[cfg(feature = "server")] +use crate::protocol::ClientMessage; + +/// A server-side [`Connection`] over an upgraded axum [`WebSocket`]. +#[cfg(feature = "server")] +pub struct WsServerConnection { + ws: WebSocket, + peer: PeerInfo, + /// Single-topic frames split off a multi-topic `Subscribe`/`Unsubscribe`, + /// drained before reading the next WS message. + pending: VecDeque>, +} + +#[cfg(feature = "server")] +impl WsServerConnection { + /// Wrap an upgraded socket with its pre-resolved peer identity. + /// + /// `auto_subscribe` seeds synthetic single-topic `Subscribe` frames so the + /// engine subscribes the client to those patterns on connect, without a + /// client message. + pub fn new(ws: WebSocket, peer: PeerInfo, auto_subscribe: &[String]) -> Self { + let pending = auto_subscribe + .iter() + .filter_map(|t| { + serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec![t.clone()], + }) + .ok() + }) + .collect(); + Self { ws, peer, pending } + } + + /// Read one logical frame, expanding a multi-topic `Subscribe`/`Unsubscribe` + /// into one single-topic frame per call (the rest are queued in `pending`). + async fn next_logical(&mut self) -> TransportResult>> { + loop { + if let Some(frame) = self.pending.pop_front() { + return Ok(Some(frame)); + } + let bytes = match self.ws.recv().await { + Some(Ok(Message::Text(t))) => t.as_str().as_bytes().to_vec(), + Some(Ok(Message::Binary(b))) => b.to_vec(), + // axum answers Ping transparently; ignore Pong; Close ends the stream. + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + Some(Ok(Message::Close(_))) | None => return Ok(None), + Some(Err(_)) => return Err(TransportError::Io), + }; + if let Some(split) = split_multi_topic(&bytes) { + self.pending.extend(split); + continue; + } + return Ok(Some(bytes)); + } + } +} + +#[cfg(feature = "server")] +impl Connection for WsServerConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + Box::pin(self.next_logical()) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + Box::pin(async move { + let text = String::from_utf8_lossy(frame).into_owned(); + self.ws + .send(Message::Text(text.into())) + .await + .map_err(|_| TransportError::Io) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +/// If `bytes` is a multi-topic `Subscribe`/`Unsubscribe`, return one re-serialized +/// single-topic frame per topic; otherwise `None` (the frame passes through). +#[cfg(feature = "server")] +fn split_multi_topic(bytes: &[u8]) -> Option>> { + let msg: ClientMessage = serde_json::from_slice(bytes).ok()?; + let (topics, is_sub) = match msg { + ClientMessage::Subscribe { topics } if topics.len() > 1 => (topics, true), + ClientMessage::Unsubscribe { topics } if topics.len() > 1 => (topics, false), + _ => return None, + }; + Some( + topics + .into_iter() + .filter_map(|t| { + let one = if is_sub { + ClientMessage::Subscribe { topics: vec![t] } + } else { + ClientMessage::Unsubscribe { topics: vec![t] } + }; + serde_json::to_vec(&one).ok() + }) + .collect(), + ) +} + +// ════════════════════════════════════════════════════════════════════ +// Client side — Dialer + Connection over tokio-tungstenite +// ════════════════════════════════════════════════════════════════════ + +/// A [`Dialer`](aimdb_core::Dialer) that opens a `tokio-tungstenite` connection +/// to a remote WS server. `run_client` ([`aimdb_core::session::run_client`]) calls +/// [`connect`](aimdb_core::Dialer::connect) on each (re)dial. +#[cfg(feature = "client")] +pub struct WsDialer { + url: String, +} + +#[cfg(feature = "client")] +impl WsDialer { + /// Dial the WS server at `url` (e.g. `wss://host/ws`). + pub fn new(url: impl Into) -> Self { + Self { url: url.into() } + } +} + +#[cfg(feature = "client")] +impl aimdb_core::Dialer for WsDialer { + fn connect( + &self, + ) -> aimdb_core::BoxFut<'_, aimdb_core::TransportResult>> { + Box::pin(async move { + let (ws, _resp) = tokio_tungstenite::connect_async(&self.url) + .await + .map_err(|_| aimdb_core::TransportError::Io)?; + Ok(Box::new(WsClientConnection { + ws, + peer: aimdb_core::PeerInfo::default(), + }) as Box) + }) + } +} + +/// A client-side [`Connection`] over a `tokio-tungstenite` stream. +#[cfg(feature = "client")] +pub struct WsClientConnection { + ws: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, + peer: aimdb_core::PeerInfo, +} + +#[cfg(feature = "client")] +impl Connection for WsClientConnection { + fn recv(&mut self) -> BoxFut<'_, TransportResult>>> { + use futures_util::StreamExt; + use tokio_tungstenite::tungstenite::Message; + Box::pin(async move { + loop { + return match self.ws.next().await { + Some(Ok(Message::Text(t))) => Ok(Some(t.as_bytes().to_vec())), + Some(Ok(Message::Binary(b))) => Ok(Some(b.to_vec())), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + Some(Ok(Message::Close(_))) | None => Ok(None), + Some(Ok(_)) => continue, // Frame — ignore + Some(Err(_)) => Err(TransportError::Io), + }; + } + }) + } + + fn send<'a>(&'a mut self, frame: &'a [u8]) -> BoxFut<'a, TransportResult<()>> { + use futures_util::SinkExt; + use tokio_tungstenite::tungstenite::Message; + Box::pin(async move { + let text = String::from_utf8_lossy(frame).into_owned(); + self.ws + .send(Message::Text(text.into())) + .await + .map_err(|_| TransportError::Io) + }) + } + + fn peer(&self) -> &PeerInfo { + &self.peer + } +} + +#[cfg(all(test, feature = "server"))] +mod tests { + use super::*; + + #[test] + fn splits_multi_topic_subscribe() { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["a".into(), "b".into(), "c".into()], + }) + .unwrap(); + let split = split_multi_topic(&frame).expect("should split"); + assert_eq!(split.len(), 3); + for (i, t) in ["a", "b", "c"].iter().enumerate() { + match serde_json::from_slice::(&split[i]).unwrap() { + ClientMessage::Subscribe { topics } => assert_eq!(topics, vec![t.to_string()]), + _ => panic!("expected single-topic Subscribe"), + } + } + } + + #[test] + fn single_topic_passes_through() { + let frame = serde_json::to_vec(&ClientMessage::Subscribe { + topics: vec!["only".into()], + }) + .unwrap(); + assert!(split_multi_topic(&frame).is_none()); + } + + #[test] + fn non_subscribe_passes_through() { + let frame = serde_json::to_vec(&ClientMessage::Ping).unwrap(); + assert!(split_multi_topic(&frame).is_none()); + } +} diff --git a/aimdb-websocket-connector/tests/e2e.rs b/aimdb-websocket-connector/tests/e2e.rs new file mode 100644 index 00000000..e4edd402 --- /dev/null +++ b/aimdb-websocket-connector/tests/e2e.rs @@ -0,0 +1,605 @@ +//! Real-socket integration tests for the WebSocket connector — black-box. +//! +//! These drive the connector through its **public API only**: a server `AimDb` +//! stood up with [`WebSocketConnector`] over a real TCP socket, talked to by a +//! raw `tokio-tungstenite` client (or the public `run_client` + [`WsDialer`] +//! engine). Server→client data is pushed by *producing a record* — an "injector" +//! record whose dynamic topic + raw serializer let a test broadcast an arbitrary +//! `(topic, payload)` through the real `pump_sink` → bus → session path. +//! +//! Needs both halves (`server` + `client`); compiles away otherwise. + +#![cfg(all(feature = "server", feature = "client"))] + +use std::future::Future; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::connector::TopicProvider; +use aimdb_core::session::{run_client, ClientConfig}; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_data_contracts::{SchemaType, Streamable}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::codec::WsCodec; +use aimdb_websocket_connector::transport::WsDialer; +use aimdb_websocket_connector::{ + AuthError, AuthHandler, AuthRequest, ClientInfo, ClientMessage, ErrorCode, Permissions, + QueryFuture, QueryHandler, QueryRecord, ServerMessage, WebSocketConnector, +}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::net::TcpStream; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::Message; + +// ── Injector record ────────────────────────────────────────────────── +// Producing one pushes `payload` out on `topic` via the real outbound path. + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Inject { + topic: String, + payload: Value, +} + +struct InjectTopic; +impl TopicProvider for InjectTopic { + fn topic(&self, v: &Inject) -> Option { + Some(v.topic.clone()) + } +} + +// ── A registered Streamable type (for the `list_topics` schema name) ── + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct Temp { + c: f32, +} +impl SchemaType for Temp { + const NAME: &'static str = "temperature"; +} +impl Streamable for Temp {} + +// ── Auth + query fixtures ──────────────────────────────────────────── + +struct DenyAuth; +impl AuthHandler for DenyAuth { + fn authenticate<'a>( + &'a self, + _request: &'a AuthRequest, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Err(AuthError::new("denied")) }) + } +} + +/// Allows the connection (allow-all permissions) but asynchronously *denies* +/// `secret/*` via `authorize_subscribe`. If the engine only consulted the static +/// permission set, `secret` would be allowed — so this proves the async hook gates. +struct AsyncTopicAuth; +impl AuthHandler for AsyncTopicAuth { + fn authenticate<'a>( + &'a self, + _request: &'a AuthRequest, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(Permissions::allow_all()) }) + } + fn authorize_subscribe<'a>( + &'a self, + _client: &'a ClientInfo, + topic: &'a str, + ) -> Pin + Send + 'a>> { + let denied = topic.starts_with("secret"); + Box::pin(async move { + tokio::task::yield_now().await; // simulate an async ACL lookup + !denied + }) + } +} + +struct OneRecordQuery; +impl QueryHandler for OneRecordQuery { + fn handle_query<'a>( + &'a self, + _pattern: &'a str, + _from: Option, + _to: Option, + _limit: Option, + ) -> QueryFuture<'a> { + Box::pin(async { + Ok(( + vec![QueryRecord { + topic: "temp".into(), + payload: json!(21.0), + ts: 7, + }], + 1, + )) + }) + } +} + +// ── Harness ────────────────────────────────────────────────────────── + +/// Reserve an ephemeral port, then free it so the server can bind it. +fn free_addr() -> SocketAddr { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() +} + +/// Wait until the server is accepting connections at `addr`. +async fn wait_for_listen(addr: SocketAddr) { + for _ in 0..200 { + if TcpStream::connect(addr).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("server never bound at {addr}"); +} + +/// Stand up a WS server (with the injector record) on an ephemeral port. The +/// caller pre-configures `ws` (auth / late-join / …); we add `bind`/`path`. +async fn spawn(ws: WebSocketConnector) -> (SocketAddr, Arc>) { + let addr = free_addr(); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(ws.bind(addr).path("/ws")); + sb.configure::("inject", |reg| { + reg.buffer(BufferCfg::SpmcRing { capacity: 1024 }) + .with_remote_access() + .link_to("ws://_") // overridden per-value by the topic provider + .with_topic_provider(InjectTopic) + .with_serializer_raw(|m: &Inject| { + Ok(serde_json::to_vec(&m.payload).expect("serialize payload")) + }) + .finish(); + }); + let (db, runner) = sb.build().await.expect("build server db"); + let db = Arc::new(db); + tokio::spawn(runner.run()); + wait_for_listen(addr).await; + (addr, db) +} + +/// Default allow-all, late-join-on server. +async fn spawn_default() -> (SocketAddr, Arc>) { + spawn(WebSocketConnector::new().with_late_join(true)).await +} + +/// Push `payload` to subscribers of `topic` (one outbound record update). +fn inject(db: &AimDb, topic: &str, payload: Value) { + db.set_record_from_json("inject", json!({ "topic": topic, "payload": payload })) + .expect("inject"); +} + +type WsClient = + tokio_tungstenite::WebSocketStream>; + +async fn ws_connect(addr: SocketAddr) -> WsClient { + tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("connect") + .0 +} + +async fn ws_send(c: &mut WsClient, m: ClientMessage) { + c.send(Message::Text(serde_json::to_string(&m).unwrap().into())) + .await + .unwrap(); +} + +/// Read the next `ServerMessage`, with a timeout so a hang fails loudly. +async fn ws_recv(c: &mut WsClient) -> ServerMessage { + loop { + match timeout(Duration::from_secs(3), c.next()) + .await + .expect("recv timed out") + { + Some(Ok(Message::Text(t))) => return serde_json::from_str(&t).unwrap(), + Some(Ok(Message::Binary(b))) => return serde_json::from_slice(&b).unwrap(), + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + other => panic!("unexpected ws frame: {other:?}"), + } + } +} + +/// Like [`ws_recv`] but returns the raw JSON with `ts` normalized to `0`. +async fn recv_value(c: &mut WsClient) -> Value { + loop { + match timeout(Duration::from_secs(3), c.next()) + .await + .expect("recv timed out") + { + Some(Ok(Message::Text(t))) => { + let mut v: Value = serde_json::from_str(&t).unwrap(); + if let Some(ts) = v.get_mut("ts") { + *ts = json!(0); + } + return v; + } + Some(Ok(Message::Ping(_))) | Some(Ok(Message::Pong(_))) => continue, + other => panic!("unexpected ws frame: {other:?}"), + } + } +} + +// ── Server e2e ─────────────────────────────────────────────────────── + +#[tokio::test] +async fn server_subscribe_ack_and_wildcard_fanout() { + let (addr, db) = spawn_default().await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["sensors/#".into()], + }, + ) + .await; + assert!( + matches!(ws_recv(&mut c).await, ServerMessage::Subscribed { topics } if topics == vec!["sensors/#".to_string()]) + ); + + // The ack means the bus subscription is registered, so a fan-out reaches us. + inject(&db, "sensors/temp/vienna", json!(22.5)); + match ws_recv(&mut c).await { + ServerMessage::Data { topic, payload, .. } => { + assert_eq!(topic, "sensors/temp/vienna"); + assert_eq!(payload, Some(json!(22.5))); + } + other => panic!("expected Data, got {other:?}"), + } +} + +#[tokio::test] +async fn server_multi_topic_subscribe_and_unsubscribe() { + let (addr, db) = spawn_default().await; + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["a".into(), "b".into()], + }, + ) + .await; + let mut acked = Vec::new(); + for _ in 0..2 { + if let ServerMessage::Subscribed { topics } = ws_recv(&mut c).await { + acked.extend(topics); + } + } + acked.sort(); + assert_eq!(acked, vec!["a".to_string(), "b".to_string()]); + + inject(&db, "a", json!(1)); + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "a")); + + // Unsubscribe "a"; a later "a" must not arrive, but "b" still does. + ws_send( + &mut c, + ClientMessage::Unsubscribe { + topics: vec!["a".into()], + }, + ) + .await; + tokio::time::sleep(Duration::from_millis(100)).await; // let the unsub settle + inject(&db, "a", json!(2)); + inject(&db, "b", json!(3)); + match ws_recv(&mut c).await { + ServerMessage::Data { topic, .. } => assert_eq!(topic, "b", "only 'b' should arrive"), + other => panic!("expected Data on b, got {other:?}"), + } +} + +#[tokio::test] +async fn server_late_join_snapshot() { + let (addr, db) = spawn_default().await; + // Produce the value first so the late-join cache holds it, then subscribe. + inject(&db, "sensors/temp", json!(99)); + tokio::time::sleep(Duration::from_millis(100)).await; + + let mut c = ws_connect(addr).await; + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["sensors/temp".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + match ws_recv(&mut c).await { + ServerMessage::Snapshot { topic, payload } => { + assert_eq!(topic, "sensors/temp"); + assert_eq!(payload, Some(json!(99))); + } + other => panic!("expected Snapshot, got {other:?}"), + } +} + +#[tokio::test] +async fn server_query_and_list_topics() { + let addr = free_addr(); + let mut ws = WebSocketConnector::new().with_query_handler(OneRecordQuery); + ws.register::(); + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(ws.bind(addr).path("/ws")); + sb.configure::("temp", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://temp") + .with_serializer_raw(|t: &Temp| Ok(serde_json::to_vec(t).unwrap())) + .finish(); + }); + let (_db, runner) = sb.build().await.expect("build db"); + tokio::spawn(runner.run()); + wait_for_listen(addr).await; + + let mut c = ws_connect(addr).await; + + ws_send( + &mut c, + ClientMessage::Query { + id: "q1".into(), + pattern: "#".into(), + from: None, + to: None, + limit: None, + }, + ) + .await; + match ws_recv(&mut c).await { + ServerMessage::QueryResult { id, records, total } => { + assert_eq!(id, "q1"); // the String id round-trips + assert_eq!(total, 1); + assert_eq!(records.len(), 1); + } + other => panic!("expected QueryResult, got {other:?}"), + } + + ws_send(&mut c, ClientMessage::ListTopics { id: "l1".into() }).await; + match ws_recv(&mut c).await { + ServerMessage::TopicList { id, topics } => { + assert_eq!(id, "l1"); + assert_eq!(topics.len(), 1); + assert_eq!(topics[0].name, "temp"); + assert_eq!(topics[0].schema_type.as_deref(), Some("temperature")); + } + other => panic!("expected TopicList, got {other:?}"), + } +} + +#[tokio::test] +async fn server_ping_pong() { + let (addr, _db) = spawn_default().await; + let mut c = ws_connect(addr).await; + ws_send(&mut c, ClientMessage::Ping).await; + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Pong)); +} + +#[tokio::test] +async fn server_rejects_unauthenticated_upgrade() { + let (addr, _db) = spawn(WebSocketConnector::new().with_auth(DenyAuth)).await; + // The upgrade must be refused with HTTP 401 → the WS handshake fails. + let result = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")).await; + assert!(result.is_err(), "auth-rejected upgrade should not connect"); +} + +#[tokio::test] +async fn server_survives_malformed_frame() { + let (addr, db) = spawn_default().await; + let mut c = ws_connect(addr).await; + + // Garbage that is not a ClientMessage — the session must skip it, not die. + c.send(Message::Text("{not valid".to_string().into())) + .await + .unwrap(); + // The connection is still usable afterwards. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + inject(&db, "x", json!(1)); + assert!(matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "x")); +} + +// ── Client engine e2e (run_client + WsDialer over a real socket) ───── + +#[tokio::test] +async fn client_engine_receives_broadcast_over_real_socket() { + let (addr, db) = spawn_default().await; + + let config = ClientConfig { + topic_routed_subs: true, + reconnect: false, + ..ClientConfig::default() + }; + let (handle, engine) = run_client( + WsDialer::new(format!("ws://{addr}/ws")), + WsCodec::new(), + config, + Arc::new(TokioAdapter), + ); + let driver = tokio::spawn(engine); + + let mut stream = handle.subscribe("sensors/temp").unwrap(); + + // Subscription registration is async; re-inject until the value arrives. + let mut got = None; + for _ in 0..100 { + inject(&db, "sensors/temp", json!(42)); + if let Ok(Some(item)) = timeout(Duration::from_millis(20), stream.next()).await { + got = Some(item); + break; + } + } + // The record value round-trips: Data{payload:42} → engine stream yields b"42". + assert_eq!(&got.expect("a value")[..], b"42"); + + drop(handle); + drop(stream); + let _ = driver.await; +} + +// ── Concurrency / backpressure ─────────────────────────────────────── + +#[tokio::test] +async fn many_clients_fanout() { + let (addr, db) = spawn_default().await; + + let mut clients = Vec::new(); + for _ in 0..20 { + let mut c = ws_connect(addr).await; + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["evt/#".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + clients.push(c); + } + + // One broadcast reaches all 20. + inject(&db, "evt/x", json!(1)); + for c in &mut clients { + assert!(matches!(ws_recv(c).await, ServerMessage::Data { topic, .. } if topic == "evt/x")); + } +} + +#[tokio::test] +async fn stalled_client_does_not_block_a_healthy_one() { + let (addr, db) = spawn_default().await; + + // Stalled: subscribes but never reads — its socket backpressures. + let mut stalled = ws_connect(addr).await; + ws_send( + &mut stalled, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + + let mut healthy = ws_connect(addr).await; + ws_send( + &mut healthy, + ClientMessage::Subscribe { + topics: vec!["x".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut healthy).await, + ServerMessage::Subscribed { .. } + )); + tokio::time::sleep(Duration::from_millis(100)).await; // let the stalled sub register + + // Flood well past the bounded funnel (256). This also overruns the injector + // ring, so the outbound `pump_sink` consumer lags — it must skip the gap and + // keep publishing (not die), while the stalled client's pump drops on overflow + // and the healthy client keeps up. + for i in 0..2000u32 { + inject(&db, "x", json!(i)); + } + + assert!( + matches!(ws_recv(&mut healthy).await, ServerMessage::Data { topic, .. } if topic == "x"), + "healthy client must keep receiving past a stalled peer", + ); + let _ = stalled.close(None).await; +} + +// ── Golden wire frames (locks the exact on-the-wire JSON shape) ────── + +#[tokio::test] +async fn golden_wire_frames() { + let (addr, db) = spawn_default().await; + inject(&db, "t", json!(5)); // seed the late-join cache + tokio::time::sleep(Duration::from_millis(100)).await; + + let mut c = ws_connect(addr).await; + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["t".into()], + }, + ) + .await; + assert_eq!( + recv_value(&mut c).await, + json!({"type": "subscribed", "topics": ["t"]}) + ); + assert_eq!( + recv_value(&mut c).await, + json!({"type": "snapshot", "topic": "t", "payload": 5}) + ); + + inject(&db, "t", json!(42)); + assert_eq!( + recv_value(&mut c).await, + json!({"type": "data", "topic": "t", "payload": 42, "ts": 0}) + ); + + ws_send(&mut c, ClientMessage::Ping).await; + assert_eq!(recv_value(&mut c).await, json!({"type": "pong"})); +} + +// ── Async authorization over a real socket ─────────────────────────── + +#[tokio::test] +async fn async_authorize_subscribe_gates_despite_allow_all_permissions() { + let (addr, db) = spawn(WebSocketConnector::new().with_auth(AsyncTopicAuth)).await; + let mut c = ws_connect(addr).await; + + // Denied topic: permissions are allow-all, but the *async* hook says no. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["secret/x".into()], + }, + ) + .await; + match ws_recv(&mut c).await { + ServerMessage::Error { code, .. } => assert!(matches!(code, ErrorCode::Forbidden)), + other => panic!("expected Forbidden Error for denied subscribe, got {other:?}"), + } + + // An allowed topic still works end-to-end. + ws_send( + &mut c, + ClientMessage::Subscribe { + topics: vec!["public/x".into()], + }, + ) + .await; + assert!(matches!( + ws_recv(&mut c).await, + ServerMessage::Subscribed { .. } + )); + inject(&db, "public/x", json!(1)); + assert!( + matches!(ws_recv(&mut c).await, ServerMessage::Data { topic, .. } if topic == "public/x") + ); +} diff --git a/aimdb-websocket-connector/tests/ws_roundtrip.rs b/aimdb-websocket-connector/tests/ws_roundtrip.rs new file mode 100644 index 00000000..00888a1e --- /dev/null +++ b/aimdb-websocket-connector/tests/ws_roundtrip.rs @@ -0,0 +1,131 @@ +//! Layer 1.3 (doc 039-validation) — full **AimDB ↔ AimDB over a real WebSocket**. +//! +//! A server `AimDb` (served by `WebSocketConnector`) and a client `AimDb` (whose +//! records carry `ws-client://` links via `WsClientConnector`). This exercises the +//! whole public path both directions over a real socket: +//! - **client → server**: producing the client's `cfg` record writes it to the +//! server (`link_to("ws-client://cfg")` → `Write` → server `link_from("ws://cfg")`). +//! - **server → client**: updating the server's `tele` record streams it back +//! (`link_to("ws://tele")` broadcast → client subscription → client `tele`). + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use aimdb_core::buffer::BufferCfg; +use aimdb_core::{AimDb, AimDbBuilder}; +use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_websocket_connector::{WebSocketConnector, WsClientConnector}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +struct Msg { + v: u64, +} + +/// Grab a probably-free ephemeral port (the WS builder binds internally and does +/// not surface `:0`'s assigned port, so we pick one up front). +fn free_addr() -> SocketAddr { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let a = l.local_addr().unwrap(); + drop(l); + a +} + +/// Re-assert `db.` reaches `want`, re-driving `push` each tick so the test is +/// robust against subscription-registration timing. +async fn mirror_reaches( + db: &Arc>, + key: &str, + want: &serde_json::Value, + mut push: impl FnMut(), +) -> bool { + for _ in 0..100 { + push(); + tokio::time::sleep(Duration::from_millis(25)).await; + if db.try_latest_as_json(key).as_ref() == Some(want) { + return true; + } + } + false +} + +#[tokio::test] +async fn ws_mirrors_record_both_directions() { + let addr = free_addr(); + + // --- server: tele (broadcast source) + cfg (inbound write target) -------- + let mut sb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WebSocketConnector::new().bind(addr).path("/ws")); + sb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws://tele") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + sb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws://cfg") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + let (server_db, server_runner) = sb.build().await.expect("build server db"); + let server_db = Arc::new(server_db); + tokio::spawn(server_runner.run()); + + // Give the server a moment to bind before the client dials. + tokio::time::sleep(Duration::from_millis(100)).await; + + // --- client: cfg links *to* the server, tele links *from* it ------------- + let mut cb = AimDbBuilder::new() + .runtime(Arc::new(TokioAdapter)) + .with_connector(WsClientConnector::new(format!("ws://{addr}/ws"))); + cb.configure::("cfg", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_to("ws-client://cfg") + .with_serializer_raw(|m: &Msg| Ok(serde_json::to_vec(m).expect("serialize"))) + .finish(); + }); + cb.configure::("tele", |reg| { + reg.buffer(BufferCfg::SingleLatest) + .with_remote_access() + .link_from("ws-client://tele") + .with_deserializer_raw(|d: &[u8]| { + serde_json::from_slice::(d).map_err(|e| e.to_string()) + }) + .finish(); + }); + let (client_db, client_runner) = cb.build().await.expect("build client db"); + let client_db = Arc::new(client_db); + tokio::spawn(client_runner.run()); + + // server → client: updating server `tele` mirrors to client `tele`. + let want_tele = json!({ "v": 9 }); + let mirrored_in = mirror_reaches(&client_db, "tele", &want_tele, || { + server_db + .set_record_from_json("tele", json!({ "v": 9 })) + .expect("set server tele"); + }) + .await; + assert!(mirrored_in, "server→client mirror did not reach the client"); + + // client → server: producing client `cfg` mirrors to server `cfg`. + let want_cfg = json!({ "v": 7 }); + let mirrored_out = mirror_reaches(&server_db, "cfg", &want_cfg, || { + client_db + .set_record_from_json("cfg", json!({ "v": 7 })) + .expect("set client cfg"); + }) + .await; + assert!( + mirrored_out, + "client→server mirror did not reach the server" + ); +} diff --git a/docs/design/remote-access-via-connectors.md b/docs/design/remote-access-via-connectors.md new file mode 100644 index 00000000..cc68cae8 --- /dev/null +++ b/docs/design/remote-access-via-connectors.md @@ -0,0 +1,73 @@ +# Remote access via connectors + +**Issue:** aimdb-dev/aimdb#39 — *Enable remote access for embedded / `no_std` environments* +**Status:** 🟢 Architecture settled; Phases 0–6 landed in code, Phase 7 (on-target validation) open. +**Scope:** This is the single design doc for the connector-convergence initiative. It captures the *what* and *why* and the load-bearing decisions — not implementation detail (read the code under [`aimdb-core/src/session/`](../../aimdb-core/src/session/) for that). + +--- + +## The problem + +Issue #39 wants AimX remote access to run on **embedded** (`no_std`/Embassy). The naive path — a new `RemoteTransport` trait with UDS/serial/TCP impls — would bolt a second I/O abstraction next to the connector framework that *already* crosses the std/Embassy boundary, and leave AimDB maintaining **four hand-rolled networking stacks**: an AimX server + client and a WebSocket server + client, each re-implementing bind/accept/connect, sessions, framing, and RPC. + +So the real task is **not** "add a transport." It is: **converge those four stacks onto one shared, runtime-agnostic session abstraction in the connector layer**, after which embedded remote access — and any future transport — falls out for free. + +## The decision + +Pursue **convergence**, not a parallel transport trait. The connector layer already solves cross-runtime I/O and spawn-free execution; remote access rides it. + +A connector is a **composition of capabilities** over the existing, kind-agnostic [`ConnectorBuilder::build -> Vec`](../../aimdb-core/src/connector.rs) spine (driven by one `FuturesUnordered`, no `tokio::spawn`). The framework owns every loop; an author implements only a small I/O adapter per capability and composes them in `build()`. + +| capability | role | framework provides | author writes | +|---|---|---|---| +| **Sink** | data out (MQTT-pub, HTTP) | consume loop (`pump_sink`) | `publish()` — today's `Connector` | +| **Source** | data in (MQTT-sub) | read+route loop (`pump_source`) | `next() -> (topic, bytes)` | +| **Server** | accept sessions (AimX, WS) | accept + `run_session`/`serve` (reactive) | `Listener` + `Dispatch` + codec | +| **Client** | dial sessions (AimX, WS, sensor MCU) | `run_client`/`pump_client` (proactive: handshake, RPC demux, reconnect) | `Dialer` + codec | + +**Server and Client share one substrate** — a framed `Connection`, an `EnvelopeCodec` (outer protocol frame), and one role-neutral logical message set (`Inbound`/`Outbound`) — so they are two engines over shared parts, not two stacks. The substrate is split across three layers: **transport** (`Connection`/`Listener`/`Dialer`), **codec** (`EnvelopeCodec`), and **dispatch** (`Dispatch` factory → per-connection `Session` with `call`/`subscribe`/`write`). The `EnvelopeCodec` *nests* the existing [M16 record-value `JsonCodec`](032-M16-aimx-json-codec.md) rather than replacing it. + +## Load-bearing decisions + +1. **`Payload = Arc<[u8]>` (raw bytes)**, not `serde_json::Value`. Cheap-clone for fan-out, `no_std+alloc`-native; one serde pass on hot paths — a JSON tree materializes only inside RPC handlers that inspect structure. +2. **One `Dispatch` trait** carries all reply cardinalities: `call` (one) / `subscribe` (stream) / `write` (none). A subscription is just a method whose reply is a `Stream`; both existing stacks already interleave RPC + streaming over one connection. +3. **`publish` is a sibling capability**, not absorbed into the session trait. `Sink` is today's `Connector` verbatim (Phase 1 collapsed the skeleton onto it — `pump_sink` takes `Arc`). +4. **Client-first embedded order.** A sensor MCU *dials a gateway and pushes records*, so `Dialer` + `run_client` is the embedded-critical, smallest-footprint path (one connection, no accept/fan-out) against #39's ~60–100 KB / ≥256 KB RAM budget. The substrate stays role-neutral so server and client share it. + +## Invariants (hold across every phase) + +- **Behavior-preserving ports** — porting a stack changes *no* wire protocol; wire-capture/golden tests gate each. +- **One engine per role** — server (reactive) and client (proactive) are distinct, each proven in `std` then `no_std`-ed once; never fork a role into parallel std/no_std engines or re-implement the shared substrate per role. +- **Toolkit is additive** — the `ConnectorBuilder -> Vec` escape hatch always works; no connector is forced through a capability that doesn't fit. +- **Single-writer-per-key** stays intact — `Session::write` routes through the existing producer/arbiter path; remote clients are request streams, never direct co-writers. +- **Spawn-free** — every phase only appends `BoxFuture`s to the runner. + +## Roadmap & status + +Std-first, behavior-preserving, incremental: build the abstraction in `std`, port the four stacks onto it wire-identically, *then* `no_std` the engines and add embedded transports. Each phase ships standalone value. + +| # | Phase | Ships | Status | +|---|---|---|---| +| 0 | Freeze `dyn`-safe contracts (the decisions above + trait skeletons) | signature record | ✅ | +| 1 | Data-plane toolkit: `Source` + `pump_sink`/`pump_source`; migrate MQTT | easier third-party connectors | ✅ | +| 2 | Session substrate + server (`run_session`/`serve`) & client (`run_client`/`pump_client`) engines, std | the shared machinery | ✅ | +| 3 | Port AimX (server + client) onto the engines | AimX on shared engine; legacy loops deleted | ✅ | +| 4 | Port WebSocket (server + client) onto the engines | four stacks → two engines | ✅ landed (on-socket validation ongoing) | +| 5 | Make the engines runtime-neutral (`futures` channels + adapter `TimeOps`, no tokio/embassy) | engines cross-compile to `thumbv7em` | ✅ | +| 6 | Embedded transport crates (`Listener`+`Dialer`) | remote access over real links | 🟡 `aimdb-uds-connector` landed (UDS, both halves); serial/TCP + the no_std AimX *server* port are tracked follow-ups (below) | +| 7 | Validate on target MCU — memory budget, #39 acceptance | **#39 delivered** | ⬜ open — tracked by #13 under umbrella #39 | + +**Value milestones:** after Phase 1, third-party connectors are a few lines; after Phase 4, all four hand-rolled stacks collapse onto two shared engines (shippable end state even if #39 is deprioritized); after Phase 7, embedded remote access on the connector framework. + +**Tracked follow-ups (open issues):** +- **#120** — no_std AimX *server* (dispatch) port: cross-cutting `AnyRecord` + `RecordMetadataTracker` de-std. The AimX codec is already `no_std+alloc`; only `AimxDispatch` is std-gated. +- **#121** — `aimdb-tcp-connector` (tokio + embassy-net); **#122** — `aimdb-serial-connector` (COBS over `tokio-serial` + `embedded-io-async`). The two remaining Phase-6 transports. +- **#123** — transport-agnostic host client + `cli`/`mcp` `--connect ` resolver. +- **#41** — dropped-event tracking for remote-access subscriptions. +- **#13** — performance validation & benchmarking (the Phase-7 gate). + +## Foundations reused (not rebuilt) + +- Spawn-free runner + `ConnectorBuilder` spine ([028](028-M13-remove-spawn-trait.md)) and the spawn-free AimX supervisor ([030](030-M13-aimx-remote-spawn-free.md)). +- The `no_std`-capable record-value codec ([032](032-M16-aimx-json-codec.md)) — nested by the `EnvelopeCodec`. +- Record binding: `collect_inbound_routes` / `collect_outbound_routes` + `ProducerTrait` / `ConsumerTrait`. diff --git a/examples/embassy-knx-connector-demo/src/main.rs b/examples/embassy-knx-connector-demo/src/main.rs index 7b8b980e..61b22f31 100644 --- a/examples/embassy-knx-connector-demo/src/main.rs +++ b/examples/embassy-knx-connector-demo/src/main.rs @@ -127,7 +127,7 @@ async fn button_handler( // ============================================================================ /// KNX/IP gateway IP address (modify for your network) -const KNX_GATEWAY_IP: &str = "192.168.1.19"; +const KNX_GATEWAY_IP: &str = "192.168.1.4"; /// KNX/IP gateway port (default: 3671) const KNX_GATEWAY_PORT: u16 = 3671; diff --git a/examples/embassy-mqtt-connector-demo/src/main.rs b/examples/embassy-mqtt-connector-demo/src/main.rs index 789a6a52..8f112b86 100644 --- a/examples/embassy-mqtt-connector-demo/src/main.rs +++ b/examples/embassy-mqtt-connector-demo/src/main.rs @@ -175,7 +175,7 @@ async fn server_room_temp_producer( // ============================================================================ /// MQTT broker IP address (modify for your network) -const MQTT_BROKER_IP: &str = "192.168.1.3"; +const MQTT_BROKER_IP: &str = "192.168.1.10"; /// MQTT broker port const MQTT_BROKER_PORT: u16 = 1883; diff --git a/examples/remote-access-demo/Cargo.toml b/examples/remote-access-demo/Cargo.toml index bc683fe7..ccc5e97f 100644 --- a/examples/remote-access-demo/Cargo.toml +++ b/examples/remote-access-demo/Cargo.toml @@ -26,7 +26,9 @@ aimdb-tokio-adapter = { path = "../../aimdb-tokio-adapter", features = [ "metrics", ] } aimdb-client = { path = "../../aimdb-client" } +aimdb-uds-connector = { path = "../../aimdb-uds-connector" } tokio = { version = "1.48", features = ["full"] } +futures = "0.3" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } serde = { version = "1.0", features = ["derive"] } diff --git a/examples/remote-access-demo/src/client.rs b/examples/remote-access-demo/src/client.rs index 0822ccac..3a64ab40 100644 --- a/examples/remote-access-demo/src/client.rs +++ b/examples/remote-access-demo/src/client.rs @@ -1,658 +1,187 @@ //! Remote Access Demo - Client //! -//! Simple client that connects to the demo server and calls record.list +//! Connects to the demo server over the engine-based [`AimxConnection`] (the +//! shared session engine + reshaped AimX-v2 wire) and walks through the AimX +//! surface: list / get / set, the producer-override safety check, `record.drain` +//! history, and a live subscription. //! //! Run with: //! ``` //! cargo run --bin client //! ``` -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::io::{BufRead, BufReader, Write}; -use std::os::unix::net::UnixStream; - -#[derive(Debug, Serialize)] -struct Request { - id: u64, - method: String, - #[serde(skip_serializing_if = "Option::is_none")] - params: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(untagged)] -enum Response { - Success { id: u64, result: serde_json::Value }, - Error { id: u64, error: ErrorObject }, -} - -#[derive(Debug, Deserialize)] -struct EventMessage { - event: Event, -} - -#[derive(Debug, Deserialize)] -struct Event { - subscription_id: String, - sequence: u64, - timestamp: String, - data: serde_json::Value, - #[serde(default)] - dropped: Option, -} +use std::time::Duration; -#[derive(Debug, Deserialize)] -struct ErrorObject { - code: String, - message: String, - #[serde(default)] - details: Option, -} - -#[derive(Debug, Deserialize)] -struct WelcomeMessage { - version: String, - server: String, - permissions: Vec, - writable_records: Vec, - #[serde(default)] - max_subscriptions: Option, - #[serde(default)] - #[allow(dead_code)] // Parsed from JSON but not used in demo - authenticated: Option, -} - -fn main() -> Result<(), Box> { - println!("🔌 Connecting to AimDB server..."); +use aimdb_client::AimxConnection; +use futures::StreamExt; +use serde_json::json; +#[tokio::main] +async fn main() -> Result<(), Box> { let socket_path = "/tmp/aimdb-demo.sock"; - let mut stream = UnixStream::connect(socket_path).map_err(|e| { - format!( - "Failed to connect to {}: {}\nMake sure the server is running!", - socket_path, e - ) - })?; - - let mut reader = BufReader::new(stream.try_clone()?); + println!("🔌 Connecting to AimDB server at {socket_path} ..."); - println!("✅ Connected!"); - println!(); - - // Send Hello message - println!("📤 Sending handshake..."); - let hello = json!({ - "version": "1.0", - "client": "aimdb-demo-client", - "capabilities": [], - }); - - writeln!(stream, "{}", hello)?; - stream.flush()?; - - // Read Welcome message - let mut line = String::new(); - reader.read_line(&mut line)?; + let conn = AimxConnection::connect(socket_path).await.map_err(|e| { + format!("Failed to connect to {socket_path}: {e}\nMake sure the server is running!") + })?; - let welcome: WelcomeMessage = serde_json::from_str(&line)?; - println!("📥 Received welcome from server: {}", welcome.server); + let welcome = conn.server_info(); + println!("✅ Connected! Welcome from server: {}", welcome.server); println!(" Version: {}", welcome.version); println!(" Permissions: {:?}", welcome.permissions); println!(" Writable records: {:?}", welcome.writable_records); - println!(" Max subscriptions: {:?}", welcome.max_subscriptions); println!(); - // Send record.list request + // ── record.list ────────────────────────────────────────────────────── println!("📤 Requesting record list..."); - let request = Request { - id: 1, - method: "record.list".to_string(), - params: None, - }; - - let request_json = serde_json::to_string(&request)?; - writeln!(stream, "{}", request_json)?; - stream.flush()?; - - // Read response - let mut response_line = String::new(); - reader.read_line(&mut response_line)?; - - let response: Response = serde_json::from_str(&response_line)?; - - match response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("📋 Registered Records:"); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - if let Some(details) = error.details { - println!(" Details: {}", details); - } - } + let records = conn.list_records().await?; + println!("📋 {} registered records:", records.len()); + for r in &records { + println!(" • {} ({})", r.record_key, r.name); } - println!(); // ── Point-in-time reads: record.get ────────────────────────────────── - // record.get serves a single "current value", so it only works on buffers - // that have a canonical latest. SingleLatest (Config/AppSettings, below) - // does. SpmcRing does NOT: a ring keeps a *history* for independent - // consumers, so there is no one "latest" to return — record.get answers - // not_found by design. Read rings with record.drain (history) or - // record.subscribe (live), both demonstrated further down. - - println!("📤 record.get on Temperature (SpmcRing — expecting not_found)..."); - let get_request = Request { - id: 2, - method: "record.get".to_string(), - params: Some(json!({"record": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&get_request)?)?; - stream.flush()?; - - let mut get_response_line = String::new(); - reader.read_line(&mut get_response_line)?; - let get_response: Response = serde_json::from_str(&get_response_line)?; - - match get_response { - Response::Error { id, error } if error.code == "not_found" => { - println!("✅ Expected not_found (request_id: {}): {}", id, error.message); - println!( - " ℹ️ Rings have no point-in-time latest — use record.drain / record.subscribe (below)." - ); - } - Response::Success { id, result } => { - println!("⚠️ Unexpected success (request_id: {}):", id); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + // `record.get` is a point-in-time read. For a SingleLatest/state record it + // returns the canonical latest, non-destructively. + println!("📤 record.get on Config (SingleLatest — point-in-time read)..."); + match conn.get_record("server::Config").await { + Ok(v) => println!("⚙️ Current Config:\n{}", serde_json::to_string_pretty(&v)?), + Err(e) => println!("❌ Error: {e}"), } - println!(); - // Test record.get for Config - println!("📤 record.get on Config (SingleLatest — point-in-time read)..."); - let config_request = Request { - id: 4, - method: "record.get".to_string(), - params: Some(json!({"record": "server::Config"})), - }; - - let config_request_json = serde_json::to_string(&config_request)?; - writeln!(stream, "{}", config_request_json)?; - stream.flush()?; - - let mut config_response_line = String::new(); - reader.read_line(&mut config_response_line)?; - let config_response: Response = serde_json::from_str(&config_response_line)?; - - match config_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("⚙️ Current Config:"); - println!("{}", serde_json::to_string_pretty(&result)?); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + // A ring (SpmcRing) has no canonical latest, so the server falls back to + // *draining* this connection's cursor and returning the most recent value. A + // fresh cursor starts at the ring tail, so the first read is empty until a new + // value arrives — we retry over a couple of ticks. (This opens the *same* + // cursor `record.drain` uses below.) + println!("📤 record.get on Temperature (SpmcRing — drains the cursor for the latest)..."); + let mut latest = None; + for _ in 0..10 { + if let Ok(v) = conn.get_record("server::Temperature").await { + latest = Some(v); + break; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + match latest { + Some(v) => println!( + "🌡️ Most recent Temperature (via drain fallback):\n{}", + serde_json::to_string_pretty(&v)? + ), + None => println!("ℹ️ No reading yet — the ring cursor was still empty."), } - println!(); + // ── record.set (write operations) ──────────────────────────────────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("✍️ Testing record.set (Write Operations)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - - // Test 1: Get current AppSettings - println!("📤 Getting current AppSettings..."); - let get_settings_request = Request { - id: 5, - method: "record.get".to_string(), - params: Some(json!({"record": "server::AppSettings"})), - }; + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); - writeln!(stream, "{}", serde_json::to_string(&get_settings_request)?)?; - stream.flush()?; - - let mut settings_response_line = String::new(); - reader.read_line(&mut settings_response_line)?; - let settings_response: Response = serde_json::from_str(&settings_response_line)?; - - match settings_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("⚙️ Original AppSettings:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); + println!("📤 Current AppSettings:"); + match conn.get_record("server::AppSettings").await { + Ok(v) => println!("{}\n", serde_json::to_string_pretty(&v)?), + Err(e) => { + println!("❌ Error: {e}"); return Ok(()); } - }; + } - // Test 2: Modify and set new AppSettings println!("📤 Updating AppSettings (enabling feature_flag_alpha)..."); let new_settings = json!({ "log_level": "debug", "max_connections": 200, "feature_flag_alpha": true }); - - let set_request = Request { - id: 6, - method: "record.set".to_string(), - params: Some(json!({ - "name": "server::AppSettings", - "value": new_settings - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&set_request)?)?; - stream.flush()?; - - let mut set_response_line = String::new(); - reader.read_line(&mut set_response_line)?; - let set_response: Response = serde_json::from_str(&set_response_line)?; - - match set_response { - Response::Success { id, result } => { - println!("✅ Success! record.set completed (request_id: {})", id); - println!(); - println!("✨ Updated AppSettings:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - if let Some(details) = error.details { - println!(" Details: {}", details); - } + match conn.set_record("server::AppSettings", new_settings).await { + Ok(v) => println!("✅ record.set ok:\n{}\n", serde_json::to_string_pretty(&v)?), + Err(e) => { + println!("❌ Error: {e}"); return Ok(()); } } - // Test 3: Verify the change by getting again - println!("📤 Verifying update by getting AppSettings again..."); - let verify_request = Request { - id: 7, - method: "record.get".to_string(), - params: Some(json!({"record": "server::AppSettings"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&verify_request)?)?; - stream.flush()?; - - let mut verify_response_line = String::new(); - reader.read_line(&mut verify_response_line)?; - let verify_response: Response = serde_json::from_str(&verify_response_line)?; - - match verify_response { - Response::Success { id, result } => { - println!("✅ Success! (request_id: {})", id); - println!(); - println!("✔️ Verified - AppSettings after update:"); - println!("{}", serde_json::to_string_pretty(&result)?); - println!(); - } - Response::Error { id, error } => { - println!("❌ Error! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + println!("📤 Verifying update..."); + match conn.get_record("server::AppSettings").await { + Ok(v) => println!( + "✔️ AppSettings after update:\n{}\n", + serde_json::to_string_pretty(&v)? + ), + Err(e) => println!("❌ Error: {e}"), } - // Test 4: Try to set Temperature (should fail - has producer) + // ── Safety: overriding a record with a producer must be denied ──────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("🛡️ Testing Safety: Try to override Temperature (has producer)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - + println!("🛡️ Safety: try to override Temperature (has producer)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); println!("📤 Attempting to set Temperature (SHOULD FAIL)..."); - let bad_set_request = Request { - id: 8, - method: "record.set".to_string(), - params: Some(json!({ - "name": "server::Temperature", - "value": { - "sensor_id": "hacked-sensor", - "celsius": 999.9, - "timestamp": 0 - } - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&bad_set_request)?)?; - stream.flush()?; - - let mut bad_set_response_line = String::new(); - reader.read_line(&mut bad_set_response_line)?; - let bad_set_response: Response = serde_json::from_str(&bad_set_response_line)?; - - match bad_set_response { - Response::Success { id, result } => { - println!("❌ UNEXPECTED! record.set succeeded when it should have failed!"); - println!(" Request ID: {}", id); - println!(" Result: {}", result); - println!(" ⚠️ This is a security issue - producer protection not working!"); - } - Response::Error { id, error } => { - println!( - "✅ EXPECTED FAILURE! Safety check worked (request_id: {})", - id - ); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - println!(" 🛡️ Protection confirmed: Cannot override records with producers"); - if let Some(details) = error.details { - println!(" Details: {}", details); - } - } + match conn + .set_record( + "server::Temperature", + json!({ "sensor_id": "hacked", "celsius": 999.9, "timestamp": 0.0 }), + ) + .await + { + Ok(v) => println!("❌ UNEXPECTED success — producer protection failed: {v}"), + Err(_) => println!("✅ Expected failure — cannot override a record with a producer.\n"), } - println!(); - + // ── record.drain (history) ─────────────────────────────────────────── println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("🧪 Testing Record History (record.drain)"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); - - // Drain #1: Cold start — creates the drain reader, returns empty - println!("📤 Drain #1: Creating drain reader for Temperature (cold start)..."); - let drain1_request = Request { - id: 9, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain1_request)?)?; - stream.flush()?; - - let mut drain1_line = String::new(); - reader.read_line(&mut drain1_line)?; - let drain1_response: Response = serde_json::from_str(&drain1_line)?; - - match &drain1_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - println!("✅ Drain #1 response (request_id: {})", id); - println!(" Values returned: {} (expected 0 on cold start)", count); - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #1 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - // Wait for values to accumulate (server produces every 2s) - println!("⏳ Waiting 7 seconds for temperature readings to accumulate..."); - std::thread::sleep(std::time::Duration::from_secs(7)); - - // Drain #2: Should return accumulated values (~3 readings at 2s interval) - println!("📤 Drain #2: Fetching accumulated Temperature history..."); - let drain2_request = Request { - id: 10, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain2_request)?)?; - stream.flush()?; - - let mut drain2_line = String::new(); - reader.read_line(&mut drain2_line)?; - let drain2_response: Response = serde_json::from_str(&drain2_line)?; - - match &drain2_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - let values = result["values"].as_array(); - println!("✅ Drain #2 response (request_id: {})", id); - println!(" Values returned: {} (expected ~3)", count); - if let Some(vals) = values { - for (i, val) in vals.iter().enumerate() { - let celsius = val["celsius"].as_f64().unwrap_or(0.0); - let sensor = val["sensor_id"].as_str().unwrap_or("?"); - println!(" 📊 [{}] {:.1} °C from {}", i, celsius, sensor); - } - } - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #2 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - // Drain #3: Immediately after — should be empty (nothing new since last drain) - println!("📤 Drain #3: Immediate re-drain (should be empty)..."); - let drain3_request = Request { - id: 11, - method: "record.drain".to_string(), - params: Some(json!({"name": "server::Temperature"})), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain3_request)?)?; - stream.flush()?; - - let mut drain3_line = String::new(); - reader.read_line(&mut drain3_line)?; - let drain3_response: Response = serde_json::from_str(&drain3_line)?; - - match &drain3_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - println!("✅ Drain #3 response (request_id: {})", id); - println!( - " Values returned: {} (expected 0 — nothing new since last drain)", - count - ); - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #3 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } + println!("🧪 Record History (record.drain)"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); + + // `record.get` above already opened this connection's Temperature cursor, so + // this drain returns only what's accrued since (they share one cursor). + println!("📤 Drain #1: history since the (already-open) cursor..."); + let d1 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (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; + + println!("📤 Drain #2: accumulated history..."); + let d2 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (expected ~3)", d2.count); + for (i, v) in d2.values.iter().enumerate() { + let celsius = v["celsius"].as_f64().unwrap_or(0.0); + let sensor = v["sensor_id"].as_str().unwrap_or("?"); + println!(" 📊 [{i}] {celsius:.1} °C from {sensor}"); } - - // Drain #4: Test with limit parameter - println!("⏳ Waiting 5 seconds, then draining with limit=2..."); - std::thread::sleep(std::time::Duration::from_secs(5)); - - let drain4_request = Request { - id: 12, - method: "record.drain".to_string(), - params: Some(json!({ - "name": "server::Temperature", - "limit": 2 - })), - }; - - writeln!(stream, "{}", serde_json::to_string(&drain4_request)?)?; - stream.flush()?; - - let mut drain4_line = String::new(); - reader.read_line(&mut drain4_line)?; - let drain4_response: Response = serde_json::from_str(&drain4_line)?; - - match &drain4_response { - Response::Success { id, result } => { - let count = result["count"].as_u64().unwrap_or(0); - let values = result["values"].as_array(); - println!("✅ Drain #4 response (request_id: {})", id); - println!(" Values returned: {} (limit was 2)", count); - if let Some(vals) = values { - for (i, val) in vals.iter().enumerate() { - let celsius = val["celsius"].as_f64().unwrap_or(0.0); - let sensor = val["sensor_id"].as_str().unwrap_or("?"); - println!(" 📊 [{}] {:.1} °C from {}", i, celsius, sensor); - } - } - println!(); - } - Response::Error { id, error } => { - println!("❌ Drain #4 failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - } - - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!("📡 Testing Subscriptions"); - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!(); - // Subscribe to Temperature updates - println!("📤 Subscribing to Temperature updates..."); - let subscribe_request = Request { - id: 13, - method: "record.subscribe".to_string(), - params: Some(json!({ - "name": "server::Temperature" - })), - }; + println!("📤 Drain #3: immediate re-drain (should be empty)..."); + let d3 = conn.drain_record("server::Temperature").await?; + println!(" Values: {} (expected 0)\n", d3.count); - let subscribe_json = serde_json::to_string(&subscribe_request)?; - writeln!(stream, "{}", subscribe_json)?; - stream.flush()?; + println!("⏳ Waiting 5s, then draining with limit=2..."); + tokio::time::sleep(Duration::from_secs(5)).await; + let d4 = conn + .drain_record_with_limit("server::Temperature", 2) + .await?; + println!(" Values: {} (limit was 2)\n", d4.count); - // Read subscription response - let mut subscribe_response_line = String::new(); - reader.read_line(&mut subscribe_response_line)?; - - let subscribe_response: Response = serde_json::from_str(&subscribe_response_line)?; - - let subscription_id = match subscribe_response { - Response::Success { id, result } => { - println!("✅ Subscribed! (request_id: {})", id); - let sub_id = result["subscription_id"].as_str().unwrap().to_string(); - println!(" Subscription ID: {}", sub_id); - println!(); - println!("📊 Receiving live temperature updates (will receive 5 events)..."); - println!(); - sub_id - } - Response::Error { id, error } => { - println!("❌ Subscription failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - return Ok(()); - } - }; + // ── Subscriptions ──────────────────────────────────────────────────── + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + println!("📡 Subscriptions"); + println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"); - // Receive 5 events + println!("📤 Subscribing to Temperature (will receive 5 events)..."); + let mut stream = conn.subscribe("server::Temperature")?; for i in 1..=5 { - let mut event_line = String::new(); - reader.read_line(&mut event_line)?; - - // Try to parse as EventMessage - if let Ok(event_msg) = serde_json::from_str::(&event_line) { - let event = event_msg.event; - println!("📨 Event #{} (seq: {})", i, event.sequence); - println!(" Subscription: {}", event.subscription_id); - println!(" Timestamp: {}", event.timestamp); - if let Some(dropped) = event.dropped { - println!(" ⚠️ Dropped events: {}", dropped); + match stream.next().await { + Some(v) => println!("📨 Event #{i}: {}", serde_json::to_string(&v)?), + None => { + println!("⚠️ Stream ended early"); + break; } - println!(" Data: {}", serde_json::to_string_pretty(&event.data)?); - println!(); - } else { - println!("⚠️ Received unexpected message: {}", event_line.trim()); } - - // Small delay to show streaming behavior - std::thread::sleep(std::time::Duration::from_millis(500)); } + // Dropping the stream stops local delivery (no explicit unsubscribe needed). + drop(stream); - // Unsubscribe - println!("📤 Unsubscribing from Temperature..."); - let unsubscribe_request = Request { - id: 14, - method: "record.unsubscribe".to_string(), - params: Some(json!({ - "subscription_id": subscription_id - })), - }; - - let unsubscribe_json = serde_json::to_string(&unsubscribe_request)?; - writeln!(stream, "{}", unsubscribe_json)?; - stream.flush()?; - - // Read unsubscribe response - let mut unsubscribe_response_line = String::new(); - reader.read_line(&mut unsubscribe_response_line)?; - - // Parse response - filter out any stray events - let unsubscribe_response: Result = - serde_json::from_str(&unsubscribe_response_line); - - match unsubscribe_response { - Ok(Response::Success { id, result }) => { - println!("✅ Unsubscribed! (request_id: {})", id); - println!( - " Status: {}", - result["status"].as_str().unwrap_or("unknown") - ); - println!(); - } - Ok(Response::Error { id, error }) => { - println!("❌ Unsubscribe failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - Err(_) => { - // Might be a stray event, try reading next line - println!("⚠️ Received unexpected message, retrying..."); - let mut retry_line = String::new(); - reader.read_line(&mut retry_line)?; - match serde_json::from_str::(&retry_line) { - Ok(Response::Success { id, result }) => { - println!("✅ Unsubscribed! (request_id: {})", id); - println!( - " Status: {}", - result["status"].as_str().unwrap_or("unknown") - ); - println!(); - } - Ok(Response::Error { id, error }) => { - println!("❌ Unsubscribe failed! (request_id: {})", id); - println!(" Code: {}", error.code); - println!(" Message: {}", error.message); - } - Err(e) => { - println!("⚠️ Failed to parse unsubscribe response: {}", e); - } - } - } - } - - println!("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); - println!(); + println!("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); println!("👋 Disconnecting..."); - Ok(()) } diff --git a/examples/remote-access-demo/src/server.rs b/examples/remote-access-demo/src/server.rs index d5e3d335..39956632 100644 --- a/examples/remote-access-demo/src/server.rs +++ b/examples/remote-access-demo/src/server.rs @@ -15,6 +15,7 @@ use aimdb_core::remote::{AimxConfig, SecurityPolicy}; use aimdb_core::{buffer::BufferCfg, AimDbBuilder, Consumer, Producer, RuntimeContext}; use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt}; +use aimdb_uds_connector::UdsServer; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tracing::info; @@ -90,10 +91,12 @@ async fn main() -> Result<(), Box> { info!("🔒 Security policy: ReadWrite"); info!("✍️ Writable records: AppSettings"); - // Build database with remote access enabled + // Build database with remote access enabled. Remote access is now just a + // connector: register the UDS *server* via `with_connector` (the client + // side, by contrast, uses `UdsClient` + `link_to`/`link_from`). let mut builder = AimDbBuilder::new() .runtime(adapter) - .with_remote_access(remote_config); + .with_connector(UdsServer::from_config(remote_config)); // Configure records // Use SpmcRing for Temperature and SystemStatus to support record.drain history. diff --git a/examples/tokio-knx-connector-demo/src/main.rs b/examples/tokio-knx-connector-demo/src/main.rs index 8f535282..e6f1ce4e 100644 --- a/examples/tokio-knx-connector-demo/src/main.rs +++ b/examples/tokio-knx-connector-demo/src/main.rs @@ -83,7 +83,7 @@ async fn main() -> DbResult<()> { println!("⚠️ Update gateway URL and group addresses to match your setup!\n"); let mut builder = AimDbBuilder::new().runtime(runtime).with_connector( - aimdb_knx_connector::KnxConnector::new("knx://192.168.1.19:3671"), + aimdb_knx_connector::KnxConnector::new("knx://192.168.1.4:3671"), ); // Temperature sensors (inbound) - using link_address() from key metadata diff --git a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs index 962c7043..2ebb2138 100644 --- a/examples/weather-mesh-demo/weather-station-gamma/src/main.rs +++ b/examples/weather-mesh-demo/weather-station-gamma/src/main.rs @@ -336,9 +336,9 @@ async fn main(spawner: Spawner) { let sign = if neg { "-" } else { "" }; info!("📊 DewPoint: {}{}.{}°C", sign, whole, frac); producer.produce(DewPoint { - celsius: dew_point, - timestamp, - }); + celsius: dew_point, + timestamp, + }); } } }) diff --git a/tools/aimdb-cli/CHANGELOG.md b/tools/aimdb-cli/CHANGELOG.md index e9700fac..5c313609 100644 --- a/tools/aimdb-cli/CHANGELOG.md +++ b/tools/aimdb-cli/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -No changes yet. +### Changed + +- **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** All commands (`watch`, `record`, `graph`) now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. `aimdb watch` subscribes via the engine, which streams updates routed by request id — there is no server-allocated subscription id to display, and `--queue-size` is accepted for compatibility but no longer meaningful (queue sizing is now an engine concern). ## [0.6.0] - 2026-03-11 diff --git a/tools/aimdb-cli/Cargo.toml b/tools/aimdb-cli/Cargo.toml index f3f5f86d..124dba9a 100644 --- a/tools/aimdb-cli/Cargo.toml +++ b/tools/aimdb-cli/Cargo.toml @@ -25,6 +25,7 @@ aimdb-core = { version = "1.1.0", path = "../../aimdb-core", features = [ ] } serde = { version = "1", features = ["derive"] } serde_json = "1" +futures = "0.3" # CLI framework clap = { version = "4", features = ["derive", "cargo"] } diff --git a/tools/aimdb-cli/src/commands/graph.rs b/tools/aimdb-cli/src/commands/graph.rs index 0c221876..4c6e2aa2 100644 --- a/tools/aimdb-cli/src/commands/graph.rs +++ b/tools/aimdb-cli/src/commands/graph.rs @@ -4,8 +4,8 @@ use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; use serde_json::Value; @@ -79,7 +79,7 @@ impl GraphCommand { async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let nodes = client.graph_nodes().await?; @@ -98,7 +98,7 @@ async fn list_nodes(socket: Option<&str>, format: OutputFormat) -> CliResult<()> async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let edges = client.graph_edges().await?; @@ -117,7 +117,7 @@ async fn list_edges(socket: Option<&str>, format: OutputFormat) -> CliResult<()> async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let order = client.graph_topo_order().await?; @@ -136,7 +136,7 @@ async fn show_topo_order(socket: Option<&str>, format: OutputFormat) -> CliResul async fn export_dot(socket: Option<&str>, name: &str) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let nodes = client.graph_nodes().await?; let edges = client.graph_edges().await?; diff --git a/tools/aimdb-cli/src/commands/record.rs b/tools/aimdb-cli/src/commands/record.rs index 5375e577..d83507cd 100644 --- a/tools/aimdb-cli/src/commands/record.rs +++ b/tools/aimdb-cli/src/commands/record.rs @@ -2,8 +2,8 @@ use crate::error::CliResult; use crate::output::{json, table, OutputFormat}; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; /// Record management commands @@ -89,7 +89,7 @@ async fn list_records( writable_only: bool, ) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let mut records = client.list_records().await?; @@ -113,7 +113,7 @@ async fn list_records( async fn get_record(name: &str, socket: Option<&str>, format: OutputFormat) -> CliResult<()> { let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let value = client.get_record(name).await?; @@ -151,7 +151,7 @@ async fn set_record( } let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let client = AimxConnection::connect(&instance.socket_path).await?; let result = client.set_record(name, value).await?; diff --git a/tools/aimdb-cli/src/commands/watch.rs b/tools/aimdb-cli/src/commands/watch.rs index 16dfad79..465f5733 100644 --- a/tools/aimdb-cli/src/commands/watch.rs +++ b/tools/aimdb-cli/src/commands/watch.rs @@ -2,9 +2,10 @@ use crate::error::CliResult; use crate::output::live; -use aimdb_client::connection::AimxClient; use aimdb_client::discovery::find_instance; +use aimdb_client::AimxConnection; use clap::Args; +use futures::StreamExt; use tokio::signal; /// Watch a record for live updates @@ -50,54 +51,48 @@ async fn watch_record( max_count: usize, show_full: bool, ) -> CliResult<()> { + let _ = queue_size; // queue sizing is now an engine concern; kept as a CLI flag let instance = find_instance(socket).await?; - let mut client = AimxClient::connect(&instance.socket_path).await?; + let conn = AimxConnection::connect(&instance.socket_path).await?; - // Subscribe to record - let subscription_id = client.subscribe(record_name, queue_size).await?; + // Subscribe to the record (the engine routes updates back by request id; no + // server-allocated subscription id to track). + let mut stream = conn.subscribe(record_name)?; - // Print start message - live::print_watch_start(record_name, &subscription_id); + live::print_watch_start(record_name); // Set up Ctrl+C handler let (cancel_tx, mut cancel_rx) = tokio::sync::oneshot::channel(); - tokio::spawn(async move { if signal::ctrl_c().await.is_ok() { let _ = cancel_tx.send(()); } }); - // Receive events - let mut count = 0; + // Receive updates. The reshaped wire carries no server sequence, so the + // watcher counts locally. + let mut count: u64 = 0; let unlimited = max_count == 0; loop { tokio::select! { - event = client.receive_event() => { - let event = event?; - - // Only show events for this subscription - if event.subscription_id == subscription_id { - live::print_event(&event, show_full); - - count += 1; - if !unlimited && count >= max_count { - break; + next = stream.next() => { + match next { + Some(data) => { + count += 1; + live::print_event(count, &data, show_full); + if !unlimited && count >= max_count as u64 { + break; + } } + None => break, // stream ended (record closed or subscribe rejected) } } - _ = &mut cancel_rx => { - // User pressed Ctrl+C - break; - } + _ = &mut cancel_rx => break, // Ctrl+C } } - // Unsubscribe cleanly - client.unsubscribe(&subscription_id).await?; - + // Dropping the stream stops local delivery (no explicit unsubscribe needed). live::print_watch_stop(); - Ok(()) } diff --git a/tools/aimdb-cli/src/error.rs b/tools/aimdb-cli/src/error.rs index 4c937487..7ef1321a 100644 --- a/tools/aimdb-cli/src/error.rs +++ b/tools/aimdb-cli/src/error.rs @@ -17,7 +17,7 @@ pub enum CliError { ConnectionFailed { socket: String, reason: String }, /// No running AimDB instances found - #[error("No AimDB instances found\n Searched: /tmp, /var/run/aimdb\n Hint: Start an AimDB application with .with_remote_access()")] + #[error("No AimDB instances found\n Searched: /tmp, /var/run/aimdb\n Hint: Start an AimDB application that registers a remote-access server (e.g. UdsServer via .with_connector(...))")] NoInstancesFound, /// Requested record does not exist diff --git a/tools/aimdb-cli/src/output/live.rs b/tools/aimdb-cli/src/output/live.rs index 9424bed6..d9a1954e 100644 --- a/tools/aimdb-cli/src/output/live.rs +++ b/tools/aimdb-cli/src/output/live.rs @@ -1,77 +1,42 @@ //! Live Output Formatting (for watch command) -use aimdb_client::protocol::Event; -use chrono::{DateTime, Utc}; +use chrono::Utc; use colored::Colorize; -/// Format an event for live display -pub fn format_event(event: &Event, show_full: bool) -> String { - let mut output = String::new(); - - // Parse timestamp - let timestamp = parse_timestamp(&event.timestamp); - let time_str = timestamp +/// Format a subscription update for live display. +/// +/// The reshaped AimX-v2 wire drops the server-minted `timestamp`/`dropped` +/// fields, so the watcher stamps the receipt time locally and tracks its own +/// sequence counter; `data` is the decoded record value. +pub fn format_event(seq: u64, data: &serde_json::Value, show_full: bool) -> String { + let time_str = Utc::now() .format("%Y-%m-%d %H:%M:%S%.3f") .to_string() .dimmed(); + let seq_str = format!("seq:{seq}").cyan(); - // Format sequence - let seq_str = format!("seq:{}", event.sequence).cyan(); - - // Build status line - output.push_str(&format!("{} | {} | ", time_str, seq_str)); - - // Show dropped events warning if any - if let Some(dropped) = event.dropped { - let warning = format!("⚠️ {} events dropped | ", dropped).yellow(); - output.push_str(&warning.to_string()); - } - - // Format data + let mut output = format!("{time_str} | {seq_str} | "); if show_full { - // Pretty print full JSON - if let Ok(formatted) = serde_json::to_string_pretty(&event.data) { + if let Ok(formatted) = serde_json::to_string_pretty(data) { output.push('\n'); output.push_str(&formatted); } else { - output.push_str(&format!("{}", event.data)); + output.push_str(&format!("{data}")); } } else { - // Compact single-line JSON - output.push_str(&format!("{}", event.data)); + output.push_str(&format!("{data}")); } - output } -/// Parse timestamp string to DateTime -/// Expects format: seconds.nanoseconds (e.g., "1730379296.123456789") -fn parse_timestamp(timestamp_str: &str) -> DateTime { - // Try parsing as floating point (seconds.nanoseconds) - if let Ok(secs_f64) = timestamp_str.parse::() { - let secs = secs_f64.trunc() as i64; - let nsec = ((secs_f64.fract() * 1_000_000_000.0).round()) as u32; - if let Some(dt) = DateTime::from_timestamp(secs, nsec) { - return dt; - } - } - - // Fallback to now if parsing fails - Utc::now() -} - -/// Print a live event to stdout -pub fn print_event(event: &Event, show_full: bool) { - println!("{}", format_event(event, show_full)); +/// Print a live update to stdout. +pub fn print_event(seq: u64, data: &serde_json::Value, show_full: bool) { + println!("{}", format_event(seq, data, show_full)); } /// Print subscription start message -pub fn print_watch_start(record_name: &str, subscription_id: &str) { - println!( - "📡 Watching record: {} (subscription: {})", - record_name.bold(), - subscription_id.dimmed() - ); +pub fn print_watch_start(record_name: &str) { + println!("📡 Watching record: {}", record_name.bold()); println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); } @@ -89,30 +54,19 @@ mod tests { #[test] fn test_format_event() { - let event = Event { - subscription_id: "sub123".to_string(), - sequence: 42, - timestamp: "1730379296".to_string(), - data: json!({"temperature": 23.5}), - dropped: None, - }; - - let formatted = format_event(&event, false); + let data = json!({"temperature": 23.5}); + let formatted = format_event(42, &data, false); assert!(formatted.contains("seq:42")); assert!(formatted.contains("temperature")); } #[test] fn test_format_event_with_dropped() { - let event = Event { - subscription_id: "sub123".to_string(), - sequence: 42, - timestamp: "1730379296".to_string(), - data: json!({"value": 1}), - dropped: Some(5), - }; - - let formatted = format_event(&event, false); - assert!(formatted.contains("5 events dropped")); + // AimX-v2 wire does not carry dropped counts; format_event receives + // only the decoded value. Verify data is still rendered correctly. + let data = json!({"value": 1}); + let formatted = format_event(42, &data, false); + assert!(formatted.contains("seq:42")); + assert!(formatted.contains("value")); } } diff --git a/tools/aimdb-mcp/CHANGELOG.md b/tools/aimdb-mcp/CHANGELOG.md index 85a09752..e7f0975d 100644 --- a/tools/aimdb-mcp/CHANGELOG.md +++ b/tools/aimdb-mcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Migrated to the engine-based `aimdb-client::AimxConnection` (Issue #39).** The connection pool and every tool now use `AimxConnection` instead of the retired `AimxClient`, speaking the reshaped **AimX-v2** protocol. Internal-only; no tool surface or behavior change (drain clients are still pooled per socket behind an `Arc>`). + ## [0.8.0] - 2026-05-22 ### Added diff --git a/tools/aimdb-mcp/src/connection.rs b/tools/aimdb-mcp/src/connection.rs index 6de67733..0e800257 100644 --- a/tools/aimdb-mcp/src/connection.rs +++ b/tools/aimdb-mcp/src/connection.rs @@ -3,7 +3,7 @@ //! Manages persistent connections to AimDB instances to avoid //! reconnecting on every tool call. Includes auto-reconnect logic. -use aimdb_client::connection::AimxClient; +use aimdb_client::AimxConnection; use aimdb_client::ClientError; use std::collections::HashMap; use std::sync::Arc; @@ -23,8 +23,8 @@ pub struct ConnectionPool { /// Track which connections we've attempted (for logging/metrics) connections: Arc>>, /// Persistent drain clients — kept alive so drain readers accumulate values - /// Key: socket_path, Value: shared AimxClient - drain_clients: Arc>>>>, + /// Key: socket_path, Value: shared AimxConnection + drain_clients: Arc>>>>, } impl std::fmt::Debug for ConnectionPool { @@ -47,11 +47,11 @@ impl ConnectionPool { /// Get or create a connection to an AimDB instance /// - /// Note: Since AimxClient doesn't implement Clone, we create a fresh + /// Note: Since AimxConnection doesn't implement Clone, we create a fresh /// connection each time. The pool tracks connection metadata for /// monitoring and future optimization (e.g., persistent connections - /// via Arc> if AimxClient becomes Sync). - pub async fn get_connection(&self, socket_path: &str) -> Result { + /// via Arc> if AimxConnection becomes Sync). + pub async fn get_connection(&self, socket_path: &str) -> Result { let mut pool = self.connections.lock().await; // Update or insert connection metadata @@ -68,9 +68,9 @@ impl ConnectionPool { pool.insert(socket_path.to_string(), ConnectionEntry { last_used: now }); } - // Always create a new connection (until AimxClient supports cloning/sharing) + // Always create a new connection (until AimxConnection supports cloning/sharing) drop(pool); // Release lock before async operation - AimxClient::connect(socket_path).await + AimxConnection::connect(socket_path).await } /// Remove a connection from the pool (called when operations fail) @@ -90,7 +90,7 @@ impl ConnectionPool { pub async fn get_drain_client( &self, socket_path: &str, - ) -> Result>, ClientError> { + ) -> Result>, ClientError> { let drain_map = self.drain_clients.lock().await; if let Some(client) = drain_map.get(socket_path) { @@ -103,7 +103,7 @@ impl ConnectionPool { // Drop lock before async connect drop(drain_map); - let client = AimxClient::connect(socket_path).await?; + let client = AimxConnection::connect(socket_path).await?; let shared = Arc::new(tokio::sync::Mutex::new(client)); let mut drain_map = self.drain_clients.lock().await; diff --git a/tools/aimdb-mcp/src/resources/records.rs b/tools/aimdb-mcp/src/resources/records.rs index 3b5e8ded..73c2617a 100644 --- a/tools/aimdb-mcp/src/resources/records.rs +++ b/tools/aimdb-mcp/src/resources/records.rs @@ -5,7 +5,7 @@ use crate::error::{McpError, McpResult}; use crate::protocol::{Resource, ResourceContent, ResourceReadResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde_json::json; use std::path::Path; use tracing::debug; @@ -82,7 +82,7 @@ pub async fn read_records_resource(socket_path: &str) -> McpResult) -> McpResult })?; // Connect and list live records - let mut client = AimxClient::connect(&socket_path) + let client = AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)?; diff --git a/tools/aimdb-mcp/src/tools/buffer_metrics.rs b/tools/aimdb-mcp/src/tools/buffer_metrics.rs index 3bb6d686..f14a05e0 100644 --- a/tools/aimdb-mcp/src/tools/buffer_metrics.rs +++ b/tools/aimdb-mcp/src/tools/buffer_metrics.rs @@ -6,7 +6,7 @@ //! on the server). use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::Deserialize; use serde_json::{json, Value}; use tracing::debug; @@ -24,13 +24,13 @@ struct ResetBufferMetricsParams { socket_path: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(socket_path: &str) -> McpResult { if let Some(pool) = super::connection_pool() { pool.get_connection(socket_path) .await .map_err(McpError::Client) } else { - AimxClient::connect(socket_path) + AimxConnection::connect(socket_path) .await .map_err(McpError::Client) } @@ -45,7 +45,7 @@ pub async fn get_buffer_metrics(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("get_buffer_metrics: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; let raw = client.list_records().await.map_err(McpError::Client)?; let matching: Vec<_> = raw @@ -75,7 +75,7 @@ pub async fn reset_buffer_metrics(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("reset_buffer_metrics: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; match client.reset_buffer_metrics().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/graph.rs b/tools/aimdb-mcp/src/tools/graph.rs index 07285585..bdc774e4 100644 --- a/tools/aimdb-mcp/src/tools/graph.rs +++ b/tools/aimdb-mcp/src/tools/graph.rs @@ -1,7 +1,7 @@ //! Graph introspection tools (graph_nodes, graph_edges, graph_topo_order) use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -55,13 +55,13 @@ pub async fn graph_nodes(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -100,13 +100,13 @@ pub async fn graph_edges(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -143,13 +143,13 @@ pub async fn graph_topo_order(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; diff --git a/tools/aimdb-mcp/src/tools/instance.rs b/tools/aimdb-mcp/src/tools/instance.rs index e4a43fd9..4f167504 100644 --- a/tools/aimdb-mcp/src/tools/instance.rs +++ b/tools/aimdb-mcp/src/tools/instance.rs @@ -1,7 +1,7 @@ //! Instance-related tools (discover_instances, get_instance_info) use crate::error::McpResult; -use aimdb_client::{self, connection::AimxClient}; +use aimdb_client::{self, AimxConnection}; use serde::{Deserialize, Serialize}; use serde_json::Value; use tracing::debug; @@ -103,7 +103,7 @@ pub async fn get_instance_info(args: Option) -> McpResult { pool.get_connection(&socket_path).await? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path).await? + AimxConnection::connect(&socket_path).await? }; // Get server info from the welcome message diff --git a/tools/aimdb-mcp/src/tools/profiling.rs b/tools/aimdb-mcp/src/tools/profiling.rs index 1e91ba20..1a97d47c 100644 --- a/tools/aimdb-mcp/src/tools/profiling.rs +++ b/tools/aimdb-mcp/src/tools/profiling.rs @@ -8,7 +8,7 @@ //! feature; without it, records simply carry no `stage_profiling` data. use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::Deserialize; use serde_json::{json, Value}; use tracing::debug; @@ -25,13 +25,13 @@ struct ResetStageProfilingParams { socket_path: Option, } -async fn connect(socket_path: &str) -> McpResult { +async fn connect(socket_path: &str) -> McpResult { if let Some(pool) = super::connection_pool() { pool.get_connection(socket_path) .await .map_err(McpError::Client) } else { - AimxClient::connect(socket_path) + AimxConnection::connect(socket_path) .await .map_err(McpError::Client) } @@ -49,7 +49,7 @@ pub async fn get_stage_profiling(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("get_stage_profiling: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; let records = client.list_records().await.map_err(McpError::Client)?; let mut out = Vec::new(); @@ -110,7 +110,7 @@ pub async fn reset_stage_profiling(args: Option) -> McpResult { .map_err(|e| McpError::InvalidParams(format!("reset_stage_profiling: {e}")))?; let socket_path = super::resolve_socket_path(params.socket_path)?; - let mut client = connect(&socket_path).await?; + let client = connect(&socket_path).await?; match client.reset_stage_profiling().await { Ok(_) => Ok(json!({ "reset": true, diff --git a/tools/aimdb-mcp/src/tools/record.rs b/tools/aimdb-mcp/src/tools/record.rs index ad90c17c..9934929b 100644 --- a/tools/aimdb-mcp/src/tools/record.rs +++ b/tools/aimdb-mcp/src/tools/record.rs @@ -1,9 +1,10 @@ //! Record-related tools (list_records, get_record, set_record) use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::{AimxConnection, ClientError}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::time::{Duration, Instant}; use tracing::debug; /// Parameters for list_records tool @@ -95,13 +96,13 @@ pub async fn list_records(args: Option) -> McpResult { debug!("🔌 Connecting to {}", socket_path); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -158,27 +159,59 @@ pub async fn get_record(args: Option) -> McpResult { socket_path, params.record_name ); - // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { - pool.get_connection(&socket_path) - .await - .map_err(McpError::Client)? - } else { - // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) - .await - .map_err(McpError::Client)? - }; + // Reuse the *persistent* connection (the same pool `drain_record` uses) rather + // than a throwaway one. For ring buffers (`SpmcRing`, which has no canonical + // latest), the server's `record.get` falls back to *this connection's* drain + // cursor (see `aimdb_core::session::aimx::dispatch`'s `record_get`). A fresh + // connection per call opens a new cursor at the ring tail every time and always + // reads empty → `not_found`; the persistent connection lets that cursor + // accumulate. The get→drain fallback already lives server-side, so nothing is + // duplicated here — we just stop discarding the connection. + let pool = super::connection_pool() + .ok_or_else(|| McpError::Internal("Connection pool not initialized".to_string()))?; - // Get record value - let value = client - .get_record(¶ms.record_name) + let client_arc = pool + .get_drain_client(&socket_path) .await .map_err(McpError::Client)?; - debug!("✅ Retrieved record '{}'", params.record_name); - - Ok(value) + let client = client_arc.lock().await; + + // A ring's drain cursor opens at the tail, so the first read is empty until a + // value is produced after it opened. Briefly retry on `not_found` so a one-shot + // `get_record` on a ring returns the latest value instead of failing. This adds + // no latency for `single_latest` records (they return immediately via the + // server's canonical-latest path) nor for an already-warm ring cursor. Note: a + // record that simply doesn't exist also reports `not_found`, so a bad name + // costs the full window before erroring. + let deadline = Instant::now() + Duration::from_secs(3); + loop { + let err = match client.get_record(¶ms.record_name).await { + Ok(value) => { + debug!("✅ Retrieved record '{}'", params.record_name); + return Ok(value); + } + Err(e) => e, + }; + + let cursor_warming = + matches!(&err, ClientError::ServerError { code, .. } if code == "not_found"); + + if cursor_warming && Instant::now() < deadline { + tokio::time::sleep(Duration::from_millis(250)).await; + continue; + } + + // A genuine connection/protocol error (not a warming ring cursor) means the + // persistent client is unhealthy — drop it so the next call reconnects. + if !cursor_warming { + let socket = socket_path.clone(); + let pool = pool.clone(); + tokio::spawn(async move { pool.invalidate_drain_client(&socket).await }); + } + + return Err(McpError::Client(err)); + } } /// Set the value of a writable record @@ -206,13 +239,13 @@ pub async fn set_record(args: Option) -> McpResult { ); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? }; @@ -264,7 +297,7 @@ pub async fn drain_record(args: Option) -> McpResult { .await .map_err(McpError::Client)?; - let mut client = client_arc.lock().await; + let client = client_arc.lock().await; // Drain record values let response = match params.limit { @@ -325,7 +358,7 @@ mod tests { let err = result.unwrap_err(); assert!( - err.message().contains("Failed to connect") || err.message().contains("No such file") + err.message().contains("Connection failed") || err.message().contains("No such file") ); } diff --git a/tools/aimdb-mcp/src/tools/schema.rs b/tools/aimdb-mcp/src/tools/schema.rs index fc48c5fa..12f69cd5 100644 --- a/tools/aimdb-mcp/src/tools/schema.rs +++ b/tools/aimdb-mcp/src/tools/schema.rs @@ -1,7 +1,7 @@ //! Schema query tool - infers JSON Schema from record values use crate::error::{McpError, McpResult}; -use aimdb_client::AimxClient; +use aimdb_client::AimxConnection; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use tracing::debug; @@ -117,13 +117,13 @@ pub async fn query_schema(args: Option) -> McpResult { ); // Get or create connection from pool (if available) - let mut client = if let Some(pool) = super::connection_pool() { + let client = if let Some(pool) = super::connection_pool() { pool.get_connection(&socket_path) .await .map_err(McpError::Client)? } else { // Fallback to direct connection if pool not initialized - AimxClient::connect(&socket_path) + AimxConnection::connect(&socket_path) .await .map_err(McpError::Client)? };