Skip to content

Commit 2608ed4

Browse files
author
test
committed
feat(tcp-connector): enhance socket recycling and cancellation handling in accept logic
1 parent 6f04f68 commit 2608ed4

2 files changed

Lines changed: 129 additions & 20 deletions

File tree

aimdb-tcp-connector/src/embassy_transport.rs

Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,12 @@ where
144144

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

@@ -190,6 +196,47 @@ impl TcpSocketSlot {
190196
}
191197
}
192198

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

295-
/// Accept one connection on pooled socket `index`, recycling that socket
296-
/// back into its slot when the returned connection drops.
342+
/// Accept one connection on pooled socket `index`, recycling that socket back
343+
/// into its slot when the returned connection drops. **Test-only** (gated on
344+
/// `_test-embassy-loopback`): the shipped pooled path is `TcpServer<N>`, which
345+
/// runs one worker per slot; this lets the transport-level loopback test drive
346+
/// the same-port fan-out directly, without the session engine.
297347
///
298-
/// The [`Listener`] impl (`N = 1`) and the `TcpServer` workers each accept a
299-
/// single slot at a time; this exposes the same per-slot accept so a caller
300-
/// can keep several pooled sockets in `accept()` on one endpoint at once
301-
/// (the concurrent-listener property `with_buffers` exists for). Panics if
302-
/// `index >= N`.
348+
/// The slot stores a single `Waker`, so this takes `&self` on the contract
349+
/// that the caller drives **at most one accept per `index`** (the test uses
350+
/// distinct indices). Panics if `index >= N`.
351+
#[cfg(feature = "_test-embassy-loopback")]
352+
#[doc(hidden)]
303353
pub fn accept_on(
304354
&self,
305355
index: usize,
@@ -333,26 +383,42 @@ impl<const N: usize> TcpListener<N> {
333383

334384
impl Listener for TcpListener<1> {
335385
fn accept(&mut self) -> BoxFut<'_, TransportResult<Box<dyn Connection>>> {
336-
Box::pin(self.accept_on(0))
386+
// `&mut self` already serializes accepts on the sole slot, so the
387+
// one-waiter-per-slot invariant holds here.
388+
let slot = self.slots[0].clone();
389+
let local_endpoint = self.local_endpoint;
390+
Box::pin(SendFutureWrapper(async move {
391+
accept_on_slot(&slot, local_endpoint).await
392+
}))
337393
}
338394
}
339395

340396
/// Take the socket from `slot`, accept one inbound connection on it, and hand
341-
/// back a recyclable [`TcpConnection`]. Shared by [`TcpListener::accept_on`] and
342-
/// the per-slot server workers so all pooled accept paths behave identically.
397+
/// back a recyclable [`TcpConnection`]. Shared by the [`Listener`] impl, the
398+
/// per-slot server workers, and the test-only `accept_on` so all pooled accept
399+
/// paths behave identically.
343400
async fn accept_on_slot(
344401
slot: &Arc<TcpSocketSlot>,
345402
local_endpoint: IpListenEndpoint,
346403
) -> TransportResult<Box<dyn Connection>> {
347-
let mut socket = poll_fn(|cx| slot.poll_take(cx)).await;
348-
socket.abort();
349-
match socket.accept(local_endpoint).await {
404+
let socket = poll_fn(|cx| slot.poll_take(cx)).await;
405+
// The socket lives in this guard until it either moves into a `TcpConnection`
406+
// (success) or is returned to the slot. If the whole future is dropped while
407+
// `accept()` is still pending, the guard's `Drop` recycles the socket so the
408+
// slot is never left permanently empty.
409+
let mut guard = SlotReturn::new(slot, socket);
410+
guard.socket_mut().abort();
411+
// Bind the result before matching so the `accept()` future's borrow of
412+
// `guard` ends here, freeing `guard` for `into_socket` / `drop` below.
413+
let accepted = guard.socket_mut().accept(local_endpoint).await;
414+
match accepted {
350415
Ok(()) => {
416+
let socket = guard.into_socket();
351417
Ok(Box::new(TcpConnection::reusable(socket, slot.clone())) as Box<dyn Connection>)
352418
}
353419
Err(_) => {
354-
socket.abort();
355-
slot.put(socket);
420+
// Dropping `guard` aborts the socket and returns it to the slot.
421+
drop(guard);
356422
// `accept()` can fail synchronously (e.g. port-0 `InvalidPort`);
357423
// without this await the caller re-enters `accept()` immediately with
358424
// no yield point, starving the executor. Yield so a misconfig

aimdb-tcp-connector/tests/embassy_loopback.rs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,12 @@
88
//!
99
//! - recycle: accept -> exchange -> drop -> re-accept (`recycle_then_reaccept`);
1010
//! - concurrency: one pooled `N = 2` listener keeps both sockets in `accept()`
11-
//! on a single port while two clients dial it (`two_concurrent_sessions`);
11+
//! on a single port (via the test-only `accept_on`, one accept per index) while
12+
//! two clients dial it (`two_concurrent_sessions`);
1213
//! - redial: after a failed connect and after a dropped link
13-
//! (`dialer_redials_after_failure_and_drop`).
14+
//! (`dialer_redials_after_failure_and_drop`);
15+
//! - cancellation: a cancelled accept returns its socket to the slot
16+
//! (`cancelled_accept_recycles_socket`).
1417
#![cfg(feature = "_test-embassy-loopback")]
1518

1619
extern crate alloc;
@@ -258,7 +261,7 @@ fn recycle_then_reaccept() {
258261
/// Each client lands on a distinct pooled slot; a broken pool (only one socket
259262
/// accepting, or both racing to the same slot) would hang the second session and
260263
/// trip the watchdog. (Wiring the pool into the AimX session engine via
261-
/// `TcpServer<N>` is covered elsewhere; this stays at the transport layer.)
264+
/// `TcpServer<N>` is intentionally outside this transport-level smoke test.)
262265
#[test]
263266
fn two_concurrent_sessions() {
264267
drive(|server_stack, client_stack| async move {
@@ -268,8 +271,9 @@ fn two_concurrent_sessions() {
268271
let dialer_a = TcpDialer::new(client_stack, endpoint(7001), buf(), buf());
269272
let dialer_b = TcpDialer::new(client_stack, endpoint(7001), buf(), buf());
270273

271-
// Both pooled sockets accept on 7001 while both clients dial it; each
272-
// client establishes against a separate slot.
274+
// Drive the pooled sockets directly via the test-only `accept_on`, one
275+
// accept per index (its single-caller-per-index contract): both sockets
276+
// accept on 7001 while both clients dial it, each landing on its own slot.
273277
let (a_srv, b_srv, a_cli, b_cli) = futures::join!(
274278
listener.accept_on(0),
275279
listener.accept_on(1),
@@ -323,3 +327,42 @@ fn dialer_redials_after_failure_and_drop() {
323327
exchange(server.as_mut(), client.as_mut(), b"again").await;
324328
});
325329
}
330+
331+
/// A cancelled accept — its future dropped mid-`accept()`, as a `select!` timeout
332+
/// or shutdown branch would drop it — must return the pooled socket to its slot.
333+
/// Without the drop guard the socket is dropped instead of recycled, the slot
334+
/// stays empty, and the follow-up accept below would hang until the watchdog.
335+
#[test]
336+
fn cancelled_accept_recycles_socket() {
337+
use futures::future::{ready, select, Either};
338+
use futures::pin_mut;
339+
340+
drive(|server_stack, client_stack| async move {
341+
let mut listener = TcpListener::new(server_stack, 7005u16, buf(), buf());
342+
let dialer = TcpDialer::new(client_stack, endpoint(7005), buf(), buf());
343+
344+
// No client dials 7005, so this accept takes the pooled socket and then
345+
// parks in `TcpSocket::accept`. Cancel it by letting a ready future win a
346+
// `select`, dropping the accept future while its accept is still pending.
347+
{
348+
let accept = listener.accept();
349+
pin_mut!(accept);
350+
match select(accept, ready(())).await {
351+
Either::Right(_) => {} // `ready` won -> the accept future is cancelled
352+
Either::Left(_) => panic!("accept resolved with no client dialing"),
353+
}
354+
} // the parked accept future drops here, recycling its socket into the slot
355+
356+
// The recycled socket must be back in its slot: a real accept + dial now
357+
// succeeds. A leaked slot would hang this accept and trip the watchdog.
358+
let (accepted, connected) = futures::join!(listener.accept(), dialer.connect());
359+
let mut server = accepted.expect("accept after a cancelled accept (socket recycled)");
360+
let mut client = connected.expect("connect after a cancelled accept");
361+
exchange(server.as_mut(), client.as_mut(), b"post-cancel").await;
362+
});
363+
}
364+
365+
// Note: there is no shipped public multi-slot accept to misuse — the pooled path
366+
// is `TcpServer<N>` (one worker per slot) and the `accept_on` used above is a
367+
// test-only, single-caller-per-index hook. So the one-waiter-per-slot invariant
368+
// is upheld by construction and there is nothing to assert at runtime.

0 commit comments

Comments
 (0)