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