Phase 13.6 static channels#92
Conversation
Prep work for phase 13.6 (static-pool ChannelFactory). Fixes a
trait-shape bug uncovered during 13.6 design: `ChannelFactory::bounded`
declares `<const N: usize>` but the associated-type GAT didn't carry
N, so backends that need N at the storage level (`embassy-sync`)
silently hardcoded a single capacity (16) regardless of the call-site
request. The const-generic on the method was advisory only; the
storage shape never honored it.
# Trait change (breaking)
Before:
type BoundedSender<T: Send + 'static>: MpscSend<T>;
type BoundedReceiver<T: Send + 'static>: MpscRecv<T>;
fn bounded<T: Send + 'static, const N: usize>()
-> (Self::BoundedSender<T>, Self::BoundedReceiver<T>);
After:
type BoundedSender<T: Send + 'static, const N: usize>: MpscSend<T>;
type BoundedReceiver<T: Send + 'static, const N: usize>: MpscRecv<T>;
fn bounded<T: Send + 'static, const N: usize>()
-> (Self::BoundedSender<T, N>, Self::BoundedReceiver<T, N>);
# Backend impls
- `tokio_transport::TokioChannels`: passes N through; ignored at the
storage level (tokio mpsc stores capacity at runtime).
- `embassy_channels::EmbassySyncChannels`: now actually uses the
call-site N for the embassy `Channel<_, T, N>` storage. Previously
hardcoded to 16.
# Storage-site standardization
`SocketManager`'s bounded sender/receiver fields now spell out
`BoundedReceiver<_, 16>` / `BoundedSender<_, 16>` — both bind paths
(discovery + unicast) standardized to N=16. Unicast was historically
N=4, a tokio-conservative choice with no semantic requirement; bumping
to 16 matches what embassy already used and what discovery already
asked for.
`Inner`'s control channel stays at N=4 (it's a separate channel) —
its storage type is now `BoundedReceiver<_, 4>` / `BoundedSender<_, 4>`.
# Why this is its own commit
Phase 13.6's main work is the static-pool `ChannelFactory` impl
(`StaticChannels<NO, NB, NU>` with per-T monomorphization via a
`static_channels!` macro, atomic free-list reclamation, and close
semantics for graceful run-loop shutdown). The const-N fix is
genuinely independent of that work and benefits any future
ChannelFactory impl that cares about per-channel capacity. Landing
it separately keeps the 13.6-main commit focused on the static-pool
design.
# Verification
- `cargo test --all-features --lib`: 457 / 457 pass.
- `cargo clippy --all-features --all-targets`: clean.
- `tests/bare_metal_client` witness still passes.
# What this leaves for 13.6 (main)
The static-pool `ChannelFactory` itself: `StaticChannels<const NO,
const NB, const NU>` with per-T pool storage, atomic free-list,
poison-flag close semantics, and a `static_channels!` macro that
consumers invoke with their distinct payload types. Plus rewriting
the `bare_metal_client` witness to use StaticChannels (dropping the
`JoinHandle::abort()` workaround the EmbassySyncChannels impl forced).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reshape `ChannelFactory` so the three constructor methods (`oneshot`, `bounded`, `unbounded`) gain `where T: *Pooled<Self>` bounds and dispatch to that trait's pair-builder by default, instead of being direct constructors. Three new traits in `transport.rs`: - `OneshotPooled<C>: Send + Sized + 'static` - `BoundedPooled<C, const N: usize>: Send + Sized + 'static` - `UnboundedPooled<C>: Send + Sized + 'static` Each carries a `*_pair() -> (C::Sender<Self>, C::Receiver<Self>)` constructor. `TokioChannels` and `EmbassySyncChannels` publish blanket `impl<T: Send + 'static> *Pooled<Self> for T` (TokioChannels also blankets bounded over `const N`), so existing user code is unaffected. A static-pool `ChannelFactory` (phase 13.6c+) instead publishes per-`T` `*Pooled<Self>` impls — typically generated by a macro — each pointing at a declared `static` pool. Calling `C::oneshot::<NotDeclared>()` against such a backend fails at the call site with `OneshotPooled<MyChannels> is not implemented for NotDeclared`, turning "forgot to declare a pool" from a runtime panic into a compile error. What this leaves for 13.6c: - `src/static_channels/` module with pool primitives (slot, pool, free-list, send/recv handle types) and atomic ordering. - The `static_channels!` macro (13.6d) that generates per-`T` `*Pooled<MyChannels>` impls from a user pool-layout declaration. - The alloc-panicking witness test (13.6e). Bound propagation: - The 7-bound bundle (3 oneshot, 3 bounded, 1 unbounded) is repeated inline at each impl block that constructs channels through `C`: `ControlMessage<P, C>`, `Inner<P, F, S, Tm, R, C>`, `SendMessage<P, C>`, `SocketManager<MD, C>`, `Client<MD, R, I, C>`. A doc comment in `client::mod` explains why a single `C: ClientChannels<P>` trait alias does not work today (stable Rust does not elaborate where-clause bounds, and macros do not expand inside `where` clauses) and points at the implied-bounds RFC that would let it collapse. - `ControlMessage`, `SendMessage`, `ReceivedMessage` go from `pub(super)` to `pub` and are re-exported from `client` so the forthcoming `static_channels!` macro can name them when generating per-`T` `*Pooled<MyChannels>` impls. - `MessageDefinitions` gains a `Send` bound on every impl that bundles the channeled types — `Result<P, Error>: OneshotPooled<C>` requires `P: Send`. Already present on `Client`; added on `Inner` and the `SendMessage`/`ControlMessage` factory impls. Verification: - `cargo build` clean across `client-tokio`, `client+bare_metal+std`, `server`, all-features. - `cargo test --all-features -- --test-threads=1`: 479 tests pass (457 lib + 11 client_server + 1 bare_metal_client + 1 bare_metal workspace member + 9 doctests). - `cargo clippy --all-targets --all-features` clean. - `cargo fmt -- --check` clean. Collateral: `cargo fmt` swept up pre-existing baseline format drift in `examples/`, `src/lib.rs`, `src/server/`, and one inner-test match arm — included in this commit rather than split off. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`src/static_channels/mod.rs` (~600 LOC) introduces no-alloc pool
primitives that back the upcoming `static_channels!` macro
(phase 13.6d). The module is gated on `feature = "bare_metal"` and
sits beside `crate::embassy_channels` (which still heap-alloc's
`Arc<Channel<...>>` per call).
Three families:
- `OneshotSlot<T>` + `OneshotPool<T, POOL_SIZE>` →
`StaticOneshotSender<T>` / `StaticOneshotReceiver<T>`
implementing `OneshotSend<T>` / `OneshotRecv<T>`.
- `MpscSlot<T, SLOT_CAP>` + `MpscPool<T, POOL_SIZE, SLOT_CAP>` →
`StaticBoundedSender<T, SLOT_CAP>` /
`StaticBoundedReceiver<T, SLOT_CAP>` implementing
`MpscSend<T>` / `MpscRecv<T>`.
- The same `MpscPool::claim_unbounded` returns
`StaticUnboundedSender<T, SLOT_CAP>` /
`StaticUnboundedReceiver<T, SLOT_CAP>` implementing
`UnboundedSend<T>` / `UnboundedRecv<T>` (different trait
semantics, same slot machinery — `SLOT_CAP` is the effective
"unbounded" capacity).
Design notes baked into the doc comment:
- All slot/pool types have const `new()`, so a `static` array of
slots initializes in const context (`[const { Slot::new() }; N]`,
stable since 1.79).
- Free-list seeded lazily on the first `claim`. Operations are
serialized via `embassy_sync::blocking_mutex::Mutex<CSRawMutex,
Cell<usize>>`, sidestepping Treiber-stack ABA without giving up
no-alloc.
- Slot-index recovery on release uses pointer arithmetic against
the pool's `slots[0]` base — handles never carry the pool's
`POOL_SIZE` const-generic. Reclaim hook is a `&'static dyn
*Reclaim<T>` trait object on each handle (one vtable
indirection per drop), erasing both `POOL_SIZE` and `SLOT_CAP`
from the public sender/receiver types.
- Cancellation: oneshot sender drop sets a slot-level cancel bit
and wakes a per-slot `AtomicWaker`; receiver's `recv()` future
re-checks after registering both the cancel waker and the
channel's internal waker (registered via a transient
stack-pinned `chan.receive()` future, same trick the existing
`EmbassySyncBoundedReceiver::poll_recv` uses). MPSC mirrors
this: last-sender-drops sets `closed`, wakes the receiver,
and `recv()` resolves to `None`.
Pool exhaustion semantics: `claim*()` returns `Option`. The
forthcoming `*Pooled::*_pair` impls cannot signal exhaustion
through their return type (the `ChannelFactory` trait's three
constructor methods return un-fallible pairs), so the macro
will `expect()` on `claim` — pool exhaustion becomes a panic
documented as a configuration error.
Receiver-drop-while-bounded-sender-blocked-on-full-channel is an
accepted v1 limitation. Documented in the module docstring.
Tests (7, all passing): oneshot send/recv happy path,
sender-drop cancels receiver, claim/release cycles back to a
fresh pool, pool exhaustion returns `None`, bounded send/recv,
clone-then-drop-all closes the receiver, unbounded `send_now`
returns `Err(value)` when the slot's fixed capacity is full.
What this leaves for 13.6d:
- The `static_channels!` declarative macro that takes a
user-authored pool layout and emits per-`T`
`*Pooled<MyChannels>` impls dispatching to declared `static`
pools.
What this leaves for 13.6e:
- `tests/static_channels_witness.rs` — alloc-panicking
`#[global_allocator]` shim verifying zero heap allocation
after `Client::new` returns.
- `tests/bare_metal_client.rs` updated to use `StaticChannels`,
dropping the `JoinHandle::abort` workaround once the
end-of-life close semantics are exercised end-to-end.
Verification:
- `cargo build` clean: `bare_metal`, `client+bare_metal+std`,
`client-tokio`, `server`, all-features.
- `cargo test --all-features -- --test-threads=1`: 486 tests
pass (464 lib including the 7 new static_channels + 11
client_server + 1 bare_metal_client + 1 bare_metal example +
9 doctests).
- `cargo clippy --all-targets --all-features` clean.
- `cargo fmt -- --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Declarative macro that takes a user-authored pool layout and emits
the per-`T` `*Pooled<MyChannels>` impls + a `ChannelFactory` impl
on a unit struct. Lives in `src/static_channels/mod.rs` next to the
primitives, exported at crate root via `#[macro_export]`.
Macro grammar:
define_static_channels! {
name: MyChannels,
oneshot: [
(Result<(), MyError>, 80),
(RebootResponse, 4),
],
bounded: [
((ControlMessage<P, MyChannels>, 4), 1),
((SendMessage<P, MyChannels>, 16), 8),
],
unbounded: [
(ClientUpdate<P>, 1),
],
}
Each entry is a tuple. Bounded uses `((T, slot_cap), pool_size)`
to let the `:ty` matcher disambiguate the type from the literals.
Unbounded entries take `(T, pool_size)` only — every unbounded
slot gets `UNBOUNDED_DEFAULT_CAP = 128` (matching the existing
embassy-sync default), exposed as a public const.
Generated impls:
- One unit struct + `ChannelFactory` impl with associated types
pointing at this module's `StaticOneshotSender` /
`StaticBoundedSender<_, N>` / `StaticUnboundedSender<_, 128>`
(and matching receivers).
- One `OneshotPooled<$name> for T` impl per oneshot entry,
each wrapping a function-local `static OneshotPool<T,
POOL_SIZE>`. Function-scoped statics dodge name-collision
across types without a `paste!` dep.
- Same shape for `BoundedPooled<$name, SLOT_CAP> for T` and
`UnboundedPooled<$name> for T`.
- Pool exhaustion reaches the user as a panic with a stringified
type name and pool-size in the message.
Tests (3 added, 10 in static_channels total):
- `macro_oneshot_dispatches_through_factory` — `MyChannels::oneshot::<u32>()`
end-to-end through the `ChannelFactory` trait.
- `macro_bounded_dispatches_through_factory` — same for `bounded::<u8, 4>()`.
- `macro_unbounded_dispatches_through_factory` — same for
`unbounded::<u16>()`.
What this leaves for 13.6e:
- `tests/static_channels_witness.rs` — alloc-panicking
`#[global_allocator]` shim verifying zero heap allocation
after `Client::new` returns, declaring the client's
`MyChannels` via this macro.
- `tests/bare_metal_client.rs` updated to use a macro-declared
`StaticChannels`, dropping the `JoinHandle::abort` workaround.
Verification:
- `cargo build` clean across `bare_metal`, `client+bare_metal+std`,
`client-tokio`, `server`, all-features.
- `cargo test --all-features -- --test-threads=1`: 489 tests pass
(467 lib including the 10 static_channels + 11 client_server
+ 1 bare_metal_client + 1 bare_metal example + 9 doctests).
- `cargo clippy --all-targets --all-features` clean.
- `cargo fmt -- --check` clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…annels
Three changes:
1. **`tests/bare_metal_client.rs`** — switch from
`EmbassySyncChannels` (heap-alloc per call) to a
macro-declared `TestStaticChannels` via
`define_static_channels!`. The Client integration test now
exercises the static-pool channel path end-to-end. Pool sizes
are deliberately small (oneshot pool=8/4/4, bounded pool=1/4/4,
unbounded pool=1) — production firmware sizes pools to the
workload's high-water mark.
2. **`tests/static_channels_alloc_witness.rs`** (new) — counting
global allocator wired through `#[global_allocator]` plus two
`#[tokio::test]`s that assert specific operations do not
allocate after construction:
- `no_alloc_when_claiming_oneshot_through_static_pool`:
claim/send/release on a warmed-up pool allocates zero
times.
- `client_interface_read_after_construction_does_not_allocate`:
16 successive `client.interface()` calls allocate zero
times.
Tests serialize over a `MEASURE_LOCK` to keep parallel test
execution from interleaving allocations across measurement
regions.
This is a softer witness than the panicking-allocator harness
the design memo specifies. The full panic-on-alloc gate
requires (a) a no-alloc test executor (tokio's runtime
allocates), (b) a no-alloc `Spawner` impl for per-socket
loops, and (c) stack-based `E2ERegistryHandle` /
`InterfaceHandle` impls. Each of those is real work and lives
under phase 16's HighTec-target CI harness umbrella. The
counting witness here catches per-call channel-storage
regressions today; phase 16 catches everything else.
3. **`src/embassy_channels.rs` docstring** — point at
`crate::static_channels` and `define_static_channels!` as the
no-alloc bare-metal path; `EmbassySyncChannels` is now framed
as the on-ramp for `std + alloc` integration.
`Cargo.toml` declares the new test under `required-features =
["client", "bare_metal"]`, matching `bare_metal_client`.
Verification:
- `cargo build` clean across `bare_metal`, `client+bare_metal+std`,
`client-tokio`, `server`, all-features.
- `cargo test --all-features -- --test-threads=1`: 491 tests pass
(467 lib + 11 client_server + 1 bare_metal_client + 2 alloc
witness + 1 bare_metal example + 9 doctests).
- `cargo clippy --all-targets --all-features` clean.
- `cargo fmt -- --check` clean.
This concludes phase 13.6 — a 4-commit stack on
feature/phase13_6_static_channels:
- 13.6a: const-N quirk fix (already on branch).
- 13.6b: ChannelFactory per-T `*Pooled<C>` bounds.
- 13.6c: src/static_channels/ pool primitives.
- 13.6d: define_static_channels! macro.
- 13.6e: this commit — alloc-counting witness + integration.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ous wake - Add four missing tests: oneshot waker fires on send, oneshot cancel waker fires on sender drop, mpsc close waker fires when last sender drops, bounded-pool exhaustion returns None. - Remove spurious cancel_waker.wake() from OneshotSend::send Ok branch; embassy-sync's channel waker already wakes the receiver on value arrival, making the cancel_waker call redundant. - Add manual Debug impls for all ten public pool/handle types. - Add #[derive(Debug)] to the struct generated by define_static_channels!. - Accept optional vis: $vis:vis prefix in define_static_channels! via @Body delegation arm; callers without vis: default to pub. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces a static-pool, bare-metal–friendly ChannelFactory backend and updates the transport/channel abstraction so channel construction can be backed by per-type pools (enabling “no heap after Client::new” for channel storage).
Changes:
- Added
static_channelsmodule providingOneshotPool/MpscPoolprimitives plus thedefine_static_channels!macro for generating per-Tpooled channel wiring. - Reshaped
transport::ChannelFactoryto delegate constructors viaOneshotPooled/BoundedPooled/UnboundedPooledand made bounded channel associated types capacity-typed (const N). - Updated client internals, embassy/tokio backends, and added witness tests for bare-metal/static-pool integration and allocation behavior.
Reviewed changes
Copilot reviewed 16 out of 17 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
src/static_channels/mod.rs |
New static-pool channel primitives and define_static_channels! macro. |
src/transport.rs |
Updates ChannelFactory to use pooled traits + const-capacity bounded types; adds pooled trait definitions/docs. |
src/tokio_transport.rs |
Adapts TokioChannels to new pooled-trait + const-capacity Bounded* associated types. |
src/embassy_channels.rs |
Adapts EmbassySyncChannels to new pooled-trait + const-capacity Bounded* associated types. |
src/client/mod.rs |
Exposes message types for static pool declarations; adds required pooled bounds. |
src/client/inner.rs |
Makes ControlMessage public and adds pooled bounds required by new factory shape. |
src/client/socket_manager.rs |
Updates bounded channel types to ..., 16 and adds pooled bounds needed for construction. |
src/lib.rs |
Exposes static_channels module under bare_metal; minor re-export cleanup. |
tests/bare_metal_client.rs |
Switches witness test to macro-declared static channels backend. |
tests/static_channels_alloc_witness.rs |
New integration test using a counting allocator to witness “no allocations” for static-pool hot-path ops. |
Cargo.toml |
Registers new static_channels_alloc_witness integration test. |
src/server/subscription_manager.rs |
Formatting-only change. |
src/server/mod.rs |
Formatting-only test change. |
examples/discovery_client/src/main.rs |
Formatting-only change. |
examples/client_server/src/main.rs |
Formatting-only change. |
examples/bare_metal/src/main.rs |
Formatting-only change. |
.gitignore |
Updates ignored entries ordering/additions. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| impl<T: Send + 'static> OneshotSend<T> for StaticOneshotSender<T> { | ||
| fn send(mut self, value: T) -> Result<(), T> { | ||
| match self.slot.chan.try_send(value) { | ||
| Ok(()) => { | ||
| self.sent = true; | ||
| Ok(()) | ||
| } | ||
| Err(embassy_sync::channel::TrySendError::Full(v)) => Err(v), | ||
| } |
There was a problem hiding this comment.
StaticOneshotSender::send doesn’t detect the receiver-drop case: OneshotSend::send is documented to return Err(value) if the receiver was already dropped, but this implementation only errors on TrySendError::Full (which shouldn’t happen for a fresh oneshot slot). This can report Ok(()) while the value is immediately dropped on slot reclaim.
Consider checking the slot’s O_RECEIVER_ALIVE bit (and/or maintaining an explicit “receiver dropped” flag) before/after try_send and returning Err(value) when the receiver is gone, to match the transport::OneshotSend contract.
| impl<T: Send + 'static, const SLOT_CAP: usize> MpscSend<T> for StaticBoundedSender<T, SLOT_CAP> { | ||
| async fn send(&self, value: T) -> Result<(), ()> { | ||
| self.slot.chan.send(value).await; | ||
| Ok(()) | ||
| } |
There was a problem hiding this comment.
StaticBoundedSender::send always returns Ok(()) and awaits Channel::send unconditionally. This violates the MpscSend contract in transport (must return Err(()) if the receiver was dropped) and can also hang indefinitely if the receiver is gone and the channel becomes full.
Consider integrating the slot’s closed flag into the send path (and waking blocked senders on receiver drop), so send() can fail fast with Err(()) when the receiver is dropped instead of always succeeding / potentially deadlocking.
| impl<T: Send + 'static, const SLOT_CAP: usize> UnboundedSend<T> | ||
| for StaticUnboundedSender<T, SLOT_CAP> | ||
| { | ||
| fn send_now(&self, value: T) -> Result<(), T> { | ||
| self.slot.chan.try_send(value).map_err(|e| match e { | ||
| embassy_sync::channel::TrySendError::Full(v) => v, | ||
| }) | ||
| } |
There was a problem hiding this comment.
StaticUnboundedSender::send_now doesn’t handle the receiver-dropped case. UnboundedSend::send_now is documented to return Err(value) if the receiver was dropped, but this implementation only returns Err on TrySendError::Full.
Consider checking slot.closed (set in StaticUnboundedReceiver::drop) and returning Err(value) when closed, so callers get the same semantics as the Tokio backend and values aren’t silently accepted then dropped on reclaim.
| /// declare static channel pools for it via | ||
| /// `crate::transport::BoundedPooled<C, 4>`. End users typically do not | ||
| /// reference this type directly — the | ||
| /// `crate::static_channels::static_channels!` macro names it for them. |
There was a problem hiding this comment.
Doc comment references a crate::static_channels::static_channels! macro, but the macro introduced/used elsewhere in this PR is define_static_channels! (exported as crate::define_static_channels). This looks like a stale name and could mislead users trying to declare pools for ControlMessage.
| /// `crate::static_channels::static_channels!` macro names it for them. | |
| /// `crate::define_static_channels!` macro names it for them. |
| pub mod embassy_channels; | ||
| /// Static-pool no-alloc primitives for [`transport::ChannelFactory`]. | ||
| /// Backs the consumer-declared static `OneshotPool` / `MpscPool` | ||
| /// instances that the upcoming `static_channels!` macro (phase 13.6d) |
There was a problem hiding this comment.
The static_channels module doc mentions an “upcoming static_channels! macro”, but this PR exports define_static_channels! (via #[macro_export] in static_channels). If the macro has been renamed, updating this comment will prevent rustdoc users from searching for a non-existent macro name.
| /// instances that the upcoming `static_channels!` macro (phase 13.6d) | |
| /// instances that the `define_static_channels!` macro (phase 13.6d) |
| /// publish a blanket `impl<T: Send + 'static> OneshotPooled<Self> for T` | ||
| /// (and its bounded / unbounded peers), so existing user code does not | ||
| /// notice the change. A static-pool backend instead publishes per-`T` | ||
| /// impls (typically generated by a `static_channels!` macro) that wire |
There was a problem hiding this comment.
This docstring mentions per-T impls “typically generated by a static_channels! macro”, but this PR exports define_static_channels! as the macro name. Updating the documentation to the correct macro name will make the compile-error guidance actionable for users.
| /// impls (typically generated by a `static_channels!` macro) that wire | |
| /// impls (typically generated by a `define_static_channels!` macro) that wire |
| //! This module hands out `&'static` references into pre-allocated | ||
| //! `static` pools instead. The user declares pools (typically via | ||
| //! the `static_channels!` macro in phase 13.6d) sized to their | ||
| //! workload's high-water mark; once seeded, no further allocation | ||
| //! occurs. | ||
| //! | ||
| //! # Per-`T` `*Pooled<MyChannels>` impls | ||
| //! | ||
| //! Phase 13.6b reshaped `ChannelFactory` so each constructor method | ||
| //! requires `T: *Pooled<Self>`. Static-pool consumers publish per-`T` | ||
| //! impls that route to the appropriate pool. The | ||
| //! `static_channels!` macro generates them; the primitives in this | ||
| //! module are the runtime they call into. |
There was a problem hiding this comment.
The module docs refer to a static_channels! macro (lines 11–21), but the macro provided here is define_static_channels! (see later in this file). Renaming the references would help users find the right macro and avoid confusion when following the docs.
Introduce a static-pool ChannelFactory