Skip to content

phase 13.5: no-tokio Client construction#91

Draft
JustinKovacich wants to merge 1 commit into
feature/phase13_feature_flag_detanglefrom
feature/phase13_5_no_std_client
Draft

phase 13.5: no-tokio Client construction#91
JustinKovacich wants to merge 1 commit into
feature/phase13_feature_flag_detanglefrom
feature/phase13_5_no_std_client

Conversation

@JustinKovacich
Copy link
Copy Markdown
Contributor

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.

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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 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 ClientDeps and Client::new_with_deps as the no-tokio client construction path; tokio convenience constructors delegate to it.
  • Tighten trait surfaces by requiring Send futures from TransportFactory::bind and Timer::sleep, enabling Send + 'static client run loops.
  • Extract EmbassySyncChannels into src/embassy_channels.rs and add a bare_metal_client integration 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`
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
//! `simple-someip::Client::new_with_factory_spawner_timer_and_loopback`
//! `simple-someip::Client::new_with_deps`

Copilot uses AI. Check for mistakes.
Comment on lines +20 to +22
//! `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.
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
//! `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.

Copilot uses AI. Check for mistakes.
Comment on lines +121 to +125
// 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
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +179 to +186
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;
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

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

Copilot uses AI. Check for mistakes.
Comment on lines +65 to +70
//! - 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`).
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
//! 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`
Copy link

Copilot AI Apr 28, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
//! Do NOT route through `Client::new_with_factory_spawner_timer_and_loopback`
//! Do NOT route through `Client::new_with_deps`

Copilot uses AI. Check for mistakes.
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