phase 12: socket-bound relax via TransportSocket GATs#88
Conversation
Removes the `F::Socket = TokioSocket` pin from `bind_with_transport`
and `bind_discovery_seeded_with_transport`. Any `TransportFactory`
impl can now be plugged in regardless of its concrete `Socket` type,
which is the prerequisite the embassy-net adapter (and any future
bare-metal backend) needs from the trait surface.
The plan called for a GATs path because stable Rust still cannot
express `Send` bounds on RPITIT futures at use sites — RTN (RFC 3654)
remains nightly. `TransportSocket` therefore grows two GATs:
type SendFuture<'a>: Future<Output = Result<(), TransportError>>
where Self: 'a;
type RecvFuture<'a>: Future<Output = Result<ReceivedDatagram, TransportError>>
where Self: 'a;
Call sites that need to spawn a socket loop on a multithreaded
executor express the `Send` requirement via HRTBs:
F: TransportFactory,
F::Socket: Send + Sync + 'static,
for<'a> <F::Socket as TransportSocket>::SendFuture<'a>: Send,
for<'a> <F::Socket as TransportSocket>::RecvFuture<'a>: Send,
`socket_loop_future` is no longer hardcoded to `TokioSocket`; it now
takes `T: TransportSocket + Send + Sync + 'static` with the same HRTBs.
# TokioSocket: zero-allocation named futures
The natural translation of an `async fn` to a GAT is `BoxFuture`, but
that allocates a `Box` per datagram on the std/tokio path — a real
perf regression on the hot recv loop. Instead, `TokioSocket` defines
two named future structs that drive `tokio::net::UdpSocket`'s
`poll_send_to` / `poll_recv_from` directly:
pub struct SendTo<'a> { socket: &'a UdpSocket, buf: &'a [u8], target: SocketAddr }
pub struct RecvFrom<'a> { socket: &'a UdpSocket, buf: &'a mut [u8] }
Both are `Send` by auto-trait inference (all fields are `Send`), and
the futures own no heap state. This is the same pattern tokio uses
internally to back its own `async fn` socket methods.
# Bare-metal example
The mock socket in `examples/bare_metal/` gets matching named future
structs (`MockSendFut`, `MockRecvFut`) that defer their queue
operations to `poll`, so the example also models the deferred-
on-poll contract real bare-metal impls follow. Previously the queue
push happened eagerly during `send_to`, which was a contract drift
worth fixing in a file that exists specifically to be a model.
# Tests
Adds `bind_with_transport_accepts_non_tokio_socket_type`, which
defines a `WrappedSocket(TokioSocket)` newtype + `WrappingFactory`
whose `Socket = WrappedSocket`, then sends a SOME/IP-SD message
through `bind_with_transport` end-to-end. This is the witness for
the phase 12 acceptance gate ("any Socket type, no TokioSocket pin")
— without it, a future phase could regress the bound to a Tokio
pin without any test catching it. The two pre-existing
`bind_with_transport_*` tests both hardcode `Socket = TokioSocket`
and therefore only cover the previous pinned-bound shape.
Doctest in `transport.rs` updated to include the GAT types
(`BoxFuture` is used in the sketch for brevity, with a comment
pointing readers to `tokio_transport.rs` for the real zero-alloc
named-future pattern).
# What this unblocks
Phase 13 (feature-flag detangle: split \`client\` → \`client\` +
\`client-tokio\`, same for server) is the next prereq for compiling
the client with \`default-features = false\`. With phase 12 done, the
last type-system bound tying the client to tokio-shaped sockets is
gone; phase 13 is purely a Cargo features rearrangement.
Phase 12 acceptance gate met: \`bind_with_transport\` accepts
\`F: TransportFactory\` with any \`Socket\` type, no \`TokioSocket\`
pin. RTN was re-checked at phase start and remains nightly, so the
GATs path was the right call.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR evolves the transport abstraction to remove the TokioSocket type pin at bind_with_transport / bind_discovery_seeded_with_transport, enabling any TransportFactory/TransportSocket pair to be used (including upcoming bare-metal backends) while still allowing call sites to require Send for spawned socket loops on stable Rust.
Changes:
- Update
TransportSocketto use GATs (SendFuture/RecvFuture) instead of RPITITimpl Futurereturns forsend_to/recv_from. - Implement zero-allocation, named
Futuretypes forTokioSocketusingpoll_send_to/poll_recv_from. - Generalize
SocketManagerbind paths and socket loop to anyTransportSocketwith HRTBSendbounds; add an end-to-end test proving non-TokioSocketSockettypes are accepted; update the bare-metal mock to match poll-time side-effect semantics.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/transport.rs |
Introduces TransportSocket GATs and updates the public trait surface + doctest sketch accordingly. |
src/tokio_transport.rs |
Reworks TokioSocket send/recv to return named futures that drive Tokio’s poll-based UDP APIs without allocations. |
src/client/socket_manager.rs |
Removes F::Socket = TokioSocket pin by adding Send + HRTB bounds; generalizes socket loop and adds a regression test. |
examples/bare_metal/src/main.rs |
Updates the mock socket to implement the new GAT-based trait and defers send/recv side effects to poll-time. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// send/receive methods return associated future types so callers can | ||
| /// require `Send` bounds when spawning socket loops on multithreaded | ||
| /// executors. The smaller socket-level queries ([`Self::local_addr`], | ||
| /// [`Self::join_multicast_v4`], [`Self::leave_multicast_v4`]) are |
There was a problem hiding this comment.
TransportSocket docs were updated for the Phase 12 GAT-based futures, but the earlier module-level rationale in this file still states that socket methods “return impl Future” and that callers can’t name/constraint them; that is now outdated and conflicts with the new associated-type approach. Please update the earlier sections (and the Spawner docs that still mention a MockSpawner dropping futures—examples/bare_metal now uses a working spawner) so the documentation is internally consistent.
| /// send/receive methods return associated future types so callers can | |
| /// require `Send` bounds when spawning socket loops on multithreaded | |
| /// executors. The smaller socket-level queries ([`Self::local_addr`], | |
| /// [`Self::join_multicast_v4`], [`Self::leave_multicast_v4`]) are | |
| /// send/receive methods use named associated future types, which lets | |
| /// callers name those futures in trait bounds and require properties | |
| /// such as `Send` when spawning socket loops on multithreaded | |
| /// executors. The smaller socket-level queries ([`Self::local_addr`], | |
| /// [`Self::join_multicast_v4`], [`Self::leave_multicast_v4`]) remain |
|
Closing without merge to declutter the stack: this phase's changes are carried in full by the consolidated lineage under PR #114 (phase 21), which the next development stack builds on. Branch is retained. |
This is a chained PR. Prev: #87 Next: ??
Removes the
F::Socket = TokioSocketpin frombind_with_transportandbind_discovery_seeded_with_transport. AnyTransportFactoryimpl can now be plugged in regardless of its concreteSockettype, which is the prerequisite the embassy-net adapter (and any future bare-metal backend) needs from the trait surface.The plan called for a GATs path because stable Rust still cannot express
Sendbounds on RPITIT futures at use sites — RTN (RFC 3654) remains nightly.TransportSockettherefore grows two GATs:Call sites that need to spawn a socket loop on a multithreaded executor express the
Sendrequirement via HRTBs:socket_loop_futureis no longer hardcoded toTokioSocket; it now takesT: TransportSocket + Send + Sync + 'staticwith the same HRTBs.TokioSocket: zero-allocation named futures
The natural translation of an
async fnto a GAT isBoxFuture, but that allocates aBoxper datagram on the std/tokio path — a real perf regression on the hot recv loop. Instead,TokioSocketdefines two named future structs that drivetokio::net::UdpSocket'spoll_send_to/poll_recv_fromdirectly:Both are
Sendby auto-trait inference (all fields areSend), and the futures own no heap state. This is the same pattern tokio uses internally to back its ownasync fnsocket methods.Bare-metal example
The mock socket in
examples/bare_metal/gets matching named future structs (MockSendFut,MockRecvFut) that defer their queue operations topoll, so the example also models the deferred- on-poll contract real bare-metal impls follow. Previously the queue push happened eagerly duringsend_to, which was a contract drift worth fixing in a file that exists specifically to be a model.Tests
Adds
bind_with_transport_accepts_non_tokio_socket_type, which defines aWrappedSocket(TokioSocket)newtype +WrappingFactorywhoseSocket = WrappedSocket, then sends a SOME/IP-SD message throughbind_with_transportend-to-end. This is the witness for the phase 12 acceptance gate ("any Socket type, no TokioSocket pin") — without it, a future phase could regress the bound to a Tokio pin without any test catching it. The two pre-existingbind_with_transport_*tests both hardcodeSocket = TokioSocketand therefore only cover the previous pinned-bound shape.Doctest in
transport.rsupdated to include the GAT types (BoxFutureis used in the sketch for brevity, with a comment pointing readers totokio_transport.rsfor the real zero-alloc named-future pattern).What this unblocks
Phase 13 (feature-flag detangle: split `client` → `client` + `client-tokio`, same for server) is the next prereq for compiling the client with `default-features = false`. With phase 12 done, the last type-system bound tying the client to tokio-shaped sockets is gone; phase 13 is purely a Cargo features rearrangement.
Phase 12 acceptance gate met: `bind_with_transport` accepts `F: TransportFactory` with any `Socket` type, no `TokioSocket` pin. RTN was re-checked at phase start and remains nightly, so the GATs path was the right call.