Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> - [aimdb-websocket-connector/CHANGELOG.md](aimdb-websocket-connector/CHANGELOG.md)
> - [aimdb-uds-connector/CHANGELOG.md](aimdb-uds-connector/CHANGELOG.md)
> - [aimdb-serial-connector/CHANGELOG.md](aimdb-serial-connector/CHANGELOG.md)
> - [aimdb-tcp-connector/CHANGELOG.md](aimdb-tcp-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)
Expand Down
16 changes: 16 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ test:
cargo test --package aimdb-serial-connector --no-default-features --features "embassy-runtime"
@printf "$(YELLOW) → Testing TCP connector (tokio: length-prefix framing + AimX loopback)$(NC)\n"
cargo test --package aimdb-tcp-connector --no-default-features --features "_test-tokio"
@printf "$(YELLOW) → Testing TCP connector (embassy: socket recycle + concurrent slots + redial over an embassy-net loopback)$(NC)\n"
cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback

fmt:
@printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n"
Expand Down Expand Up @@ -280,6 +282,8 @@ clippy:
cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" -- -D warnings
@printf "$(YELLOW) → Clippy on TCP connector (embassy + defmt)$(NC)\n"
cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime,defmt" -- -D warnings
@printf "$(YELLOW) → Clippy on TCP connector (embassy-net loopback smoke, host)$(NC)\n"
cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -D warnings
@printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n"
cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings
@printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n"
Expand Down
15 changes: 15 additions & 0 deletions aimdb-tcp-connector/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Changelog - aimdb-tcp-connector

