Skip to content

phase 12: socket-bound relax via TransportSocket GATs#88

Closed
JustinKovacich wants to merge 1 commit into
feature/phase11_channel_replacementfrom
feature/phase12_transport_socket_flexibility
Closed

phase 12: socket-bound relax via TransportSocket GATs#88
JustinKovacich wants to merge 1 commit into
feature/phase11_channel_replacementfrom
feature/phase12_transport_socket_flexibility

Conversation

@JustinKovacich

@JustinKovacich JustinKovacich commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

This is a chained PR. Prev: #87 Next: ??

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.

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 TransportSocket to use GATs (SendFuture / RecvFuture) instead of RPITIT impl Future returns for send_to/recv_from.
  • Implement zero-allocation, named Future types for TokioSocket using poll_send_to / poll_recv_from.
  • Generalize SocketManager bind paths and socket loop to any TransportSocket with HRTB Send bounds; add an end-to-end test proving non-TokioSocket Socket types 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.

Comment thread src/transport.rs
Comment on lines +359 to 362
/// 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

Copilot AI Apr 27, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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

Copilot uses AI. Check for mistakes.
@JustinKovacich

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants