From 0bac710ffd913a0ff6071105d74a0545a0adabf2 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 05:33:54 +0000 Subject: [PATCH 1/8] feat(tcp-connector): enhance Embassy TCP transport with futures support and connection handling improvements --- aimdb-tcp-connector/Cargo.toml | 28 ++++++++++++++++++++ aimdb-tcp-connector/src/embassy_transport.rs | 16 +++++++++-- 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/aimdb-tcp-connector/Cargo.toml b/aimdb-tcp-connector/Cargo.toml index 208f3e5d..b55a428e 100644 --- a/aimdb-tcp-connector/Cargo.toml +++ b/aimdb-tcp-connector/Cargo.toml @@ -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", ] @@ -42,6 +43,20 @@ 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 } @@ -49,14 +64,27 @@ 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 = { 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" diff --git a/aimdb-tcp-connector/src/embassy_transport.rs b/aimdb-tcp-connector/src/embassy_transport.rs index 12d013b0..b9d31803 100644 --- a/aimdb-tcp-connector/src/embassy_transport.rs +++ b/aimdb-tcp-connector/src/embassy_transport.rs @@ -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; @@ -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); @@ -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 { @@ -324,6 +328,9 @@ impl Listener for TcpListener<1> { Err(_) => { socket.abort(); slot.put(socket); + // Same synchronous-failure guard as `serve_socket_slot`: + // `serve()` re-enters `accept()` immediately on `Err`, so yield. + yield_now().await; Err(TransportError::Io) } } @@ -350,6 +357,11 @@ async fn serve_socket_slot( Err(_) => { socket.abort(); slot.put(socket); + // `accept()` can fail synchronously (e.g. port-0 `InvalidPort`); + // without this await the loop retakes the slot and retries with no + // yield point, starving the executor. Yield so a misconfig + // warn-loops instead of hanging. + yield_now().await; } } } From 1e0a957d8ef39ec928a32db968dc2116e4ee86e3 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 05:34:15 +0000 Subject: [PATCH 2/8] feat(tcp-connector): add runtime smoke tests for Embassy TCP functionality --- Cargo.lock | 16 ++ aimdb-tcp-connector/tests/embassy_loopback.rs | 260 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 aimdb-tcp-connector/tests/embassy_loopback.rs diff --git a/Cargo.lock b/Cargo.lock index 6c16ee62..8495608e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -336,8 +336,15 @@ dependencies = [ "aimdb-core", "aimdb-embassy-adapter", "aimdb-tokio-adapter", + "critical-section", + "defmt 1.0.1", + "embassy-futures", "embassy-net", + "embassy-net-driver-channel", + "embassy-time-driver", "embedded-io-async 0.7.0", + "futures", + "heapless 0.9.1", "serde", "serde_json", "tokio", @@ -1452,6 +1459,15 @@ dependencies = [ "defmt 1.0.1", ] +[[package]] +name = "embassy-net-driver-channel" +version = "0.4.0" +dependencies = [ + "embassy-futures", + "embassy-net-driver", + "embassy-sync", +] + [[package]] name = "embassy-serial-connector-demo" version = "0.1.0" diff --git a/aimdb-tcp-connector/tests/embassy_loopback.rs b/aimdb-tcp-connector/tests/embassy_loopback.rs new file mode 100644 index 00000000..a988b1a6 --- /dev/null +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -0,0 +1,260 @@ +//! Runtime smoke for the Embassy TCP half (feature `_test-embassy-loopback`). +//! +//! The transport is welded to a concrete `embassy_net::tcp::TcpSocket` with no +//! seam for a fake, so socket recycling and waker handoff can only be exercised +//! over a real stack. Two `embassy-net` stacks wired by an in-memory +//! `embassy-net-driver-channel` crossover drive the real +//! `TcpListener`/`TcpDialer`/`TcpConnection` triple under `block_on`: +//! +//! - recycle: accept -> exchange -> drop -> re-accept (`recycle_then_reaccept`); +//! - concurrency: two sessions, each with its own accept slot, at once +//! (`two_concurrent_sessions`); +//! - redial: after a failed connect and after a dropped link +//! (`dialer_redials_after_failure_and_drop`). +#![cfg(feature = "_test-embassy-loopback")] + +extern crate alloc; + +use core::future::Future; + +use aimdb_core::session::{Connection, Dialer, Listener}; +use aimdb_tcp_connector::{TcpDialer, TcpListener}; +use embassy_net::{Config, IpAddress, IpEndpoint, Ipv4Address, Ipv4Cidr, Stack, StaticConfigV4}; +use embassy_net_driver_channel as ch; +use embassy_net_driver_channel::driver::{HardwareAddress, LinkState}; + +// No-op defmt logger + panic handler so the binary links (the adapter vtable and +// smoltcp reference the defmt transport). Logger/panic half of `host_test_stubs!`; +// its time driver is replaced below. +#[defmt::global_logger] +struct HostTestLogger; +unsafe impl defmt::Logger for HostTestLogger { + fn acquire() {} + unsafe fn flush() {} + unsafe fn release() {} + unsafe fn write(_bytes: &[u8]) {} +} +#[defmt::panic_handler] +fn defmt_panic() -> ! { + core::panic!("defmt panic in host test") +} +// smoltcp logs via defmt 0.3 and so references `_defmt_timestamp`; supply one. +defmt::timestamp!("{=u64}", 0u64); + +// Real wall-clock host time driver. A frozen `now()` (as in `host_test_stubs!`) +// stalls embassy-net's delayed-ACK timer, so the first `flush()` — wait-for-ACK, +// what `TcpConnection::send` does — never returns. Wakes stay immediate since +// `block_on` busy-polls anyway. +struct HostClock; +impl embassy_time_driver::Driver for HostClock { + fn now(&self) -> u64 { + use std::sync::OnceLock; + use std::time::Instant; + static START: OnceLock = OnceLock::new(); + let start = START.get_or_init(Instant::now); + // Workspace pins `embassy-time` at tick-hz-32_768. + (start.elapsed().as_micros() * 32_768 / 1_000_000) as u64 + } + fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { + waker.wake_by_ref(); + } +} +embassy_time_driver::time_driver_impl!(static HOST_CLOCK: HostClock = HostClock); + +const MTU: usize = 1514; +const SERVER_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 1); +const CLIENT_IP: Ipv4Address = Ipv4Address::new(192, 168, 0, 2); + +type ChState = ch::State; + +fn leak(v: T) -> &'static mut T { + alloc::boxed::Box::leak(alloc::boxed::Box::new(v)) +} + +/// A leaked `'static` socket buffer, as the connector constructors require. +fn buf() -> &'static mut [u8] { + alloc::boxed::Box::leak(alloc::vec![0u8; 1024].into_boxed_slice()) +} + +/// Stand up one `embassy-net` stack over a driver-channel device, all `'static`. +fn make_stack( + ip: Ipv4Address, + seed: u64, +) -> ( + Stack<'static>, + embassy_net::Runner<'static, ch::Device<'static, MTU>>, + ch::Runner<'static, MTU>, +) { + let state: &'static mut ChState = leak(ch::State::new()); + let (ch_runner, device) = ch::new(state, HardwareAddress::Ip); + let config = Config::ipv4_static(StaticConfigV4 { + address: Ipv4Cidr::new(ip, 24), + gateway: None, + dns_servers: heapless::Vec::new(), + }); + let resources = leak(embassy_net::StackResources::<4>::new()); + let (stack, net_runner) = embassy_net::new(device, config, resources, seed); + (stack, net_runner, ch_runner) +} + +/// Pump packets from one channel runner's TX into the other's RX, forever. +async fn cable(mut tx: ch::TxRunner<'static, MTU>, mut rx: ch::RxRunner<'static, MTU>) -> ! { + loop { + let tx_slot = tx.tx_buf().await; + let len = tx_slot.len(); + let mut rx_slot = rx.rx_buf().await; + rx_slot[..len].copy_from_slice(&tx_slot[..len]); + tx_slot.tx_done(); + rx_slot.rx_done(len); + } +} + +/// Run `foreground` to completion while the two crossover-wired `embassy-net` +/// stacks are polled in the background (which never ends on its own). +fn drive(foreground: F) +where + Fut: Future, + F: FnOnce(Stack<'static>, Stack<'static>) -> Fut, +{ + use futures::future::{join4, select, Either}; + use futures::pin_mut; + + let (server_stack, mut server_net, server_ch) = make_stack(SERVER_IP, 0x1111_2222); + let (client_stack, mut client_net, client_ch) = make_stack(CLIENT_IP, 0x3333_4444); + + let (server_state, server_rx, server_tx) = server_ch.split(); + let (client_state, client_rx, client_tx) = client_ch.split(); + server_state.set_link_state(LinkState::Up); + client_state.set_link_state(LinkState::Up); + + let cable_s2c = cable(server_tx, client_rx); + let cable_c2s = cable(client_tx, server_rx); + + // `run()` and the cables never return, so the join never completes — the + // background is only ever cancelled by `select` when the foreground finishes. + let background = join4(server_net.run(), client_net.run(), cable_s2c, cable_c2s); + let foreground = foreground(server_stack, client_stack); + + futures::executor::block_on(async { + pin_mut!(foreground); + pin_mut!(background); + match select(foreground, background).await { + Either::Left(_) => {} + Either::Right(_) => panic!("background stacks ended before the test"), + } + }); +} + +fn endpoint(port: u16) -> IpEndpoint { + IpEndpoint::new(IpAddress::Ipv4(SERVER_IP), port) +} + +/// Exchange one framed request + reply over an already-connected pair, asserting +/// the framing round-trips both ways. +async fn exchange(server: &mut dyn Connection, client: &mut dyn Connection, tag: &[u8]) { + client.send(tag).await.expect("client send"); + let got = server + .recv() + .await + .expect("server recv") + .expect("server frame"); + assert_eq!(got, tag, "request framing round-trip"); + + server.send(b"pong").await.expect("server send"); + let got = client + .recv() + .await + .expect("client recv") + .expect("client frame"); + assert_eq!(got, b"pong", "reply framing round-trip"); +} + +/// accept -> exchange -> disconnect -> socket recycled -> re-accept works. +#[test] +fn recycle_then_reaccept() { + drive(|server_stack, client_stack| async move { + let mut listener = TcpListener::new(server_stack, 7000u16, buf(), buf()); + let dialer = TcpDialer::new(client_stack, endpoint(7000), buf(), buf()); + + // First connection over the single pooled socket. + let (accepted, connected) = futures::join!(listener.accept(), dialer.connect()); + let mut server = accepted.expect("accept #1"); + let mut client = connected.expect("connect #1"); + exchange(server.as_mut(), client.as_mut(), b"one").await; + + // Disconnect: dropping both connections aborts and returns each socket to + // its pool (the listener slot and the dialer slot). + drop(server); + drop(client); + + // Re-accept on the recycled listener socket; redial on the recycled dialer + // socket. Both must succeed and carry a fresh framed exchange. + let (accepted, connected) = futures::join!(listener.accept(), dialer.connect()); + let mut server = accepted.expect("accept #2 (recycled socket)"); + let mut client = connected.expect("connect #2 (recycled socket)"); + exchange(server.as_mut(), client.as_mut(), b"two").await; + }); +} + +/// Two client/server sessions, each with its own accept slot, live at once — the +/// concurrent-accept-slots property the pool exists for. (The single-port +/// `TcpListener` fan-out runs the AimX session engine, covered elsewhere.) +#[test] +fn two_concurrent_sessions() { + drive(|server_stack, client_stack| async move { + let mut listener_a = TcpListener::new(server_stack, 7001u16, buf(), buf()); + let mut listener_b = TcpListener::new(server_stack, 7002u16, buf(), buf()); + let dialer_a = TcpDialer::new(client_stack, endpoint(7001), buf(), buf()); + let dialer_b = TcpDialer::new(client_stack, endpoint(7002), buf(), buf()); + + // Bring both sessions up concurrently, then exchange on both while both + // are still open. + let (a_srv, a_cli, b_srv, b_cli) = futures::join!( + listener_a.accept(), + dialer_a.connect(), + listener_b.accept(), + dialer_b.connect(), + ); + let mut a_srv = a_srv.expect("accept A"); + let mut a_cli = a_cli.expect("connect A"); + let mut b_srv = b_srv.expect("accept B"); + let mut b_cli = b_cli.expect("connect B"); + + futures::join!( + exchange(a_srv.as_mut(), a_cli.as_mut(), b"aaa"), + exchange(b_srv.as_mut(), b_cli.as_mut(), b"bbb"), + ); + }); +} + +/// Dialer reuses its single socket: a connect to a port with no listener fails +/// (the peer stack RSTs), then a connect after a listener appears succeeds; and a +/// connect after the previous link was dropped succeeds again. +#[test] +fn dialer_redials_after_failure_and_drop() { + drive(|server_stack, client_stack| async move { + let dialer = TcpDialer::new(client_stack, endpoint(7003), buf(), buf()); + + // No socket is listening on 7003 yet -> the server stack RSTs the SYN -> + // connect fails. The dialer must recycle its socket for a redial. + assert!( + dialer.connect().await.is_err(), + "connect should fail with no listener" + ); + + // Bring a listener up; the recycled dialer socket now connects. + let mut listener = TcpListener::new(server_stack, 7003u16, buf(), buf()); + let (accepted, connected) = futures::join!(listener.accept(), dialer.connect()); + let mut server = accepted.expect("accept after listener up"); + let mut client = connected.expect("redial after failed connect"); + exchange(server.as_mut(), client.as_mut(), b"live").await; + + // Drop the link, then redial on the same dialer over its recycled socket. + drop(server); + drop(client); + let (accepted, connected) = futures::join!(listener.accept(), dialer.connect()); + let mut server = accepted.expect("re-accept after drop"); + let mut client = connected.expect("redial after dropped link"); + exchange(server.as_mut(), client.as_mut(), b"again").await; + }); +} From 7219360e5bdebfe567ebdae88de06b7dea00ed8b Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 05:34:33 +0000 Subject: [PATCH 3/8] feat(tcp-connector): add embassy loopback tests and clippy checks for TCP connector --- Makefile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Makefile b/Makefile index 109ff2c1..a8e651c7 100644 --- a/Makefile +++ b/Makefile @@ -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" @@ -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" From eae447c487255728093fa39b4d65344f6f5209ef Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 05:35:12 +0000 Subject: [PATCH 4/8] feat(tcp-connector): add changelog for aimdb-tcp-connector crate --- CHANGELOG.md | 1 + aimdb-tcp-connector/CHANGELOG.md | 15 +++++++++++++++ 2 files changed, 16 insertions(+) create mode 100644 aimdb-tcp-connector/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b2bf38..a783c80d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,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) diff --git a/aimdb-tcp-connector/CHANGELOG.md b/aimdb-tcp-connector/CHANGELOG.md new file mode 100644 index 00000000..93c3af2e --- /dev/null +++ b/aimdb-tcp-connector/CHANGELOG.md @@ -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::::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. From 73f96c55296899aecdbe221585170040bcb466b4 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 13 Jul 2026 18:35:26 +0000 Subject: [PATCH 5/8] feat(tcp-connector): update embassy-net-driver-channel version and improve time scaling in tests --- aimdb-tcp-connector/Cargo.toml | 2 +- aimdb-tcp-connector/tests/embassy_loopback.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/aimdb-tcp-connector/Cargo.toml b/aimdb-tcp-connector/Cargo.toml index b55a428e..1c70b9cd 100644 --- a/aimdb-tcp-connector/Cargo.toml +++ b/aimdb-tcp-connector/Cargo.toml @@ -70,7 +70,7 @@ 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 = { path = "../_external/embassy/embassy-net-driver-channel", optional = true } +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] diff --git a/aimdb-tcp-connector/tests/embassy_loopback.rs b/aimdb-tcp-connector/tests/embassy_loopback.rs index a988b1a6..16db78a1 100644 --- a/aimdb-tcp-connector/tests/embassy_loopback.rs +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -52,8 +52,8 @@ impl embassy_time_driver::Driver for HostClock { use std::time::Instant; static START: OnceLock = OnceLock::new(); let start = START.get_or_init(Instant::now); - // Workspace pins `embassy-time` at tick-hz-32_768. - (start.elapsed().as_micros() * 32_768 / 1_000_000) as u64 + // Scale wall-clock micros to the driver's *actual* tick rate + (start.elapsed().as_micros() * u128::from(embassy_time_driver::TICK_HZ) / 1_000_000) as u64 } fn schedule_wake(&self, _at: u64, waker: &core::task::Waker) { waker.wake_by_ref(); From 83df852253efd024602abefe0ab8810b51eb45c9 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 13 Jul 2026 18:55:40 +0000 Subject: [PATCH 6/8] 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 --- aimdb-tcp-connector/tests/embassy_loopback.rs | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/aimdb-tcp-connector/tests/embassy_loopback.rs b/aimdb-tcp-connector/tests/embassy_loopback.rs index 16db78a1..905a3108 100644 --- a/aimdb-tcp-connector/tests/embassy_loopback.rs +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -116,9 +116,19 @@ where Fut: Future, F: FnOnce(Stack<'static>, Stack<'static>) -> Fut, { + use core::future::poll_fn; + use core::task::Poll; + use std::time::{Duration, Instant}; + use futures::future::{join4, select, Either}; use futures::pin_mut; + // A stuck foreground (a transport regression or a dropped crossover packet) + // would otherwise leave `block_on` pending forever, failing only at the outer + // CI timeout. Bound every test with a wall-clock watchdog so `make check` + // fails promptly and points at the hang instead. + const WATCHDOG: Duration = Duration::from_secs(20); + let (server_stack, mut server_net, server_ch) = make_stack(SERVER_IP, 0x1111_2222); let (client_stack, mut client_net, client_ch) = make_stack(CLIENT_IP, 0x3333_4444); @@ -138,9 +148,31 @@ where futures::executor::block_on(async { pin_mut!(foreground); pin_mut!(background); - match select(foreground, background).await { - Either::Left(_) => {} - Either::Right(_) => panic!("background stacks ended before the test"), + let session = select(foreground, background); + pin_mut!(session); + + let deadline = Instant::now() + WATCHDOG; + let watchdog = poll_fn(move |cx| { + if Instant::now() >= deadline { + Poll::Ready(()) + } else { + // Re-arm each poll so the executor keeps spinning and observes the + // deadline even if the stacks and foreground would otherwise park. + cx.waker().wake_by_ref(); + Poll::Pending + } + }); + pin_mut!(watchdog); + + match select(session, watchdog).await { + Either::Left((Either::Left(_), _)) => {} + Either::Left((Either::Right(_), _)) => { + panic!("background stacks ended before the test") + } + Either::Right(_) => panic!( + "watchdog: foreground stuck for {}s (transport regression or dropped packet)", + WATCHDOG.as_secs() + ), } }); } From 337d20274250ab536c2d8159792066fc95375eb4 Mon Sep 17 00:00:00 2001 From: test Date: Mon, 13 Jul 2026 19:01:18 +0000 Subject: [PATCH 7/8] feat(tcp-connector): expose TcpListener::accept_on and cover the pooled N=2 path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` + the AimX session engine. Add `TcpListener::::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 --- aimdb-tcp-connector/src/embassy_transport.rs | 82 +++++++++++-------- aimdb-tcp-connector/tests/embassy_loopback.rs | 67 +++++++++++---- 2 files changed, 97 insertions(+), 52 deletions(-) diff --git a/aimdb-tcp-connector/src/embassy_transport.rs b/aimdb-tcp-connector/src/embassy_transport.rs index b9d31803..aeeefffa 100644 --- a/aimdb-tcp-connector/src/embassy_transport.rs +++ b/aimdb-tcp-connector/src/embassy_transport.rs @@ -292,6 +292,23 @@ impl TcpListener { } } + /// 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>> + Send + '_ { + 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, @@ -316,25 +333,33 @@ impl TcpListener { impl Listener for TcpListener<1> { fn accept(&mut self) -> BoxFut<'_, TransportResult>> { - 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, + local_endpoint: IpListenEndpoint, +) -> TransportResult> { + let mut socket = poll_fn(|cx| slot.poll_take(cx)).await; + socket.abort(); + match socket.accept(local_endpoint).await { + Ok(()) => { + Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) as Box) + } + Err(_) => { socket.abort(); - match socket.accept(self.local_endpoint).await { - Ok(()) => { - Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) - as Box) - } - Err(_) => { - socket.abort(); - slot.put(socket); - // Same synchronous-failure guard as `serve_socket_slot`: - // `serve()` re-enters `accept()` immediately on `Err`, so yield. - yield_now().await; - 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) + } } } @@ -346,23 +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; - run_session(conn, codec.as_ref(), dispatch.as_ref(), &config).await; - } - Err(_) => { - socket.abort(); - slot.put(socket); - // `accept()` can fail synchronously (e.g. port-0 `InvalidPort`); - // without this await the loop retakes the slot and retries with no - // yield point, starving the executor. Yield so a misconfig - // warn-loops instead of hanging. - yield_now().await; - } + // `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; } } } diff --git a/aimdb-tcp-connector/tests/embassy_loopback.rs b/aimdb-tcp-connector/tests/embassy_loopback.rs index 905a3108..e8984a97 100644 --- a/aimdb-tcp-connector/tests/embassy_loopback.rs +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -7,8 +7,8 @@ //! `TcpListener`/`TcpDialer`/`TcpConnection` triple under `block_on`: //! //! - recycle: accept -> exchange -> drop -> re-accept (`recycle_then_reaccept`); -//! - concurrency: two sessions, each with its own accept slot, at once -//! (`two_concurrent_sessions`); +//! - concurrency: one pooled `N = 2` listener keeps both sockets in `accept()` +//! on a single port while two clients dial it (`two_concurrent_sessions`); //! - redial: after a failed connect and after a dropped link //! (`dialer_redials_after_failure_and_drop`). #![cfg(feature = "_test-embassy-loopback")] @@ -201,6 +201,30 @@ async fn exchange(server: &mut dyn Connection, client: &mut dyn Connection, tag: assert_eq!(got, b"pong", "reply framing round-trip"); } +/// Receive one frame and echo it straight back. Pairing-agnostic: with same-port +/// fan-out the stack, not the test, decides which pooled server socket a given +/// client lands on, so the server can only echo whatever frame arrives on it. +async fn echo_once(server: &mut dyn Connection) { + let got = server + .recv() + .await + .expect("server recv") + .expect("server frame"); + server.send(&got).await.expect("server echo"); +} + +/// Send `tag`, then assert the echo comes back byte-for-byte — so each client +/// verifies its own session regardless of which server socket served it. +async fn send_and_verify(client: &mut dyn Connection, tag: &[u8]) { + client.send(tag).await.expect("client send"); + let got = client + .recv() + .await + .expect("client recv") + .expect("client frame"); + assert_eq!(got, tag, "echo round-trip"); +} + /// accept -> exchange -> disconnect -> socket recycled -> re-accept works. #[test] fn recycle_then_reaccept() { @@ -228,33 +252,42 @@ fn recycle_then_reaccept() { }); } -/// Two client/server sessions, each with its own accept slot, live at once — the -/// concurrent-accept-slots property the pool exists for. (The single-port -/// `TcpListener` fan-out runs the AimX session engine, covered elsewhere.) +/// One pooled `N = 2` listener keeps both of its sockets in `accept()` on a +/// single port while two clients dial that port at once — the same-port fan-out +/// and pooled worker creation that `TcpListener::::with_buffers` exists for. +/// Each client lands on a distinct pooled slot; a broken pool (only one socket +/// accepting, or both racing to the same slot) would hang the second session and +/// trip the watchdog. (Wiring the pool into the AimX session engine via +/// `TcpServer` is covered elsewhere; this stays at the transport layer.) #[test] fn two_concurrent_sessions() { drive(|server_stack, client_stack| async move { - let mut listener_a = TcpListener::new(server_stack, 7001u16, buf(), buf()); - let mut listener_b = TcpListener::new(server_stack, 7002u16, buf(), buf()); + // One pooled listener, two sockets, both bound to port 7001. + let listener = + TcpListener::<2>::with_buffers(server_stack, 7001u16, [buf(), buf()], [buf(), buf()]); let dialer_a = TcpDialer::new(client_stack, endpoint(7001), buf(), buf()); - let dialer_b = TcpDialer::new(client_stack, endpoint(7002), buf(), buf()); + let dialer_b = TcpDialer::new(client_stack, endpoint(7001), buf(), buf()); - // Bring both sessions up concurrently, then exchange on both while both - // are still open. - let (a_srv, a_cli, b_srv, b_cli) = futures::join!( - listener_a.accept(), + // Both pooled sockets accept on 7001 while both clients dial it; each + // client establishes against a separate slot. + let (a_srv, b_srv, a_cli, b_cli) = futures::join!( + listener.accept_on(0), + listener.accept_on(1), dialer_a.connect(), - listener_b.accept(), dialer_b.connect(), ); - let mut a_srv = a_srv.expect("accept A"); + let mut a_srv = a_srv.expect("accept slot 0"); + let mut b_srv = b_srv.expect("accept slot 1"); let mut a_cli = a_cli.expect("connect A"); - let mut b_srv = b_srv.expect("accept B"); let mut b_cli = b_cli.expect("connect B"); + // Drive both sessions at once. Servers echo (the stack picks the pairing); + // each client verifies its own tag returns. futures::join!( - exchange(a_srv.as_mut(), a_cli.as_mut(), b"aaa"), - exchange(b_srv.as_mut(), b_cli.as_mut(), b"bbb"), + echo_once(a_srv.as_mut()), + echo_once(b_srv.as_mut()), + send_and_verify(a_cli.as_mut(), b"aaa"), + send_and_verify(b_cli.as_mut(), b"bbb"), ); }); } From 2608ed445e1945edda478c0862d72f60e5cb855a Mon Sep 17 00:00:00 2001 From: test Date: Tue, 14 Jul 2026 21:23:16 +0000 Subject: [PATCH 8/8] feat(tcp-connector): enhance socket recycling and cancellation handling in accept logic --- aimdb-tcp-connector/src/embassy_transport.rs | 96 ++++++++++++++++--- aimdb-tcp-connector/tests/embassy_loopback.rs | 53 +++++++++- 2 files changed, 129 insertions(+), 20 deletions(-) diff --git a/aimdb-tcp-connector/src/embassy_transport.rs b/aimdb-tcp-connector/src/embassy_transport.rs index aeeefffa..62c945ec 100644 --- a/aimdb-tcp-connector/src/embassy_transport.rs +++ b/aimdb-tcp-connector/src/embassy_transport.rs @@ -144,6 +144,12 @@ where struct TcpSocketSlot { socket: RefCell>>, + // Single `Waker`: at most one accept ever waits on a given slot. Every shipped + // accept path holds one waiter per slot by construction — the `&mut self` + // `Listener` impl serializes accepts on the sole slot, and `TcpServer` runs + // exactly one worker per slot. The pooled per-slot accept is test-only (see + // `accept_on`), and its single caller drives one accept per index. So this + // waker is never clobbered. waker: RefCell>, } @@ -190,6 +196,47 @@ impl TcpSocketSlot { } } +/// Owns a socket taken out of its slot for the duration of an `accept()`, and +/// returns it to the slot if dropped before the accept succeeds. Without this, +/// an accept future dropped mid-`accept` (a `select!` timeout or shutdown branch +/// winning the race) would drop the socket instead of recycling it, leaving the +/// slot permanently empty so every later accept on that index waits forever. +/// [`SlotReturn::into_socket`] defuses it once the socket moves into a +/// [`TcpConnection`] on the success path. +struct SlotReturn<'a> { + slot: &'a Arc, + socket: Option>, +} + +impl<'a> SlotReturn<'a> { + fn new(slot: &'a Arc, socket: TcpSocket<'static>) -> Self { + Self { + slot, + socket: Some(socket), + } + } + + fn socket_mut(&mut self) -> &mut TcpSocket<'static> { + self.socket + .as_mut() + .expect("socket present until into_socket") + } + + /// Take the socket back, defusing the guard so its `Drop` becomes a no-op. + fn into_socket(mut self) -> TcpSocket<'static> { + self.socket.take().expect("socket taken exactly once") + } +} + +impl Drop for SlotReturn<'_> { + fn drop(&mut self) { + if let Some(mut socket) = self.socket.take() { + socket.abort(); + self.slot.put(socket); + } + } +} + /// A reusable Embassy TCP dialer backed by one caller-owned socket. /// /// Unlike moved-in UART peripherals, `embassy-net` TCP sockets can be reused @@ -292,14 +339,17 @@ impl TcpListener { } } - /// Accept one connection on pooled socket `index`, recycling that socket - /// back into its slot when the returned connection drops. + /// Accept one connection on pooled socket `index`, recycling that socket back + /// into its slot when the returned connection drops. **Test-only** (gated on + /// `_test-embassy-loopback`): the shipped pooled path is `TcpServer`, which + /// runs one worker per slot; this lets the transport-level loopback test drive + /// the same-port fan-out directly, without the session engine. /// - /// 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`. + /// The slot stores a single `Waker`, so this takes `&self` on the contract + /// that the caller drives **at most one accept per `index`** (the test uses + /// distinct indices). Panics if `index >= N`. + #[cfg(feature = "_test-embassy-loopback")] + #[doc(hidden)] pub fn accept_on( &self, index: usize, @@ -333,26 +383,42 @@ impl TcpListener { impl Listener for TcpListener<1> { fn accept(&mut self) -> BoxFut<'_, TransportResult>> { - Box::pin(self.accept_on(0)) + // `&mut self` already serializes accepts on the sole slot, so the + // one-waiter-per-slot invariant holds here. + let slot = self.slots[0].clone(); + let local_endpoint = self.local_endpoint; + Box::pin(SendFutureWrapper(async move { + accept_on_slot(&slot, local_endpoint).await + })) } } /// 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. +/// back a recyclable [`TcpConnection`]. Shared by the [`Listener`] impl, the +/// per-slot server workers, and the test-only `accept_on` so all pooled accept +/// paths behave identically. async fn accept_on_slot( slot: &Arc, local_endpoint: IpListenEndpoint, ) -> TransportResult> { - let mut socket = poll_fn(|cx| slot.poll_take(cx)).await; - socket.abort(); - match socket.accept(local_endpoint).await { + let socket = poll_fn(|cx| slot.poll_take(cx)).await; + // The socket lives in this guard until it either moves into a `TcpConnection` + // (success) or is returned to the slot. If the whole future is dropped while + // `accept()` is still pending, the guard's `Drop` recycles the socket so the + // slot is never left permanently empty. + let mut guard = SlotReturn::new(slot, socket); + guard.socket_mut().abort(); + // Bind the result before matching so the `accept()` future's borrow of + // `guard` ends here, freeing `guard` for `into_socket` / `drop` below. + let accepted = guard.socket_mut().accept(local_endpoint).await; + match accepted { Ok(()) => { + let socket = guard.into_socket(); Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) as Box) } Err(_) => { - socket.abort(); - slot.put(socket); + // Dropping `guard` aborts the socket and returns it to the slot. + drop(guard); // `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 diff --git a/aimdb-tcp-connector/tests/embassy_loopback.rs b/aimdb-tcp-connector/tests/embassy_loopback.rs index e8984a97..a8986afc 100644 --- a/aimdb-tcp-connector/tests/embassy_loopback.rs +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -8,9 +8,12 @@ //! //! - recycle: accept -> exchange -> drop -> re-accept (`recycle_then_reaccept`); //! - concurrency: one pooled `N = 2` listener keeps both sockets in `accept()` -//! on a single port while two clients dial it (`two_concurrent_sessions`); +//! on a single port (via the test-only `accept_on`, one accept per index) while +//! two clients dial it (`two_concurrent_sessions`); //! - redial: after a failed connect and after a dropped link -//! (`dialer_redials_after_failure_and_drop`). +//! (`dialer_redials_after_failure_and_drop`); +//! - cancellation: a cancelled accept returns its socket to the slot +//! (`cancelled_accept_recycles_socket`). #![cfg(feature = "_test-embassy-loopback")] extern crate alloc; @@ -258,7 +261,7 @@ fn recycle_then_reaccept() { /// Each client lands on a distinct pooled slot; a broken pool (only one socket /// accepting, or both racing to the same slot) would hang the second session and /// trip the watchdog. (Wiring the pool into the AimX session engine via -/// `TcpServer` is covered elsewhere; this stays at the transport layer.) +/// `TcpServer` is intentionally outside this transport-level smoke test.) #[test] fn two_concurrent_sessions() { drive(|server_stack, client_stack| async move { @@ -268,8 +271,9 @@ fn two_concurrent_sessions() { let dialer_a = TcpDialer::new(client_stack, endpoint(7001), buf(), buf()); let dialer_b = TcpDialer::new(client_stack, endpoint(7001), buf(), buf()); - // Both pooled sockets accept on 7001 while both clients dial it; each - // client establishes against a separate slot. + // Drive the pooled sockets directly via the test-only `accept_on`, one + // accept per index (its single-caller-per-index contract): both sockets + // accept on 7001 while both clients dial it, each landing on its own slot. let (a_srv, b_srv, a_cli, b_cli) = futures::join!( listener.accept_on(0), listener.accept_on(1), @@ -323,3 +327,42 @@ fn dialer_redials_after_failure_and_drop() { exchange(server.as_mut(), client.as_mut(), b"again").await; }); } + +/// A cancelled accept — its future dropped mid-`accept()`, as a `select!` timeout +/// or shutdown branch would drop it — must return the pooled socket to its slot. +/// Without the drop guard the socket is dropped instead of recycled, the slot +/// stays empty, and the follow-up accept below would hang until the watchdog. +#[test] +fn cancelled_accept_recycles_socket() { + use futures::future::{ready, select, Either}; + use futures::pin_mut; + + drive(|server_stack, client_stack| async move { + let mut listener = TcpListener::new(server_stack, 7005u16, buf(), buf()); + let dialer = TcpDialer::new(client_stack, endpoint(7005), buf(), buf()); + + // No client dials 7005, so this accept takes the pooled socket and then + // parks in `TcpSocket::accept`. Cancel it by letting a ready future win a + // `select`, dropping the accept future while its accept is still pending. + { + let accept = listener.accept(); + pin_mut!(accept); + match select(accept, ready(())).await { + Either::Right(_) => {} // `ready` won -> the accept future is cancelled + Either::Left(_) => panic!("accept resolved with no client dialing"), + } + } // the parked accept future drops here, recycling its socket into the slot + + // The recycled socket must be back in its slot: a real accept + dial now + // succeeds. A leaked slot would hang this accept and trip the watchdog. + let (accepted, connected) = futures::join!(listener.accept(), dialer.connect()); + let mut server = accepted.expect("accept after a cancelled accept (socket recycled)"); + let mut client = connected.expect("connect after a cancelled accept"); + exchange(server.as_mut(), client.as_mut(), b"post-cancel").await; + }); +} + +// Note: there is no shipped public multi-slot accept to misuse — the pooled path +// is `TcpServer` (one worker per slot) and the `accept_on` used above is a +// test-only, single-caller-per-index hook. So the one-waiter-per-slot invariant +// is upheld by construction and there is nothing to assert at runtime.