All notable changes to the `aimdb-tcp-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 length-prefixed TCP transport for AimDB remote access (AimX over TCP, refs #121).** Contributes the `Dialer`/`Listener`/`Connection` transport triple plus thin `TcpClient`/`TcpServer` sugar; the AimX codec + dispatch and the runtime-neutral session engines (`run_client`/`serve`) are reused from `aimdb-core`. Every AimX envelope is framed as a `u32` big-endian length prefix. Two runtime halves:
- **`tokio-runtime`** (std, host/gateway) — TCP transport over `tokio::net`.
- **`embassy-runtime`** (`no_std + alloc`, MCU) — an explicit pool of caller-buffered `embassy-net` sockets, one accept/session worker per slot (`TcpServer::<N>::with_buffers`), with socket recycling across reconnects. A synchronous `accept()` failure (e.g. a port-0 endpoint rejected as `InvalidPort`) yields instead of spinning the cooperative executor.
- **Embassy TCP runtime smoke test** (`_test-embassy-loopback`) — exercises the socket pool over two real `embassy-net` stacks wired by an in-memory driver-channel crossover: recycle → re-accept, concurrent accept slots, and dialer redial after a failed connect / dropped link.
28 changes: 28 additions & 0 deletions aimdb-tcp-connector/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ embassy-runtime = [
"aimdb-embassy-adapter/connectors",
"aimdb-embassy-adapter/embassy-net-support",
"dep:embassy-net",
"dep:embassy-futures",
"dep:embedded-io-async",
]

Expand All @@ -42,21 +43,48 @@ defmt = ["aimdb-core/defmt"]

_test-tokio = ["tokio-runtime", "dep:aimdb-tokio-adapter"]

# Internal: the Embassy TCP half's runtime smoke (`tests/embassy_loopback.rs`)
# stands up two real `embassy-net` stacks wired by an in-memory driver-channel
# crossover, so it needs `medium-ip`/`proto-ipv4` and the host-only loopback
# deps. Kept off `embassy-runtime` (production never pulls a network device or a
# critical-section impl) and out of `[dev-dependencies]` (they would compile into
# the `_test-tokio` build too). Run with `--features _test-embassy-loopback`.
_test-embassy-loopback = [
"embassy-runtime",
"embassy-net/medium-ip",
"embassy-net/proto-ipv4",
"dep:embassy-net-driver-channel",
"dep:critical-section",
]

[dependencies]
aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false }

tokio = { workspace = true, optional = true, features = ["net", "io-util"] }

aimdb-embassy-adapter = { version = "0.6.0", path = "../aimdb-embassy-adapter", default-features = false, optional = true }
embassy-net = { workspace = true, optional = true }
embassy-futures = { workspace = true, optional = true }
embedded-io-async = { workspace = true, optional = true }

aimdb-tokio-adapter = { version = "0.6.0", path = "../aimdb-tokio-adapter", optional = true }

# test-only (see `_test-embassy-loopback`)
embassy-net-driver-channel = { version = "0.4.0", path = "../_external/embassy/embassy-net-driver-channel", optional = true }
critical-section = { version = "1.1", features = ["std"], optional = true }

[dev-dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "io-util", "net"] }
serde = { workspace = true }
serde_json = { workspace = true }
# The Embassy loopback smoke expands `host_test_stubs!()` (no-op defmt logger +
# host time driver) and drives the stack with `futures::executor::block_on`; the
# macro references `defmt`/`embassy-time-driver` at the call site.
futures = { workspace = true }
defmt = { workspace = true }
embassy-time-driver = { path = "../_external/embassy/embassy-time-driver" }
# `StaticConfigV4.dns_servers` is a heapless 0.9 `Vec` (embassy-net's version).
heapless = "0.9"

[[example]]
name = "tcp_demo"
Expand Down
82 changes: 53 additions & 29 deletions aimdb-tcp-connector/src/embassy_transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ use aimdb_core::session::{
};
use aimdb_embassy_adapter::connectors::{EmbassySessionClient, OneShotCell};
use aimdb_embassy_adapter::SendFutureWrapper;
use embassy_futures::yield_now;
use embassy_net::tcp::TcpSocket;
use embassy_net::{IpEndpoint, IpListenEndpoint, Stack};
use embedded_io_async::Write;
Expand Down Expand Up @@ -114,6 +115,8 @@ impl Connection for TcpConnection {
impl Drop for TcpConnection {
fn drop(&mut self) {
if let Some(mut socket) = self.socket.take() {
// Double abort is intentional: this one resets the link promptly on
// drop; the next taker re-aborts before reuse for a clean socket.
socket.abort();
if let Some(recycler) = &self.recycler {
recycler.put(socket);
Expand Down Expand Up @@ -147,8 +150,9 @@ struct TcpSocketSlot {
// SAFETY: single-core cooperative Embassy executor; the socket and stack stay on
// the same executor task set, and `RefCell` is never borrowed from another core.
unsafe impl Send for TcpSocketSlot {}
// SAFETY: same invariant. This is only shared so the dropped connection can
// return its socket to the dialer-owned slot.
// SAFETY: same invariant. Shared only so a dropped connection can return its
// socket to the slot — owned by a dialer, an accept/session worker, or the
// `Listener` compat path, never touched from more than one at a time.
unsafe impl Sync for TcpSocketSlot {}

impl TcpSocketSlot {
Expand Down Expand Up @@ -288,6 +292,23 @@ impl<const N: usize> TcpListener<N> {
}
}

/// Accept one connection on pooled socket `index`, recycling that socket
/// back into its slot when the returned connection drops.
///
/// The [`Listener`] impl (`N = 1`) and the `TcpServer` workers each accept a
/// single slot at a time; this exposes the same per-slot accept so a caller
/// can keep several pooled sockets in `accept()` on one endpoint at once
/// (the concurrent-listener property `with_buffers` exists for). Panics if
/// `index >= N`.
pub fn accept_on(
&self,
index: usize,
) -> impl Future<Output = TransportResult<Box<dyn Connection>>> + Send + '_ {
Comment on lines +303 to +306

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When a checked-out slot has 2 separate tasks waiting on accept_on(index), both call poll_take, but TcpSocketSlot stores only one Option<Waker>, the 2nd registration overwrites the first, so after the socket returns only one task wakes while the other can remain pending forever.
Because this new method takes &self, safe callers can create this state, so enforce one waiter per index or support multiple waiters.

let slot = self.slots[index].clone();
let local_endpoint = self.local_endpoint;
SendFutureWrapper(async move { accept_on_slot(&slot, local_endpoint).await })
}

fn into_server_futures(
self,
codec: Arc<AimxCodec>,
Expand All @@ -312,22 +333,33 @@ impl<const N: usize> TcpListener<N> {

impl Listener for TcpListener<1> {
fn accept(&mut self) -> BoxFut<'_, TransportResult<Box<dyn Connection>>> {
Box::pin(SendFutureWrapper(async move {
let slot = &self.slots[0];
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
Box::pin(self.accept_on(0))
}
}

/// Take the socket from `slot`, accept one inbound connection on it, and hand
/// back a recyclable [`TcpConnection`]. Shared by [`TcpListener::accept_on`] and
/// the per-slot server workers so all pooled accept paths behave identically.
async fn accept_on_slot(
slot: &Arc<TcpSocketSlot>,
local_endpoint: IpListenEndpoint,
) -> TransportResult<Box<dyn Connection>> {
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
socket.abort();
match socket.accept(local_endpoint).await {
Comment on lines +347 to +349

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an accept_on future is dropped after taking the socket but while TcpSocket::accept is pending - for e.g., when a timeout or shutdown select wins, the async local drops the TcpSocket instead of calling slot.put. The slot remains None, so every later accept on that index waits forever, you may want to retain the socket in a drop guard or otherwise return it on cancellation.

Ok(()) => {
Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) as Box<dyn Connection>)
}
Err(_) => {
socket.abort();
match socket.accept(self.local_endpoint).await {
Ok(()) => {
Ok(Box::new(TcpConnection::reusable(socket, slot.clone()))
as Box<dyn Connection>)
}
Err(_) => {
socket.abort();
slot.put(socket);
Err(TransportError::Io)
}
}
}))
slot.put(socket);
// `accept()` can fail synchronously (e.g. port-0 `InvalidPort`);
// without this await the caller re-enters `accept()` immediately with
// no yield point, starving the executor. Yield so a misconfig
// warn-loops instead of hanging.
yield_now().await;
Err(TransportError::Io)
}
}
}

Expand All @@ -339,18 +371,10 @@ async fn serve_socket_slot(
config: SessionConfig,
) {
loop {
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
socket.abort();
match socket.accept(local_endpoint).await {
Ok(()) => {
let conn =
Box::new(TcpConnection::reusable(socket, slot.clone())) as Box<dyn Connection>;
run_session(conn, codec.as_ref(), dispatch.as_ref(), &config).await;
}
Err(_) => {
socket.abort();
slot.put(socket);
}
// `accept_on_slot` already yields on the synchronous-failure path, so a
// misconfig warn-loops here instead of starving the executor.
if let Ok(conn) = accept_on_slot(&slot, local_endpoint).await {
run_session(conn, codec.as_ref(), dispatch.as_ref(), &config).await;
}
}
}
Expand Down
Loading