diff --git a/CHANGELOG.md b/CHANGELOG.md index 5357efa6..66132e53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 > - [aimdb-websocket-connector/CHANGELOG.md](aimdb-websocket-connector/CHANGELOG.md) > - [aimdb-uds-connector/CHANGELOG.md](aimdb-uds-connector/CHANGELOG.md) > - [aimdb-serial-connector/CHANGELOG.md](aimdb-serial-connector/CHANGELOG.md) +> - [aimdb-tcp-connector/CHANGELOG.md](aimdb-tcp-connector/CHANGELOG.md) > - [aimdb-ws-protocol/CHANGELOG.md](aimdb-ws-protocol/CHANGELOG.md) > - [aimdb-wasm-adapter/CHANGELOG.md](aimdb-wasm-adapter/CHANGELOG.md) > - [aimdb-sync/CHANGELOG.md](aimdb-sync/CHANGELOG.md) diff --git a/Cargo.lock b/Cargo.lock index 28c81327..c81c21d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,8 +337,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", @@ -1462,6 +1469,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/Makefile b/Makefile index 2b764643..de54dc74 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" 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. diff --git a/aimdb-tcp-connector/Cargo.toml b/aimdb-tcp-connector/Cargo.toml index 208f3e5d..1c70b9cd 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 = { version = "0.4.0", path = "../_external/embassy/embassy-net-driver-channel", optional = true } +critical-section = { version = "1.1", features = ["std"], optional = true } + [dev-dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "time", "io-util", "net"] } serde = { workspace = true } serde_json = { workspace = true } +# The Embassy loopback smoke expands `host_test_stubs!()` (no-op defmt logger + +# host time driver) and drives the stack with `futures::executor::block_on`; the +# macro references `defmt`/`embassy-time-driver` at the call site. +futures = { workspace = true } +defmt = { workspace = true } +embassy-time-driver = { path = "../_external/embassy/embassy-time-driver" } +# `StaticConfigV4.dns_servers` is a heapless 0.9 `Vec` (embassy-net's version). +heapless = "0.9" [[example]] name = "tcp_demo" diff --git a/aimdb-tcp-connector/src/embassy_transport.rs b/aimdb-tcp-connector/src/embassy_transport.rs index 12d013b0..62c945ec 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); @@ -141,14 +144,21 @@ 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>, } // 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 { @@ -186,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 @@ -288,6 +339,26 @@ impl TcpListener { } } + /// 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 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, + ) -> 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, @@ -312,25 +383,52 @@ impl TcpListener { impl Listener for TcpListener<1> { fn accept(&mut self) -> BoxFut<'_, TransportResult>> { + // `&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 { - let slot = &self.slots[0]; - let mut socket = poll_fn(|cx| slot.poll_take(cx)).await; - 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); - Err(TransportError::Io) - } - } + 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 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 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(_) => { + // 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 + // warn-loops instead of hanging. + yield_now().await; + Err(TransportError::Io) + } + } +} + async fn serve_socket_slot( slot: Arc, local_endpoint: IpListenEndpoint, @@ -339,18 +437,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_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 new file mode 100644 index 00000000..a8986afc --- /dev/null +++ b/aimdb-tcp-connector/tests/embassy_loopback.rs @@ -0,0 +1,368 @@ +//! 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: one pooled `N = 2` listener keeps both sockets in `accept()` +//! 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`); +//! - cancellation: a cancelled accept returns its socket to the slot +//! (`cancelled_accept_recycles_socket`). +#![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); + // 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(); + } +} +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 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); + + 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); + 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() + ), + } + }); +} + +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"); +} + +/// 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() { + 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; + }); +} + +/// 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 intentionally outside this transport-level smoke test.) +#[test] +fn two_concurrent_sessions() { + drive(|server_stack, client_stack| async move { + // 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(7001), buf(), buf()); + + // 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), + dialer_a.connect(), + dialer_b.connect(), + ); + 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_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!( + 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"), + ); + }); +} + +/// 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; + }); +} + +/// 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.