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}