Skip to main content

nros_node/executor/
storage.rs

1//! phase-271 / phase-409 — per-entry [`Executor`](super::spin::Executor)
2//! storage (issues 0110, 0563, 0936).
3//!
4//! The executor's sized tables (callback table + arena + scheduling-context
5//! tables + the node/session/dispatch tables) used to be inline fields sized by
6//! build-time consts baked into `nros-node` — one size for every entry sharing a
7//! compiled `nros-node`, and, worse, a size that every function MOVING an
8//! `Executor` had to make room for on its stack. Here the ENTRY supplies its own
9//! storage, sized to its topology, so a fat native entry and a lean embedded
10//! entry in one workspace each get the right size with no workspace-global env,
11//! and the `Executor` VALUE stays a small fixed header.
12//!
13//! Per the "C/C++ is a thin wrapper of Rust" principle the PUBLIC API stays
14//! generic-free: the entry hands a raw, 8-aligned `&mut [MaybeUninit<u64>]` backing
15//! (sized via [`executor_storage_u64_len`]); `nros-node` carves it privately into
16//! the typed sub-slices ([`carve`]). The only `unsafe` is that carve, validated
17//! against the `#[repr(C)]` reference [`ExecutorStorage`] layout by unit test.
18
19use core::{
20    alloc::Layout,
21    mem::{MaybeUninit, align_of, needs_drop, size_of},
22    ops::{Deref, DerefMut},
23};
24
25use super::{
26    arena::CallbackMeta,
27    monitor::{MAX_VIOLATIONS, Violation},
28    node_record::NodeRecord,
29    sched_context::{SchedContext, SchedContextId, SporadicState},
30    spin::{ComponentSlot, DispatchSlot, MAX_REMAPS, RemapRule},
31};
32use crate::session::ConcreteSession;
33
34#[cfg(feature = "alloc")]
35type SporadicAtomic = (
36    portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
37    super::spin::OpaqueTimerHandle,
38);
39
40/// phase-272 — `(node name, namespace, sched context)`, keyed by the node's
41/// fully-qualified identity.
42pub(crate) type NodeSchedEntry = (heapless::String<64>, heapless::String<64>, SchedContextId);
43/// phase-273 — [`NodeSchedEntry`] plus the callback-group name that narrows it.
44pub(crate) type GroupSchedEntry = (
45    heapless::String<64>,
46    heapless::String<64>,
47    heapless::String<32>,
48    SchedContextId,
49);
50/// Issue 0436 — `(rmw name, locator)` for one extra session.
51pub(crate) type ExtraSessionId = (heapless::String<32>, heapless::String<128>);
52/// Phase 228.C — one callback-group name in a tier executor's filter.
53pub(crate) type GroupName = heapless::String<32>;
54
55// ============================================================================
56// CarvedVec — a `heapless::Vec` whose capacity lives in the caller's backing
57// ============================================================================
58
59/// A push-only vector over CARVED storage: the elements live in the caller's
60/// backing, the `Executor` holds only a fat pointer and a fill cursor.
61///
62/// phase-409 (issue 0961) — this is the shape phase-271 gave the six sized
63/// tables, generalised so the other nine can use it too. It exists because
64/// `heapless::Vec<T, N>` puts `N * size_of::<T>()` bytes INSIDE the struct, so
65/// every knob that sets an `N` also sets the stack frame of every function that
66/// moves an `Executor`: `MAX_CBS` 14 -> 36 (a handle-count fix) added ~3.7 KiB
67/// to `Executor::open_in`'s prologue on a part whose main thread has 32 KiB in
68/// total.
69///
70/// Deliberately `MaybeUninit<T>` rather than `Option<T>`, for three reasons:
71/// [`as_slice`](Self::as_slice) can hand back the `&[T]` the public
72/// `Executor::nodes()` accessor already returns; `carve` writes NOTHING at open
73/// (the `memclr` in issue 0961's fault was the executor's tables being zeroed);
74/// and the layout matches `heapless::Vec`'s, so the storage cost is the same
75/// bytes in a different place.
76///
77/// Occupied slots are `[0, len)` and IN PUSH ORDER — several callers index by
78/// insertion position (`NodeId` IS an index into `nodes`; `session_idx` is an
79/// index into `extra_sessions`), so this must never gain a `swap_remove`.
80pub(crate) struct CarvedVec<'s, T> {
81    slots: &'s mut [MaybeUninit<T>],
82    len: usize,
83}
84
85impl<'s, T> CarvedVec<'s, T> {
86    /// Wrap carved, UNINITIALISED storage as an empty vector.
87    fn new(slots: &'s mut [MaybeUninit<T>]) -> Self {
88        Self { slots, len: 0 }
89    }
90
91    /// Slots this vector can hold — the entry's sizing, not a build-time const.
92    pub(crate) fn capacity(&self) -> usize {
93        self.slots.len()
94    }
95
96    /// Append `value`, returning it in `Err` when full (`heapless::Vec::push`).
97    pub(crate) fn push(&mut self, value: T) -> Result<(), T> {
98        if self.len == self.capacity() {
99            return Err(value);
100        }
101        self.slots[self.len].write(value);
102        self.len += 1;
103        Ok(())
104    }
105
106    /// Drop every element and reset the cursor.
107    pub(crate) fn clear(&mut self) {
108        let len = core::mem::replace(&mut self.len, 0);
109        if needs_drop::<T>() {
110            for slot in &mut self.slots[..len] {
111                // SAFETY: `[0, len)` was initialised by `push` and is dropped
112                // exactly once — `len` is reset above before this loop runs, so
113                // a panicking `T::drop` cannot make a second pass see it.
114                unsafe { slot.assume_init_drop() };
115            }
116        }
117    }
118
119    /// The initialised prefix.
120    pub(crate) fn as_slice(&self) -> &[T] {
121        // SAFETY: `push` initialises `[0, len)` and nothing shortens `len`
122        // without dropping (see `clear`).
123        unsafe { &*(&self.slots[..self.len] as *const [MaybeUninit<T>] as *const [T]) }
124    }
125
126    /// The initialised prefix, mutably.
127    pub(crate) fn as_mut_slice(&mut self) -> &mut [T] {
128        // SAFETY: as [`as_slice`](Self::as_slice).
129        unsafe { &mut *(&mut self.slots[..self.len] as *mut [MaybeUninit<T>] as *mut [T]) }
130    }
131}
132
133impl<T> Deref for CarvedVec<'_, T> {
134    type Target = [T];
135    fn deref(&self) -> &[T] {
136        self.as_slice()
137    }
138}
139
140impl<T> DerefMut for CarvedVec<'_, T> {
141    fn deref_mut(&mut self) -> &mut [T] {
142        self.as_mut_slice()
143    }
144}
145
146impl<T> Drop for CarvedVec<'_, T> {
147    fn drop(&mut self) {
148        // The backing is the CALLER's memory, so nothing drops these elements
149        // for us — and one of these tables is `extra_sessions`, whose elements
150        // close RMW sessions. A `Drop` impl on the vector (rather than a pass in
151        // `Executor::drop`) keeps the ORDER the inline `heapless::Vec`s had:
152        // `Executor`'s fields drop in declaration order, so the primary
153        // `session` still closes before the extras.
154        self.clear();
155    }
156}
157
158// ============================================================================
159// Layout
160// ============================================================================
161
162/// The typed reference layout the [`carve`] mirrors. `#[repr(C)]` so its field
163/// offsets are the deterministic declaration-order layout the const-fn below
164/// reproduces; a unit test asserts they agree. Only referenced by tests.
165#[cfg(test)]
166#[repr(C)]
167pub(crate) struct ExecutorStorage<
168    const CBS: usize,
169    const SC: usize,
170    const ARENA: usize,
171    const NODES: usize,
172> {
173    arena: [MaybeUninit<u8>; ARENA],
174    entries: [Option<CallbackMeta>; CBS],
175    sched_contexts: [Option<SchedContext>; SC],
176    sched_context_bindings: [SchedContextId; CBS],
177    sporadic_states: [Option<SporadicState>; SC],
178    #[cfg(feature = "alloc")]
179    sporadic_atomic_states: [Option<SporadicAtomic>; SC],
180    remaps: [Option<RemapRule>; MAX_REMAPS],
181    // phase-409 — the nine that phase-271 left inline.
182    nodes: [MaybeUninit<NodeRecord>; NODES],
183    extra_sessions: [MaybeUninit<ConcreteSession>; NODES],
184    extra_session_ids: [MaybeUninit<ExtraSessionId>; NODES],
185    node_sched_table: [MaybeUninit<NodeSchedEntry>; NODES],
186    dispatch_slots: [MaybeUninit<DispatchSlot>; NODES],
187    component_slots: [MaybeUninit<ComponentSlot>; NODES],
188    active_groups: [MaybeUninit<GroupName>; NODES],
189    group_sched_table: [MaybeUninit<GroupSchedEntry>; CBS],
190    monitor_violations: [MaybeUninit<Violation>; MAX_VIOLATIONS],
191}
192
193/// The typed, mutable sub-slices an [`Executor`](super::spin::Executor) borrows
194/// from a carved backing. Element memory is initialised by [`carve`] for the
195/// `Option`/`SchedContextId` tables; the [`CarvedVec`] tables are handed over
196/// UNINITIALISED and fill on push.
197pub(crate) struct ExecutorSlices<'s> {
198    pub(crate) arena: &'s mut [MaybeUninit<u8>],
199    pub(crate) entries: &'s mut [Option<CallbackMeta>],
200    pub(crate) sched_contexts: &'s mut [Option<SchedContext>],
201    pub(crate) sched_context_bindings: &'s mut [SchedContextId],
202    pub(crate) sporadic_states: &'s mut [Option<SporadicState>],
203    #[cfg(feature = "alloc")]
204    pub(crate) sporadic_atomic_states: &'s mut [Option<SporadicAtomic>],
205    /// Issue 0563 — the SEVENTH sized table. phase-271 moved six of these out
206    /// of `Executor` and into caller-owned storage; the remap table was left
207    /// inline and grew to 6664 bytes of an 11632-byte struct (57%), which is
208    /// what made constructing an executor a ~9.3 KB STACK temporary and
209    /// overflowed the Zephyr Cortex-M main stack in issue 0552.
210    ///
211    /// Fixed count (`MAX_REMAPS`) rather than a new `ExecutorSizing` knob:
212    /// the capability is unchanged, so this needs no public API change and no
213    /// regeneration of every entry's sizing. The required backing grows by the
214    /// same bytes, but that lands in the caller's STATIC buffer instead of on
215    /// the stack, which is the entire point.
216    pub(crate) remaps: &'s mut [Option<RemapRule>],
217    // phase-409 (issue 0961) — the remaining nine, same reasoning one campaign
218    // later. `MAX_CBS` 14 -> 36 put ~3.7 KiB of `group_sched_table` on the stack
219    // of every function that moves an `Executor`.
220    pub(crate) nodes: CarvedVec<'s, NodeRecord>,
221    pub(crate) extra_sessions: CarvedVec<'s, ConcreteSession>,
222    pub(crate) extra_session_ids: CarvedVec<'s, ExtraSessionId>,
223    pub(crate) node_sched_table: CarvedVec<'s, NodeSchedEntry>,
224    pub(crate) dispatch_slots: CarvedVec<'s, DispatchSlot>,
225    pub(crate) component_slots: CarvedVec<'s, ComponentSlot>,
226    pub(crate) active_groups: CarvedVec<'s, GroupName>,
227    pub(crate) group_sched_table: CarvedVec<'s, GroupSchedEntry>,
228    pub(crate) monitor_violations: CarvedVec<'s, Violation>,
229}
230
231/// Byte offsets of each field within the backing + total size/align. Computed
232/// identically by [`executor_storage_layout`] and [`carve`] (single source of
233/// truth), reproducing `#[repr(C)]` declaration-order layout.
234struct FieldOffsets {
235    arena: usize,
236    entries: usize,
237    sched_contexts: usize,
238    sched_context_bindings: usize,
239    sporadic_states: usize,
240    #[cfg(feature = "alloc")]
241    sporadic_atomic_states: usize,
242    remaps: usize,
243    nodes: usize,
244    extra_sessions: usize,
245    extra_session_ids: usize,
246    node_sched_table: usize,
247    dispatch_slots: usize,
248    component_slots: usize,
249    active_groups: usize,
250    group_sched_table: usize,
251    monitor_violations: usize,
252    size: usize,
253    align: usize,
254}
255
256const fn align_up(off: usize, align: usize) -> usize {
257    off.div_ceil(align) * align
258}
259
260const fn compute_offsets(sizing: ExecutorSizing) -> FieldOffsets {
261    let ExecutorSizing {
262        cbs,
263        sc,
264        arena,
265        nodes: node_slots,
266    } = sizing;
267    let mut off = 0usize;
268    let mut max_align = 1usize;
269
270    // arena: [MaybeUninit<u8>; arena] — align 1, at offset 0.
271    let arena_off = 0usize;
272    off += arena;
273
274    macro_rules! place {
275        ($n:expr, $ty:ty) => {{
276            let a = align_of::<$ty>();
277            if a > max_align {
278                max_align = a;
279            }
280            off = align_up(off, a);
281            let at = off;
282            off += $n * size_of::<$ty>();
283            at
284        }};
285    }
286
287    let entries = place!(cbs, Option<CallbackMeta>);
288    let sched_contexts = place!(sc, Option<SchedContext>);
289    let sched_context_bindings = place!(cbs, SchedContextId);
290    let sporadic_states = place!(sc, Option<SporadicState>);
291    #[cfg(feature = "alloc")]
292    let sporadic_atomic_states = place!(sc, Option<SporadicAtomic>);
293    let remaps = place!(MAX_REMAPS, Option<RemapRule>);
294    let nodes = place!(node_slots, NodeRecord);
295    let extra_sessions = place!(node_slots, ConcreteSession);
296    let extra_session_ids = place!(node_slots, ExtraSessionId);
297    let node_sched_table = place!(node_slots, NodeSchedEntry);
298    let dispatch_slots = place!(node_slots, DispatchSlot);
299    let component_slots = place!(node_slots, ComponentSlot);
300    let active_groups = place!(node_slots, GroupName);
301    let group_sched_table = place!(cbs, GroupSchedEntry);
302    let monitor_violations = place!(MAX_VIOLATIONS, Violation);
303
304    let size = align_up(off, max_align);
305    FieldOffsets {
306        arena: arena_off,
307        entries,
308        sched_contexts,
309        sched_context_bindings,
310        sporadic_states,
311        #[cfg(feature = "alloc")]
312        sporadic_atomic_states,
313        remaps,
314        nodes,
315        extra_sessions,
316        extra_session_ids,
317        node_sched_table,
318        dispatch_slots,
319        component_slots,
320        active_groups,
321        group_sched_table,
322        monitor_violations,
323        size,
324        align: max_align,
325    }
326}
327
328/// Byte [`Layout`] of the backing a `sizing`-sized executor needs.
329/// Public + non-generic so the macro / FFI can size a raw backing.
330pub const fn executor_storage_layout(sizing: ExecutorSizing) -> Layout {
331    let o = compute_offsets(sizing);
332    // SAFETY: `align` is a power of two (a `max` of `align_of` results) and `size`
333    // is rounded up to it; both are non-zero.
334    unsafe { Layout::from_size_align_unchecked(o.size, o.align) }
335}
336
337/// Number of `u64` words a backing must hold for a `sizing`-sized executor.
338/// `u64` backing is 8-aligned, which covers every field (all `align_of ≤ 8`;
339/// asserted in tests), so the entry never hand-aligns. The macro emits
340/// `[MaybeUninit<u64>; executor_storage_u64_len(sizing)]`.
341pub const fn executor_storage_u64_len(sizing: ExecutorSizing) -> usize {
342    executor_storage_layout(sizing).size().div_ceil(8)
343}
344
345/// Per-entry executor sizing — the entity counts an [`Executor`](super::spin::Executor)
346/// is built to hold. **Public + non-generic** (the "C/C++ is a thin wrapper"
347/// principle): the entry / macro / FFI supplies these as plain `usize`s rather
348/// than as type/const generics C can't name. Used to size + carve the backing.
349///
350/// `cbs` is capped at 64 by the executor's `u64` ready-set bitmask (asserted in
351/// [`carve`]-time / `open_in`).
352#[derive(Clone, Copy)]
353pub struct ExecutorSizing {
354    /// Callback-table slots (`entries`, the per-entry SC bindings, and the
355    /// per-callback-group sched table). ≤ 64.
356    pub cbs: usize,
357    /// Scheduling-context slots (`sched_contexts` + sporadic state tables).
358    pub sc: usize,
359    /// Bump-allocator arena size in bytes.
360    pub arena: usize,
361    /// phase-409 — Node slots. One worst-case extra session, extra-session id,
362    /// node-sched binding, dispatch slot, component slot and callback-group
363    /// filter entry per Node, so ONE count covers all seven tables (that is the
364    /// upper bound each of them already assumed under `MAX_NODES`).
365    pub nodes: usize,
366}
367
368impl ExecutorSizing {
369    /// The build-time default (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`/`MAX_NODES`
370    /// consts) — the backward-compatible size the `alloc` convenience
371    /// constructors leak.
372    pub const DEFAULT: Self = Self {
373        cbs: crate::config::MAX_CBS,
374        sc: crate::config::MAX_SC,
375        arena: crate::config::ARENA_SIZE,
376        nodes: crate::config::MAX_NODES,
377    };
378
379    /// `u64` words a backing must hold for this sizing (see
380    /// [`executor_storage_u64_len`]).
381    pub const fn u64_len(&self) -> usize {
382        executor_storage_u64_len(*self)
383    }
384}
385
386/// The exact `#[repr(C)]` byte layout the C/C++ FFI's inline executor buffer must
387/// hold: an [`Executor`](super::spin::Executor)`<'static>` header immediately
388/// followed by a default-sized ([`ExecutorSizing::DEFAULT`]) storage backing.
389///
390/// The FFI keeps the executor inline (heap-free — matching the Rust no-alloc
391/// requirement) and carves its per-entry tables from the SAME buffer's
392/// [`backing`](Self::backing) tail. Because that buffer is **pinned** — the C
393/// caller allocates it, it is initialised in place, and it is only ever reached
394/// through a stable `nros_executor_t*` (never moved after init) — the resulting
395/// self-borrow (the header's slices pointing into the same struct's tail) is
396/// sound. The FFI probes `size_of` of THIS type (not bare `Executor`) to size
397/// its `_opaque` array, and reinterprets `_opaque` as `*mut ExecutorInlineStorage`
398/// (the executor stays at offset 0, so existing offset-0 accessors are unchanged).
399///
400/// phase-409 — moving the last nine tables into the backing moves bytes from
401/// `exec` to `backing` and leaves this total roughly where it was; what changes
402/// is the STACK, because the header is what `open_in` builds and returns by
403/// value.
404#[repr(C)]
405pub struct ExecutorInlineStorage {
406    /// The executor, written in place (offset 0) by `from_session_ptr_in`.
407    pub exec: MaybeUninit<super::spin::Executor<'static>>,
408    /// The carved backing the executor's slices borrow (the buffer's tail).
409    pub backing: [MaybeUninit<u64>; ExecutorSizing::DEFAULT.u64_len()],
410}
411
412/// Carve an 8-aligned `u64` backing into the typed executor slices.
413///
414/// # Safety
415/// - `backing.len() * 8` must be ≥ `executor_storage_layout(sizing).size()`.
416/// - The returned slices alias `backing`; it must outlive them (the `'s` bound)
417///   and not be otherwise accessed while they live.
418///
419/// The `Option`/binding tables are initialised here (`entries`/SC tables →
420/// `None`, bindings → `SchedContextId(0)`), so the returned `&mut [T]` reference
421/// validly-init memory. The [`CarvedVec`] tables are handed over empty and
422/// UNINITIALISED — nothing reads past their fill cursor.
423pub(crate) unsafe fn carve<'s>(
424    backing: &'s mut [MaybeUninit<u64>],
425    sizing: ExecutorSizing,
426) -> ExecutorSlices<'s> {
427    let ExecutorSizing {
428        cbs,
429        sc,
430        arena,
431        nodes: node_slots,
432    } = sizing;
433    let o = compute_offsets(sizing);
434    // Fail-loud on EVERY profile (not `debug_assert!`): embedded release builds
435    // strip debug-assertions, and a backing that is too small is silent memory
436    // corruption — the carved `entries`/`sched_contexts` tables run past the end
437    // of `backing` into whatever .bss follows (e.g. a C carrier's `__nros_c_inst`),
438    // leaving a NULL `drop_fn` that faults in `Executor::drop`. This is exactly
439    // how a STALE config-header mirror (C buffer sized from an out-of-date
440    // `NROS_*_STORAGE_SIZE`) manifested as a `jalr -> 0` on threadx-riscv64 (#131).
441    // Panic here instead, at open, with the two sizes named.
442    assert!(
443        backing.len() * 8 >= o.size,
444        "executor backing too small: {} bytes < {} required — the storage buffer \
445         (NROS_*_STORAGE_SIZE) disagrees with the executor layout; rebuild clean so \
446         the generated config header matches",
447        backing.len() * 8,
448        o.size
449    );
450    let base = backing.as_mut_ptr() as *mut u8;
451
452    // One `CarvedVec` over `$n` slots at byte offset `$at`. No element writes —
453    // the vector starts empty. Expanded inside the `unsafe` block below;
454    // SAFETY there: `compute_offsets` placed `$n` `$ty`s at `$at`, aligned and
455    // inside the asserted `o.size`, and each region is placed exactly once, so
456    // the slices do not alias.
457    macro_rules! carved {
458        ($at:expr, $n:expr, $ty:ty) => {{
459            let p = base.add($at) as *mut MaybeUninit<$ty>;
460            CarvedVec::new(core::slice::from_raw_parts_mut(p, $n))
461        }};
462    }
463
464    unsafe {
465        // arena — no init needed (MaybeUninit).
466        let arena_s =
467            core::slice::from_raw_parts_mut(base.add(o.arena) as *mut MaybeUninit<u8>, arena);
468
469        let entries_p = base.add(o.entries) as *mut Option<CallbackMeta>;
470        let mut i = 0;
471        while i < cbs {
472            entries_p.add(i).write(None);
473            i += 1;
474        }
475        let entries_s = core::slice::from_raw_parts_mut(entries_p, cbs);
476
477        let sc_p = base.add(o.sched_contexts) as *mut Option<SchedContext>;
478        let mut i = 0;
479        while i < sc {
480            sc_p.add(i).write(None);
481            i += 1;
482        }
483        let sched_contexts_s = core::slice::from_raw_parts_mut(sc_p, sc);
484
485        let bind_p = base.add(o.sched_context_bindings) as *mut SchedContextId;
486        let mut i = 0;
487        while i < cbs {
488            bind_p.add(i).write(SchedContextId(0));
489            i += 1;
490        }
491        let bindings_s = core::slice::from_raw_parts_mut(bind_p, cbs);
492
493        let sp_p = base.add(o.sporadic_states) as *mut Option<SporadicState>;
494        let mut i = 0;
495        while i < sc {
496            sp_p.add(i).write(None);
497            i += 1;
498        }
499        let sporadic_s = core::slice::from_raw_parts_mut(sp_p, sc);
500
501        #[cfg(feature = "alloc")]
502        let atomic_s = {
503            let ap = base.add(o.sporadic_atomic_states) as *mut Option<SporadicAtomic>;
504            let mut i = 0;
505            while i < sc {
506                ap.add(i).write(None);
507                i += 1;
508            }
509            core::slice::from_raw_parts_mut(ap, sc)
510        };
511
512        let remaps_p = base.add(o.remaps) as *mut Option<RemapRule>;
513        let mut i = 0;
514        while i < MAX_REMAPS {
515            remaps_p.add(i).write(None);
516            i += 1;
517        }
518        let remaps_s = core::slice::from_raw_parts_mut(remaps_p, MAX_REMAPS);
519
520        ExecutorSlices {
521            arena: arena_s,
522            entries: entries_s,
523            sched_contexts: sched_contexts_s,
524            sched_context_bindings: bindings_s,
525            sporadic_states: sporadic_s,
526            #[cfg(feature = "alloc")]
527            sporadic_atomic_states: atomic_s,
528            remaps: remaps_s,
529            nodes: carved!(o.nodes, node_slots, NodeRecord),
530            extra_sessions: carved!(o.extra_sessions, node_slots, ConcreteSession),
531            extra_session_ids: carved!(o.extra_session_ids, node_slots, ExtraSessionId),
532            node_sched_table: carved!(o.node_sched_table, node_slots, NodeSchedEntry),
533            dispatch_slots: carved!(o.dispatch_slots, node_slots, DispatchSlot),
534            component_slots: carved!(o.component_slots, node_slots, ComponentSlot),
535            active_groups: carved!(o.active_groups, node_slots, GroupName),
536            group_sched_table: carved!(o.group_sched_table, cbs, GroupSchedEntry),
537            monitor_violations: carved!(o.monitor_violations, MAX_VIOLATIONS, Violation),
538        }
539    }
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545
546    const CBS: usize = crate::config::MAX_CBS;
547    const SC: usize = crate::config::MAX_SC;
548    const ARENA: usize = crate::config::ARENA_SIZE;
549    const NODES: usize = crate::config::MAX_NODES;
550    const DEFAULT: ExecutorSizing = ExecutorSizing::DEFAULT;
551
552    #[test]
553    fn layout_matches_typed_repr_c() {
554        // The manual const-fn layout must equal the compiler's `#[repr(C)]` layout
555        // of the typed storage — proof the carve offsets are the real field offsets.
556        let got = executor_storage_layout(DEFAULT);
557        let want = Layout::new::<ExecutorStorage<CBS, SC, ARENA, NODES>>();
558        assert_eq!(got.size(), want.size(), "size");
559        assert_eq!(got.align(), want.align(), "align");
560    }
561
562    /// phase-409 — and the FIELD offsets, one by one. The size/align check above
563    /// is the one phase-271 shipped, and it does not distinguish a REORDER that
564    /// happens to pad the same: with sixteen regions instead of seven, "the
565    /// totals agree" stopped being adequate evidence that `carve` hands out the
566    /// region it names. A mismatch here is `nodes` being handed
567    /// `extra_sessions`' memory, which is not a size bug and no total would show.
568    #[test]
569    fn every_carved_region_starts_where_repr_c_puts_it() {
570        type Ref = ExecutorStorage<CBS, SC, ARENA, NODES>;
571        let o = compute_offsets(DEFAULT);
572        macro_rules! same {
573            ($f:ident) => {
574                assert_eq!(
575                    o.$f,
576                    core::mem::offset_of!(Ref, $f),
577                    concat!("offset of `", stringify!($f), "`")
578                );
579            };
580        }
581        same!(arena);
582        same!(entries);
583        same!(sched_contexts);
584        same!(sched_context_bindings);
585        same!(sporadic_states);
586        #[cfg(feature = "alloc")]
587        same!(sporadic_atomic_states);
588        same!(remaps);
589        same!(nodes);
590        same!(extra_sessions);
591        same!(extra_session_ids);
592        same!(node_sched_table);
593        same!(dispatch_slots);
594        same!(component_slots);
595        same!(active_groups);
596        same!(group_sched_table);
597        same!(monitor_violations);
598    }
599
600    #[test]
601    fn u64_backing_covers_all_field_aligns() {
602        assert!(align_of::<Option<CallbackMeta>>() <= 8);
603        assert!(align_of::<Option<SchedContext>>() <= 8);
604        assert!(align_of::<SchedContextId>() <= 8);
605        assert!(align_of::<Option<SporadicState>>() <= 8);
606        // phase-409 — the nine that moved. A type whose alignment exceeds 8
607        // would make the `u64` backing insufficient for the whole carve, not
608        // just for its own table.
609        assert!(align_of::<NodeRecord>() <= 8);
610        assert!(align_of::<ConcreteSession>() <= 8);
611        assert!(align_of::<ExtraSessionId>() <= 8);
612        assert!(align_of::<NodeSchedEntry>() <= 8);
613        assert!(align_of::<DispatchSlot>() <= 8);
614        assert!(align_of::<ComponentSlot>() <= 8);
615        assert!(align_of::<GroupName>() <= 8);
616        assert!(align_of::<GroupSchedEntry>() <= 8);
617        assert!(align_of::<Violation>() <= 8);
618        assert!(executor_storage_layout(DEFAULT).align() <= 8);
619    }
620
621    /// phase-409 (issue 0961) — every knob-scaled table's per-slot cost is
622    /// charged to the BACKING, which is where the caller put it, and not to the
623    /// `Executor` value, which is what `open_in` builds on the stack and
624    /// returns by value.
625    ///
626    /// Deltas rather than absolute sizes, the shape
627    /// `the_default_subscription_buffer_is_unchanged` uses: a total says
628    /// nothing about which knob moved it, while the difference between two
629    /// sizings isolates exactly one knob's per-slot width.
630    #[test]
631    fn every_knob_scaled_table_is_charged_to_the_backing() {
632        let base = executor_storage_layout(DEFAULT).size();
633
634        let one_more_node = executor_storage_layout(ExecutorSizing {
635            nodes: DEFAULT.nodes + 1,
636            ..DEFAULT
637        })
638        .size();
639        let per_node = size_of::<NodeRecord>()
640            + size_of::<ConcreteSession>()
641            + size_of::<ExtraSessionId>()
642            + size_of::<NodeSchedEntry>()
643            + size_of::<DispatchSlot>()
644            + size_of::<ComponentSlot>()
645            + size_of::<GroupName>();
646        assert!(
647            one_more_node - base >= per_node,
648            "one more Node slot must cost its seven tables ({per_node} B) in the \
649             backing; it cost {}",
650            one_more_node - base
651        );
652
653        // `arena` is its own field, so `..DEFAULT` holds it while `cbs` moves —
654        // which isolates the callback-indexed tables. `group_sched_table` is the
655        // one that coupled `NROS_EXECUTOR_MAX_CBS` to the main thread's stack.
656        let one_more_cb = executor_storage_layout(ExecutorSizing {
657            cbs: DEFAULT.cbs + 1,
658            ..DEFAULT
659        })
660        .size();
661        assert!(
662            one_more_cb - base >= size_of::<GroupSchedEntry>(),
663            "one more callback slot must cost a `group_sched_table` entry \
664             ({} B) in the backing; it cost {}",
665            size_of::<GroupSchedEntry>(),
666            one_more_cb - base
667        );
668    }
669
670    /// The other half of the same claim, and the one issue 0961 is about: the
671    /// VALUE does not move.
672    ///
673    /// A CEILING rather than an equality, because the header's exact size is a
674    /// target + feature detail — measured 1016 B on the `std` lane and 2048 B
675    /// under `--all-features`, and the difference is almost entirely the primary
676    /// session (`SessionStore` is 16 B over `MockSession`, 536 B over
677    /// `CffiSession`) plus `scheduler-os-priority`'s worker pool. Both of those
678    /// are allowed for BY NAME below, so what is left is a tight budget on the
679    /// header proper.
680    ///
681    /// It is a ceiling on the same number at every knob value, which is the
682    /// property under test: 1016 B at BOTH the shipped defaults and the island's
683    /// `MAX_CBS=36` / `MAX_NODES=6`. Before this phase it was 5072 B and
684    /// 12768 B respectively.
685    ///
686    /// If this fires for a field you MEANT to add, the fix is almost always to
687    /// carve it rather than to raise the ceiling: `Executor::open_in` builds
688    /// this value on the stack and returns it by value, and `nros_cpp_init`
689    /// holds the returned value, so every byte is charged to two frames of every
690    /// image's boot path.
691    #[test]
692    fn the_executor_value_does_not_scale_with_the_knobs() {
693        let value = size_of::<super::super::spin::Executor<'static>>();
694        // Two things in the header are legitimately large and are NOT scaled by
695        // `MAX_CBS` / `MAX_NODES`, so they get named allowances rather than a
696        // looser ceiling for every build:
697        //   * the primary session, held by value (that is what owning a session
698        //     means), whose size is the backend's, not a knob's;
699        //   * `scheduler-os-priority`'s worker pool, a pair of `FnvIndexMap`s
700        //     sized by `MAX_PRIORITY_LEVELS` — an opt-in capability.
701        #[allow(unused_mut)]
702        let mut ceiling = 1280 + size_of::<super::super::spin::SessionStore>();
703        #[cfg(all(
704            feature = "alloc",
705            feature = "rmw-cffi",
706            feature = "scheduler-os-priority"
707        ))]
708        {
709            ceiling += size_of::<super::super::os_priority::OsPriorityPool>();
710        }
711        assert!(
712            value <= ceiling,
713            "`Executor` is {value} B at MAX_CBS={CBS} / MAX_NODES={NODES}, over \
714             the {ceiling} B this value is budgeted; a table that scales with a \
715             knob has come back inline (issue 0961)."
716        );
717    }
718
719    // phase-361 W8.e / issue 0594 — this test heap-allocates its backing array,
720    // so it needs `alloc`. It compiled under `--no-default-features` only
721    // because feature unification from another workspace member happened to
722    // turn `nros-node/alloc` on; nothing does that now.
723    #[cfg(feature = "alloc")]
724    #[test]
725    fn carve_yields_right_lengths_and_inits() {
726        // Heap-allocate: the default test config (MAX_CBS/MAX_SC/ARENA_SIZE from
727        // build.rs) makes this backing array tens of KB, well past
728        // `clippy::large_stack_arrays`'s threshold — and the size here is
729        // incidental (mirrors production config), not the point under test, so
730        // boxing is the right fix rather than an allow.
731        let mut backing =
732            alloc::vec![const { MaybeUninit::<u64>::uninit() }; executor_storage_u64_len(DEFAULT)]
733                .into_boxed_slice();
734        let s = unsafe { carve(&mut backing, DEFAULT) };
735        assert_eq!(s.arena.len(), ARENA);
736        assert_eq!(s.entries.len(), CBS);
737        assert_eq!(s.sched_contexts.len(), SC);
738        assert_eq!(s.sched_context_bindings.len(), CBS);
739        assert_eq!(s.sporadic_states.len(), SC);
740        assert!(s.entries.iter().all(|e| e.is_none()));
741        assert!(s.sched_context_bindings.iter().all(|b| b.0 == 0));
742        // phase-409 — the CarvedVecs come back empty at their carved capacity.
743        assert_eq!(s.nodes.capacity(), NODES);
744        assert_eq!(s.extra_sessions.capacity(), NODES);
745        assert_eq!(s.extra_session_ids.capacity(), NODES);
746        assert_eq!(s.node_sched_table.capacity(), NODES);
747        assert_eq!(s.dispatch_slots.capacity(), NODES);
748        assert_eq!(s.component_slots.capacity(), NODES);
749        assert_eq!(s.active_groups.capacity(), NODES);
750        assert_eq!(s.group_sched_table.capacity(), CBS);
751        assert_eq!(s.monitor_violations.capacity(), MAX_VIOLATIONS);
752        assert_eq!(s.nodes.len(), 0);
753        assert_eq!(s.group_sched_table.len(), 0);
754    }
755
756    #[test]
757    fn carved_vec_pushes_in_order_and_refuses_when_full() {
758        let mut slots = [const { MaybeUninit::<u32>::uninit() }; 3];
759        let mut v = CarvedVec::new(&mut slots);
760        assert_eq!(v.capacity(), 3);
761        assert!(v.push(10).is_ok());
762        assert!(v.push(20).is_ok());
763        assert!(v.push(30).is_ok());
764        // Order is load-bearing: `NodeId` IS an index into `nodes`.
765        assert_eq!(v.as_slice(), &[10, 20, 30]);
766        // Full is a REFUSAL that hands the value back, like `heapless::Vec`.
767        assert_eq!(v.push(40), Err(40));
768        v.clear();
769        assert!(v.is_empty());
770        assert!(v.push(50).is_ok());
771        assert_eq!(v.as_slice(), &[50]);
772    }
773
774    /// The elements live in the CALLER's backing, so nothing drops them unless
775    /// the vector does — and `extra_sessions` holds RMW sessions. Without this
776    /// a bridge executor would leak every extra session it opened.
777    #[test]
778    fn carved_vec_drops_its_elements() {
779        use core::sync::atomic::{AtomicUsize, Ordering};
780        static DROPS: AtomicUsize = AtomicUsize::new(0);
781        struct Counted;
782        impl Drop for Counted {
783            fn drop(&mut self) {
784                DROPS.fetch_add(1, Ordering::SeqCst);
785            }
786        }
787
788        let mut slots = [const { MaybeUninit::<Counted>::uninit() }; 4];
789        {
790            let mut v = CarvedVec::new(&mut slots);
791            assert!(v.push(Counted).is_ok());
792            assert!(v.push(Counted).is_ok());
793        }
794        assert_eq!(
795            DROPS.load(Ordering::SeqCst),
796            2,
797            "both pushed elements dropped — and only the pushed ones, not the \
798             uninitialised tail"
799        );
800    }
801}