From 0bac710ffd913a0ff6071105d74a0545a0adabf2 Mon Sep 17 00:00:00 2001 From: test Date: Sun, 12 Jul 2026 05:33:54 +0000 Subject: [PATCH 1/7] 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 208f3e5..b55a428 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 12d013b..b9d3180 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/7] 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 6c16ee6..8495608 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 0000000..a988b1a --- /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/7] 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 109ff2c..a8e651c 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/7] 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 59b2bf3..a783c80 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 0000000..93c3af2 --- /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/7] 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 b55a428..1c70b9c 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 a988b1a..16db78a 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/7] 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 16db78a..905a310 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/7] 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 b9d3180..aeeefff 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 905a310..e8984a9 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"), ); }); }