From 503a55d52cef629ee0f0862bbff3265be7074073 Mon Sep 17 00:00:00 2001 From: Justin Kovacich Date: Tue, 28 Apr 2026 10:37:21 -0400 Subject: [PATCH 1/2] =?UTF-8?q?phase=2016:=20no-alloc=20CI=20gate=20?= =?UTF-8?q?=E2=80=94=20StaticE2EHandle,=20AtomicInterfaceHandle,=20panic-a?= =?UTF-8?q?llocator=20witness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two no-alloc handle types in src/transport.rs (bare_metal feature): - StaticE2EHandle: wraps &'static embassy-sync critical-section Mutex>; implements E2ERegistryHandle without any heap allocation on the hot path. - AtomicInterfaceHandle: wraps &'static AtomicU32; encodes IPv4 as u32; implements InterfaceHandle with single atomic load/store. Both types are Clone+Copy via thin-pointer semantics and satisfy Clone+Send+Sync+'static without Arc or RwLock. Add tests/no_alloc_witness.rs (harness=false) with a PanicAllocator #[global_allocator] that panics on any heap allocation while armed. Witnesses: AtomicInterfaceHandle get/set, StaticE2EHandle contains_key/ protect/check after construction-time register, and WitnessChannels oneshot warm claim+send — all verified alloc-free under the panic harness. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.toml | 5 + src/lib.rs | 2 + src/transport.rs | 134 ++++++++++++++++++++++ tests/no_alloc_witness.rs | 236 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 377 insertions(+) create mode 100644 tests/no_alloc_witness.rs diff --git a/Cargo.toml b/Cargo.toml index b93bf9c2..34644ed6 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 65dfb0fe..b26ff273 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 9c1e1724..2841f57f 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -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::>::new( +/// RefCell::new(E2ERegistry::new()), +/// ), +/// )); +/// (StaticE2EHandle::new(registry_storage), AtomicInterfaceHandle::new(&IFACE_ADDR)) +/// } +/// ``` +#[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) + } + } + + // 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> { + 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); + /// ``` + #[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 diff --git a/tests/no_alloc_witness.rs b/tests/no_alloc_witness.rs new file mode 100644 index 00000000..8bc62cda --- /dev/null +++ b/tests/no_alloc_witness.rs @@ -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] +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), + ], + 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)), + ); + + 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::(); + 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(); + }); +} + +// ── 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"); +} From 085c3f7a6ab39241eecf5dba3e1de7a2b2790455 Mon Sep 17 00:00:00 2001 From: Justin Kovacich Date: Tue, 28 Apr 2026 11:10:51 -0400 Subject: [PATCH 2/2] phase 16 review: explicit CI gate, first-claim/recv/Profile5 witnesses, drop unsafe Send/Sync, abort-on-alloc instead of panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - Add explicit `cargo test --test no_alloc_witness` step in ci.yml so the no-alloc gate is visible in CI logs (independent of nextest's handling of harness=false binaries). src/transport.rs: - Remove the redundant `unsafe impl Send`/`unsafe impl Sync` blocks on StaticE2EHandle and AtomicInterfaceHandle. Auto-traits derive correctly through `&'static T` since BlockingMutex> is Sync and AtomicU32 is Sync. - Document why AtomicInterfaceHandle uses Ordering::Relaxed (single synchronized datum, no happens-before to establish). - Add a "No-allocator targets" note pointing at StaticCell::init for the eventual const-friendly E2ERegistry::new path. tests/no_alloc_witness.rs: - Replace `panic!` in PanicAllocator with `diagnose_and_abort`, which disarms first then aborts via process::abort() — keeps the diagnostic off the panic-unwind path (whose machinery also allocates). - Add witness_static_channels_first_claim using a fresh `(u64, 4)` oneshot variant — proves first-claim seeds the free-list alloc-free, the case that runs once at boot on a real bare-metal target. - Add witness_static_channels_oneshot_recv — polls the recv future once via Waker::noop so the channel's poll path is measured without an executor. - Add Profile5 protect/check round-trip witness (data_length matched to payload length to avoid the tracing::warn! mismatch path). - Rewrite the `harness = false` comment to explain the actual reason (libtest TLS, worker pool, per-test bookkeeping all allocate before main() runs). --- .github/workflows/ci.yml | 2 + src/transport.rs | 28 ++++++-- tests/no_alloc_witness.rs | 130 ++++++++++++++++++++++++++++++-------- 3 files changed, 127 insertions(+), 33 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa278bd8..11090617 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/src/transport.rs b/src/transport.rs index 2841f57f..864f02f5 100644 --- a/src/transport.rs +++ b/src/transport.rs @@ -804,6 +804,14 @@ mod std_handle_impls { /// (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}; @@ -834,9 +842,10 @@ pub mod bare_metal_handle_impls { } } - // SAFETY: &'static is already Sync; CriticalSectionRawMutex is Send + Sync. - unsafe impl Send for StaticE2EHandle {} - unsafe impl Sync for StaticE2EHandle {} + // 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) { @@ -882,6 +891,14 @@ pub mod bare_metal_handle_impls { /// 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); @@ -892,9 +909,8 @@ pub mod bare_metal_handle_impls { } } - // SAFETY: &'static AtomicU32 is already Send + Sync. - unsafe impl Send for AtomicInterfaceHandle {} - unsafe impl Sync for AtomicInterfaceHandle {} + // Send + Sync are derived automatically: `&'static AtomicU32` is + // `Send + Sync` because `AtomicU32` is `Sync`. impl InterfaceHandle for AtomicInterfaceHandle { fn get(&self) -> Ipv4Addr { diff --git a/tests/no_alloc_witness.rs b/tests/no_alloc_witness.rs index 8bc62cda..c6b870be 100644 --- a/tests/no_alloc_witness.rs +++ b/tests/no_alloc_witness.rs @@ -3,12 +3,13 @@ //! //! # 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. +//! `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 //! @@ -27,9 +28,14 @@ //! 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. +//! 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 //! @@ -41,15 +47,19 @@ //! 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}; -use simple_someip::transport::{AtomicInterfaceHandle, OneshotSend, StaticE2EHandle}; +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, }; @@ -60,14 +70,24 @@ 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) { - panic!( - "allocation forbidden: {} bytes, align {}", - layout.size(), - layout.align() - ); + diagnose_and_abort("alloc", layout.size(), layout.align()); } // SAFETY: forwarding to System with caller's layout contract. unsafe { System.alloc(layout) } @@ -80,11 +100,7 @@ unsafe impl GlobalAlloc for PanicAllocator { 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() - ); + diagnose_and_abort("alloc_zeroed", layout.size(), layout.align()); } // SAFETY: forwarding to System. unsafe { System.alloc_zeroed(layout) } @@ -92,11 +108,7 @@ unsafe impl GlobalAlloc for PanicAllocator { 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 - ); + diagnose_and_abort("realloc", layout.size(), new_size); } // SAFETY: forwarding to System; invariants upheld by caller. unsafe { System.realloc(ptr, layout, new_size) } @@ -124,6 +136,9 @@ 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), @@ -191,12 +206,21 @@ fn witness_static_e2e_handle_protect_check() { 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", || { + assert_no_alloc("StaticE2EHandle::protect + check round-trip (Profile4)", || { let len = handle .protect(key, payload, [0u8; 8], &mut protected) .expect("profile registered") @@ -206,6 +230,19 @@ fn witness_static_e2e_handle_protect_check() { 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() { @@ -222,6 +259,43 @@ fn witness_static_channels_oneshot() { }); } +/// 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() { @@ -230,7 +304,9 @@ fn main() { 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"); }