Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ required-features = ["client", "bare_metal"]
name = "static_channels_alloc_witness"
required-features = ["client", "bare_metal"]

[[test]]
name = "no_alloc_witness"
required-features = ["client", "bare_metal"]
harness = false

[[test]]
name = "bare_metal_server"
required-features = ["server", "bare_metal"]
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -209,3 +209,5 @@ pub use transport::{
OneshotCancelled, OneshotRecv, OneshotSend, ReceivedDatagram, SocketOptions, Spawner, Timer,
TransportError, TransportFactory, TransportSocket, UnboundedRecv, UnboundedSend,
};
#[cfg(feature = "bare_metal")]

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

These re-exports are gated only on feature = "bare_metal", but the newly added handle implementations currently rely on std-only E2ERegistry. If the implementation is updated to cfg(all(feature = "bare_metal", feature = "std")) (or otherwise made std-free), this pub use should match to avoid build failures under --no-default-features --features bare_metal.

Suggested change
#[cfg(feature = "bare_metal")]
#[cfg(all(feature = "bare_metal", feature = "std"))]

Copilot uses AI. Check for mistakes.
pub use transport::{AtomicInterfaceHandle, StaticE2EHandle, StaticE2EStorage};
134 changes: 134 additions & 0 deletions src/transport.rs
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,140 @@ mod std_handle_impls {
}
}

/// Bare-metal no-alloc impls of [`E2ERegistryHandle`] and [`InterfaceHandle`].
///
/// These types satisfy `Clone + Send + Sync + 'static` without any heap
/// allocation. The backing storage lives in a caller-owned `static`; the
/// handles are thin `&'static` pointers that are trivially `Copy`.
///
/// # Production pattern
///
/// ```ignore
/// use core::cell::RefCell;
/// use core::sync::atomic::{AtomicU32, Ordering};
/// use embassy_sync::blocking_mutex::Mutex;
/// use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;
/// use simple_someip::e2e::E2ERegistry;
/// use simple_someip::transport::{StaticE2EHandle, AtomicInterfaceHandle};
///
/// // Initialize once in main() before spawning tasks.
/// fn init() -> (StaticE2EHandle, AtomicInterfaceHandle) {
/// static IFACE_ADDR: AtomicU32 = AtomicU32::new(0);
/// // E2ERegistry::new() is not const so the storage is heap-placed once.
/// let registry_storage: &'static _ = Box::leak(Box::new(
/// Mutex::<CriticalSectionRawMutex, RefCell<E2ERegistry>>::new(
/// RefCell::new(E2ERegistry::new()),
/// ),
/// ));
/// (StaticE2EHandle::new(registry_storage), AtomicInterfaceHandle::new(&IFACE_ADDR))
/// }
/// ```
#[cfg(feature = "bare_metal")]

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

bare_metal_handle_impls depends on crate::e2e::E2ERegistry, but E2ERegistry is only exported when feature = "std" (see src/e2e/mod.rs). With --no-default-features --features bare_metal, this module will fail to compile even if these handles aren’t used. Consider gating this module (and its re-exports) behind cfg(all(feature = "bare_metal", feature = "std")), or redesigning the storage alias/handle to not require the std-only E2ERegistry type.

Suggested change
#[cfg(feature = "bare_metal")]
#[cfg(all(feature = "bare_metal", feature = "std"))]

Copilot uses AI. Check for mistakes.
pub mod bare_metal_handle_impls {
use super::{E2ERegistryHandle, InterfaceHandle};
use crate::e2e::{E2ECheckStatus, E2EKey, E2EProfile, E2ERegistry, Error as E2EError};
use core::cell::RefCell;
use core::net::Ipv4Addr;
use core::sync::atomic::{AtomicU32, Ordering};
use embassy_sync::blocking_mutex::Mutex;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;

/// Convenience type alias for the embassy-sync critical-section mutex
/// backing [`StaticE2EHandle`].
pub type StaticE2EStorage = Mutex<CriticalSectionRawMutex, RefCell<E2ERegistry>>;

/// No-alloc [`E2ERegistryHandle`] backed by a `&'static` critical-section
/// mutex.
///
/// All clones are the same thin pointer. Construct via [`StaticE2EHandle::new`]
/// and supply a `&'static StaticE2EStorage` (typically obtained via
/// `Box::leak` during system init, since [`E2ERegistry::new`] is not const).
#[derive(Clone, Copy)]
pub struct StaticE2EHandle(&'static StaticE2EStorage);

impl StaticE2EHandle {
/// Wraps a static reference to the backing mutex.
pub const fn new(storage: &'static StaticE2EStorage) -> Self {
Comment on lines +837 to +840

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

unsafe impl Send/Sync is a significant safety escape hatch. It would be safer to rely on auto-traits (if StaticE2EStorage is actually Send + Sync) rather than forcing Send/Sync manually. If the underlying embassy_sync::blocking_mutex::Mutex<..., RefCell<_>> is intentionally not Send/Sync on some targets, these unsafe impls could be unsound; consider removing them or switching to a backing type that is explicitly Send + Sync.

Copilot uses AI. Check for mistakes.
Self(storage)
}
}

// SAFETY: &'static is already Sync; CriticalSectionRawMutex is Send + Sync.
unsafe impl Send for StaticE2EHandle {}
unsafe impl Sync for StaticE2EHandle {}

impl E2ERegistryHandle for StaticE2EHandle {
fn register(&self, key: E2EKey, profile: E2EProfile) {
self.0.lock(|cell| cell.borrow_mut().register(key, profile));
}

fn unregister(&self, key: &E2EKey) {
self.0.lock(|cell| cell.borrow_mut().unregister(key));
}

fn contains_key(&self, key: &E2EKey) -> bool {
self.0.lock(|cell| cell.borrow().contains_key(key))
}

fn protect(
&self,
key: E2EKey,
payload: &[u8],
upper_header: [u8; 8],
output: &mut [u8],
) -> Option<Result<usize, E2EError>> {
self.0
.lock(|cell| cell.borrow_mut().protect(key, payload, upper_header, output))
}

fn check<'a>(
&self,
key: E2EKey,
payload: &'a [u8],
upper_header: [u8; 8],
) -> Option<(E2ECheckStatus, &'a [u8])> {
self.0.lock(|cell| cell.borrow_mut().check(key, payload, upper_header))
}
}

/// No-alloc [`InterfaceHandle`] backed by a `&'static AtomicU32`.
///
/// IPv4 addresses are encoded as big-endian `u32` (`Ipv4Addr::into::<u32>`).
/// All clones are the same thin pointer. Declare the backing storage in a
/// `static`:
///
/// ```ignore
/// static IFACE_ADDR: AtomicU32 = AtomicU32::new(0);
/// let handle = AtomicInterfaceHandle::new(&IFACE_ADDR);
/// ```
#[derive(Clone, Copy)]
pub struct AtomicInterfaceHandle(&'static AtomicU32);

impl AtomicInterfaceHandle {
/// Wraps a static reference to the backing atomic.
pub const fn new(addr: &'static AtomicU32) -> Self {
Self(addr)
}
}

// SAFETY: &'static AtomicU32 is already Send + Sync.
unsafe impl Send for AtomicInterfaceHandle {}
unsafe impl Sync for AtomicInterfaceHandle {}

impl InterfaceHandle for AtomicInterfaceHandle {
fn get(&self) -> Ipv4Addr {
Ipv4Addr::from(self.0.load(Ordering::Relaxed))
}

fn set(&self, addr: Ipv4Addr) {
self.0.store(u32::from(addr), Ordering::Relaxed);
}
}
}

#[cfg(feature = "bare_metal")]
pub use bare_metal_handle_impls::{AtomicInterfaceHandle, StaticE2EHandle, StaticE2EStorage};

// ── Channel-handle abstraction ────────────────────────────────────────────
//
// `ChannelFactory` and its associated sender / receiver traits abstract over
Expand Down
236 changes: 236 additions & 0 deletions tests/no_alloc_witness.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
//! Phase-16 no-alloc CI gate: prove that the bare-metal handle types and
//! static-pool channels do not invoke the global allocator on the hot path.
//!
//! # Why `harness = false`
//!
//! The standard `#[test]` harness allocates internally (each test run wraps
//! the test in an `Arc` for lifecycle tracking). With a panic-on-alloc
//! `#[global_allocator]` that would fire immediately on test-harness setup,
//! before any of our code runs. `harness = false` removes the harness: this
//! file defines its own `main()` that runs the witness functions directly and
//! exits with a non-zero status (via panic) on any unexpected allocation.
//!
//! # Strategy
//!
//! A [`PanicAllocator`] replaces the global allocator. It is disarmed by
//! default; [`assert_no_alloc`] arms it around a closure, causing any
//! allocation inside the closure to panic — turning a latent regression into
//! a hard CI failure. Because `main()` is single-threaded and all witnessed
//! operations are synchronous (no yield points), no background allocations
//! can fire while the allocator is armed.
//!
//! # What is witnessed
//!
//! 1. [`AtomicInterfaceHandle`] `get` / `set` are provably alloc-free (thin
//! pointer to a `static AtomicU32`).
//! 2. [`StaticE2EHandle`] `contains_key` / `protect` / `check` do not
//! allocate after the registry is configured. Registration itself may
//! allocate (the backing [`E2ERegistry`] uses a `HashMap`); that is
//! acceptable as a construction-time cost.
//! 3. [`define_static_channels!`] oneshot `claim` + `send` do not allocate
//! after the pool is warmed. The first claim seeds the pool's free-list;
//! subsequent warm claims are alloc-free.
//!
//! # What this does not witness
//!
//! A fully no-alloc `Client` or `Server` run loop additionally requires a
//! no-alloc `Spawner`, no-alloc transport, and a no-tokio executor. That
//! end-to-end harness requires further work. The counting allocator in
//! `tests/static_channels_alloc_witness.rs` covers the channel-storage hot
//! path in a tokio-hosted context; this file extends it to the handle layer
//! with a stricter panic harness.

use core::cell::RefCell;
use core::net::Ipv4Addr;
use core::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use std::alloc::{GlobalAlloc, Layout, System};

use embassy_sync::blocking_mutex::Mutex as BlockingMutex;
use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;

use simple_someip::e2e::{E2EKey, E2EProfile, E2ERegistry, Profile4Config};
use simple_someip::transport::{AtomicInterfaceHandle, OneshotSend, StaticE2EHandle};
use simple_someip::{
ChannelFactory, E2ERegistryHandle, InterfaceHandle, StaticE2EStorage, define_static_channels,
};

// ── Panic allocator ───────────────────────────────────────────────────────

static ARMED: AtomicBool = AtomicBool::new(false);

struct PanicAllocator;

unsafe impl GlobalAlloc for PanicAllocator {
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
if ARMED.load(Ordering::Relaxed) {
panic!(
"allocation forbidden: {} bytes, align {}",
layout.size(),
layout.align()
);
}
// SAFETY: forwarding to System with caller's layout contract.
unsafe { System.alloc(layout) }
}

unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
// SAFETY: forwarding to System; ptr/layout from System::alloc.
unsafe { System.dealloc(ptr, layout) }
}

unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
if ARMED.load(Ordering::Relaxed) {
panic!(
"allocation forbidden (alloc_zeroed): {} bytes, align {}",
layout.size(),
layout.align()
);
}
// SAFETY: forwarding to System.
unsafe { System.alloc_zeroed(layout) }
}

unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
if ARMED.load(Ordering::Relaxed) {
panic!(
"allocation forbidden (realloc): {} → {} bytes",
layout.size(),
new_size
);
}
// SAFETY: forwarding to System; invariants upheld by caller.
unsafe { System.realloc(ptr, layout, new_size) }
}
}

#[global_allocator]
Comment on lines +113 to +118

Copilot AI Apr 28, 2026

Copy link

Choose a reason for hiding this comment

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

assert_no_alloc leaves the allocator armed if f() panics (including the expected “allocation forbidden” panic). That can trigger cascading panics during unwinding/panic-hook printing and make failures harder to interpret. Consider disarming via an RAII guard (Drop) or wrapping f in std::panic::catch_unwind to ensure ARMED is always reset before the panic propagates.

Copilot uses AI. Check for mistakes.
static GLOBAL: PanicAllocator = PanicAllocator;

/// Arm the panic allocator for the duration of `f`, then disarm.
///
/// Any heap allocation inside `f` causes an immediate panic, which exits
/// the process with a non-zero status code — CI failure.
fn assert_no_alloc<T>(label: &str, f: impl FnOnce() -> T) -> T {
ARMED.store(true, Ordering::SeqCst);
let result = f();
ARMED.store(false, Ordering::SeqCst);
println!(" [pass] {label}");
result
}

// ── Static channels ───────────────────────────────────────────────────────

define_static_channels! {
name: WitnessChannels,
oneshot: [
(u32, 8),
],
bounded: [
((u32, 4), 2),
],
unbounded: [
(u32, 2),
],
}

// ── Backing statics ───────────────────────────────────────────────────────

static IFACE_ADDR: AtomicU32 = AtomicU32::new(0);

// ── Witness functions ─────────────────────────────────────────────────────

fn witness_atomic_interface_handle() {
let handle = AtomicInterfaceHandle::new(&IFACE_ADDR);
// Initialize outside the armed window.
handle.set(Ipv4Addr::LOCALHOST);

assert_no_alloc("AtomicInterfaceHandle::set / ::get", || {
handle.set(Ipv4Addr::new(192, 168, 1, 1));
assert_eq!(handle.get(), Ipv4Addr::new(192, 168, 1, 1));
handle.set(Ipv4Addr::LOCALHOST);
assert_eq!(handle.get(), Ipv4Addr::LOCALHOST);
});
}

fn witness_static_e2e_handle_reads() {
// Box::leak allocates — that is an accepted construction-time cost.
let storage: &'static StaticE2EStorage =
Box::leak(Box::new(BlockingMutex::<CriticalSectionRawMutex, RefCell<E2ERegistry>>::new(
RefCell::new(E2ERegistry::new()),
)));
let handle = StaticE2EHandle::new(storage);

// register() allocates into the HashMap — also construction-time.
handle.register(
E2EKey::new(0x1234, 0x0001),
E2EProfile::Profile4(Profile4Config::new(0xDEAD_BEEF, 15)),
);

// Hot-path reads must be alloc-free.
assert_no_alloc("StaticE2EHandle::contains_key (hit)", || {
assert!(handle.contains_key(&E2EKey::new(0x1234, 0x0001)));
});

assert_no_alloc("StaticE2EHandle::contains_key (miss)", || {
assert!(!handle.contains_key(&E2EKey::new(0xFFFF, 0x0000)));
});

assert_no_alloc("StaticE2EHandle::check (absent key → None)", || {
assert!(handle.check(E2EKey::new(0xFFFF, 0x0000), b"payload", [0u8; 8]).is_none());
});
}

fn witness_static_e2e_handle_protect_check() {
let storage: &'static StaticE2EStorage =
Box::leak(Box::new(BlockingMutex::<CriticalSectionRawMutex, RefCell<E2ERegistry>>::new(
RefCell::new(E2ERegistry::new()),
)));
let handle = StaticE2EHandle::new(storage);

handle.register(
E2EKey::new(0x0001, 0x8001),
E2EProfile::Profile4(Profile4Config::new(0x1234_5678, 15)),
);

let key = E2EKey::new(0x0001, 0x8001);
let payload = b"hello";
let mut protected = [0u8; 64];

assert_no_alloc("StaticE2EHandle::protect + check round-trip", || {
let len = handle
.protect(key, payload, [0u8; 8], &mut protected)
.expect("profile registered")
.expect("protect succeeded");
let (status, stripped) =
handle.check(key, &protected[..len], [0u8; 8]).expect("profile registered");
assert_eq!(status, simple_someip::E2ECheckStatus::Ok);
assert_eq!(stripped, payload);
});
}

fn witness_static_channels_oneshot() {
// Warm the pool: first claim/release seeds the free-list.
{
let (tx, _rx) = WitnessChannels::oneshot::<u32>();
tx.send(42u32).ok();
}

// Second claim must not allocate.
assert_no_alloc("WitnessChannels::oneshot warm claim + send", || {
let (tx, _rx) = WitnessChannels::oneshot::<u32>();
tx.send(99u32).ok();
});
}

// ── Entry point ───────────────────────────────────────────────────────────

fn main() {
println!("no-alloc witness:");

witness_atomic_interface_handle();
witness_static_e2e_handle_reads();
witness_static_e2e_handle_protect_check();
witness_static_channels_oneshot();

println!("all witnesses passed");
}
Loading