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