phase 13.5: no-tokio Client construction#91
Conversation
Makes `Client` constructible without the `client-tokio` feature, by
generic-ifying `Inner` over `TransportFactory` + `Timer` and ungating
the client engine modules.
# Public API
New `pub struct ClientDeps<F, S, Tm, R, I>` bundles the five
pluggable infrastructure types (`TransportFactory`, `Spawner`,
`Timer`, `E2ERegistryHandle`, `InterfaceHandle`). New constructor:
Client::new_with_deps(deps, multicast_loopback)
-> (Self, ClientUpdates<_, C>, impl Future<...> + Send + 'static)
This is the no-tokio entry point — available under `feature = "client"`
alone (no `client-tokio` required). Bare-metal callers supply their own
impls of every dependency.
The `client-tokio` convenience constructor
`Client::new_with_spawner_and_loopback` now delegates to
`new_with_deps` after constructing a `ClientDeps` with `TokioTransport`
/ `TokioSpawner` / `TokioTimer` / `Arc<Mutex<E2ERegistry>>` /
`Arc<RwLock<Ipv4Addr>>` defaults — one source of truth for `Inner`
construction.
`ClientDeps` is re-exported at the crate root as `simple_someip::ClientDeps`.
# Inner refactor
`Inner` grows two generics:
pub(super) struct Inner<P, F, S, Tm, R, C>
where
F: TransportFactory + Send + Sync + 'static,
Tm: Timer + Send + Sync + 'static,
`Inner::bind_discovery` / `bind_unicast` now use `&self.factory`
instead of the previously-hardcoded `&TokioTransport`. The 125ms
idle-tick `sleep_fut` in `run_future` uses `&self.timer` instead of
`TokioTimer.sleep(...)`.
`Inner::build`'s argument list grew from 4 to 6 (factory + timer
added). Every test site that constructed an `Inner` directly was
updated; tests use a `TestInner` type alias to keep signatures
readable.
# Trait surface tightenings
`TransportFactory::bind` and `Timer::sleep` return types gained
`+ Send`:
fn bind(...) -> impl Future<Output = ...> + Send;
fn sleep(&self, duration: Duration) -> impl Future<Output = ()> + Send;
Required so the `Inner::run_future` future can be `Send + 'static`
(needed for `Spawner::spawn` on multithreaded executors). All
in-tree impls already satisfy these. **Breaking change** for any
downstream impl returning a non-`Send` future; pre-1.0, but flag.
The doctests in `transport.rs` (`Minimal adapter sketch`) were updated
to show the explicit `+ Send` so users following them as templates
land on a compatible shape.
# `EmbassySyncChannels` extracted
The bare-metal `ChannelFactory` impl previously lived in
`crate::tokio_transport::embassy_channels` (gated behind
`client-tokio` / `server`), making it unreachable on
`--features client,bare_metal`. Moved to `crate::embassy_channels`
(gated only by `feature = "bare_metal"`). `extern crate alloc;`
added when `bare_metal` is on, since `EmbassySyncChannels` uses
`Arc<Channel<...>>`.
The `embassy_channels` module docstring now flags the per-call
`Arc<Channel>` allocation (every `oneshot()` heap-allocates), which
violates the "zero heap after Client::new" goal. The fix is a
follow-on phase (`StaticChannels<const POOL_SIZE: usize>`); until
then, `EmbassySyncChannels` is useful for bringing up the trait
surface end-to-end on `std + alloc` targets and as a template for
consumers writing their own no-alloc impl.
# Other cleanups
- `client::Error::Io(std::io::Error)` removed — unused since phase
12 routed all transport errors through `TransportError::Io(IoErrorKind)`.
- `service_registry`: `std::collections::HashMap` → fixed-cap
`heapless::FnvIndexMap<_, _, 32>`. `ServiceRegistry::insert`
returns `Result<(), ServiceRegistryFull>`; `AddEndpoint` control
message now surfaces `Error::Capacity("service_registry")` when
the registry is full. SD-driven auto-populate logs a warning and
drops the offer.
- Misfiled `impl E2ERegistryHandle for Arc<Mutex<E2ERegistry>>` /
`impl InterfaceHandle for Arc<RwLock<Ipv4Addr>>` moved out of
`tokio_transport` (pure std, no tokio dep) into
`transport::std_handle_impls`, gated by `feature = "std"`. This
is what unblocks `--features client` from needing
`tokio_transport` at all.
# Tests / examples
New `tests/bare_metal_client.rs` (gated `required-features =
["client", "bare_metal"]`, no client-tokio, no server) constructs a
`Client` with `EmbassySyncChannels` + a hand-rolled `MockFactory`
/ `MockTimer` / `Spawner` and verifies the run-loop is `Send +
'static` (proven by `tokio::spawn`). Compile-witness is the
load-bearing assertion. Runtime graceful-shutdown is not tested
because `EmbassySyncChannels` doesn't surface "all senders
dropped"; that's a 13.6 concern.
`examples/bare_metal/main.rs` docstring updated to reflect phase
13.5 outcome.
`[dev-dependencies]` widened: tokio gains `macros` + `time`
features (for `#[tokio::test]` + `tokio::time::timeout`);
`critical-section = { features = ["std"] }` added so host tests
can link `EmbassySyncChannels`'s `embassy-sync` dependency. The
host critical-section impl is **not** the same as a firmware-target
impl; phase 16 stands up the TriCore-target verification.
# Test mod gating tightenings
When `inner` / `socket_manager` / `service_registry` / `session`
got ungated from `client-tokio` (so the engine compiles on
`--features client`), their test mods + test-only convenience
methods (`bind`, `bind_discovery_seeded`,
`force_sd_session_wrapped_for_test`, the
`ForceSdSessionWrappedForTest` enum variant) had to keep their
client-tokio gates because they reference tokio types directly.
All such gates flipped from `#[cfg(test)]` to
`#[cfg(all(test, feature = "client-tokio"))]`.
# Verification
- `cargo test --all-features -- --test-threads=1`: 457 lib + 1 +
1 (new bare_metal_client) + 11 + 9 doc, 0 failures.
- `cargo test --no-default-features --features "client,bare_metal"
--test bare_metal_client`: 1 passed.
- `cargo clippy --all-features --all-targets`: clean.
- `cargo clippy --no-default-features --features client
--all-targets`: clean.
- `cargo clippy --no-default-features --features client,bare_metal
--all-targets`: clean.
- Feature matrix '', 'std', 'bare_metal', 'client', 'client-tokio',
'client,server', 'client-tokio,server', 'client,bare_metal',
'client-tokio,server,bare_metal' all build clean.
- `cargo doc --all-features --no-deps`: clean.
- `cargo run --manifest-path examples/bare_metal/Cargo.toml`: runs
end-to-end.
# What this leaves for 13.6
Per-call heap allocations in `EmbassySyncChannels::oneshot()` /
`bounded()` / `unbounded()` violate "zero heap after Client::new
returns." The fix is a static-pool `ChannelFactory` impl, which
may require trait-shape adjustment to permit `&'static Sender` /
`&'static Receiver` ownership. That is its own phase.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR refactors the client engine so Client can be constructed without the client-tokio feature by making Inner generic over TransportFactory and Timer, introducing a ClientDeps dependency bundle, and extracting bare-metal channel support into a tokio-independent module.
Changes:
- Add
ClientDepsandClient::new_with_depsas the no-tokio client construction path; tokio convenience constructors delegate to it. - Tighten trait surfaces by requiring
Sendfutures fromTransportFactory::bindandTimer::sleep, enablingSend + 'staticclient run loops. - Extract
EmbassySyncChannelsintosrc/embassy_channels.rsand add abare_metal_clientintegration test and dev-dependency support.
Reviewed changes
Copilot reviewed 12 out of 13 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/bare_metal_client.rs | New integration test proving Client construction/run-future Send + 'static under client + bare_metal without client-tokio. |
| src/transport.rs | Require + Send for TransportFactory::bind and Timer::sleep; update docs and channel-factory commentary. |
| src/tokio_transport.rs | Remove embedded EmbassySyncChannels impl and replace with extraction note. |
| src/lib.rs | Add alloc gating for bare_metal, export embassy_channels, and re-export Client/ClientDeps under feature="client". |
| src/embassy_channels.rs | New embassy-sync-backed ChannelFactory implementation. |
| src/client/socket_manager.rs | Tighten test helper gating to #[cfg(all(test, feature="client-tokio"))]. |
| src/client/service_registry.rs | Replace HashMap with fixed-cap heapless::FnvIndexMap; add capacity error type and tests. |
| src/client/mod.rs | Introduce ClientDeps and Client::new_with_deps; route tokio constructors through dependency bundle. |
| src/client/inner.rs | Generic-ify Inner over TransportFactory + Timer; update binding/sleep paths; handle service registry capacity. |
| src/client/error.rs | Remove unused Error::Io(std::io::Error) variant. |
| examples/bare_metal/src/main.rs | Update phase documentation to reflect 13.5 and extracted embassy channels. |
| Cargo.toml | Add critical-section/std dev-dependency; expand tokio dev-dep features; add bare_metal_client test target. |
| Cargo.lock | Lockfile update for new dev-dependency (critical-section). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| //! gate below — i.e. NO tokio, NO socket2 pulled in via the crate | ||
| //! itself. The test still uses the host's tokio runtime as a generic | ||
| //! executor (tokio is a `dev-dependency`), but every type fed to | ||
| //! `simple-someip::Client::new_with_factory_spawner_timer_and_loopback` |
There was a problem hiding this comment.
The module-level doc comment references Client::new_with_factory_spawner_timer_and_loopback, but that constructor doesn't exist in the codebase (the new no-tokio entry point is Client::new_with_deps). Update this reference so the witness test documentation matches the public API.
| //! `simple-someip::Client::new_with_factory_spawner_timer_and_loopback` | |
| //! `simple-someip::Client::new_with_deps` |
| //! `client-tokio`) is the load-bearing assertion; the runtime | ||
| //! send/recv at the end is a sanity check that the wired-up generics | ||
| //! actually drive a working pipeline. |
There was a problem hiding this comment.
The doc comment says there's a “runtime send/recv at the end” sanity check, but the test only asserts client.interface() and then aborts the run-loop. Either add the described send/recv sanity check or adjust the wording to avoid overstating what the test verifies.
| //! `client-tokio`) is the load-bearing assertion; the runtime | |
| //! send/recv at the end is a sanity check that the wired-up generics | |
| //! actually drive a working pipeline. | |
| //! `client-tokio`) is the load-bearing assertion; the runtime portion | |
| //! is only a light sanity check that construction and basic wiring work | |
| //! far enough to obtain the client interface, not a full send/recv | |
| //! pipeline verification. |
| // No data: return Pending and wake immediately to keep | ||
| // the run-loop ticking. Real bare-metal impls park the | ||
| // task on an interrupt-driven waker. | ||
| cx.waker().wake_by_ref(); | ||
| Poll::Pending |
There was a problem hiding this comment.
MockRecvFut wakes the task on every poll when the inbound queue is empty. When used inside the client's run-loop, this can create a tight busy-loop and peg a CPU core during the test. Prefer returning Poll::Pending without waking (let the timer/control channel drive wakeups), or only wake when new inbound data is actually pushed.
| async fn sleep(&self, _duration: Duration) { | ||
| // The witness here is "the *crate* doesn't pull tokio under | ||
| // `--features client,bare_metal`," not "the test runs without | ||
| // tokio at all." The test runtime itself is `#[tokio::test]` | ||
| // (tokio is a `dev-dependency`), so using `tokio::task::yield_now` | ||
| // inside this mock is fine — it only proves the production | ||
| // crate's no-tokio path compiles. | ||
| tokio::task::yield_now().await; |
There was a problem hiding this comment.
MockTimer::sleep ignores the requested duration and yields immediately, which undershoots the Timer contract (“MUST NOT undershoot”) and can make the client run-loop spin. Consider implementing it with tokio::time::sleep(duration) (tokio is already a dev-dependency here) so the mock respects the trait semantics and avoids unnecessary CPU usage.
| async fn sleep(&self, _duration: Duration) { | |
| // The witness here is "the *crate* doesn't pull tokio under | |
| // `--features client,bare_metal`," not "the test runs without | |
| // tokio at all." The test runtime itself is `#[tokio::test]` | |
| // (tokio is a `dev-dependency`), so using `tokio::task::yield_now` | |
| // inside this mock is fine — it only proves the production | |
| // crate's no-tokio path compiles. | |
| tokio::task::yield_now().await; | |
| async fn sleep(&self, duration: Duration) { | |
| // The witness here is "the *crate* doesn't pull tokio under | |
| // `--features client,bare_metal`," not "the test runs without | |
| // tokio at all." The test runtime itself is `#[tokio::test]` | |
| // (tokio is a `dev-dependency`), so using tokio's timer here is | |
| // fine — it preserves the `Timer` contract while still only | |
| // exercising the production crate's no-tokio path at compile time. | |
| tokio::time::sleep(duration).await; |
| //! - Phase 13.5: `Client` is now constructible without | ||
| //! `client-tokio`. `Inner` carries `F: TransportFactory` and | ||
| //! `T: Timer` generics, and the new | ||
| //! `Client::new_with_factory_spawner_timer_and_loopback` | ||
| //! constructor takes everything explicitly. Witness: | ||
| //! `tests/bare_metal_client.rs` (gated on `client + bare_metal`). |
There was a problem hiding this comment.
This docstring references a Client::new_with_factory_spawner_timer_and_loopback constructor, but the client’s no-tokio entry point in this PR is Client::new_with_deps (and the other constructors are behind client-tokio). Update the example documentation to reflect the actual API name.
| //! Instead, depend on `simple-someip` with `default-features = false, | ||
| //! features = ["bare_metal"]` and consume the already-portable layers | ||
| //! directly: | ||
| //! Do NOT route through `Client::new_with_factory_spawner_timer_and_loopback` |
There was a problem hiding this comment.
The no_alloc recommendation section names Client::new_with_factory_spawner_timer_and_loopback, which doesn’t exist. Update this to the current constructor (Client::new_with_deps) or otherwise align it with the actual API to avoid confusing readers.
| //! Do NOT route through `Client::new_with_factory_spawner_timer_and_loopback` | |
| //! Do NOT route through `Client::new_with_deps` |
Makes
Clientconstructible without theclient-tokiofeature, by generic-ifyingInneroverTransportFactory+Timerand ungating the client engine modules.Public API
New
pub struct ClientDeps<F, S, Tm, R, I>bundles the five pluggable infrastructure types (TransportFactory,Spawner,Timer,E2ERegistryHandle,InterfaceHandle). New constructor:This is the no-tokio entry point — available under
feature = "client"alone (noclient-tokiorequired). Bare-metal callers supply their own impls of every dependency.The
client-tokioconvenience constructorClient::new_with_spawner_and_loopbacknow delegates tonew_with_depsafter constructing aClientDepswithTokioTransport/TokioSpawner/TokioTimer/Arc<Mutex<E2ERegistry>>/Arc<RwLock<Ipv4Addr>>defaults — one source of truth forInnerconstruction.ClientDepsis re-exported at the crate root assimple_someip::ClientDeps.Inner refactor
Innergrows two generics:Inner::bind_discovery/bind_unicastnow use&self.factoryinstead of the previously-hardcoded&TokioTransport. The 125ms idle-ticksleep_futinrun_futureuses&self.timerinstead ofTokioTimer.sleep(...).Inner::build's argument list grew from 4 to 6 (factory + timer added). Every test site that constructed anInnerdirectly was updated; tests use aTestInnertype alias to keep signatures readable.Trait surface tightenings
TransportFactory::bindandTimer::sleepreturn types gained+ Send:Required so the
Inner::run_futurefuture can beSend + 'static(needed forSpawner::spawnon multithreaded executors). All in-tree impls already satisfy these. Breaking change for any downstream impl returning a non-Sendfuture; pre-1.0, but flag.The doctests in
transport.rs(Minimal adapter sketch) were updated to show the explicit+ Sendso users following them as templates land on a compatible shape.EmbassySyncChannelsextractedThe bare-metal
ChannelFactoryimpl previously lived incrate::tokio_transport::embassy_channels(gated behindclient-tokio/server), making it unreachable on--features client,bare_metal. Moved tocrate::embassy_channels(gated only byfeature = "bare_metal").extern crate alloc;added whenbare_metalis on, sinceEmbassySyncChannelsusesArc<Channel<...>>.The
embassy_channelsmodule docstring now flags the per-callArc<Channel>allocation (everyoneshot()heap-allocates), which violates the "zero heap after Client::new" goal. The fix is a follow-on phase (StaticChannels<const POOL_SIZE: usize>); until then,EmbassySyncChannelsis useful for bringing up the trait surface end-to-end onstd + alloctargets and as a template for consumers writing their own no-alloc impl.Other cleanups
client::Error::Io(std::io::Error)removed — unused since phase 12 routed all transport errors throughTransportError::Io(IoErrorKind).service_registry:std::collections::HashMap→ fixed-capheapless::FnvIndexMap<_, _, 32>.ServiceRegistry::insertreturnsResult<(), ServiceRegistryFull>;AddEndpointcontrol message now surfacesError::Capacity("service_registry")when the registry is full. SD-driven auto-populate logs a warning and drops the offer.impl E2ERegistryHandle for Arc<Mutex<E2ERegistry>>/impl InterfaceHandle for Arc<RwLock<Ipv4Addr>>moved out oftokio_transport(pure std, no tokio dep) intotransport::std_handle_impls, gated byfeature = "std". This is what unblocks--features clientfrom needingtokio_transportat all.Tests / examples
New
tests/bare_metal_client.rs(gatedrequired-features = ["client", "bare_metal"], no client-tokio, no server) constructs aClientwithEmbassySyncChannels+ a hand-rolledMockFactory/MockTimer/Spawnerand verifies the run-loop isSend + 'static(proven bytokio::spawn). Compile-witness is the load-bearing assertion. Runtime graceful-shutdown is not tested becauseEmbassySyncChannelsdoesn't surface "all senders dropped"; that's a 13.6 concern.examples/bare_metal/main.rsdocstring updated to reflect phase 13.5 outcome.[dev-dependencies]widened: tokio gainsmacros+timefeatures (for#[tokio::test]+tokio::time::timeout);critical-section = { features = ["std"] }added so host tests can linkEmbassySyncChannels'sembassy-syncdependency. The host critical-section impl is not the same as a firmware-target impl; phase 16 stands up the TriCore-target verification.Test mod gating tightenings
When
inner/socket_manager/service_registry/sessiongot ungated fromclient-tokio(so the engine compiles on--features client), their test mods + test-only convenience methods (bind,bind_discovery_seeded,force_sd_session_wrapped_for_test, theForceSdSessionWrappedForTestenum variant) had to keep their client-tokio gates because they reference tokio types directly. All such gates flipped from#[cfg(test)]to#[cfg(all(test, feature = "client-tokio"))].Verification
cargo test --all-features -- --test-threads=1: 457 lib + 1 + 1 (new bare_metal_client) + 11 + 9 doc, 0 failures.cargo test --no-default-features --features "client,bare_metal" --test bare_metal_client: 1 passed.cargo clippy --all-features --all-targets: clean.cargo clippy --no-default-features --features client --all-targets: clean.cargo clippy --no-default-features --features client,bare_metal --all-targets: clean.cargo doc --all-features --no-deps: clean.cargo run --manifest-path examples/bare_metal/Cargo.toml: runs end-to-end.What this leaves for 13.6
Per-call heap allocations in
EmbassySyncChannels::oneshot()/bounded()/unbounded()violate "zero heap after Client::new returns." The fix is a static-poolChannelFactoryimpl, which may require trait-shape adjustment to permit&'static Sender/&'static Receiverownership. That is its own phase.