phase 20d: Server::new_with_handles + new_passive_with_handles#107
Conversation
Adds the no-alloc-friendly counterparts to `Server::new_with_deps` and `Server::new_passive_with_deps`. Caller supplies all four storage handles (`H` for sockets, `Hsd` for SD state, `Hep` for EventPublisher) pre-built; Server stores them directly without calling `factory.bind(...)` internally and without invoking any `Wrappable*Handle::wrap` step. This is the constructor path bare-metal-no-alloc consumers need: they own their UDP transport (lwIP, vendor IP stack, etc.), bind sockets externally, and wrap them via `StaticSocketHandle` / `&'static SdStateManager` / `&'static EventPublisher<...>` — materializing the static storage themselves at boot. Surface additions: - `pub struct ServerHandles<F, Tm, R, S, H, Hsd, Hep>` — bundle of factory + timer + e2e_registry + subscriptions + the four pre-built handles. Mirrors `ServerDeps` for the same caller ergonomics. - `Server::new_with_handles(deps, config) -> Result<Self, Error>` — constructs an active server (announcement loop runnable, run loop runnable). Back-fills `config.local_port` from `unicast_socket.local_addr()` so SD offers advertise the bound port. - `Server::new_passive_with_handles(deps, config) -> Result<Self, Error>` — same shape, marks `is_passive = true`. - Re-exported via `simple_someip::ServerHandles`. Both constructors live in the existing `impl@521` block whose bounds (`H: SocketHandle`, `Hsd: SdStateHandle`, `Hep: EventPublisherHandle` — all without `Wrappable*`) match what the no-alloc path requires. Both are synchronous (`pub fn`, not `pub async fn`) — no `factory.bind()` to await. Std users who prefer the async- ergonomic path keep using the existing `new_with_deps` / `new_passive_with_deps`. Combined with phases 20a-c, this completes the four-item "no-alloc Server completion" remediation surfaced by the 20-pre alloc audit: - 20a: `run_with_buffers` — caller-provided recv buffer (clears audit category-D recv-buffer item). - 20b: `SdStateHandle` — drops `Arc<SdStateManager>` (clears audit E.2). - 20c: `EventPublisherHandle` — drops `Arc<EventPublisher>` (clears audit E.1). - 20d: this commit — `new_with_handles` + `new_passive_with_handles` (clears audit category-D socket-Arc item by exposing the pre-built-handle path). A consumer building TC4D firmware with `default-features = false, features = ["client", "server", "bare_metal"]` and banning `extern crate alloc` can now construct a Server via `Server::new_with_handles(...)` using `&'static`-backed handles, drive it via `run_with_buffers(&mut [u8; N], &mut [u8; N])` over `static`-declared receive buffers, and emit SD via `announcement_loop_local`. Zero `alloc::*` surfaces in any production code path under that feature combo. What this leaves: an actual no-alloc bare-metal example / integration test against `simple-someip-embassy-net` (or a future `simple-someip-lwip` adapter) using these constructors. That's a separate "validation" commit — 20d ships the API; the witness comes when the lwip adapter exists. Gates green: - cargo fmt --check - cargo clippy --tests (2 pre-existing warnings, unrelated) - cargo build --workspace --all-targets - cargo build --no-default-features --features client,server,bare_metal - cargo build -p simple-someip-embassy-net --target thumbv7em-none-eabihf - cargo test --features client-tokio,server-tokio --test client_server -- --test-threads=1 (11/11) - cargo test --features client,server,bare_metal --test bare_metal_e2e (2/2) - cargo test -p simple-someip-embassy-net --test loopback (3/3) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a new constructor path for server::Server that accepts pre-built socket/SD/publisher/SD-state handles, enabling externally-managed transports (e.g., bare-metal stacks) to instantiate a Server without factory.bind(...) or Wrappable*Handle::wrap(...).
Changes:
- Introduces
ServerHandles<...>as a dependency bundle mirroringServerDeps, but including pre-bound socket/state/publisher handles. - Adds
Server::new_with_handles(...)andServer::new_passive_with_handles(...)synchronous constructors that store the supplied handles directly and back-fillconfig.local_portfromunicast_socket.local_addr(). - Re-exports
ServerHandlesat the crate root underfeature = "server".
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
src/server/mod.rs |
Adds ServerHandles and the new handle-based active/passive constructors. |
src/lib.rs |
Re-exports ServerHandles for public API access. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| mut config: ServerConfig, | ||
| ) -> Result<Self, Error> { | ||
| let bound_port = deps.unicast_socket.socket().local_addr()?.port(); | ||
| config.local_port = bound_port; |
There was a problem hiding this comment.
new_with_handles overwrites config.local_port unconditionally with the socket’s bound port. This differs from new_with_deps (which only back-fills when the caller passed local_port = 0) and can silently mask a misconfiguration where the caller thinks they bound a specific port but actually didn’t. Consider either (a) only overwriting when config.local_port == 0, or (b) returning Error::InvalidUsage(...) / logging a warning if config.local_port != 0 && config.local_port != bound_port.
| let bound_port = deps.unicast_socket.socket().local_addr()?.port(); | ||
| config.local_port = bound_port; | ||
| tracing::info!( | ||
| "Server (handles) bound to {}:{} for service 0x{:04X}", | ||
| config.interface, | ||
| bound_port, | ||
| config.service_id | ||
| ); |
There was a problem hiding this comment.
new_with_handles uses config.interface later when constructing IpV4Endpoint options (e.g. in send_unicast_offer), but here it only back-fills the port from unicast_socket.local_addr() and does not validate that the socket is actually bound to config.interface. If the caller bound to a different IP (or 0.0.0.0), the server may advertise the wrong endpoint address. Consider validating local_addr.ip() against config.interface (with an explicit exception if the socket is bound to INADDR_ANY) and returning Error::InvalidUsage(...) or at least emitting a warning on mismatch.
| Ok(Self { | ||
| config, | ||
| unicast_socket: deps.unicast_socket, | ||
| sd_socket: deps.sd_socket, |
There was a problem hiding this comment.
For an active server, the SD socket is expected to be bound to the SOME/IP-SD port (30490) so outgoing announcements and SubscribeAck/Nack responses have the correct source port. new_with_handles currently trusts the caller and never checks sd_socket.socket().local_addr()?.port() == sd::MULTICAST_PORT; if it’s wrong, SD traffic will be emitted from an unexpected port and many peers will ignore it. Consider validating the SD socket port here (and returning Error::InvalidUsage(...) on mismatch) to fail fast on misconfiguration.
| pub fn new_with_handles( | ||
| deps: ServerHandles<F, Tm, R, S, H, Hsd, Hep>, | ||
| mut config: ServerConfig, | ||
| ) -> Result<Self, Error> { |
There was a problem hiding this comment.
There are extensive constructor-focused tests in this file, but the new public constructor new_with_handles isn’t covered. Adding a unit test that builds a ServerHandles with a minimal TransportSocket stub (local_addr returning a known port) would help ensure config.local_port back-fill and the resulting is_passive flag behave as intended.
| mut config: ServerConfig, | ||
| ) -> Result<Self, Error> { | ||
| let bound_port = deps.unicast_socket.socket().local_addr()?.port(); | ||
| config.local_port = bound_port; |
There was a problem hiding this comment.
new_passive_with_handles overwrites config.local_port unconditionally with the socket’s bound port. For consistency with new_passive_with_deps (and to avoid masking mismatches), consider only back-filling when config.local_port == 0, or returning Error::InvalidUsage(...) / logging a warning if a non-zero config.local_port does not match the socket’s bound port.
| config.local_port = bound_port; | |
| if config.local_port == 0 { | |
| config.local_port = bound_port; | |
| } else if config.local_port != bound_port { | |
| return Err(Error::InvalidUsage(format!( | |
| "new_passive_with_handles local_port mismatch: config.local_port={} but unicast socket is bound to {}", | |
| config.local_port, bound_port | |
| ))); | |
| } |
| let bound_port = deps.unicast_socket.socket().local_addr()?.port(); | ||
| config.local_port = bound_port; | ||
| tracing::info!( | ||
| "Passive server (handles) bound to {}:{} for service 0x{:04X}", | ||
| config.interface, | ||
| bound_port, | ||
| config.service_id | ||
| ); |
There was a problem hiding this comment.
Same as new_with_handles: new_passive_with_handles relies on config.interface for SD endpoint construction elsewhere, but doesn’t validate it against unicast_socket.local_addr().ip(). A mismatch can lead to incorrect endpoint IP being advertised in OfferService responses. Consider validating (or warning) on interface mismatch, with an explicit exception for sockets bound to 0.0.0.0 if you want to allow INADDR_ANY binds.
Adds the no-alloc-friendly counterparts to
Server::new_with_depsandServer::new_passive_with_deps. Caller supplies all four storage handles (Hfor sockets,Hsdfor SD state,Hepfor EventPublisher) pre-built; Server stores them directly without callingfactory.bind(...)internally and without invoking anyWrappable*Handle::wrapstep.This is the constructor path bare-metal-no-alloc consumers need: they own their UDP transport (lwIP, vendor IP stack, etc.), bind sockets externally, and wrap them via
StaticSocketHandle/&'static SdStateManager/&'static EventPublisher<...>— materializing the static storage themselves at boot.Surface additions:
pub struct ServerHandles<F, Tm, R, S, H, Hsd, Hep>— bundle of factory + timer + e2e_registry + subscriptions + the four pre-built handles. MirrorsServerDepsfor the same caller ergonomics.Server::new_with_handles(deps, config) -> Result<Self, Error>— constructs an active server (announcement loop runnable, run loop runnable). Back-fillsconfig.local_portfromunicast_socket.local_addr()so SD offers advertise the bound port.Server::new_passive_with_handles(deps, config) -> Result<Self, Error>— same shape, marksis_passive = true.simple_someip::ServerHandles.Both constructors live in the existing
impl@521block whose bounds (H: SocketHandle,Hsd: SdStateHandle,Hep: EventPublisherHandle— all withoutWrappable*) match what the no-alloc path requires.Both are synchronous (
pub fn, notpub async fn) — nofactory.bind()to await. Std users who prefer the async- ergonomic path keep using the existingnew_with_deps/new_passive_with_deps.Combined with phases 20a-c, this completes the four-item "no-alloc Server completion" remediation surfaced by the 20-pre alloc audit:
run_with_buffers— caller-provided recv buffer (clears audit category-D recv-buffer item).SdStateHandle— dropsArc<SdStateManager>(clears audit E.2).EventPublisherHandle— dropsArc<EventPublisher>(clears audit E.1).new_with_handles+new_passive_with_handles(clears audit category-D socket-Arc item by exposing the pre-built-handle path).A consumer building TC4D firmware with
default-features = false, features = ["client", "server", "bare_metal"]and banningextern crate alloccan now construct a Server viaServer::new_with_handles(...)using&'static-backed handles, drive it viarun_with_buffers(&mut [u8; N], &mut [u8; N])overstatic-declared receive buffers, and emit SD viaannouncement_loop_local. Zeroalloc::*surfaces in any production code path under that feature combo.What this leaves: an actual no-alloc bare-metal example / integration test against
simple-someip-embassy-net(or a futuresimple-someip-lwipadapter) using these constructors. That's a separate "validation" commit — 20d ships the API; the witness comes when the lwip adapter exists.Gates green: