diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa278bd..1109061 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,6 +65,8 @@ jobs: with: tool: cargo-llvm-cov, cargo-nextest - run: cargo test --no-default-features + - name: No-alloc witness (explicit gate) + run: cargo test --features client,bare_metal --test no_alloc_witness - run: cargo llvm-cov nextest --all-features --lcov --output-path ./target/lcov.info - name: Upload Coverage report uses: codecov/codecov-action@v5 diff --git a/Cargo.toml b/Cargo.toml index b93bf9c..34644ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] diff --git a/src/lib.rs b/src/lib.rs index 65dfb0f..b26ff27 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -209,3 +209,5 @@ pub use transport::{ OneshotCancelled, OneshotRecv, OneshotSend, ReceivedDatagram, SocketOptions, Spawner, Timer, TransportError, TransportFactory, TransportSocket, UnboundedRecv, UnboundedSend, }; +#[cfg(feature = "bare_metal")] +pub use transport::{AtomicInterfaceHandle, StaticE2EHandle, StaticE2EStorage}; diff --git a/src/transport.rs b/src/transport.rs index 9c1e172..864f02f 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -776,6 +776,156 @@ 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::>::new( +/// RefCell::new(E2ERegistry::new()), +/// ), +/// )); +/// (StaticE2EHandle::new(registry_storage), AtomicInterfaceHandle::new(&IFACE_ADDR)) +/// } +/// ``` +/// +/// # No-allocator targets +/// +/// The example above uses `Box::leak` because [`E2ERegistry::new`] is not +/// currently `const`. On a target with no allocator, swap that for a +/// `static`-cell pattern (e.g. `static_cell::StaticCell::init`) once the +/// registry constructor becomes `const`-friendly. The handle layer itself +/// never allocates — only the one-time storage materialization does. +#[cfg(feature = "bare_metal")] +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>; + + /// 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 { + Self(storage) + } + } + + // Send + Sync are derived automatically: `&'static StaticE2EStorage` + // is `Send + Sync` because `BlockingMutex>` is `Sync` (the embassy-sync mutex serializes + // access to the inner `RefCell`, which is itself `Send`). + + 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> { + 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::`). + /// 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); + /// ``` + /// + /// # Memory ordering + /// + /// Both `get` and `set` use [`Ordering::Relaxed`]. The address is the + /// only synchronized datum — no other memory state is published or + /// observed alongside it — so single-location atomicity is sufficient. + /// A reader will eventually observe the latest write; there is no + /// happens-before relationship to establish with surrounding memory. + #[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) + } + } + + // Send + Sync are derived automatically: `&'static AtomicU32` is + // `Send + Sync` because `AtomicU32` is `Sync`. + + 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 diff --git a/tests/no_alloc_witness.rs b/tests/no_alloc_witness.rs new file mode 100644 index 0000000..c6b870b --- /dev/null +++ b/tests/no_alloc_witness.rs @@ -0,0 +1,312 @@ +//! 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` +//! +//! `libtest` allocates during process startup — thread-local storage, a +//! worker thread pool for parallel test execution, and per-test bookkeeping +//! (the harness wraps each test in heap-allocated state). With a +//! panic-on-alloc `#[global_allocator]` that would fire before any of our +//! code runs. `harness = false` removes the harness: this file defines its +//! own `main()` that runs the witness functions directly on the main thread +//! and aborts the process 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 first-claim, warm-claim, and +//! receiver-poll paths are alloc-free. First-claim is exercised on a +//! pool that has never been touched before (the `u64` variant), which +//! is the case that runs once at boot on a real bare-metal target. +//! `recv()` is polled with [`Waker::noop`] so we measure the channel +//! path without an executor. +//! 4. Both Profile4 and Profile5 protect/check round-trips through +//! [`StaticE2EHandle`] 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::future::Future; +use core::net::Ipv4Addr; +use core::pin::Pin; +use core::sync::atomic::{AtomicBool, AtomicU32, Ordering}; +use core::task::{Context, Waker}; +use std::alloc::{GlobalAlloc, Layout, System}; +use std::process; + +use embassy_sync::blocking_mutex::Mutex as BlockingMutex; +use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex; + +use simple_someip::e2e::{E2EKey, E2EProfile, E2ERegistry, Profile4Config, Profile5Config}; +use simple_someip::transport::{AtomicInterfaceHandle, OneshotRecv, OneshotSend, StaticE2EHandle}; +use simple_someip::{ + ChannelFactory, E2ERegistryHandle, InterfaceHandle, StaticE2EStorage, define_static_channels, +}; + +// ── Panic allocator ─────────────────────────────────────────────────────── + +static ARMED: AtomicBool = AtomicBool::new(false); + +struct PanicAllocator; + +/// Disarm the allocator, print a diagnostic, then abort. +/// +/// We disarm first so the formatter is allowed to allocate while building +/// the diagnostic — otherwise the diagnostic would re-trigger the allocator +/// trap and we'd lose the message. Aborting (rather than panicking) keeps +/// us off the panic-unwind path, whose machinery also allocates. +fn diagnose_and_abort(kind: &str, size: usize, align_or_new: usize) -> ! { + ARMED.store(false, Ordering::SeqCst); + eprintln!( + "no_alloc_witness: forbidden allocation ({kind}): {size} bytes / {align_or_new}", + ); + process::abort(); +} + +unsafe impl GlobalAlloc for PanicAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + if ARMED.load(Ordering::Relaxed) { + diagnose_and_abort("alloc", 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) { + diagnose_and_abort("alloc_zeroed", 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) { + diagnose_and_abort("realloc", layout.size(), new_size); + } + // SAFETY: forwarding to System; invariants upheld by caller. + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +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(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), + // A separate type used exclusively by the first-claim witness so + // its pool has never been touched before we arm the allocator. + (u64, 4), + ], + 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::>::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::>::new( + RefCell::new(E2ERegistry::new()), + ))); + let handle = StaticE2EHandle::new(storage); + + handle.register( + E2EKey::new(0x0001, 0x8001), + E2EProfile::Profile4(Profile4Config::new(0x1234_5678, 15)), + ); + // Register a second profile (Profile5) so the protect/check witness + // covers both profile families' hot paths, not just Profile4. + handle.register( + E2EKey::new(0x0002, 0x8002), + // data_length must equal payload length (5 = b"hello".len()) + // — a mismatch routes through `tracing::warn!`, which is fine in + // production but adds noise to a no-alloc witness. + E2EProfile::Profile5(Profile5Config::new(0xABCD, 5, 15)), + ); + + let key = E2EKey::new(0x0001, 0x8001); + let payload = b"hello"; + let mut protected = [0u8; 64]; + + assert_no_alloc("StaticE2EHandle::protect + check round-trip (Profile4)", || { + 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); + }); + + let key5 = E2EKey::new(0x0002, 0x8002); + let mut protected5 = [0u8; 64]; + assert_no_alloc("StaticE2EHandle::protect + check round-trip (Profile5)", || { + let len = handle + .protect(key5, payload, [0u8; 8], &mut protected5) + .expect("profile registered") + .expect("protect succeeded"); + let (status, stripped) = + handle.check(key5, &protected5[..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::(); + tx.send(42u32).ok(); + } + + // Second claim must not allocate. + assert_no_alloc("WitnessChannels::oneshot warm claim + send", || { + let (tx, _rx) = WitnessChannels::oneshot::(); + tx.send(99u32).ok(); + }); +} + +/// First-claim witness: a freshly declared static pool (the `u64` variant +/// in [`WitnessChannels`], untouched until this point) must seed its +/// free-list and hand out the first slot without allocating. This is the +/// case that runs once at boot on a real bare-metal target. +fn witness_static_channels_first_claim() { + assert_no_alloc("WitnessChannels::oneshot:: FIRST claim + send", || { + let (tx, _rx) = WitnessChannels::oneshot::(); + tx.send(7u64).ok(); + }); +} + +/// Receiver hot-path witness: polling the recv future once on a slot that +/// already has a value must not allocate. Uses [`Waker::noop`] so we don't +/// drag in an executor. +fn witness_static_channels_oneshot_recv() { + // Warm the pool first so this witness measures only the recv path. + { + let (tx, _rx) = WitnessChannels::oneshot::(); + tx.send(1u32).ok(); + } + + assert_no_alloc("WitnessChannels::oneshot recv (value already pending)", || { + let (tx, rx) = WitnessChannels::oneshot::(); + tx.send(123u32).ok(); + let mut fut = rx.recv(); + // SAFETY: `fut` is stack-pinned and dropped before this scope ends; + // no reference escapes. + let pinned = unsafe { Pin::new_unchecked(&mut fut) }; + let waker = Waker::noop(); + let mut cx = Context::from_waker(waker); + match pinned.poll(&mut cx) { + core::task::Poll::Ready(Ok(v)) => assert_eq!(v, 123), + other => panic!("expected Ready(Ok(123)), got {other:?}"), + } + }); +} + +// ── 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_first_claim(); + witness_static_channels_oneshot(); + witness_static_channels_oneshot_recv(); + + println!("all witnesses passed"); +}