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