Phase14 server trait surface#90
Conversation
There was a problem hiding this comment.
Pull request overview
This PR completes the “Phase14 server trait surface” split by making the server implementation generic over transport/timer/subscription/E2E handles so that a server can be built without pulling in tokio/socket2 (those are now confined to the server-tokio feature).
Changes:
- Split server feature flags into
server(trait-surface) andserver-tokio(tokio + socket2 convenience constructors/defaults). - Generalize
Server,EventPublisher, and SD send paths overTransportFactory/TransportSocketandTimer; addServerDepsand*_with_depsconstructors. - Add a compile-witness test (
bare_metal_server) provingServeris constructible withoutserver-tokio, and update existing tests/examples accordingly.
Reviewed changes
Copilot reviewed 14 out of 15 changed files in this pull request and generated 13 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/client_server.rs | Adjusts test helpers to use explicit tokio-flavored server/publisher aliases. |
| tests/bare_metal_server.rs | New witness test exercising server construction via trait-surface deps (no server-tokio). |
| src/tokio_transport.rs | Tightens feature gating and adjusts multicast loop option application; updates related tests. |
| src/server/subscription_manager.rs | Gates tokio-backed SubscriptionHandle impl behind server-tokio. |
| src/server/service_info.rs | Adds #[must_use] on Subscriber::new. |
| src/server/sd_state.rs | Makes SD offer sending generic over TransportSocket; gates tests behind server-tokio. |
| src/server/mod.rs | Introduces ServerDeps, makes Server generic over transport/timer, adds *_with_deps constructors, updates run loop to transport trait types, updates tests. |
| src/server/event_publisher.rs | Makes publisher generic over TransportSocket; gates tests behind server-tokio and updates test bindings. |
| src/server/error.rs | Adds Error::Transport for transport-layer failures. |
| src/lib.rs | Updates documentation and exports for server/server-tokio split; adjusts tokio backend gating. |
| src/client/socket_manager.rs | Updates docs to reflect Phase 14b status. |
| examples/client_server/Cargo.toml | Switches example to depend on server-tokio instead of server. |
| examples/bare_metal/src/main.rs | Updates docs to reflect Phase 14a/14b completion and new witness test. |
| Cargo.toml | Implements server/server-tokio feature split; updates required-features; adds bare_metal_server test target. |
| .gitignore | Adds .claude/ / CLAUDE.md patterns and reorders entries. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| let server_handle = tokio::spawn(async move { | ||
| let mut buf = vec![0u8; 65535]; | ||
| let (len, addr) = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | ||
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); |
There was a problem hiding this comment.
Same formatting issue as above: multiple let statements are chained on a single line, reducing readability. Please run rustfmt or split these into separate lines.
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); | |
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | |
| let len = datagram.bytes_received; | |
| let addr = std::net::SocketAddr::V4(datagram.source); |
| let server_handle = tokio::spawn(async move { | ||
| let mut buf = vec![0u8; 65535]; | ||
| let (len, addr) = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | ||
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); |
There was a problem hiding this comment.
Same formatting issue as above: multiple let statements are chained on a single line, reducing readability. Please run rustfmt or split these into separate lines.
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); | |
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | |
| let len = datagram.bytes_received; | |
| let addr = std::net::SocketAddr::V4(datagram.source); |
| let server_handle = tokio::spawn(async move { | ||
| let mut buf = vec![0u8; 65535]; | ||
| let (len, addr) = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | ||
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); |
There was a problem hiding this comment.
Same formatting issue as above: multiple let statements are chained on a single line, reducing readability. Please run rustfmt or split these into separate lines.
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); | |
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | |
| let len = datagram.bytes_received; | |
| let addr = std::net::SocketAddr::V4(datagram.source); |
| let server_handle = tokio::spawn(async move { | ||
| let mut buf = vec![0u8; 65535]; | ||
| let (len, addr) = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | ||
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); |
There was a problem hiding this comment.
Same formatting issue as above: multiple let statements are chained on a single line, reducing readability. Please run rustfmt or split these into separate lines.
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); | |
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | |
| let len = datagram.bytes_received; | |
| let addr = std::net::SocketAddr::V4(datagram.source); |
| Err(_) => Err(std::io::Error::other( | ||
| "transport: failed to read local_addr", | ||
| )), |
There was a problem hiding this comment.
unicast_local_addr discards the underlying TransportError and always returns a generic std::io::Error::other(...). This makes debugging harder and drops the original error kind (e.g., Unsupported vs AddressInUse). Consider either (a) returning Result<SocketAddrV4, TransportError> here, or (b) mapping TransportError into an io::Error with a meaningful ErrorKind and message (preserving details).
| Err(_) => Err(std::io::Error::other( | |
| "transport: failed to read local_addr", | |
| )), | |
| Err(err) => Err(std::io::Error::other(err)), |
| let server_handle = tokio::spawn(async move { | ||
| let mut buf = vec![0u8; 65535]; | ||
| let (len, addr) = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | ||
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); |
There was a problem hiding this comment.
Same formatting issue as above: multiple let statements are chained on a single line, reducing readability. Please run rustfmt or split these into separate lines.
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); let len = datagram.bytes_received; let addr = std::net::SocketAddr::V4(datagram.source); | |
| let datagram = server.unicast_socket.recv_from(&mut buf).await.unwrap(); | |
| let len = datagram.bytes_received; | |
| let addr = std::net::SocketAddr::V4(datagram.source); |
| // Only set the multicast-loop flag when the caller is doing | ||
| // multicast (i.e. they configured a multicast interface). Calling | ||
| // `set_multicast_loop_v4` on a plain-unicast socket on some | ||
| // backends can return EOPNOTSUPP / EINVAL; even on Linux where it | ||
| // succeeds, it's a meaningless syscall. Mirrors the behavior of | ||
| // the `client::SocketManager` discovery-bind path. | ||
| if options.multicast_if_v4.is_some() { |
There was a problem hiding this comment.
SocketOptions::multicast_if_v4 is documented as optional ("None lets the backend choose"), but this code only applies multicast_loop_v4 when multicast_if_v4 is Some. That means a caller who wants multicast loopback behavior while letting the backend choose the interface (i.e. multicast_if_v4 = None) will silently get the OS default instead of the requested loop setting. Consider applying set_multicast_loop_v4 whenever the caller is configuring multicast loopback (or adjusting SocketOptions to make the loop flag tri-state so "unspecified" can avoid the syscall on pure-unicast binds).
| // Only set the multicast-loop flag when the caller is doing | |
| // multicast (i.e. they configured a multicast interface). Calling | |
| // `set_multicast_loop_v4` on a plain-unicast socket on some | |
| // backends can return EOPNOTSUPP / EINVAL; even on Linux where it | |
| // succeeds, it's a meaningless syscall. Mirrors the behavior of | |
| // the `client::SocketManager` discovery-bind path. | |
| if options.multicast_if_v4.is_some() { | |
| // Apply the multicast-loop flag independently from interface | |
| // selection. `multicast_if_v4 = None` means "let the backend choose | |
| // the interface", not "ignore multicast loopback configuration". | |
| // | |
| // Keep avoiding a needless multicast-specific syscall for the | |
| // common default case on pure-unicast binds, but still honor an | |
| // explicit loopback change even when the interface is left | |
| // unspecified. | |
| if options.multicast_if_v4.is_some() || !options.multicast_loop_v4 { |
| // Bind unicast socket for receiving subscriptions. | ||
| let unicast_addr = SocketAddrV4::new(config.interface, config.local_port); | ||
| let unicast_socket = Arc::new(UdpSocket::bind(unicast_addr).await?); | ||
| let unicast_socket = Arc::new(factory.bind(unicast_addr, &SocketOptions::new()).await?); | ||
| tracing::info!( | ||
| "Server bound to {} for service 0x{:04X}", | ||
| unicast_addr, | ||
| config.service_id | ||
| ); |
There was a problem hiding this comment.
When config.local_port is 0 (ephemeral), the socket bind will pick a real port but config.local_port is stored unchanged. SD offers (and other places that use self.config.local_port) will then advertise port 0 unless the caller manually calls set_local_port. Consider reading unicast_socket.local_addr()?.port() after bind and updating the stored config before constructing the publisher / returning the Server.
| // Bind unicast socket at the configured local_port. | ||
| let unicast_addr = SocketAddrV4::new(config.interface, config.local_port); | ||
| let unicast_socket = Arc::new(UdpSocket::bind(unicast_addr).await?); | ||
| let unicast_socket = Arc::new(factory.bind(unicast_addr, &SocketOptions::new()).await?); | ||
| tracing::info!( | ||
| "Passive server bound to {} for service 0x{:04X}", | ||
| unicast_addr, | ||
| config.service_id | ||
| ); |
There was a problem hiding this comment.
Same issue as the non-passive constructor: if config.local_port is 0, the unicast socket will bind to an ephemeral port but config.local_port remains 0. That can cause the passive server to publish events with source/advertised port 0 unless callers manually patch it via set_local_port. Consider updating the stored config from unicast_socket.local_addr() after binding.
| pin_mut!(unicast_fut, sd_fut); | ||
| select! { | ||
| result = unicast_fut => { | ||
| let (len, addr) = result?; | ||
| (len, addr, "unicast", true) | ||
| let datagram = result?; | ||
| ( |
There was a problem hiding this comment.
The surrounding SAFETY comment (just above this select!) still references tokio::net::UdpSocket::recv_from cancel-safety. Since this code now selects over TransportSocket::recv_from futures, that guarantee is transport-dependent. Please update the comment (or encode the invariant in the TransportSocket contract) so it doesn't rely on tokio-specific behavior.
Splits the `server` Cargo feature so the strategic-goal feature combo
`features = ["bare_metal", "client", "server"]` builds without tokio.
Phase 14b will retarget the server engine to the trait surface and
expose a working server under the bare `server` feature; this commit
is purely the topology change.
# Cargo features
Before:
server = ["std", "dep:tokio", "dep:socket2", "dep:futures"]
After:
server = ["std"] # topology marker
server-tokio = ["server", "dep:tokio", "dep:socket2", "dep:futures"]
# Module gates flipped
- `pub mod server;` feature = "server" → "server-tokio"
- `pub use server::Server;` ditto
- `pub use server::SubscriptionHandle;` ditto
- `tokio_transport` mod gate `client-tokio or server` → `client-tokio or server-tokio`
# Tests / examples
- `[[test]] client_server` requires `["client-tokio", "server-tokio"]`
- `examples/client_server` uses `["client-tokio", "server-tokio"]`
- `examples/bare_metal/main.rs` status note + lib.rs feature-flag table updated
# Verification
- All 12 feature-matrix combos build clean, including the strategic
combo `client,server,bare_metal`.
- 457 lib + 11 + 1 + 1 + 9 doc tests pass with --all-features.
- clippy clean with --all-features --all-targets.
- bare_metal example runs end-to-end; bare_metal_client witness test passes.
# What this leaves for 14b
The bare `server` feature compiles to nothing useful today — every
production code path in src/server/* still uses tokio internals.
Phase 14b mirrors phase 13.5 on the server: introduces
`ServerDeps<F, Tm, R, S>`, makes `Server<R, S, F, Tm>` and
`EventPublisher<R, S, T>` generic over the transport+timer, replaces
the hand-rolled `socket2::Socket` SD bind with `factory.bind()`, and
ungates the engine from `server-tokio`. Estimate per the phase 14
scoping report: ~1.5-2 ew.
Per phase 13.5 lessons doc finding #5: introduce a `TestServer`
type alias before any default-type-param drops in 14b.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three doc-text references still named the pre-split feature gates or the wrong phase number: - src/tokio_transport.rs:9 / :16 — "feature = client" / "server" → "client-tokio" / "server-tokio". The actual cfg attribute on the module declaration was already correct; this is the surrounding prose + an inline doctest cfg gate that mismatched the gate it was describing. - src/client/socket_manager.rs:43 — "deferred to Phase 14" → updated to reflect the 14a/14b split (14a topology landed in b7fc30f; the substantive engine-retargeting work is 14b). No code or feature behavior changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors phase 13.5 on the server side. `Server` is now generic over
`<R, S, F, Tm>`; the bare `server` feature exposes a working
trait-surface server reachable via `Server::new_with_deps`, and
`server-tokio` provides the `TokioTransport` / `TokioTimer` /
`Arc<Mutex<E2ERegistry>>` / `Arc<RwLock<SubscriptionManager>>`
convenience defaults.
# Public API
New `pub struct ServerDeps<F, Tm, R, S>` bundle (4 fields: factory,
timer, e2e_registry, subscriptions). Mirrors `ClientDeps`. No
`Spawner` (server has no internal task spawning), no `InterfaceHandle`
(interface lives in `ServerConfig`).
New constructors under just `feature = "server"`:
- `Server::new_with_deps(deps, config, multicast_loopback)` —
binds unicast + SD multicast via `factory.bind(...)`.
- `Server::new_passive_with_deps(deps, config)` — binds unicast +
ephemeral SD placeholder for external-SD-dispatcher integration.
Tokio convenience constructors (`Server::new`, `new_with_loopback`,
`new_passive`) are gated `server-tokio` and now delegate to
`new_with_deps` / `new_passive_with_deps` after constructing a
`ServerDeps` with tokio defaults.
`ServerDeps` re-exported from the crate root as
`simple_someip::ServerDeps`. `Subscriber` newly re-exported from
`simple_someip::server` (it's the return type of
`SubscriptionHandle::get_subscribers`; was implicitly part of the
public trait surface but not nameable).
# Engine refactor
`Server<R, S, F, Tm>` stores:
- `unicast_socket: Arc<F::Socket>`, `sd_socket: Arc<F::Socket>` —
was `Arc<UdpSocket>`.
- `publisher: Arc<EventPublisher<R, S, F::Socket>>` — `EventPublisher`
is now `<R, S, T>` generic over its socket type.
- `factory: F`, `timer: Tm` — both stored to support bare-metal
factories carrying state and the announcement-loop's 1-second tick.
`announcement_loop` replaced `TokioTimer.sleep(...)` with
`self.timer.sleep(...)`. `sd_state::send_offer_service` now generic
over `T: TransportSocket`.
`subscription_manager`: `impl SubscriptionHandle for Arc<RwLock<SubscriptionManager>>`
and the `tokio::sync::RwLock` import gated to `server-tokio`.
# Cargo features
Before:
server = ["std"] # topology marker
server-tokio = ["server", "dep:tokio", "dep:socket2", "dep:futures"]
After:
server = ["std", "dep:futures"] # working trait-surface server
server-tokio = ["server", "dep:tokio", "dep:socket2"] # tokio convenience defaults
`futures` moves to `server` because the engine uses `futures::select!`.
`tokio` and `socket2` stay only on the `server-tokio` flavor.
# Bind path consolidation
The hand-rolled `socket2::Socket::new(...)` SD-multicast bind in
`Server::new_with_loopback` is gone. `new_with_deps` calls
`factory.bind(sd_addr, &SocketOptions { reuse_address, reuse_port,
multicast_if_v4: Some(interface), multicast_loop_v4 })` which routes
through `TokioTransport::bind`'s already-existing socket2 path. No
behavior change on the tokio side; bare-metal callers control the
bind path entirely.
# Tests
- `tests/bare_metal_server.rs` (new): witness test gated on
`["server", "bare_metal"]`. Builds `MockFactory` + `MockSocket` +
`MockTimer` + `MockSubscriptions` (a hand-rolled `SubscriptionHandle`
impl backed by `std::sync::Mutex<Vec<...>>`) and proves
`Server::new_with_deps` + `new_passive_with_deps` succeed and
return a `Server` whose announcement-loop future is `Send + 'static`.
Compile witness is the load-bearing assertion.
- `tests/client_server.rs`: `TestServer` / `TestEventPublisher`
type aliases introduced (per phase 13.5 lessons #5) so existing
callers don't churn over the new generic params.
- Server's internal `#[cfg(test)] mod tests` blocks tightened to
`#[cfg(all(test, feature = "server-tokio"))]` since they use
`tokio::test` / `tokio::net::UdpSocket` (per lesson #7).
# `tokio_transport::bind_with_options` bug fix folded in
`set_multicast_loop_v4` was called unconditionally regardless of
whether the caller configured a multicast interface — this can fail
on backends that error on the call for plain-unicast sockets. Now
only called when `multicast_if_v4` is `Some`. Surfaced by the new
SD-bind path; mirrors the same conditional in the client's
discovery-bind path.
# Verification
- `cargo test --all-features -- --test-threads=1`: 457 lib + 1 + 1
+ 2 (new bare_metal_server witness) + 11 + 9 doc. 0 failures.
- `cargo clippy --all-features --all-targets`: clean.
- Feature matrix `''`, `client,server`, `client,server,bare_metal`,
`server`, `client-tokio,server-tokio` all build clean.
- `bare_metal_client` witness still passes.
# What this leaves for follow-on phases
- Phase 13.6 (static-pool ChannelFactory): unaffected by 14b but
still pending. The const-N quirk fix landed separately on the
13.6 branch.
- Phase 16 (no-alloc CI): `Server::new_with_deps` still uses
`Arc<EventPublisher>` and `Arc<F::Socket>` internally, so a strict
no_alloc build does not yet pass. Phase 13.6 (static channels) +
follow-on `Arc` elimination will close this.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7453b75 to
22a1737
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// Convert a [`std::net::SocketAddr`] into a [`SocketAddrV4`] for the | ||
| /// transport layer. SOME/IP-SD is IPv4-only at this layer; if a V6 | ||
| /// address ever surfaces here it indicates a misconfiguration upstream | ||
| /// (a V6 socket binding the SD port, or a V6 source address surfaced | ||
| /// by a transport that should not produce one). Returns | ||
| /// [`std::io::ErrorKind::Unsupported`] in that case so the caller can | ||
| /// log and drop the message instead of panicking. | ||
| fn socket_addr_v4(addr: std::net::SocketAddr) -> Result<SocketAddrV4, Error> { | ||
| match addr { | ||
| std::net::SocketAddr::V4(v4) => Ok(v4), | ||
| std::net::SocketAddr::V6(_) => Err(Error::Io(std::io::Error::new( | ||
| std::io::ErrorKind::Unsupported, | ||
| "IPv6 SD address is not supported", | ||
| ))), | ||
| } |
There was a problem hiding this comment.
socket_addr_v4 treats an IPv6 address as Error::Io(std::io::ErrorKind::Unsupported). Since this is an unsupported transport-layer capability (IPv4-only surface), it would be more consistent to return Error::Transport(TransportError::Unsupported) (or otherwise surface it via the transport error path) so callers can match/report it uniformly with other transport limitations.
| //! - **Working server without tokio** (Phase 14b): the bare `server` | ||
| //! feature is currently a topology marker only (Phase 14a, commit | ||
| //! `b7fc30f`). The actual server engine still requires | ||
| //! `server-tokio` because `server::sd_state` / | ||
| //! `server::subscription_manager` reference tokio types directly. | ||
| //! Phase 14b retargets the engine to the trait surface (mirroring | ||
| //! phase 13.5 on the client) so a working server lives under just | ||
| //! `server`. |
There was a problem hiding this comment.
The “Remaining gaps” bullet still states that the bare server feature is only a topology marker and that a working server still requires server-tokio. In this PR the server engine has been retargeted to the transport/timer/subscription trait surface and server is now functional without tokio, so this doc comment is now inaccurate. Update or remove this bullet to reflect the current feature split/state (e.g. describe server as trait-surface and server-tokio as convenience defaults).
|
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. |
Make the same changes that split off the client traits to the server. A server can be produced without tokio or socket2 dependencies.