Skip to content

Commit 301cd58

Browse files
lxsaahtestclaude
authored
Feat/tcp connecotr hardening (#179)
* feat(tcp-connector): enhance Embassy TCP transport with futures support and connection handling improvements * feat(tcp-connector): add runtime smoke tests for Embassy TCP functionality * feat(tcp-connector): add embassy loopback tests and clippy checks for TCP connector * feat(tcp-connector): add changelog for aimdb-tcp-connector crate * feat(tcp-connector): update embassy-net-driver-channel version and improve time scaling in tests * feat(tcp-connector): bound embassy loopback tests with a wall-clock watchdog The loopback harness polls two non-terminating embassy-net stacks in the background, so a transport regression or a dropped crossover packet would leave `block_on` pending until the outer CI timeout instead of failing the test. Race the foreground/background `select` against a 20s wall-clock watchdog that re-arms its waker each poll (so the deadline is observed even when the stacks would otherwise park) and panics with a clear message. Addresses review feedback on #179. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tcp-connector): expose TcpListener::accept_on and cover the pooled N=2 path The Embassy loopback concurrency test used two unrelated `TcpListener<1>` on different ports, so it never touched `with_buffers` construction or same-port fan-out. Rework it to stand up one `TcpListener::<2>::with_buffers(...)` on a single port and dial it with two clients. Reaching the N>1 pool from an integration test required a public accept: the `Listener` impl is `N=1`-only and the pooled path (`into_server_futures`/ `serve_socket_slot`) was private, reachable only through `TcpServer<N>` + the AimX session engine. Add `TcpListener::<N>::accept_on(index)` and factor the accept body — previously duplicated between the `N=1` `Listener` impl and the server workers — into a shared `accept_on_slot`. Side benefit: `with_buffers` can now be driven directly instead of only through `TcpServer`. Addresses review feedback on #179. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(tcp-connector): enhance socket recycling and cancellation handling in accept logic --------- Co-authored-by: test <test@test.local> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b32cfce commit 301cd58

7 files changed

Lines changed: 550 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1818
> - [aimdb-websocket-connector/CHANGELOG.md](aimdb-websocket-connector/CHANGELOG.md)
1919
> - [aimdb-uds-connector/CHANGELOG.md](aimdb-uds-connector/CHANGELOG.md)
2020
> - [aimdb-serial-connector/CHANGELOG.md](aimdb-serial-connector/CHANGELOG.md)
21+
> - [aimdb-tcp-connector/CHANGELOG.md](aimdb-tcp-connector/CHANGELOG.md)
2122
> - [aimdb-ws-protocol/CHANGELOG.md](aimdb-ws-protocol/CHANGELOG.md)
2223
> - [aimdb-wasm-adapter/CHANGELOG.md](aimdb-wasm-adapter/CHANGELOG.md)
2324
> - [aimdb-sync/CHANGELOG.md](aimdb-sync/CHANGELOG.md)

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ test:
179179
cargo test --package aimdb-serial-connector --no-default-features --features "embassy-runtime"
180180
@printf "$(YELLOW) → Testing TCP connector (tokio: length-prefix framing + AimX loopback)$(NC)\n"
181181
cargo test --package aimdb-tcp-connector --no-default-features --features "_test-tokio"
182+
@printf "$(YELLOW) → Testing TCP connector (embassy: socket recycle + concurrent slots + redial over an embassy-net loopback)$(NC)\n"
183+
cargo test --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback
182184

183185
fmt:
184186
@printf "$(GREEN)Formatting code (workspace members only)...$(NC)\n"
@@ -280,6 +282,8 @@ clippy:
280282
cargo clippy --package aimdb-tcp-connector --target thumbv7em-none-eabihf --target-dir $(EMBEDDED_CHECK_TARGET_DIR) --no-default-features --features "embassy-runtime" -- -D warnings
281283
@printf "$(YELLOW) → Clippy on TCP connector (embassy + defmt)$(NC)\n"
282284
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
285+
@printf "$(YELLOW) → Clippy on TCP connector (embassy-net loopback smoke, host)$(NC)\n"
286+
cargo clippy --package aimdb-tcp-connector --no-default-features --features "_test-embassy-loopback" --test embassy_loopback -- -D warnings
283287
@printf "$(YELLOW) → Clippy on WASM adapter$(NC)\n"
284288
cargo clippy --package aimdb-wasm-adapter --target wasm32-unknown-unknown --features "wasm-runtime" -- -D warnings
285289
@printf "$(YELLOW) → Clippy on benchmarking infrastructure (host-only, incl. benches)$(NC)\n"

aimdb-tcp-connector/CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Changelog - aimdb-tcp-connector
2+
3+
All notable changes to the `aimdb-tcp-connector` crate will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **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:
13+
- **`tokio-runtime`** (std, host/gateway) — TCP transport over `tokio::net`.
14+
- **`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.
15+
- **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.

aimdb-tcp-connector/Cargo.toml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ embassy-runtime = [
3434
"aimdb-embassy-adapter/connectors",
3535
"aimdb-embassy-adapter/embassy-net-support",
3636
"dep:embassy-net",
37+
"dep:embassy-futures",
3738
"dep:embedded-io-async",
3839
]
3940

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

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

46+
# Internal: the Embassy TCP half's runtime smoke (`tests/embassy_loopback.rs`)
47+
# stands up two real `embassy-net` stacks wired by an in-memory driver-channel
48+
# crossover, so it needs `medium-ip`/`proto-ipv4` and the host-only loopback
49+
# deps. Kept off `embassy-runtime` (production never pulls a network device or a
50+
# critical-section impl) and out of `[dev-dependencies]` (they would compile into
51+
# the `_test-tokio` build too). Run with `--features _test-embassy-loopback`.
52+
_test-embassy-loopback = [
53+
"embassy-runtime",
54+
"embassy-net/medium-ip",
55+
"embassy-net/proto-ipv4",
56+
"dep:embassy-net-driver-channel",
57+
"dep:critical-section",
58+
]
59+
4560
[dependencies]
4661
aimdb-core = { version = "1.1.0", path = "../aimdb-core", default-features = false }
4762

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

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

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

72+
# test-only (see `_test-embassy-loopback`)
73+
embassy-net-driver-channel = { version = "0.4.0", path = "../_external/embassy/embassy-net-driver-channel", optional = true }
74+
critical-section = { version = "1.1", features = ["std"], optional = true }
75+
5676
[dev-dependencies]
5777
tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "io-util", "net"] }
5878
serde = { workspace = true }
5979
serde_json = { workspace = true }
80+
# The Embassy loopback smoke expands `host_test_stubs!()` (no-op defmt logger +
81+
# host time driver) and drives the stack with `futures::executor::block_on`; the
82+
# macro references `defmt`/`embassy-time-driver` at the call site.
83+
futures = { workspace = true }
84+
defmt = { workspace = true }
85+
embassy-time-driver = { path = "../_external/embassy/embassy-time-driver" }
86+
# `StaticConfigV4.dns_servers` is a heapless 0.9 `Vec` (embassy-net's version).
87+
heapless = "0.9"
6088

6189
[[example]]
6290
name = "tcp_demo"

aimdb-tcp-connector/src/embassy_transport.rs

Lines changed: 118 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ use aimdb_core::session::{
2525
};
2626
use aimdb_embassy_adapter::connectors::{EmbassySessionClient, OneShotCell};
2727
use aimdb_embassy_adapter::SendFutureWrapper;
28+
use embassy_futures::yield_now;
2829
use embassy_net::tcp::TcpSocket;
2930
use embassy_net::{IpEndpoint, IpListenEndpoint, Stack};
3031
use embedded_io_async::Write;
@@ -114,6 +115,8 @@ impl Connection for TcpConnection {
114115
impl Drop for TcpConnection {
115116
fn drop(&mut self) {
116117
if let Some(mut socket) = self.socket.take() {
118+
// Double abort is intentional: this one resets the link promptly on
119+
// drop; the next taker re-aborts before reuse for a clean socket.
117120
socket.abort();
118121
if let Some(recycler) = &self.recycler {
119122
recycler.put(socket);
@@ -141,14 +144,21 @@ where
141144

142145
struct TcpSocketSlot {
143146
socket: RefCell<Option<TcpSocket<'static>>>,
147+
// Single `Waker`: at most one accept ever waits on a given slot. Every shipped
148+
// accept path holds one waiter per slot by construction — the `&mut self`
149+
// `Listener` impl serializes accepts on the sole slot, and `TcpServer<N>` runs
150+
// exactly one worker per slot. The pooled per-slot accept is test-only (see
151+
// `accept_on`), and its single caller drives one accept per index. So this
152+
// waker is never clobbered.
144153
waker: RefCell<Option<Waker>>,
145154
}
146155

147156
// SAFETY: single-core cooperative Embassy executor; the socket and stack stay on
148157
// the same executor task set, and `RefCell` is never borrowed from another core.
149158
unsafe impl Send for TcpSocketSlot {}
150-
// SAFETY: same invariant. This is only shared so the dropped connection can
151-
// return its socket to the dialer-owned slot.
159+
// SAFETY: same invariant. Shared only so a dropped connection can return its
160+
// socket to the slot — owned by a dialer, an accept/session worker, or the
161+
// `Listener` compat path, never touched from more than one at a time.
152162
unsafe impl Sync for TcpSocketSlot {}
153163

154164
impl TcpSocketSlot {
@@ -186,6 +196,47 @@ impl TcpSocketSlot {
186196
}
187197
}
188198

199+
/// Owns a socket taken out of its slot for the duration of an `accept()`, and
200+
/// returns it to the slot if dropped before the accept succeeds. Without this,
201+
/// an accept future dropped mid-`accept` (a `select!` timeout or shutdown branch
202+
/// winning the race) would drop the socket instead of recycling it, leaving the
203+
/// slot permanently empty so every later accept on that index waits forever.
204+
/// [`SlotReturn::into_socket`] defuses it once the socket moves into a
205+
/// [`TcpConnection`] on the success path.
206+
struct SlotReturn<'a> {
207+
slot: &'a Arc<TcpSocketSlot>,
208+
socket: Option<TcpSocket<'static>>,
209+
}
210+
211+
impl<'a> SlotReturn<'a> {
212+
fn new(slot: &'a Arc<TcpSocketSlot>, socket: TcpSocket<'static>) -> Self {
213+
Self {
214+
slot,
215+
socket: Some(socket),
216+
}
217+
}
218+
219+
fn socket_mut(&mut self) -> &mut TcpSocket<'static> {
220+
self.socket
221+
.as_mut()
222+
.expect("socket present until into_socket")
223+
}
224+
225+
/// Take the socket back, defusing the guard so its `Drop` becomes a no-op.
226+
fn into_socket(mut self) -> TcpSocket<'static> {
227+
self.socket.take().expect("socket taken exactly once")
228+
}
229+
}
230+
231+
impl Drop for SlotReturn<'_> {
232+
fn drop(&mut self) {
233+
if let Some(mut socket) = self.socket.take() {
234+
socket.abort();
235+
self.slot.put(socket);
236+
}
237+
}
238+
}
239+
189240
/// A reusable Embassy TCP dialer backed by one caller-owned socket.
190241
///
191242
/// Unlike moved-in UART peripherals, `embassy-net` TCP sockets can be reused
@@ -288,6 +339,26 @@ impl<const N: usize> TcpListener<N> {
288339
}
289340
}
290341

342+
/// Accept one connection on pooled socket `index`, recycling that socket back
343+
/// into its slot when the returned connection drops. **Test-only** (gated on
344+
/// `_test-embassy-loopback`): the shipped pooled path is `TcpServer<N>`, which
345+
/// runs one worker per slot; this lets the transport-level loopback test drive
346+
/// the same-port fan-out directly, without the session engine.
347+
///
348+
/// The slot stores a single `Waker`, so this takes `&self` on the contract
349+
/// that the caller drives **at most one accept per `index`** (the test uses
350+
/// distinct indices). Panics if `index >= N`.
351+
#[cfg(feature = "_test-embassy-loopback")]
352+
#[doc(hidden)]
353+
pub fn accept_on(
354+
&self,
355+
index: usize,
356+
) -> impl Future<Output = TransportResult<Box<dyn Connection>>> + Send + '_ {
357+
let slot = self.slots[index].clone();
358+
let local_endpoint = self.local_endpoint;
359+
SendFutureWrapper(async move { accept_on_slot(&slot, local_endpoint).await })
360+
}
361+
291362
fn into_server_futures(
292363
self,
293364
codec: Arc<AimxCodec>,
@@ -312,25 +383,52 @@ impl<const N: usize> TcpListener<N> {
312383

313384
impl Listener for TcpListener<1> {
314385
fn accept(&mut self) -> BoxFut<'_, TransportResult<Box<dyn Connection>>> {
386+
// `&mut self` already serializes accepts on the sole slot, so the
387+
// one-waiter-per-slot invariant holds here.
388+
let slot = self.slots[0].clone();
389+
let local_endpoint = self.local_endpoint;
315390
Box::pin(SendFutureWrapper(async move {
316-
let slot = &self.slots[0];
317-
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
318-
socket.abort();
319-
match socket.accept(self.local_endpoint).await {
320-
Ok(()) => {
321-
Ok(Box::new(TcpConnection::reusable(socket, slot.clone()))
322-
as Box<dyn Connection>)
323-
}
324-
Err(_) => {
325-
socket.abort();
326-
slot.put(socket);
327-
Err(TransportError::Io)
328-
}
329-
}
391+
accept_on_slot(&slot, local_endpoint).await
330392
}))
331393
}
332394
}
333395

396+
/// Take the socket from `slot`, accept one inbound connection on it, and hand
397+
/// back a recyclable [`TcpConnection`]. Shared by the [`Listener`] impl, the
398+
/// per-slot server workers, and the test-only `accept_on` so all pooled accept
399+
/// paths behave identically.
400+
async fn accept_on_slot(
401+
slot: &Arc<TcpSocketSlot>,
402+
local_endpoint: IpListenEndpoint,
403+
) -> TransportResult<Box<dyn Connection>> {
404+
let socket = poll_fn(|cx| slot.poll_take(cx)).await;
405+
// The socket lives in this guard until it either moves into a `TcpConnection`
406+
// (success) or is returned to the slot. If the whole future is dropped while
407+
// `accept()` is still pending, the guard's `Drop` recycles the socket so the
408+
// slot is never left permanently empty.
409+
let mut guard = SlotReturn::new(slot, socket);
410+
guard.socket_mut().abort();
411+
// Bind the result before matching so the `accept()` future's borrow of
412+
// `guard` ends here, freeing `guard` for `into_socket` / `drop` below.
413+
let accepted = guard.socket_mut().accept(local_endpoint).await;
414+
match accepted {
415+
Ok(()) => {
416+
let socket = guard.into_socket();
417+
Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) as Box<dyn Connection>)
418+
}
419+
Err(_) => {
420+
// Dropping `guard` aborts the socket and returns it to the slot.
421+
drop(guard);
422+
// `accept()` can fail synchronously (e.g. port-0 `InvalidPort`);
423+
// without this await the caller re-enters `accept()` immediately with
424+
// no yield point, starving the executor. Yield so a misconfig
425+
// warn-loops instead of hanging.
426+
yield_now().await;
427+
Err(TransportError::Io)
428+
}
429+
}
430+
}
431+
334432
async fn serve_socket_slot(
335433
slot: Arc<TcpSocketSlot>,
336434
local_endpoint: IpListenEndpoint,
@@ -339,18 +437,10 @@ async fn serve_socket_slot(
339437
config: SessionConfig,
340438
) {
341439
loop {
342-
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
343-
socket.abort();
344-
match socket.accept(local_endpoint).await {
345-
Ok(()) => {
346-
let conn =
347-
Box::new(TcpConnection::reusable(socket, slot.clone())) as Box<dyn Connection>;
348-
run_session(conn, codec.as_ref(), dispatch.as_ref(), &config).await;
349-
}
350-
Err(_) => {
351-
socket.abort();
352-
slot.put(socket);
353-
}
440+
// `accept_on_slot` already yields on the synchronous-failure path, so a
441+
// misconfig warn-loops here instead of starving the executor.
442+
if let Ok(conn) = accept_on_slot(&slot, local_endpoint).await {
443+
run_session(conn, codec.as_ref(), dispatch.as_ref(), &config).await;
354444
}
355445
}
356446
}

0 commit comments

Comments
 (0)