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::{AliveSlot, MAX_VIOLATIONS, Violation},
28    node_record::NodeRecord,
29    sched_context::{SchedContext, SchedContextId, SporadicState},
30    spin::{ComponentSlot, DispatchSlot, MAX_REMAPS, RemapRule},
31    types::SlotTag,
32};
33use crate::session::ConcreteSession;
34
35/// Issue 1812 — the parameter services' carved home. A zero-sized stand-in
36/// where the services are not compiled, so the region costs nothing there and
37/// `carve` still has one type to place.
38#[cfg(feature = "param-store")]
39pub(crate) type ParamHome = crate::param_store::ParamHome;
40#[cfg(not(feature = "param-store"))]
41pub(crate) type ParamHome = ();
42
43#[cfg(feature = "alloc")]
44type SporadicAtomic = (
45    portable_atomic_util::Arc<super::sched_context::AtomicSporadicState>,
46    super::spin::OpaqueTimerHandle,
47);
48
49/// phase-272 — `(node name, namespace, sched context)`, keyed by the node's
50/// fully-qualified identity.
51pub(crate) type NodeSchedEntry = (heapless::String<64>, heapless::String<64>, SchedContextId);
52/// phase-273 — [`NodeSchedEntry`] plus the callback-group name that narrows it.
53pub(crate) type GroupSchedEntry = (
54    heapless::String<64>,
55    heapless::String<64>,
56    heapless::String<32>,
57    SchedContextId,
58);
59/// Issue 0436 — `(rmw name, locator)` for one extra session.
60pub(crate) type ExtraSessionId = (heapless::String<32>, heapless::String<128>);
61/// Phase 228.C — one callback-group name in a tier executor's filter.
62pub(crate) type GroupName = heapless::String<32>;
63
64/// The node half of an [`ActiveGroup`] key: a hash of `(name, namespace)`.
65///
66/// FNV-1a, 64-bit, over `name` then `\0` then the normalised namespace. The
67/// separator matters: without it `("ab", "c")` and `("a", "bc")` hash alike,
68/// and a node name may legally contain any of the namespace's characters.
69///
70/// FNV because the tree already depends on it (heapless's `FnvIndexMap`), it
71/// is `const`-friendly and allocation-free, and this runs on the registration
72/// path of every entity on an MCU. A 64-bit collision across the handful of
73/// nodes in one image is not a risk worth a bigger key: at 32 entries the
74/// probability is around 3e-17.
75///
76/// ONE home, deliberately. Both ends of the comparison call this —
77/// `set_active_groups` hashing what the tier declared, and `create_entity`
78/// hashing the node an entity belongs to. Two hashers that agree by
79/// inspection is exactly the defect class issue 1172 is filed under.
80pub(crate) fn node_identity_hash(name: &str, namespace: &str) -> u64 {
81    const OFFSET: u64 = 0xcbf2_9ce4_8422_2325;
82    const PRIME: u64 = 0x0000_0100_0000_01b3;
83    let norm_ns = if namespace.is_empty() { "/" } else { namespace };
84    let mut h = OFFSET;
85    for b in name
86        .as_bytes()
87        .iter()
88        .chain(core::iter::once(&0u8))
89        .chain(norm_ns.as_bytes())
90    {
91        h ^= *b as u64;
92        h = h.wrapping_mul(PRIME);
93    }
94    h
95}
96
97/// One entry of a tier's active-group filter: `(node identity hash, group)`.
98///
99/// issue 1172 — the filter used to be the GROUP NAME ALONE, which cannot tell
100/// node A's `ctrl` from node B's `ctrl`. Two nodes may legitimately declare the
101/// same group id on different tiers and nothing rejects it, so a tier filter
102/// was admitting entities it did not own. The sibling table for the
103/// sched-context path (`group_sched_table`, `bind_group_sched`) has been
104/// node-qualified all along; only this one was not.
105///
106/// The node half is a HASH of `(name, namespace)` rather than the strings.
107/// Storing them would cost 184 B per entry against this table's 48 B, and the
108/// table is CARVED one entry per node slot — +144 B per slot, which is 4.6 KB
109/// of static executor arena at 32 slots. The group name stays readable because
110/// a refused filter should be able to say WHICH group it refused.
111///
112/// The hash has exactly one home, [`node_identity_hash`], and both ends of the
113/// comparison call it — codegen never hashes anything, it emits plain strings.
114/// A second hasher would be this phase's own defect in a new place.
115pub(crate) type ActiveGroup = (u64, GroupName);
116
117// ============================================================================
118// CarvedVec — a `heapless::Vec` whose capacity lives in the caller's backing
119// ============================================================================
120
121/// A push-only vector over CARVED storage: the elements live in the caller's
122/// backing, the `Executor` holds only a fat pointer and a fill cursor.
123///
124/// phase-409 (issue 0961) — this is the shape phase-271 gave the six sized
125/// tables, generalised so the other nine can use it too. It exists because
126/// `heapless::Vec<T, N>` puts `N * size_of::<T>()` bytes INSIDE the struct, so
127/// every knob that sets an `N` also sets the stack frame of every function that
128/// moves an `Executor`: `MAX_CBS` 14 -> 36 (a handle-count fix) added ~3.7 KiB
129/// to `Executor::open_in`'s prologue on a part whose main thread has 32 KiB in
130/// total.
131///
132/// Deliberately `MaybeUninit<T>` rather than `Option<T>`, for three reasons:
133/// [`as_slice`](Self::as_slice) can hand back the `&[T]` the public
134/// `Executor::nodes()` accessor already returns; `carve` writes NOTHING at open
135/// (the `memclr` in issue 0961's fault was the executor's tables being zeroed);
136/// and the layout matches `heapless::Vec`'s, so the storage cost is the same
137/// bytes in a different place.
138///
139/// Occupied slots are `[0, len)` and IN PUSH ORDER — several callers index by
140/// insertion position (`NodeId` IS an index into `nodes`; `session_idx` is an
141/// index into `extra_sessions`), so this must never gain a `swap_remove`.
142pub(crate) struct CarvedVec<'s, T> {
143    slots: &'s mut [MaybeUninit<T>],
144    len: usize,
145}
146
147impl<'s, T> CarvedVec<'s, T> {
148    /// Wrap carved, UNINITIALISED storage as an empty vector.
149    fn new(slots: &'s mut [MaybeUninit<T>]) -> Self {
150        Self { slots, len: 0 }
151    }
152
153    /// Slots this vector can hold — the entry's sizing, not a build-time const.
154    pub(crate) fn capacity(&self) -> usize {
155        self.slots.len()
156    }
157
158    /// Append `value`, returning it in `Err` when full (`heapless::Vec::push`).
159    pub(crate) fn push(&mut self, value: T) -> Result<(), T> {
160        if self.len == self.capacity() {
161            return Err(value);
162        }
163        self.slots[self.len].write(value);
164        self.len += 1;
165        Ok(())
166    }
167
168    /// Remove and return the OLDEST element (index 0), shifting the rest down
169    /// one so push order is kept. O(len); phase-474 I1's violation ring is its
170    /// one caller, at `MAX_VIOLATIONS` (default 8) slots.
171    pub(crate) fn pop_front(&mut self) -> Option<T> {
172        if self.len == 0 {
173            return None;
174        }
175        // SAFETY: `[0, len)` is initialised; slot 0 is read out exactly once
176        // and then overwritten by the shift (or left outside `[0, len - 1)`).
177        let first = unsafe { self.slots[0].assume_init_read() };
178        let base = self.slots.as_mut_ptr();
179        // SAFETY: both ranges lie inside `slots`; `copy` handles the overlap.
180        unsafe { core::ptr::copy(base.add(1), base, self.len - 1) };
181        self.len -= 1;
182        Some(first)
183    }
184
185    /// Drop every element and reset the cursor.
186    pub(crate) fn clear(&mut self) {
187        let len = core::mem::replace(&mut self.len, 0);
188        if needs_drop::<T>() {
189            for slot in &mut self.slots[..len] {
190                // SAFETY: `[0, len)` was initialised by `push` and is dropped
191                // exactly once — `len` is reset above before this loop runs, so
192                // a panicking `T::drop` cannot make a second pass see it.
193                unsafe { slot.assume_init_drop() };
194            }
195        }
196    }
197
198    /// The initialised prefix.
199    pub(crate) fn as_slice(&self) -> &[T] {
200        // SAFETY: `push` initialises `[0, len)` and nothing shortens `len`
201        // without dropping (see `clear`).
202        unsafe { &*(&self.slots[..self.len] as *const [MaybeUninit<T>] as *const [T]) }
203    }
204
205    /// The initialised prefix, mutably.
206    pub(crate) fn as_mut_slice(&mut self) -> &mut [T] {
207        // SAFETY: as [`as_slice`](Self::as_slice).
208        unsafe { &mut *(&mut self.slots[..self.len] as *mut [MaybeUninit<T>] as *mut [T]) }
209    }
210}
211
212impl<T> Deref for CarvedVec<'_, T> {
213    type Target = [T];
214    fn deref(&self) -> &[T] {
215        self.as_slice()
216    }
217}
218
219impl<T> DerefMut for CarvedVec<'_, T> {
220    fn deref_mut(&mut self) -> &mut [T] {
221        self.as_mut_slice()
222    }
223}
224
225impl<T> Drop for CarvedVec<'_, T> {
226    fn drop(&mut self) {
227        // The backing is the CALLER's memory, so nothing drops these elements
228        // for us — and one of these tables is `extra_sessions`, whose elements
229        // close RMW sessions. A `Drop` impl on the vector (rather than a pass in
230        // `Executor::drop`) keeps the ORDER the inline `heapless::Vec`s had:
231        // `Executor`'s fields drop in declaration order, so the primary
232        // `session` still closes before the extras.
233        self.clear();
234    }
235}
236
237// ============================================================================
238// Layout
239// ============================================================================
240
241/// The typed reference layout the [`carve`] mirrors. `#[repr(C)]` so its field
242/// offsets are the deterministic declaration-order layout the const-fn below
243/// reproduces; a unit test asserts they agree. Only referenced by tests.
244#[cfg(test)]
245#[repr(C)]
246pub(crate) struct ExecutorStorage<
247    const CBS: usize,
248    const SC: usize,
249    const ARENA: usize,
250    const NODES: usize,
251    const PARAMS: usize,
252    const HOMES: usize,
253> {
254    arena: [MaybeUninit<u8>; ARENA],
255    entries: [Option<CallbackMeta>; CBS],
256    sched_contexts: [Option<SchedContext>; SC],
257    sched_context_bindings: [SchedContextId; CBS],
258    sporadic_states: [Option<SporadicState>; SC],
259    #[cfg(feature = "alloc")]
260    sporadic_atomic_states: [Option<SporadicAtomic>; SC],
261    remaps: [Option<RemapRule>; MAX_REMAPS],
262    // phase-409 — the nine that phase-271 left inline.
263    nodes: [MaybeUninit<NodeRecord>; NODES],
264    extra_sessions: [MaybeUninit<ConcreteSession>; NODES],
265    extra_session_ids: [MaybeUninit<ExtraSessionId>; NODES],
266    node_sched_table: [MaybeUninit<NodeSchedEntry>; NODES],
267    dispatch_slots: [MaybeUninit<DispatchSlot>; NODES],
268    component_slots: [MaybeUninit<ComponentSlot>; NODES],
269    active_groups: [MaybeUninit<ActiveGroup>; NODES],
270    group_sched_table: [MaybeUninit<GroupSchedEntry>; CBS],
271    monitor_violations: [MaybeUninit<Violation>; MAX_VIOLATIONS],
272    alive_slots: [AliveSlot; SC],
273    slot_tags: [SlotTag; CBS],
274    // phase-382 W3' — the parameter store, LAST.
275    params: [MaybeUninit<nros_params::ParameterSlot>; PARAMS],
276    // issue 1812 — the parameter services' home, after the store.
277    param_home: [MaybeUninit<ParamHome>; HOMES],
278}
279
280/// The typed, mutable sub-slices an [`Executor`](super::spin::Executor) borrows
281/// from a carved backing. Element memory is initialised by [`carve`] for the
282/// `Option`/`SchedContextId` tables; the [`CarvedVec`] tables are handed over
283/// UNINITIALISED and fill on push.
284pub(crate) struct ExecutorSlices<'s> {
285    pub(crate) arena: &'s mut [MaybeUninit<u8>],
286    pub(crate) entries: &'s mut [Option<CallbackMeta>],
287    pub(crate) sched_contexts: &'s mut [Option<SchedContext>],
288    pub(crate) sched_context_bindings: &'s mut [SchedContextId],
289    pub(crate) sporadic_states: &'s mut [Option<SporadicState>],
290    #[cfg(feature = "alloc")]
291    pub(crate) sporadic_atomic_states: &'s mut [Option<SporadicAtomic>],
292    /// Issue 0563 — the SEVENTH sized table. phase-271 moved six of these out
293    /// of `Executor` and into caller-owned storage; the remap table was left
294    /// inline and grew to 6664 bytes of an 11632-byte struct (57%), which is
295    /// what made constructing an executor a ~9.3 KB STACK temporary and
296    /// overflowed the Zephyr Cortex-M main stack in issue 0552.
297    ///
298    /// Fixed count (`MAX_REMAPS`) rather than a new `ExecutorSizing` knob:
299    /// the capability is unchanged, so this needs no public API change and no
300    /// regeneration of every entry's sizing. The required backing grows by the
301    /// same bytes, but that lands in the caller's STATIC buffer instead of on
302    /// the stack, which is the entire point.
303    pub(crate) remaps: &'s mut [Option<RemapRule>],
304    // phase-409 (issue 0961) — the remaining nine, same reasoning one campaign
305    // later. `MAX_CBS` 14 -> 36 put ~3.7 KiB of `group_sched_table` on the stack
306    // of every function that moves an `Executor`.
307    pub(crate) nodes: CarvedVec<'s, NodeRecord>,
308    pub(crate) extra_sessions: CarvedVec<'s, ConcreteSession>,
309    pub(crate) extra_session_ids: CarvedVec<'s, ExtraSessionId>,
310    pub(crate) node_sched_table: CarvedVec<'s, NodeSchedEntry>,
311    pub(crate) dispatch_slots: CarvedVec<'s, DispatchSlot>,
312    pub(crate) component_slots: CarvedVec<'s, ComponentSlot>,
313    pub(crate) active_groups: CarvedVec<'s, ActiveGroup>,
314    pub(crate) group_sched_table: CarvedVec<'s, GroupSchedEntry>,
315    pub(crate) monitor_violations: CarvedVec<'s, Violation>,
316    /// Alive supervision, one record per SC. Initialised by [`carve`] like the
317    /// SC tables above it: a zeroed record is the correct starting state.
318    pub(crate) alive_slots: &'s mut [AliveSlot],
319    /// phase-476 W0 — one lifetime tag per callback slot, parallel to
320    /// `entries`. Initialised by [`carve`] to the default (generation 0, no
321    /// owner), which is what a never-used slot is.
322    pub(crate) slot_tags: &'s mut [SlotTag],
323    /// phase-382 W3' — the EIGHTH sized table phase-382 planned and the region
324    /// issue 1706 asked for: the parameter store's slots, handed over
325    /// UNINITIALISED. The executor initialises them the first time something
326    /// declares a parameter (`nros_params::ParameterTable::init_in`), so an
327    /// image that carves a store and never declares pays the `.bss` and no
328    /// boot time. Empty when the sizing carves no store, which is every image
329    /// the build does not know to build one ([`ExecutorSizing::params`]).
330    pub(crate) params: &'s mut [MaybeUninit<nros_params::ParameterSlot>],
331    /// Issue 1812 — the parameter SERVICES' home ([`ParamHome`]): one when the
332    /// sizing carves a store, none otherwise. Handed over UNINITIALISED, like
333    /// `params`; `ParamHome::init_in` builds the state in it the first time
334    /// something asks for the store.
335    pub(crate) param_home: &'s mut [MaybeUninit<ParamHome>],
336}
337
338/// Byte offsets of each field within the backing + total size/align. Computed
339/// identically by [`executor_storage_layout`] and [`carve`] (single source of
340/// truth), reproducing `#[repr(C)]` declaration-order layout.
341struct FieldOffsets {
342    arena: usize,
343    entries: usize,
344    sched_contexts: usize,
345    sched_context_bindings: usize,
346    sporadic_states: usize,
347    #[cfg(feature = "alloc")]
348    sporadic_atomic_states: usize,
349    remaps: usize,
350    nodes: usize,
351    extra_sessions: usize,
352    extra_session_ids: usize,
353    node_sched_table: usize,
354    dispatch_slots: usize,
355    component_slots: usize,
356    active_groups: usize,
357    group_sched_table: usize,
358    monitor_violations: usize,
359    alive_slots: usize,
360    slot_tags: usize,
361    params: usize,
362    param_home: usize,
363    size: usize,
364    align: usize,
365}
366
367/// The placement arithmetic and its data types live in `nros-executor-layout`
368/// (issue 1197) so a HOST build script can evaluate them for a target it is not
369/// compiling. Re-exported here because this is where every consumer already
370/// looks, and because `NATIVE` — the only part that names the types — has to
371/// live in the crate that owns them.
372pub use nros_executor_layout::{Counts as RegionCounts, RegionUnit, RegionUnits};
373
374/// What THIS compiler says, for the target it is compiling.
375///
376/// The one thing the shared crate cannot provide: it would have to name the
377/// types, and they live here.
378pub const NATIVE_UNITS: RegionUnits = RegionUnits {
379    callback_meta: unit_of::<Option<CallbackMeta>>(),
380    sched_context: unit_of::<Option<SchedContext>>(),
381    sched_context_id: unit_of::<SchedContextId>(),
382    sporadic_state: unit_of::<Option<SporadicState>>(),
383    #[cfg(feature = "alloc")]
384    sporadic_atomic: unit_of::<Option<SporadicAtomic>>(),
385    #[cfg(not(feature = "alloc"))]
386    sporadic_atomic: RegionUnit { size: 0, align: 1 },
387    remap_rule: unit_of::<Option<RemapRule>>(),
388    node_record: unit_of::<NodeRecord>(),
389    concrete_session: unit_of::<ConcreteSession>(),
390    extra_session_id: unit_of::<ExtraSessionId>(),
391    node_sched_entry: unit_of::<NodeSchedEntry>(),
392    dispatch_slot: unit_of::<DispatchSlot>(),
393    component_slot: unit_of::<ComponentSlot>(),
394    group_name: unit_of::<ActiveGroup>(),
395    group_sched_entry: unit_of::<GroupSchedEntry>(),
396    violation: unit_of::<Violation>(),
397    alive_slot: unit_of::<AliveSlot>(),
398    slot_tag: unit_of::<SlotTag>(),
399    parameter_slot: unit_of::<nros_params::ParameterSlot>(),
400    param_home: unit_of::<ParamHome>(),
401};
402
403/// issue 1197 — publish the backing's TOTAL size as a symbol whose storage size
404/// IS the number, so a build script can recover it from the rlib without running
405/// anything. `nros-sizes-build::extract_sizes` reads exactly this shape, the way
406/// `nros-c` recovers `EXECUTOR_OPAQUE_U64S`.
407///
408/// **The total rather than the per-region units, and that is a decision.** The
409/// units alone would be more robust — they do not vary with the executor knobs,
410/// so a probe cache keyed by (target, features) is correct for them, and the
411/// caller supplies `alloc` and the counts explicitly. But the backing is
412/// `arena + tables`, and `ARENA_SIZE` is DERIVED in this crate's build script
413/// from `MAX_CBS`, `RX_BUF_SIZE` and `ACTION_CLIENTS`. A consumer computing from
414/// units would have to re-implement that derivation — a second implementation,
415/// which is the whole thing this design refuses. So the consumer probes the one
416/// number, and keys its probe directory by the executor env, because unlike the
417/// units this DOES vary with it.
418///
419/// Behind a feature and OFF by default: it is `#[used]`, and a shipped image
420/// should not carry a static that exists only to be measured. The probe enables
421/// it for its own nested build, which is a separate compilation from the linked
422/// one.
423#[cfg(feature = "layout-size-markers")]
424#[used]
425#[unsafe(no_mangle)]
426#[doc(hidden)]
427pub static __NROS_LU_SZ_executor_backing: [u8;
428    crate::executor::backing::EXECUTOR_BACKING_DEFAULT_U64S * 8] =
429    [0u8; crate::executor::backing::EXECUTOR_BACKING_DEFAULT_U64S * 8];
430
431const fn unit_of<T>() -> RegionUnit {
432    RegionUnit {
433        size: size_of::<T>(),
434        align: align_of::<T>(),
435    }
436}
437
438/// The counts this build was configured with, for `sizing`.
439const fn counts_for(sizing: ExecutorSizing) -> RegionCounts {
440    RegionCounts {
441        cbs: sizing.cbs,
442        sc: sizing.sc,
443        nodes: sizing.nodes,
444        arena: sizing.arena,
445        remaps: MAX_REMAPS,
446        violations: MAX_VIOLATIONS,
447        alloc: cfg!(feature = "alloc"),
448        params: sizing.params,
449        param_home: param_homes(sizing),
450    }
451}
452
453/// Issue 1812 — how many parameter-service homes a `sizing` carves: one exactly
454/// when it carves a store. The services are what the store is FOR on an image
455/// that declares parameters, so the two travel together, and an image whose
456/// build knows of no store pays for neither (issue 1702's rule).
457pub(crate) const fn param_homes(sizing: ExecutorSizing) -> usize {
458    if sizing.params > 0 { 1 } else { 0 }
459}
460
461const fn compute_offsets(sizing: ExecutorSizing) -> FieldOffsets {
462    compute_offsets_with(sizing, &NATIVE_UNITS)
463}
464
465/// Adapter over the shared arithmetic: same numbers, named the way `carve`
466/// wants them. The placement itself is `nros_executor_layout::offsets` — one
467/// implementation, so an external evaluation cannot drift from this one.
468///
469/// By reference: the unit table crossed clippy's 256-byte pass-by-value line
470/// when phase-476 W0 added `slot_tag`.
471const fn compute_offsets_with(sizing: ExecutorSizing, units: &RegionUnits) -> FieldOffsets {
472    let o = nros_executor_layout::offsets(counts_for(sizing), *units);
473    FieldOffsets {
474        arena: o.arena,
475        entries: o.entries,
476        sched_contexts: o.sched_contexts,
477        sched_context_bindings: o.sched_context_bindings,
478        sporadic_states: o.sporadic_states,
479        #[cfg(feature = "alloc")]
480        sporadic_atomic_states: o.sporadic_atomic_states,
481        remaps: o.remaps,
482        nodes: o.nodes,
483        extra_sessions: o.extra_sessions,
484        extra_session_ids: o.extra_session_ids,
485        node_sched_table: o.node_sched_table,
486        dispatch_slots: o.dispatch_slots,
487        component_slots: o.component_slots,
488        active_groups: o.active_groups,
489        group_sched_table: o.group_sched_table,
490        monitor_violations: o.monitor_violations,
491        alive_slots: o.alive_slots,
492        slot_tags: o.slot_tags,
493        params: o.params,
494        param_home: o.param_home,
495        size: o.size,
496        align: o.align,
497    }
498}
499
500/// Byte [`Layout`] of the backing, evaluated against a SUPPLIED unit table.
501///
502/// issue 1197 — the public half of the split. `executor_storage_layout` is this
503/// with `NATIVE_UNITS`; an external consumer that has recovered the
504/// units for another target (a build script probing an rlib, the way `nros-c`
505/// recovers `EXECUTOR_OPAQUE_U64S`) gets the same arithmetic rather than a
506/// second implementation of it.
507///
508/// The OFFSETS stay private: only `carve` needs them, and it runs in the crate
509/// that owns the types. A consumer sizing a reservation needs the total.
510pub const fn executor_storage_layout_with(sizing: ExecutorSizing, units: RegionUnits) -> Layout {
511    let o = compute_offsets_with(sizing, &units);
512    // SAFETY: as `executor_storage_layout` — `align` is a max of alignments and
513    // `size` is rounded up to it.
514    unsafe { Layout::from_size_align_unchecked(o.size, o.align) }
515}
516
517/// Byte [`Layout`] of the backing a `sizing`-sized executor needs.
518/// Public + non-generic so the macro / FFI can size a raw backing.
519pub const fn executor_storage_layout(sizing: ExecutorSizing) -> Layout {
520    let o = compute_offsets(sizing);
521    // SAFETY: `align` is a power of two (a `max` of `align_of` results) and `size`
522    // is rounded up to it; both are non-zero.
523    unsafe { Layout::from_size_align_unchecked(o.size, o.align) }
524}
525
526/// Number of `u64` words a backing must hold for a `sizing`-sized executor.
527/// `u64` backing is 8-aligned, which covers every field (all `align_of ≤ 8`;
528/// asserted in tests), so the entry never hand-aligns. The macro emits
529/// `[MaybeUninit<u64>; executor_storage_u64_len(sizing)]`.
530pub const fn executor_storage_u64_len(sizing: ExecutorSizing) -> usize {
531    executor_storage_layout(sizing).size().div_ceil(8)
532}
533
534/// Per-entry executor sizing — the entity counts an [`Executor`](super::spin::Executor)
535/// is built to hold. **Public + non-generic** (the "C/C++ is a thin wrapper"
536/// principle): the entry / macro / FFI supplies these as plain `usize`s rather
537/// than as type/const generics C can't name. Used to size + carve the backing.
538///
539/// `cbs` is capped at 64 by the executor's `u64` ready-set bitmask (asserted in
540/// `carve`-time / `open_in`).
541#[derive(Clone, Copy)]
542pub struct ExecutorSizing {
543    /// Callback-table slots (`entries`, the per-entry SC bindings, and the
544    /// per-callback-group sched table). ≤ 64.
545    pub cbs: usize,
546    /// Scheduling-context slots (`sched_contexts`, the sporadic state tables
547    /// and alive supervision's per-SC records).
548    pub sc: usize,
549    /// Bump-allocator arena size in bytes.
550    pub arena: usize,
551    /// phase-409 — Node slots. One worst-case extra session, extra-session id,
552    /// node-sched binding, dispatch slot, component slot and callback-group
553    /// filter entry per Node, so ONE count covers all seven tables (that is the
554    /// upper bound each of them already assumed under `MAX_NODES`).
555    pub nodes: usize,
556    /// phase-382 W3' — parameter-store slots carved from the backing.
557    ///
558    /// The default is [`nros_params::IMPLIED_STORE_SLOTS`]: the store's full
559    /// `MAX_PARAMETERS` when the build KNOWS the image builds one (the
560    /// contract declares a parameter, the bringup declares `param_services`,
561    /// or the launch seeds a `<param>`), and ZERO otherwise. Zero is not a
562    /// degraded mode: an executor that carved no store and is asked for one
563    /// allocates it from the heap exactly as before, with issue 1706's named
564    /// refusal if the heap cannot hold it.
565    ///
566    /// Conditional rather than fixed, and that is a decision measured in
567    /// phase-382's W3' notes: a fixed `MAX_PARAMETERS` region adds the whole
568    /// store (280,832 B on armv7m at the default 32 slots) to every image's
569    /// backing — Rust `EXECUTOR_BACKING`, every C/C++ `rclc_executor_t`, every
570    /// tier slot — which is issue 1702's rejected `.bss` static, everywhere.
571    pub params: usize,
572}
573
574impl ExecutorSizing {
575    /// The build-time default (`MAX_CBS`/`MAX_SC`/`ARENA_SIZE`/`MAX_NODES`
576    /// consts) — the backward-compatible size the `alloc` convenience
577    /// constructors leak.
578    pub const DEFAULT: Self = Self {
579        cbs: crate::config::MAX_CBS,
580        sc: crate::config::MAX_SC,
581        arena: crate::config::ARENA_SIZE,
582        nodes: crate::config::MAX_NODES,
583        params: nros_params::IMPLIED_STORE_SLOTS,
584    };
585
586    /// `u64` words a backing must hold for this sizing (see
587    /// [`executor_storage_u64_len`]).
588    pub const fn u64_len(&self) -> usize {
589        executor_storage_u64_len(*self)
590    }
591
592    /// phase-412 #4 — refuse, at COMPILE time, a sizing whose arena is below
593    /// what this image's declared entities are modelled to need.
594    ///
595    /// For a caller that supplies its own `const` sizing for the image's ONE
596    /// executor (`open_in` over a `static` sized with [`u64_len`](Self::u64_len)),
597    /// so its arena is held to the same model the build-time default is:
598    ///
599    /// ```ignore
600    /// const SIZING: nros::ExecutorSizing = nros::ExecutorSizing { .. };
601    /// const _: () = SIZING.assert_covers_model("SIZING.arena");
602    /// ```
603    ///
604    /// Opt-in, deliberately: an executor that holds a SUBSET of the image (one
605    /// tier of a tiered boot) legitimately carries less than the whole model,
606    /// and only its author knows which kind it is. See
607    /// [`arena_oracle`](super::arena_oracle) for the arms and why each is or is
608    /// not checked here.
609    pub const fn assert_covers_model(&self, source: &'static str) {
610        super::arena_oracle::assert_covers_model(self.arena, source)
611    }
612}
613
614/// The exact `#[repr(C)]` byte layout the C/C++ FFI's inline executor buffer must
615/// hold: an [`Executor`](super::spin::Executor)`<'static>` header immediately
616/// followed by a default-sized ([`ExecutorSizing::DEFAULT`]) storage backing.
617///
618/// The FFI keeps the executor inline (heap-free — matching the Rust no-alloc
619/// requirement) and carves its per-entry tables from the SAME buffer's
620/// [`backing`](Self::backing) tail. Because that buffer is **pinned** — the C
621/// caller allocates it, it is initialised in place, and it is only ever reached
622/// through a stable `rclc_executor_t*` (never moved after init) — the resulting
623/// self-borrow (the header's slices pointing into the same struct's tail) is
624/// sound. The FFI probes `size_of` of THIS type (not bare `Executor`) to size
625/// its `_opaque` array, and reinterprets `_opaque` as `*mut ExecutorInlineStorage`
626/// (the executor stays at offset 0, so existing offset-0 accessors are unchanged).
627///
628/// phase-409 — moving the last nine tables into the backing moves bytes from
629/// `exec` to `backing` and leaves this total roughly where it was; what changes
630/// is the STACK, because the header is what `open_in` builds and returns by
631/// value.
632#[repr(C)]
633pub struct ExecutorInlineStorage {
634    /// The executor, written in place (offset 0) by `from_session_ptr_in`.
635    pub exec: MaybeUninit<super::spin::Executor<'static>>,
636    /// The carved backing the executor's slices borrow (the buffer's tail).
637    pub backing: [MaybeUninit<u64>; ExecutorSizing::DEFAULT.u64_len()],
638}
639
640/// Carve an 8-aligned `u64` backing into the typed executor slices.
641///
642/// # Safety
643/// - `backing.len() * 8` must be ≥ `executor_storage_layout(sizing).size()`.
644/// - The returned slices alias `backing`; it must outlive them (the `'s` bound)
645///   and not be otherwise accessed while they live.
646///
647/// The `Option`/binding tables are initialised here (`entries`/SC tables →
648/// `None`, bindings → `SchedContextId(0)`), so the returned `&mut [T]` reference
649/// validly-init memory. The [`CarvedVec`] tables are handed over empty and
650/// UNINITIALISED — nothing reads past their fill cursor.
651pub(crate) unsafe fn carve<'s>(
652    backing: &'s mut [MaybeUninit<u64>],
653    sizing: ExecutorSizing,
654) -> ExecutorSlices<'s> {
655    let ExecutorSizing {
656        cbs,
657        sc,
658        arena,
659        nodes: node_slots,
660        params: param_slots,
661    } = sizing;
662    let o = compute_offsets(sizing);
663    // Fail-loud on EVERY profile (not `debug_assert!`): embedded release builds
664    // strip debug-assertions, and a backing that is too small is silent memory
665    // corruption — the carved `entries`/`sched_contexts` tables run past the end
666    // of `backing` into whatever .bss follows (e.g. a C carrier's `__nros_c_inst`),
667    // leaving a NULL `drop_fn` that faults in `Executor::drop`. This is exactly
668    // how a STALE config-header mirror (C buffer sized from an out-of-date
669    // `NROS_*_STORAGE_SIZE`) manifested as a `jalr -> 0` on threadx-riscv64 (#131).
670    // Panic here instead, at open, with the two sizes named.
671    assert!(
672        backing.len() * 8 >= o.size,
673        "executor backing too small: {} bytes < {} required — the storage buffer \
674         (NROS_*_STORAGE_SIZE) disagrees with the executor layout; rebuild clean so \
675         the generated config header matches",
676        backing.len() * 8,
677        o.size
678    );
679    let base = backing.as_mut_ptr() as *mut u8;
680
681    // One `CarvedVec` over `$n` slots at byte offset `$at`. No element writes —
682    // the vector starts empty. Expanded inside the `unsafe` block below;
683    // SAFETY there: `compute_offsets` placed `$n` `$ty`s at `$at`, aligned and
684    // inside the asserted `o.size`, and each region is placed exactly once, so
685    // the slices do not alias.
686    macro_rules! carved {
687        ($at:expr, $n:expr, $ty:ty) => {{
688            let p = base.add($at) as *mut MaybeUninit<$ty>;
689            CarvedVec::new(region_slice(p, $n))
690        }};
691    }
692
693    unsafe {
694        // arena — no init needed (MaybeUninit).
695        let arena_s = region_slice(base.add(o.arena) as *mut MaybeUninit<u8>, arena);
696
697        let entries_p = base.add(o.entries) as *mut Option<CallbackMeta>;
698        let mut i = 0;
699        while i < cbs {
700            entries_p.add(i).write(None);
701            i += 1;
702        }
703        let entries_s = region_slice(entries_p, cbs);
704
705        let sc_p = base.add(o.sched_contexts) as *mut Option<SchedContext>;
706        let mut i = 0;
707        while i < sc {
708            sc_p.add(i).write(None);
709            i += 1;
710        }
711        let sched_contexts_s = region_slice(sc_p, sc);
712
713        let bind_p = base.add(o.sched_context_bindings) as *mut SchedContextId;
714        let mut i = 0;
715        while i < cbs {
716            bind_p.add(i).write(SchedContextId(0));
717            i += 1;
718        }
719        let bindings_s = region_slice(bind_p, cbs);
720
721        let sp_p = base.add(o.sporadic_states) as *mut Option<SporadicState>;
722        let mut i = 0;
723        while i < sc {
724            sp_p.add(i).write(None);
725            i += 1;
726        }
727        let sporadic_s = region_slice(sp_p, sc);
728
729        #[cfg(feature = "alloc")]
730        let atomic_s = {
731            let ap = base.add(o.sporadic_atomic_states) as *mut Option<SporadicAtomic>;
732            let mut i = 0;
733            while i < sc {
734                ap.add(i).write(None);
735                i += 1;
736            }
737            region_slice(ap, sc)
738        };
739
740        let remaps_p = base.add(o.remaps) as *mut Option<RemapRule>;
741        let mut i = 0;
742        while i < MAX_REMAPS {
743            remaps_p.add(i).write(None);
744            i += 1;
745        }
746        let remaps_s = region_slice(remaps_p, MAX_REMAPS);
747
748        let alive_p = base.add(o.alive_slots) as *mut AliveSlot;
749        let mut i = 0;
750        while i < sc {
751            alive_p.add(i).write(AliveSlot::default());
752            i += 1;
753        }
754        let alive_s = region_slice(alive_p, sc);
755
756        let tags_p = base.add(o.slot_tags) as *mut SlotTag;
757        let mut i = 0;
758        while i < cbs {
759            tags_p.add(i).write(SlotTag::default());
760            i += 1;
761        }
762        let tags_s = region_slice(tags_p, cbs);
763
764        // phase-382 W3' — no init: the slots are `MaybeUninit` until the
765        // first declaration initialises them through `ParameterTable::init_in`.
766        let params_s = region_slice(
767            base.add(o.params) as *mut MaybeUninit<nros_params::ParameterSlot>,
768            param_slots,
769        );
770        // issue 1812 — no init either: `ParamHome::init_in` builds it in place.
771        let home_s = region_slice(
772            base.add(o.param_home) as *mut MaybeUninit<ParamHome>,
773            param_homes(sizing),
774        );
775
776        ExecutorSlices {
777            arena: arena_s,
778            entries: entries_s,
779            sched_contexts: sched_contexts_s,
780            sched_context_bindings: bindings_s,
781            sporadic_states: sporadic_s,
782            #[cfg(feature = "alloc")]
783            sporadic_atomic_states: atomic_s,
784            remaps: remaps_s,
785            nodes: carved!(o.nodes, node_slots, NodeRecord),
786            extra_sessions: carved!(o.extra_sessions, node_slots, ConcreteSession),
787            extra_session_ids: carved!(o.extra_session_ids, node_slots, ExtraSessionId),
788            node_sched_table: carved!(o.node_sched_table, node_slots, NodeSchedEntry),
789            dispatch_slots: carved!(o.dispatch_slots, node_slots, DispatchSlot),
790            component_slots: carved!(o.component_slots, node_slots, ComponentSlot),
791            active_groups: carved!(o.active_groups, node_slots, ActiveGroup),
792            group_sched_table: carved!(o.group_sched_table, cbs, GroupSchedEntry),
793            monitor_violations: carved!(o.monitor_violations, MAX_VIOLATIONS, Violation),
794            alive_slots: alive_s,
795            slot_tags: tags_s,
796            params: params_s,
797            param_home: home_s,
798        }
799    }
800}
801
802/// One carved region as a slice — the ONLY way `carve` builds one (issue 1771).
803///
804/// `slice::from_raw_parts_mut` requires an ALIGNED, non-null pointer even for
805/// a zero-length slice, and an empty region's offset is not always aligned:
806/// `nros_executor_layout::offsets` deliberately leaves `off` where it is for a
807/// region with no slots (the parameter store in an image without one, the
808/// atomic sporadic states without `alloc`), so the total does not grow by
809/// padding nothing uses. On thumbv7m that offset is misaligned for
810/// `ParameterSlot`, and the debug precondition check panicked at boot. An
811/// empty region needs no address at all, so it gets a dangling one.
812///
813/// # Safety
814/// For `n > 0`, `p` points at `n` properly aligned `T`s inside the backing
815/// that no other live slice covers.
816unsafe fn region_slice<'s, T>(p: *mut T, n: usize) -> &'s mut [T] {
817    if n == 0 {
818        return &mut [];
819    }
820    // SAFETY: the caller's contract, for a non-empty region.
821    unsafe { core::slice::from_raw_parts_mut(p, n) }
822}
823
824#[cfg(test)]
825mod tests {
826    use super::*;
827
828    /// issue 1197 — the arithmetic must actually READ `units`, not quietly keep
829    /// calling `size_of::<T>()` behind a parameter it ignores.
830    ///
831    /// A parameter that is accepted and unused is exactly how a "data-driven"
832    /// refactor passes its own tests while delivering nothing: `NATIVE` would
833    /// still be right, every existing assertion would still hold, and the
834    /// external consumer this exists for would get the HOST's layout back.
835    /// So this feeds a table that could not have come from this compiler and
836    /// checks the answer moves by the amount that table implies.
837    #[test]
838    fn the_placement_reads_the_unit_table_it_is_given() {
839        let sizing = ExecutorSizing {
840            cbs: 1,
841            sc: 0,
842            arena: 0,
843            nodes: 0,
844            params: 0,
845        };
846        let native = executor_storage_layout_with(sizing, NATIVE_UNITS);
847
848        // One region is scaled by `cbs`: `callback_meta`. Grow ONLY it, by a
849        // multiple of its own alignment so no padding shifts, and the total must
850        // grow by exactly one element's worth.
851        let mut fatter = NATIVE_UNITS;
852        let grown_by = fatter.callback_meta.align * 4;
853        fatter.callback_meta.size += grown_by;
854        let widened = executor_storage_layout_with(sizing, fatter);
855
856        assert_eq!(
857            widened.size(),
858            native.size() + grown_by,
859            "growing one cbs-scaled unit by {grown_by} must grow the backing by \
860             the same, or the table is being ignored"
861        );
862
863        // And a table of ZEROES must collapse the tables entirely, leaving only
864        // the arena. This is the direction that catches a `size_of` still being
865        // read for some region the parameter was threaded past.
866        let zeroed = RegionUnits {
867            callback_meta: RegionUnit { size: 0, align: 1 },
868            sched_context: RegionUnit { size: 0, align: 1 },
869            sched_context_id: RegionUnit { size: 0, align: 1 },
870            sporadic_state: RegionUnit { size: 0, align: 1 },
871            sporadic_atomic: RegionUnit { size: 0, align: 1 },
872            remap_rule: RegionUnit { size: 0, align: 1 },
873            node_record: RegionUnit { size: 0, align: 1 },
874            concrete_session: RegionUnit { size: 0, align: 1 },
875            extra_session_id: RegionUnit { size: 0, align: 1 },
876            node_sched_entry: RegionUnit { size: 0, align: 1 },
877            dispatch_slot: RegionUnit { size: 0, align: 1 },
878            component_slot: RegionUnit { size: 0, align: 1 },
879            group_name: RegionUnit { size: 0, align: 1 },
880            group_sched_entry: RegionUnit { size: 0, align: 1 },
881            violation: RegionUnit { size: 0, align: 1 },
882            alive_slot: RegionUnit { size: 0, align: 1 },
883            slot_tag: RegionUnit { size: 0, align: 1 },
884            parameter_slot: RegionUnit { size: 0, align: 1 },
885            param_home: RegionUnit { size: 0, align: 1 },
886        };
887        let arena_only = executor_storage_layout_with(
888            ExecutorSizing {
889                cbs: CBS,
890                sc: SC,
891                arena: 4096,
892                nodes: 4,
893                params: 3,
894            },
895            zeroed,
896        );
897        assert_eq!(
898            arena_only.size(),
899            4096,
900            "with every unit zero-sized the backing is the arena and nothing else"
901        );
902    }
903
904    const CBS: usize = crate::config::MAX_CBS;
905    const SC: usize = crate::config::MAX_SC;
906    const ARENA: usize = crate::config::ARENA_SIZE;
907    const NODES: usize = crate::config::MAX_NODES;
908    const DEFAULT: ExecutorSizing = ExecutorSizing::DEFAULT;
909    /// phase-382 W3' — a store carved in the layout tests whatever this build
910    /// implies (`DEFAULT.params` is 0 in a unit-test build: nothing declares a
911    /// parameter), so the region is placed and checked on every run.
912    const PARAMS: usize = 3;
913    const WITH_STORE: ExecutorSizing = ExecutorSizing {
914        params: PARAMS,
915        ..DEFAULT
916    };
917    /// The build's own default. Its `params` is `nros_params::IMPLIED_STORE_SLOTS`.
918    const DEFAULT_PARAMS: usize = DEFAULT.params;
919
920    #[test]
921    fn layout_matches_typed_repr_c() {
922        // The manual const-fn layout must equal the compiler's `#[repr(C)]` layout
923        // of the typed storage — proof the carve offsets are the real field offsets.
924        let got = executor_storage_layout(DEFAULT);
925        let want = Layout::new::<
926            ExecutorStorage<CBS, SC, ARENA, NODES, DEFAULT_PARAMS, { param_homes(DEFAULT) }>,
927        >();
928        assert_eq!(got.size(), want.size(), "size");
929        assert_eq!(got.align(), want.align(), "align");
930        // phase-382 W3' — and with a store carved, which is the layout every
931        // image that declares a parameter links.
932        let got = executor_storage_layout(WITH_STORE);
933        let want = Layout::new::<ExecutorStorage<CBS, SC, ARENA, NODES, PARAMS, 1>>();
934        assert_eq!(got.size(), want.size(), "size with a carved store");
935        assert_eq!(got.align(), want.align(), "align with a carved store");
936    }
937
938    /// phase-382 W3' — the negative control for the CONDITIONAL region: zero
939    /// store slots must leave the backing byte-for-byte what it was before the
940    /// region existed. `nros_executor_layout::offsets` skips the placement
941    /// entirely at zero (an aligned-up `off` would be padding an image with no
942    /// store paid for nothing), so the total is the one the seventeen older
943    /// regions produce on their own — computed here by zeroing the unit, which
944    /// is the layout the region cannot reach.
945    #[test]
946    fn a_backing_that_carves_no_store_does_not_grow() {
947        let none = ExecutorSizing {
948            params: 0,
949            ..DEFAULT
950        };
951        let mut no_unit = NATIVE_UNITS;
952        no_unit.parameter_slot = RegionUnit { size: 0, align: 1 };
953        no_unit.param_home = RegionUnit { size: 0, align: 1 };
954        assert_eq!(
955            executor_storage_layout(none).size(),
956            executor_storage_layout_with(WITH_STORE, no_unit).size(),
957            "a backing with no store slots must cost exactly the backing without the region"
958        );
959        assert_eq!(
960            executor_storage_layout(none).align(),
961            executor_storage_layout_with(WITH_STORE, no_unit).align(),
962        );
963        // And a store costs at least its slots, charged to the backing.
964        let grown =
965            executor_storage_layout(WITH_STORE).size() - executor_storage_layout(none).size();
966        assert!(
967            grown >= PARAMS * size_of::<nros_params::ParameterSlot>(),
968            "{PARAMS} store slots cost {grown} B, less than {PARAMS} x {} B",
969            size_of::<nros_params::ParameterSlot>()
970        );
971        // The layout's slot IS the store's slot, so a carved table is exactly
972        // a `ParameterStorage<PARAMS>`'s worth of slots.
973        assert_eq!(
974            PARAMS * size_of::<nros_params::ParameterSlot>(),
975            size_of::<nros_params::ParameterStorage<PARAMS>>()
976        );
977    }
978
979    /// phase-409 — and the FIELD offsets, one by one. The size/align check above
980    /// is the one phase-271 shipped, and it does not distinguish a REORDER that
981    /// happens to pad the same: with sixteen regions instead of seven, "the
982    /// totals agree" stopped being adequate evidence that `carve` hands out the
983    /// region it names. A mismatch here is `nodes` being handed
984    /// `extra_sessions`' memory, which is not a size bug and no total would show.
985    #[test]
986    fn every_carved_region_starts_where_repr_c_puts_it() {
987        type Ref = ExecutorStorage<CBS, SC, ARENA, NODES, PARAMS, 1>;
988        let o = compute_offsets(WITH_STORE);
989        macro_rules! same {
990            ($f:ident) => {
991                assert_eq!(
992                    o.$f,
993                    core::mem::offset_of!(Ref, $f),
994                    concat!("offset of `", stringify!($f), "`")
995                );
996            };
997        }
998        same!(arena);
999        same!(entries);
1000        same!(sched_contexts);
1001        same!(sched_context_bindings);
1002        same!(sporadic_states);
1003        #[cfg(feature = "alloc")]
1004        same!(sporadic_atomic_states);
1005        same!(remaps);
1006        same!(nodes);
1007        same!(extra_sessions);
1008        same!(extra_session_ids);
1009        same!(node_sched_table);
1010        same!(dispatch_slots);
1011        same!(component_slots);
1012        same!(active_groups);
1013        same!(group_sched_table);
1014        same!(monitor_violations);
1015        same!(alive_slots);
1016        same!(slot_tags);
1017        // phase-382 W3' — the store, last.
1018        same!(params);
1019        // issue 1812 — and the services' home after it.
1020        same!(param_home);
1021    }
1022
1023    #[test]
1024    fn u64_backing_covers_all_field_aligns() {
1025        assert!(align_of::<Option<CallbackMeta>>() <= 8);
1026        assert!(align_of::<Option<SchedContext>>() <= 8);
1027        assert!(align_of::<SchedContextId>() <= 8);
1028        assert!(align_of::<Option<SporadicState>>() <= 8);
1029        // phase-409 — the nine that moved. A type whose alignment exceeds 8
1030        // would make the `u64` backing insufficient for the whole carve, not
1031        // just for its own table.
1032        assert!(align_of::<NodeRecord>() <= 8);
1033        assert!(align_of::<ConcreteSession>() <= 8);
1034        assert!(align_of::<ExtraSessionId>() <= 8);
1035        assert!(align_of::<NodeSchedEntry>() <= 8);
1036        assert!(align_of::<DispatchSlot>() <= 8);
1037        assert!(align_of::<ComponentSlot>() <= 8);
1038        assert!(align_of::<ActiveGroup>() <= 8);
1039        assert!(align_of::<AliveSlot>() <= 8);
1040        assert!(align_of::<GroupSchedEntry>() <= 8);
1041        assert!(align_of::<Violation>() <= 8);
1042        // phase-382 W3' — the store's slot.
1043        assert!(align_of::<nros_params::ParameterSlot>() <= 8);
1044        assert!(executor_storage_layout(DEFAULT).align() <= 8);
1045        assert!(executor_storage_layout(WITH_STORE).align() <= 8);
1046    }
1047
1048    /// phase-409 (issue 0961) — every knob-scaled table's per-slot cost is
1049    /// charged to the BACKING, which is where the caller put it, and not to the
1050    /// `Executor` value, which is what `open_in` builds on the stack and
1051    /// returns by value.
1052    ///
1053    /// Deltas rather than absolute sizes, the shape
1054    /// `the_default_subscription_buffer_is_unchanged` uses: a total says
1055    /// nothing about which knob moved it, while the difference between two
1056    /// sizings isolates exactly one knob's per-slot width.
1057    #[test]
1058    fn every_knob_scaled_table_is_charged_to_the_backing() {
1059        let base = executor_storage_layout(DEFAULT).size();
1060
1061        let one_more_node = executor_storage_layout(ExecutorSizing {
1062            nodes: DEFAULT.nodes + 1,
1063            ..DEFAULT
1064        })
1065        .size();
1066        let per_node = size_of::<NodeRecord>()
1067            + size_of::<ConcreteSession>()
1068            + size_of::<ExtraSessionId>()
1069            + size_of::<NodeSchedEntry>()
1070            + size_of::<DispatchSlot>()
1071            + size_of::<ComponentSlot>()
1072            + size_of::<ActiveGroup>();
1073        assert!(
1074            one_more_node - base >= per_node,
1075            "one more Node slot must cost its seven tables ({per_node} B) in the \
1076             backing; it cost {}",
1077            one_more_node - base
1078        );
1079
1080        // `arena` is its own field, so `..DEFAULT` holds it while `cbs` moves —
1081        // which isolates the callback-indexed tables. `group_sched_table` is the
1082        // one that coupled `NROS_EXECUTOR_MAX_CBS` to the main thread's stack.
1083        let one_more_cb = executor_storage_layout(ExecutorSizing {
1084            cbs: DEFAULT.cbs + 1,
1085            ..DEFAULT
1086        })
1087        .size();
1088        assert!(
1089            one_more_cb - base >= size_of::<GroupSchedEntry>(),
1090            "one more callback slot must cost a `group_sched_table` entry \
1091             ({} B) in the backing; it cost {}",
1092            size_of::<GroupSchedEntry>(),
1093            one_more_cb - base
1094        );
1095    }
1096
1097    /// The other half of the same claim, and the one issue 0961 is about: the
1098    /// VALUE does not move.
1099    ///
1100    /// A CEILING rather than an equality, because the header's exact size is a
1101    /// target + feature detail — measured 1016 B on the `std` lane and 2048 B
1102    /// under `--all-features`, and the difference is almost entirely the primary
1103    /// session (`SessionStore` is 16 B over `MockSession`, 536 B over
1104    /// `CffiSession`) plus `scheduler-os-priority`'s worker pool. Both of those
1105    /// are allowed for BY NAME below, so what is left is a tight budget on the
1106    /// header proper.
1107    ///
1108    /// It is a ceiling on the same number at every knob value, which is the
1109    /// property under test: 1016 B at BOTH the shipped defaults and the island's
1110    /// `MAX_CBS=36` / `MAX_NODES=6`. Before this phase it was 5072 B and
1111    /// 12768 B respectively.
1112    ///
1113    /// If this fires for a field you MEANT to add, the fix is almost always to
1114    /// carve it rather than to raise the ceiling: `Executor::open_in` builds
1115    /// this value on the stack and returns it by value, and `nros_cpp_init`
1116    /// holds the returned value, so every byte is charged to two frames of every
1117    /// image's boot path.
1118    #[test]
1119    fn the_executor_value_does_not_scale_with_the_knobs() {
1120        let value = size_of::<super::super::spin::Executor<'static>>();
1121        // Two things in the header are legitimately large and are NOT scaled by
1122        // `MAX_CBS` / `MAX_NODES`, so they get named allowances rather than a
1123        // looser ceiling for every build:
1124        //   * the primary session, held by value (that is what owning a session
1125        //     means), whose size is the backend's, not a knob's;
1126        //   * `scheduler-os-priority`'s worker pool, a pair of `FnvIndexMap`s
1127        //     sized by `MAX_PRIORITY_LEVELS` — an opt-in capability.
1128        //   * phase-436 W6's wake-source table, a fixed `MAX_WAKE_SOURCES`
1129        //     array of two-pointer slots. It is exempted for the same reason
1130        //     the two above are — it does not scale with `MAX_CBS` or
1131        //     `MAX_NODES` — and it is named rather than folded into the base
1132        //     so that raising `MAX_WAKE_SOURCES` shows up as a cost here
1133        //     instead of eating another feature's headroom. The rest of
1134        //     phase-436's header fields (the park bookkeeping scalars) are
1135        //     NOT exempted and fit inside the 1280 as they should.
1136        //   * phase-462 W2's age-monitor state table, an inline
1137        //     `MAX_AGE_MONITORS` array, named for the same reason as the
1138        //     wake-source table: `AgeState` grew from one byte to eight when
1139        //     the silence rule gained its lease, and that 56 B belongs on this
1140        //     line where the next field added to it is visible, not silently
1141        //     inside the 1280. Its sibling `[MonitorState; MAX_MONITORS]`
1142        //     predates the allowance idiom and its first 8 rows stay folded
1143        //     into the base; naming it whole would mean re-tuning the base
1144        //     literal against a size that is target-dependent.
1145        //   * phase-496 W1 made both tables KNOBS (`NROS_EXECUTOR_MAX_MONITORS`
1146        //     / `..._AGE_MONITORS`, derived from the contract's row counts).
1147        //     They stay inline on purpose -- the spin loop indexes them in
1148        //     parallel with a `&'static` spec table -- so they are exempt the
1149        //     same way, by NAMED allowance: the age table whole (above), the
1150        //     rate table by its rows past the 8 the base already absorbed. A
1151        //     contract with 14 rate rows grows this value by exactly those six
1152        //     rows and nothing else can hide inside that allowance.
1153        //   * issue 1496's released-region table was a FIXED 8-entry
1154        //     `heapless::Vec` named here; issue 1667 replaced it with an
1155        //     intrusive free list whose nodes live in the freed arena bytes, so
1156        //     what remains in this value is one `usize` head, named below.
1157        //   * phase-474 I1/I2's `ViolationCounts`: the violation ring's total,
1158        //     dropped and pre-arm counters and the monitors' arming state, nine
1159        //     scalars that scale with no knob. Named so the next field added to
1160        //     them shows up here rather than eating another feature's headroom.
1161        //   * phase-474 I7's `ReportQueue`: the verdicts waiting for the
1162        //     `/diagnostics` sink (a fixed `REPORT_QUEUE` of four) and what the
1163        //     hand-over cost. Scales with no knob; named for the same reason.
1164        //   * phase-476 W0's `slot_tags`: the TABLE is carved (it scales with
1165        //     `MAX_CBS` and lives in the backing); what sits in this value is
1166        //     one slice reference, which scales with nothing.
1167        //   * phase-483 W3's `component_frame`: one optional pointer to the
1168        //     component-registration frame `nros::NodeContext` installs while a
1169        //     component's `register` runs. Scales with nothing.
1170        //   * phase-474 I11's `ReleaseGrid`: the spin loop's release grid (its
1171        //     period, next release and skipped count). Scales with nothing.
1172        //   * phase-436 A2b/A2c: whether a declared cadence is wait-paced (a
1173        //     `bool`) and the count of late wakes beyond the granule (a `u32`).
1174        //     Two scalars that scale with nothing, named so the next field added
1175        //     beside them shows up here.
1176        //   * phase-462 W3b's `hazard_table`: one `&'static` slice reference to
1177        //     the baked hazard table, whose per-hazard state lives in the
1178        //     table's own `static` cells. Scales with nothing.
1179        #[allow(unused_mut)]
1180        let mut ceiling =
1181            1280 + size_of::<super::super::spin::SessionStore>()
1182                + size_of::<super::super::monitor::ViolationCounts>()
1183                + size_of::<super::super::monitor::ReportQueue>()
1184                + size_of::<&mut [super::super::types::SlotTag]>()
1185                + size_of::<usize>()
1186                + size_of::<Option<core::ptr::NonNull<()>>>()
1187                + size_of::<super::super::spin::ReleaseGrid>()
1188                + size_of::<bool>()
1189                + size_of::<u32>()
1190                + size_of::<&'static [super::super::monitor::HazardSpec]>()
1191                + size_of::<
1192                    [Option<super::super::spin::WakeSourceSlot>;
1193                        super::super::spin::MAX_WAKE_SOURCES],
1194                >()
1195                + size_of::<
1196                    [super::super::monitor::AgeState; super::super::monitor::MAX_AGE_MONITORS],
1197                >()
1198                + size_of::<
1199                    [super::super::monitor::MonitorState; super::super::monitor::MAX_MONITORS],
1200                >()
1201                .saturating_sub(size_of::<[super::super::monitor::MonitorState; 8]>());
1202        #[cfg(all(
1203            feature = "alloc",
1204            feature = "rmw-cffi",
1205            feature = "scheduler-os-priority"
1206        ))]
1207        {
1208            ceiling += size_of::<super::super::os_priority::OsPriorityPool>();
1209        }
1210        // phase-382 W3' -- the parameter store's TABLE is carved (it scales
1211        // with `MAX_PARAMETERS` and lives in the backing); this value holds one
1212        // slice reference to it, which scales with nothing.
1213        #[cfg(feature = "param-store")]
1214        {
1215            ceiling += size_of::<&mut [MaybeUninit<nros_params::ParameterSlot>]>();
1216            // issue 1812 -- and one slice reference to the services' home,
1217            // which is carved for the same reason, plus the word that says
1218            // whether the state is carved or (with `alloc`) boxed. Neither
1219            // scales with a knob.
1220            ceiling += size_of::<&mut [MaybeUninit<ParamHome>]>();
1221            ceiling += size_of::<
1222                Option<
1223                    crate::param_store::Placed<'static, crate::param_store::ParamState<'static>>,
1224                >,
1225            >()
1226            .saturating_sub(size_of::<usize>());
1227        }
1228        // phase-425 W3b / phase-430 W1 -- `use_sim_time`'s state: the `/clock`
1229        // handle, the requested and stated flags, and the silence watch. None
1230        // scales with a knob. They sat unnamed inside the 1280 until a build
1231        // that also carried `param-services` and `lifecycle-services` went
1232        // 4 B over it, so they are named here like the rest.
1233        #[cfg(feature = "sim-time")]
1234        {
1235            ceiling += size_of::<Option<super::super::types::HandleId>>()
1236                + 2 * size_of::<bool>()
1237                + size_of::<crate::time_source::SilenceWatch>();
1238        }
1239        // `lifecycle-services`' runtime state is BOXED; this value holds one
1240        // optional pointer to it, which scales with nothing.
1241        #[cfg(feature = "lifecycle-services")]
1242        {
1243            ceiling += size_of::<
1244                Option<alloc::boxed::Box<crate::lifecycle_services::LifecycleRuntimeState>>,
1245            >();
1246        }
1247        assert!(
1248            value <= ceiling,
1249            "`Executor` is {value} B at MAX_CBS={CBS} / MAX_NODES={NODES}, over \
1250             the {ceiling} B this value is budgeted; a table that scales with a \
1251             knob has come back inline (issue 0961)."
1252        );
1253    }
1254
1255    /// issue 1771 — an empty region at a MISALIGNED offset is legal.
1256    ///
1257    /// The layout leaves an empty region's offset unaligned (no padding for
1258    /// nothing), and `carve` used to hand that pointer to
1259    /// `slice::from_raw_parts_mut`, whose debug precondition check aborts the
1260    /// image — on thumbv7m, where the offset is not a multiple of
1261    /// `ParameterSlot`'s alignment. x86_64 happened to align, so carve's own
1262    /// tests could not see it; this asks the helper at an offset that is
1263    /// misaligned on every target. Passing `p` straight to
1264    /// `from_raw_parts_mut` here aborts the test process (not catchable).
1265    #[test]
1266    fn an_empty_region_at_a_misaligned_offset_carves_to_an_empty_slice() {
1267        assert!(align_of::<nros_params::ParameterSlot>() > 1, "the premise");
1268        let mut backing = [0u64; 4];
1269        let p = (backing.as_mut_ptr() as *mut u8).wrapping_add(1)
1270            as *mut MaybeUninit<nros_params::ParameterSlot>;
1271        assert!(!p.is_aligned(), "the premise: the offset is misaligned");
1272        let s = unsafe { region_slice(p, 0) };
1273        assert!(s.is_empty());
1274        assert!(s.as_ptr().is_aligned());
1275    }
1276
1277    /// issue 1771 — `carve` itself, over layouts whose empty parameter region
1278    /// lands misaligned.
1279    ///
1280    /// Which knob values misalign `o.params` depends on every region before it,
1281    /// so a fixed config proves nothing on the next knob move: the mps2 talker
1282    /// panicked in one build and booted in another of the same leaf. Sweep the
1283    /// callback count and require that the sweep REACHES a misaligned offset
1284    /// (else this test has stopped asking the question), then carve each one.
1285    /// With the slice built straight from the pointer, the first misaligned
1286    /// case aborts the test process at the precondition check.
1287    #[cfg(feature = "alloc")]
1288    #[test]
1289    fn carve_survives_an_empty_parameter_region_at_any_alignment() {
1290        let align = align_of::<nros_params::ParameterSlot>();
1291        let mut misaligned = 0;
1292        for cbs in 1..=16 {
1293            let sizing = ExecutorSizing {
1294                cbs,
1295                sc: 1,
1296                arena: 1,
1297                nodes: 1,
1298                params: 0,
1299            };
1300            if !compute_offsets(sizing).params.is_multiple_of(align) {
1301                misaligned += 1;
1302            }
1303            let mut backing = alloc::vec![
1304                const { MaybeUninit::<u64>::uninit() };
1305                executor_storage_u64_len(sizing)
1306            ]
1307            .into_boxed_slice();
1308            let s = unsafe { carve(&mut backing, sizing) };
1309            assert!(s.params.is_empty());
1310            assert_eq!(s.entries.len(), cbs);
1311        }
1312        assert!(
1313            misaligned > 0,
1314            "no swept layout leaves the empty parameter region misaligned for \
1315             ParameterSlot (align {align}) — widen the sweep, or this test no \
1316             longer covers issue 1771"
1317        );
1318    }
1319
1320    // phase-361 W8.e / issue 0594 — this test heap-allocates its backing array,
1321    // so it needs `alloc`. It compiled under `--no-default-features` only
1322    // because feature unification from another workspace member happened to
1323    // turn `nros-node/alloc` on; nothing does that now.
1324    #[cfg(feature = "alloc")]
1325    #[test]
1326    fn carve_yields_right_lengths_and_inits() {
1327        // Heap-allocate: the default test config (MAX_CBS/MAX_SC/ARENA_SIZE from
1328        // build.rs) makes this backing array tens of KB, well past
1329        // `clippy::large_stack_arrays`'s threshold — and the size here is
1330        // incidental (mirrors production config), not the point under test, so
1331        // boxing is the right fix rather than an allow.
1332        let mut backing =
1333            alloc::vec![const { MaybeUninit::<u64>::uninit() }; executor_storage_u64_len(DEFAULT)]
1334                .into_boxed_slice();
1335        let s = unsafe { carve(&mut backing, DEFAULT) };
1336        assert_eq!(s.arena.len(), ARENA);
1337        assert_eq!(s.entries.len(), CBS);
1338        assert_eq!(s.sched_contexts.len(), SC);
1339        assert_eq!(s.sched_context_bindings.len(), CBS);
1340        assert_eq!(s.sporadic_states.len(), SC);
1341        assert!(s.entries.iter().all(|e| e.is_none()));
1342        assert!(s.sched_context_bindings.iter().all(|b| b.0 == 0));
1343        // phase-409 — the CarvedVecs come back empty at their carved capacity.
1344        assert_eq!(s.nodes.capacity(), NODES);
1345        assert_eq!(s.extra_sessions.capacity(), NODES);
1346        assert_eq!(s.extra_session_ids.capacity(), NODES);
1347        assert_eq!(s.node_sched_table.capacity(), NODES);
1348        assert_eq!(s.dispatch_slots.capacity(), NODES);
1349        assert_eq!(s.component_slots.capacity(), NODES);
1350        assert_eq!(s.active_groups.capacity(), NODES);
1351        assert_eq!(s.group_sched_table.capacity(), CBS);
1352        assert_eq!(s.monitor_violations.capacity(), MAX_VIOLATIONS);
1353        // Alive supervision: one zeroed record per SC, carved rather than inline.
1354        assert_eq!(s.alive_slots.len(), SC);
1355        assert!(
1356            s.alive_slots
1357                .iter()
1358                .all(|a| a.dispatches == 0 && !a.state.opened)
1359        );
1360        assert_eq!(s.nodes.len(), 0);
1361        assert_eq!(s.group_sched_table.len(), 0);
1362        // phase-382 W3' — the store region, at this build's implied count.
1363        assert_eq!(s.params.len(), DEFAULT.params);
1364        // issue 1812 — and the services' home, present exactly with a store.
1365        assert_eq!(s.param_home.len(), param_homes(DEFAULT));
1366    }
1367
1368    /// phase-382 W3' — carve a store and USE it, with no allocator anywhere.
1369    ///
1370    /// Outside the `alloc` gate on purpose (phase-382's trap): the point of
1371    /// carving is a store an image without a heap can hold, and
1372    /// `carve_yields_right_lengths_and_inits` above needs `alloc` only for
1373    /// its own default-sized backing. The backing here is a STATIC sized for a
1374    /// minimal executor plus the store, so nothing in this test allocates.
1375    #[test]
1376    fn a_carved_store_is_usable_without_an_allocator() {
1377        const TINY: ExecutorSizing = ExecutorSizing {
1378            cbs: 1,
1379            sc: 1,
1380            arena: 64,
1381            nodes: 1,
1382            params: PARAMS,
1383        };
1384        const WORDS: usize = executor_storage_u64_len(TINY);
1385        static mut BACKING: [MaybeUninit<u64>; WORDS] = [MaybeUninit::uninit(); WORDS];
1386        // SAFETY: this test is the only reader of `BACKING`, and it takes the
1387        // one reference once.
1388        // The raw pointer is bound first, as in `backing::take`: clippy reads
1389        // `&mut *(&raw mut X)` as `deref_addrof`, and its rewrite is the
1390        // `static_mut_refs` hazard.
1391        let ptr = &raw mut BACKING;
1392        let backing: &'static mut [MaybeUninit<u64>; WORDS] = unsafe { &mut *ptr };
1393        // SAFETY: `backing` is exactly `executor_storage_u64_len(TINY)` words.
1394        let s = unsafe { carve(backing, TINY) };
1395        assert_eq!(s.params.len(), PARAMS, "the store's slots are carved");
1396        assert_eq!(s.param_home.len(), 1, "and the services' home with them");
1397        let table = nros_params::ParameterTable::init_in(s.params);
1398        assert_eq!(table.capacity(), PARAMS);
1399        assert_eq!(table.occupied(), 0, "carved slots start empty");
1400        let mut server = nros_params::ParameterServer::new_in(table);
1401        let key = nros_params::NodeKey::PRIMARY;
1402        for i in 0..PARAMS {
1403            let mut name = heapless::String::<8>::new();
1404            let _ = core::fmt::write(&mut name, format_args!("p{i}"));
1405            assert!(server.declare(key, &name, nros_params::ParameterValue::Integer(i as i64)));
1406        }
1407        assert!(
1408            !server.declare(key, "over", nros_params::ParameterValue::Bool(true)),
1409            "capacity is the carved length"
1410        );
1411        assert_eq!(server.get(key, "p1").and_then(|v| v.as_integer()), Some(1));
1412    }
1413
1414    #[test]
1415    fn carved_vec_pushes_in_order_and_refuses_when_full() {
1416        let mut slots = [const { MaybeUninit::<u32>::uninit() }; 3];
1417        let mut v = CarvedVec::new(&mut slots);
1418        assert_eq!(v.capacity(), 3);
1419        assert!(v.push(10).is_ok());
1420        assert!(v.push(20).is_ok());
1421        assert!(v.push(30).is_ok());
1422        // Order is load-bearing: `NodeId` IS an index into `nodes`.
1423        assert_eq!(v.as_slice(), &[10, 20, 30]);
1424        // Full is a REFUSAL that hands the value back, like `heapless::Vec`.
1425        assert_eq!(v.push(40), Err(40));
1426        v.clear();
1427        assert!(v.is_empty());
1428        assert!(v.push(50).is_ok());
1429        assert_eq!(v.as_slice(), &[50]);
1430    }
1431
1432    /// phase-474 I1 -- `pop_front` takes the OLDEST and keeps push order, which
1433    /// is what lets the violation ring evict its oldest entry when full.
1434    #[test]
1435    fn carved_vec_pop_front_keeps_order() {
1436        let mut slots = [const { MaybeUninit::<u32>::uninit() }; 3];
1437        let mut v = CarvedVec::new(&mut slots);
1438        assert_eq!(v.pop_front(), None);
1439        for x in [1, 2, 3] {
1440            assert!(v.push(x).is_ok());
1441        }
1442        assert_eq!(v.pop_front(), Some(1));
1443        assert!(v.push(4).is_ok());
1444        assert_eq!(v.as_slice(), &[2, 3, 4]);
1445        assert_eq!(v.pop_front(), Some(2));
1446        assert_eq!(v.pop_front(), Some(3));
1447        assert_eq!(v.pop_front(), Some(4));
1448        assert!(v.is_empty());
1449    }
1450
1451    /// The elements live in the CALLER's backing, so nothing drops them unless
1452    /// the vector does — and `extra_sessions` holds RMW sessions. Without this
1453    /// a bridge executor would leak every extra session it opened.
1454    #[test]
1455    fn carved_vec_drops_its_elements() {
1456        use core::sync::atomic::{AtomicUsize, Ordering};
1457        static DROPS: AtomicUsize = AtomicUsize::new(0);
1458        struct Counted;
1459        impl Drop for Counted {
1460            fn drop(&mut self) {
1461                DROPS.fetch_add(1, Ordering::SeqCst);
1462            }
1463        }
1464
1465        let mut slots = [const { MaybeUninit::<Counted>::uninit() }; 4];
1466        {
1467            let mut v = CarvedVec::new(&mut slots);
1468            assert!(v.push(Counted).is_ok());
1469            assert!(v.push(Counted).is_ok());
1470        }
1471        assert_eq!(
1472            DROPS.load(Ordering::SeqCst),
1473            2,
1474            "both pushed elements dropped — and only the pushed ones, not the \
1475             uninitialised tail"
1476        );
1477    }
1478}