nros_node/executor/backing.rs
1//! phase-392 W6 — the executor's storage as a NAMED STATIC (RFC-0002 § 4.4b).
2//!
3//! # What this fixes, and what it does not
4//!
5//! Phase 392 prices an image's static RAM by reading its symbol table
6//! (`just mem-report`). Every later wave of that campaign is defined as a saving
7//! against those numbers, so a consumer the symbol table cannot see is a
8//! consumer the campaign cannot price — and the executor's storage was exactly
9//! that on every Rust image.
10//!
11//! **Not because the arena is inline.** It stopped being an inline
12//! `[MaybeUninit<u8>; ARENA_SIZE]` field in phase-271 (issue 0110), and
13//! [`Executor`](super::spin::Executor) has held `arena: &'s mut
14//! [MaybeUninit<u8>]` — a slice carved out of caller-supplied backing — ever
15//! since. Four documents, a build-script comment and a runtime advisory string
16//! went on asserting the inline shape for five phases; phase-403 caught the
17//! claim and phase-392 W6 corrected the sites.
18//!
19//! **Where that backing lives is the caller's choice**, and before this module
20//! the tree answered it three different ways:
21//!
22//! * **C** — all 34 in-tree `rclc_executor_t` objects are file-scope `static`,
23//! so the backing is carved from `_opaque` in `.bss`. Visible.
24//! * **C++** — `nros::Node::GlobalStorageHolder<0>::storage`, a template static
25//! member, so also `.bss`. Visible.
26//! * **Rust** — every board entry (`linux`, `zephyr`, `freertos`, `nuttx`,
27//! `threadx`, `esp32-qemu`, `mps2-an385`) reaches an `alloc` convenience
28//! constructor, which leaked a `Box`. **Invisible**: a heap allocation has no
29//! symbol. Measured on the native zenoh talker before this module: the largest
30//! `nros_node` RAM symbol in the whole image was **1 byte**.
31//!
32//! This module closes the Rust arm — one change, every Rust board.
33//!
34//! # It is a MOVE, not a saving, and the second half is the caller's
35//!
36//! The same bytes are reserved either way; what changes is *which budget pays*.
37//! A leaked `Box` is drawn from the image's allocator arena. On a hosted target
38//! that arena is the OS heap and has no fixed reservation, so the static costs
39//! nothing that was not already spent. **On an RTOS it is itself a fixed
40//! static** — `CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE` on Zephyr,
41//! `configTOTAL_HEAP_SIZE` on FreeRTOS — already sized to hold this backing, so
42//! turning the reservation on there without lowering that knob reserves the same
43//! bytes twice.
44//!
45//! That pairing is per-image and only the image's author can measure it, so the
46//! opt-out is a knob rather than a guess: `NROS_EXECUTOR_BACKING_U64S=0` emits
47//! no static at all and restores the leak. A non-zero value overrides the
48//! reservation's SIZE, which is also how a fat entry — one declaring
49//! `max_callbacks` above `NROS_EXECUTOR_MAX_CBS` — keeps a static instead of
50//! falling back to the heap.
51//!
52//! **The Zephyr `CONFIG_` spelling IS wired** (issue 1171; this paragraph said
53//! the opposite until then). `zephyr/Kconfig` declares
54//! `NROS_EXECUTOR_BACKING_U64S` with the tree's `-1` DERIVE sentinel as its
55//! default, and `nros-node`'s build script reaches it through the same env →
56//! `$DOTCONFIG` reader every other executor knob uses (issue 0460). `-1` does
57//! not parse as a `usize`, which is exactly how every other `-1 = derive` knob
58//! in this tree falls through to its crate default, so the shipped default is
59//! still the derived size and no Zephyr image moved the day it landed — a
60//! plain `default 0` would have meant "no static" on all of them, which is the
61//! opposite of the default this chose.
62//!
63//! # Why an image would STATE the size rather than derive it
64//!
65//! Because the derivation cannot be paired with anything. Issue 1145 lowered
66//! one leaf's `CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE` by a size copied out of
67//! `nm` output, and that subtrahend is a function of the executor knobs: move
68//! `MAX_CBS`, the arena or the rx buffer and it is silently stale in whichever
69//! direction, with nothing to say so (issue 1171). It was also wrong on the
70//! second board the same conf builds for — the derived size is **87,256 B on
71//! `mps2_an385` and 88,328 B on `native_sim/native/64`**, because the carved
72//! tables hold pointers, so one number cannot pair both.
73//!
74//! Stating it inverts the dependency: the reservation is `8 * words` bytes on
75//! every target, the arena's lowering is arithmetic a gate can check
76//! (`check-executor-backing-arena-pairing`), and a stated size BELOW what the
77//! executor needs is the `const` assertion below — a compile error naming this
78//! knob, which is the direction that would otherwise re-create the double
79//! reservation in silence.
80//!
81//! **Zephyr no longer pairs it** (issue 1611). Issue 1324 moved every Zephyr
82//! Rust allocation onto the nros heap, so picolibc's arena holds no Rust bytes
83//! and the six example confs that lowered it now state neither number. The
84//! pairing above survives on ThreadX's rung.
85//!
86//! # Why there is no `// nros-pool:` annotation
87//!
88//! `scripts/gen-pool-inventory.py` evaluates a pool as a PRODUCT of knobs at
89//! their literal defaults. This one is a SUM — the executor's carved tables plus
90//! the arena, laid out with alignment padding between them — and its largest
91//! term, `ARENA_SIZE`, is itself derived
92//! (`env_usize("NROS_EXECUTOR_ARENA_SIZE", derived_arena)` in
93//! `nros-node/build.rs`), so the inventory would record it as a computed default
94//! even if the sum were expressible. A formula that is right for one build and
95//! wrong for the rest is the drift class this tree gates against, so this
96//! follows the two documented deliberate non-annotations
97//! (`nros_rmw_zenoh::shim::publisher`, `nros_rmw_cffi`): **the size is known to
98//! the compiler, so read it from the compiler's output.** `mem-report` prices
99//! the symbol from the ELF, exactly, with no formula to drift.
100//!
101//! # Placement
102//!
103//! `NROS_EXECUTOR_BACKING_SECTION` (build-time env, read by `nros-node`'s build
104//! script) puts the static in a named section, for the amendment-A case where a
105//! part has tightly-coupled memory the linker script can target — issue 0880
106//! does the same for `nros_thread_stacks` with `DTCM`. Two constraints, both
107//! load-bearing: **the section must be `NOLOAD`**, because this static is
108//! uninitialised and a loadable section would add its whole size to the image's
109//! flash footprint; and it must be reachable by every bus master that touches
110//! the executor's buffers, which on Cortex-M7 the TCMs typically are not.
111
112use core::mem::MaybeUninit;
113
114#[cfg(nros_executor_backing_static)]
115use portable_atomic::{AtomicBool, Ordering};
116
117use super::storage::ExecutorSizing;
118
119/// The reservation's size when nothing overrides it: one default-sized
120/// executor's worth.
121///
122/// One executor, not a multiple, because an entry opens ONE through this road:
123/// a tiered boot's SPAWNED tiers take their slots from the entry's own
124/// [`TierExecutorBacking`] instead (issue 1571), each slot this many words.
125///
126/// Named by the GENERATED file when nothing overrides the size, so it is unused
127/// under `NROS_EXECUTOR_BACKING_U64S=0` (no static) and under an explicit
128/// override (a literal). Kept rather than `cfg`'d: it is what the override is
129/// judged against, and a constant that disappears with its consumer cannot be
130/// compared to anything.
131#[allow(dead_code)]
132pub const EXECUTOR_BACKING_DEFAULT_U64S: usize = ExecutorSizing::DEFAULT.u64_len();
133
134/// phase-382 W3' -- the words the DEFAULT backing spends on the carved
135/// parameter store: zero unless the build implies a store
136/// (`nros_params::IMPLIED_STORE_SLOTS`). A STATED reservation
137/// (`NROS_EXECUTOR_BACKING_U64S`, a board's `backing_u64s`) covers the
138/// executor's tables and gets this added on top -- see `nros-node/build.rs`'s
139/// `emit_executor_backing` for why the statement does not absorb it.
140#[allow(dead_code)]
141pub const PARAMETER_STORE_U64S: usize = ExecutorSizing::DEFAULT.u64_len()
142 - ExecutorSizing {
143 params: 0,
144 ..ExecutorSizing::DEFAULT
145 }
146 .u64_len();
147
148// `EXECUTOR_BACKING`, its size const, and the optional
149// `#[unsafe(link_section = …)]` on it are emitted by `build.rs`: `link_section`
150// takes a string LITERAL and the section name is a build-time input, and the
151// size may be overridden or the whole item suppressed. The file is EMPTY when
152// `NROS_EXECUTOR_BACKING_U64S=0`, which is why the include is unconditional
153// while everything that reads it is `cfg`-gated.
154include!(concat!(env!("OUT_DIR"), "/nros_executor_backing.rs"));
155
156/// The reservation must cover the sizing every `alloc` convenience constructor
157/// passes, or it is dead weight every image carries and no executor ever uses.
158///
159/// A `const` assertion rather than a test, deliberately: the only way to make it
160/// false is to set `NROS_EXECUTOR_BACKING_U64S` too low, and the person doing
161/// that wants to hear about it from the build they just ran, not from a test
162/// suite they may not run at all. `0` is the documented opt-out and is not
163/// reached here — it emits no static, so this whole arm is `cfg`'d away.
164///
165/// **It also goes false the OTHER way, and a const assertion alone cannot see
166/// that** (issue 1284): a conf that STATES the knob stays put while the executor
167/// grows under it. That fails only in the image's own build, which no
168/// merge-gating lane runs, so twelve Zephyr leaves drifted twice in a week. `just
169/// check node-std-tests` now runs this assertion per stated claim at each
170/// claimed board's pointer width, and `tests/executor_backing_claims.rs` checks
171/// the host-width claims against [`EXECUTOR_BACKING_DEFAULT_U64S`] by number;
172/// `check-executor-backing-arena-pairing --claims` says which confs claim what.
173#[cfg(nros_executor_backing_static)]
174const _: () = assert!(
175 EXECUTOR_BACKING_U64S >= EXECUTOR_BACKING_DEFAULT_U64S,
176 "NROS_EXECUTOR_BACKING_U64S is below the default executor sizing, so the \
177 reservation can never be taken and is pure dead weight; use 0 to decline \
178 the static entirely"
179);
180
181/// Has [`EXECUTOR_BACKING`] been handed out?
182///
183/// A latch, not a free list: the backing is handed out as `&'static mut` and
184/// never returned, exactly like the `Box::leak` it replaces. An executor that
185/// drops does not give it back — reclaiming it would need the executor's own
186/// tables to be provably dead first, which `Drop` cannot show for a slice it has
187/// already lent to `Dispatcher`.
188#[cfg(nros_executor_backing_static)]
189static TAKEN: AtomicBool = AtomicBool::new(false);
190
191/// Hand out [`EXECUTOR_BACKING`], once, if it can hold `words`.
192///
193/// `None` means "use the heap", and every path to it is legitimate rather than
194/// degraded — it is the caller's behaviour from before this module existed:
195///
196/// * the reservation is switched off (`NROS_EXECUTOR_BACKING_U64S=0`);
197/// * another executor already took it (a second `Executor::open` in one
198/// process — a tiered boot's spawned tiers no longer come here, they take
199/// the entry's [`TierExecutorBacking`], issue 1571);
200/// * this executor is sized past the reservation (a fat entry).
201///
202/// The `swap` is what makes the returned `&'static mut` sound: exactly one
203/// caller observes `false`, so exactly one reference to the static ever exists.
204#[cfg(nros_executor_backing_static)]
205pub(crate) fn take(words: usize) -> Option<&'static mut [MaybeUninit<u64>]> {
206 if words > EXECUTOR_BACKING_U64S {
207 return None;
208 }
209 if TAKEN.swap(true, Ordering::AcqRel) {
210 return None;
211 }
212 // SAFETY: the swap above succeeds exactly once for the life of the process,
213 // so this is the only reference ever created to `EXECUTOR_BACKING`, and it
214 // is `'static` because the static is. `words <= EXECUTOR_BACKING_U64S` was
215 // checked, so the slice is in bounds.
216 // The raw ref is bound first rather than dereferenced in place: `&mut
217 // *(&raw mut X)` reads as `deref_addrof` to clippy, and the obvious
218 // rewrite it suggests (`&mut X`) is the `static_mut_refs` hazard this
219 // spelling exists to avoid.
220 let ptr = &raw mut EXECUTOR_BACKING;
221 let all: &'static mut [MaybeUninit<u64>; EXECUTOR_BACKING_U64S] = unsafe { &mut *ptr };
222 Some(&mut all[..words])
223}
224
225/// The reservation is switched off, so there is nothing to hand out.
226///
227/// A stub rather than a `cfg` at the call site: `default_backing` reads the same
228/// either way, which is the paired-stub idiom `trace_register` uses one file
229/// over, and it keeps the `#[cfg]` in exactly one place.
230#[cfg(not(nros_executor_backing_static))]
231pub(crate) fn take(_words: usize) -> Option<&'static mut [MaybeUninit<u64>]> {
232 None
233}
234
235/// Has the static been claimed? Tests only.
236#[cfg(all(test, nros_executor_backing_static))]
237pub(crate) fn is_taken() -> bool {
238 TAKEN.load(Ordering::Acquire)
239}
240
241// ============================================================================
242// issue 1571 — the SPAWNED tiers' executor backing, as the entry's own static
243// ============================================================================
244//
245// A tiered boot opens one executor per tier. The boot tier takes
246// `EXECUTOR_BACKING` through `Executor::open` like any single-executor entry;
247// every OTHER tier used to reach `default_backing` too, find the latch already
248// taken, and `Box::leak` a default-sized block — correctly sized, but invisible
249// to `mem-report` and drawn from the allocator arena, where issue 1568 had just
250// moved every C and C++ RTOS executor (tier and single) OUT of it.
251//
252// The fix is the C/C++ METHOD, not a second one: the tier count is known when
253// the entry is generated, so the entry (`nros::main!`) emits ONE named static of
254// `N - 1` slots — the C pack's `__nros_tier_executor_storage` — and the board's
255// `run_tiers` hands each spawned tier its slot. The slot is typed, so its size is
256// the executor's exact need by construction: the tier opens with
257// `ExecutorSizing::DEFAULT` (see `Executor::open_with_session_slot`) and the slot
258// is `ExecutorSizing::DEFAULT.u64_len()` words — the same const the boot
259// reservation above is judged against, so there is ONE spelling of "a default
260// executor's worth" for both.
261//
262// It is a MOVE, exactly like the boot reservation: on an RTOS the same bytes
263// leave the allocator arena (`CONFIG_COMMON_LIBC_MALLOC_ARENA_SIZE`,
264// `configTOTAL_HEAP_SIZE`, the NuttX kernel heap, the ThreadX byte pool) and
265// become linker-visible `.bss`.
266
267/// issue 1571 — one spawned tier executor's backing: exactly the words an
268/// executor opened with [`ExecutorSizing::DEFAULT`] carves.
269pub type TierExecutorBackingSlot = [MaybeUninit<u64>; EXECUTOR_BACKING_DEFAULT_U64S];
270
271/// issue 1571 — the spawned tiers' executor backing, `N` slots in ONE named
272/// static that the ENTRY owns.
273///
274/// `nros::main!` emits one per tiered entry, with `N` = the tier count minus the
275/// boot tier (which takes the `EXECUTOR_BACKING` road), and passes
276/// [`take`](Self::take) to the board's `run_tiers`. The board then refuses a
277/// short block with [`check_tier_executor_backing`] — the Rust twin of the C
278/// library's `nros_cpp_executor_storage_check`.
279///
280/// Placement is the caller's (RFC-0002 § 4.4b): the static lives in the entry's
281/// `.bss`, so `mem-report` prices it by name.
282pub struct TierExecutorBacking<const N: usize> {
283 /// The same once-only latch as the boot reservation's, per static: the
284 /// slots go out as `&'static mut`, so exactly one caller may observe them.
285 taken: portable_atomic::AtomicBool,
286 slots: core::cell::UnsafeCell<[TierExecutorBackingSlot; N]>,
287}
288
289// SAFETY: the slots are only reachable through `take`, whose latch hands them
290// out at most once, so no two threads can ever hold a reference to them.
291unsafe impl<const N: usize> Sync for TierExecutorBacking<N> {}
292
293impl<const N: usize> TierExecutorBacking<N> {
294 /// An untouched reservation. `const` so it can initialise a `static`, and
295 /// uninitialised (plus a `false` latch), so the static is all-zero `.bss`
296 /// and costs no flash.
297 ///
298 /// `large_stack_arrays` is allowed because this is only ever evaluated in
299 /// CONST context (a `static` initialiser): the array is built by the
300 /// compiler into `.bss`, never on a stack.
301 #[allow(clippy::new_without_default, clippy::large_stack_arrays)]
302 pub const fn new() -> Self {
303 Self {
304 taken: portable_atomic::AtomicBool::new(false),
305 slots: core::cell::UnsafeCell::new(
306 [[MaybeUninit::uninit(); EXECUTOR_BACKING_DEFAULT_U64S]; N],
307 ),
308 }
309 }
310
311 /// Hand out every slot, once. A second call returns an EMPTY slice, which
312 /// the board's [`check_tier_executor_backing`] then refuses by name rather
313 /// than aliasing a slot another executor is using.
314 ///
315 /// `mut_from_ref` is the point, not an accident: `&'static self` is how a
316 /// `static` is reached, and the latch is what makes the one `&mut` sound —
317 /// the same shape as the boot reservation's `take`.
318 #[allow(clippy::mut_from_ref)]
319 pub fn take(&'static self) -> &'static mut [TierExecutorBackingSlot] {
320 if self.taken.swap(true, portable_atomic::Ordering::AcqRel) {
321 return &mut [];
322 }
323 // SAFETY: the swap above succeeds exactly once per static, so this is
324 // the only reference ever created to `slots`; `'static` because `self`
325 // is.
326 unsafe { &mut *self.slots.get() }
327 }
328}
329
330/// issue 1571 — the tier backing a board was handed does not cover the tiers it
331/// must spawn. Carries both counts so the refusal names them.
332#[derive(Debug, Clone, Copy, PartialEq, Eq)]
333pub struct TierBackingShort {
334 /// Tiers the board spawns (every tier but the boot one).
335 pub spawned: usize,
336 /// Slots it was handed.
337 pub slots: usize,
338}
339
340impl core::fmt::Display for TierBackingShort {
341 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
342 write!(
343 f,
344 "tier executor backing holds {} slot(s) but {} spawned tier(s) need one each \
345 — the entry's `TierExecutorBacking` was sized for a different tier table, \
346 or taken twice (issue 1571)",
347 self.slots, self.spawned
348 )
349 }
350}
351
352/// issue 1571 — the ONE refusal every board's `run_tiers` makes before it opens
353/// anything: `n_tiers` tiers run, one on the boot executor, so `n_tiers - 1`
354/// must each find a slot. Checked up front rather than at each spawn, so a
355/// short block never leaves a half-spawned tier chain.
356pub fn check_tier_executor_backing(
357 n_tiers: usize,
358 backing: &[TierExecutorBackingSlot],
359) -> Result<(), TierBackingShort> {
360 let spawned = n_tiers.saturating_sub(1);
361 if backing.len() < spawned {
362 return Err(TierBackingShort {
363 spawned,
364 slots: backing.len(),
365 });
366 }
367 Ok(())
368}
369
370/// Issue 1733 — the ONE gate a tier runner holds across each tier's
371/// open-and-`setup` so no two tiers declare entities on the shared session at
372/// once.
373///
374/// Every tier executor borrows the boot executor's ONE RMW session, and a
375/// tier's `setup` declares its nodes and entities on it. The backend's
376/// entity-creation path is not reentrant across threads: zenoh's
377/// `ZenohSession::create_*` takes `&mut self` and mutates the per-node
378/// liveliness table, and issue 0447 measured the topic-binding scramble that
379/// concurrent declares cause. The RTOS runners serialize by CHAINING the
380/// spawns (issue #144: tier N+1 starts only after tier N's setup returned);
381/// a runner that spawns every tier at once holds this instead. Setup runs once
382/// per boot and off the hot path, so a sleeping spin costs nothing that
383/// matters, and the spin loops that follow stay fully concurrent.
384///
385/// `no_std` and allocation-free (one atomic flag), so the same gate serves the
386/// native C++ runner (`nros-cpp`), the Linux Rust runner and the NuttX Rust
387/// runner. The backoff sleeps through the platform ABI, never spins hot: a
388/// waiter that outranks the holder under `SCHED_FIFO` would otherwise starve
389/// it on one CPU.
390#[derive(Debug, Default)]
391pub struct TierSetupGate {
392 held: portable_atomic::AtomicBool,
393}
394
395impl TierSetupGate {
396 /// An open gate.
397 #[must_use]
398 pub const fn new() -> Self {
399 Self {
400 held: portable_atomic::AtomicBool::new(false),
401 }
402 }
403
404 /// Block until this caller holds the gate; it is released when the
405 /// returned guard drops (on every return path, including a failed setup).
406 pub fn enter(&self) -> TierSetupGuard<'_> {
407 while self
408 .held
409 .compare_exchange_weak(
410 false,
411 true,
412 portable_atomic::Ordering::Acquire,
413 portable_atomic::Ordering::Relaxed,
414 )
415 .is_err()
416 {
417 super::spin::platform_sleep(core::time::Duration::from_micros(200));
418 }
419 TierSetupGuard { gate: self }
420 }
421}
422
423/// Issue 1733 — proof of holding a [`TierSetupGate`]; dropping it opens the
424/// gate.
425#[derive(Debug)]
426#[must_use = "the gate is released as soon as the guard drops"]
427pub struct TierSetupGuard<'a> {
428 gate: &'a TierSetupGate,
429}
430
431impl Drop for TierSetupGuard<'_> {
432 fn drop(&mut self) {
433 self.gate
434 .held
435 .store(false, portable_atomic::Ordering::Release);
436 }
437}
438
439// ---------------------------------------------------------------------------
440// issue 1598 — each spawned tier's TASK memory (stack + control block), the
441// same "entry declares, board uses" method one step further: the executor
442// backing above is in `.bss`, and so now is the task that runs it.
443// ---------------------------------------------------------------------------
444
445/// issue 1598 — the control-block region every [`TierTaskMemory`] carries,
446/// in 8-byte words. An UPPER BOUND over the kernels that take it (a FreeRTOS
447/// `StaticTask_t` on Cortex-M is about a hundred bytes); the port REFUSES a
448/// region smaller than its own control block at spawn, by name, so a kernel
449/// whose block outgrows this fails loudly rather than overrunning the stack
450/// that follows. Rust cannot name a C type's size at macro time, which is why
451/// this one number is a bound and the stack beside it is exact.
452pub const TIER_TASK_TCB_U64S: usize = 64;
453
454/// issue 1598 — one spawned tier's task memory as a board receives it: raw,
455/// `Copy`, no lifetime. The ENTRY owns the bytes ([`TierTaskMemory`]); the
456/// handout is once-only ([`TierTaskMemorySet::take`]), which is what makes the
457/// raw pointers exclusive.
458#[derive(Clone, Copy, Debug)]
459pub struct TierTaskMemoryRaw {
460 /// The stack: `stack_bytes` bytes, 64-byte aligned.
461 pub stack: *mut u8,
462 /// The stack's size in bytes — the tier's declared `stack_bytes` (or the
463 /// family default), rounded up to whole words.
464 pub stack_bytes: usize,
465 /// The control-block region: `tcb_bytes` bytes, 64-byte aligned.
466 pub tcb: *mut u8,
467 /// [`TIER_TASK_TCB_U64S`] words.
468 pub tcb_bytes: usize,
469}
470
471impl TierTaskMemoryRaw {
472 /// "This tier has no task memory from the entry": the port keeps its own
473 /// default (Zephyr's pool slot for a tier that declared no `stack_bytes`).
474 pub const NONE: Self = Self {
475 stack: core::ptr::null_mut(),
476 stack_bytes: 0,
477 tcb: core::ptr::null_mut(),
478 tcb_bytes: 0,
479 };
480}
481
482// SAFETY: the pointers name `'static` storage handed out once (see the set's
483// latch), so moving the row to the task that uses it is the whole point.
484unsafe impl Send for TierTaskMemoryRaw {}
485unsafe impl Sync for TierTaskMemoryRaw {}
486
487/// issue 1598 — one spawned tier's stack and control block, as ONE named static
488/// the entry owns. `STACK_U64S` is the tier's stack in 8-byte words;
489/// `TCB_U64S` the control-block region, [`TIER_TASK_TCB_U64S`] by default and
490/// `0` for a kernel whose control block the port keeps itself (Zephyr's
491/// `struct k_thread`, issue 1232 — a Rust entry cannot size it).
492///
493/// The control block comes first and the struct is 64-byte aligned, so the
494/// stack starts on a boundary every in-tree kernel accepts for a thread stack
495/// (the TCB region is a whole number of 64-byte lines).
496#[repr(C, align(64))]
497pub struct TierTaskMemory<const STACK_U64S: usize, const TCB_U64S: usize = TIER_TASK_TCB_U64S> {
498 tcb: core::cell::UnsafeCell<[MaybeUninit<u64>; TCB_U64S]>,
499 stack: core::cell::UnsafeCell<[MaybeUninit<u64>; STACK_U64S]>,
500}
501
502// SAFETY: the bytes are reached only through `raw`, collected into a
503// `TierTaskMemorySet` whose latch hands them out at most once.
504unsafe impl<const STACK_U64S: usize, const TCB_U64S: usize> Sync
505 for TierTaskMemory<STACK_U64S, TCB_U64S>
506{
507}
508
509impl<const STACK_U64S: usize, const TCB_U64S: usize> TierTaskMemory<STACK_U64S, TCB_U64S> {
510 /// An untouched reservation, `const` so it initialises a `static`
511 /// (all-zero `.bss`, no flash).
512 #[allow(clippy::new_without_default, clippy::large_stack_arrays)]
513 pub const fn new() -> Self {
514 Self {
515 tcb: core::cell::UnsafeCell::new([MaybeUninit::uninit(); TCB_U64S]),
516 stack: core::cell::UnsafeCell::new([MaybeUninit::uninit(); STACK_U64S]),
517 }
518 }
519
520 /// The raw row for this static — `const`, so the entry builds its
521 /// [`TierTaskMemorySet`] in a `static` initialiser.
522 pub const fn raw(&'static self) -> TierTaskMemoryRaw {
523 TierTaskMemoryRaw {
524 stack: self.stack.get() as *mut u8,
525 stack_bytes: STACK_U64S * core::mem::size_of::<u64>(),
526 tcb: self.tcb.get() as *mut u8,
527 tcb_bytes: TCB_U64S * core::mem::size_of::<u64>(),
528 }
529 }
530}
531
532/// issue 1598 — the spawned tiers' task memory, `N` rows in spawn (chain)
533/// order, behind a once-only latch.
534pub struct TierTaskMemorySet<const N: usize> {
535 taken: portable_atomic::AtomicBool,
536 rows: [TierTaskMemoryRaw; N],
537}
538
539impl<const N: usize> TierTaskMemorySet<N> {
540 /// `const`, for a `static` initialiser over the per-tier statics.
541 pub const fn new(rows: [TierTaskMemoryRaw; N]) -> Self {
542 Self {
543 taken: portable_atomic::AtomicBool::new(false),
544 rows,
545 }
546 }
547
548 /// Hand out every row, once. A second call returns an EMPTY slice, which a
549 /// board refuses by count rather than giving two tasks one stack.
550 pub fn take(&'static self) -> &'static [TierTaskMemoryRaw] {
551 if self.taken.swap(true, portable_atomic::Ordering::AcqRel) {
552 return &[];
553 }
554 &self.rows
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561
562 /// Issue 1733 — the gate admits ONE holder at a time, across threads, and
563 /// lets every waiter through once the holder drops its guard. Eight
564 /// threads released together each count themselves in and out of the
565 /// critical section; any overlap (a second holder) is recorded. A
566 /// `TierSetupGate::enter` that admitted concurrently is exactly the native
567 /// C++ runner before the issue: four tier setups declaring at once.
568 #[test]
569 fn tier_setup_gate_admits_one_holder_at_a_time() {
570 use std::sync::{
571 Arc, Barrier,
572 atomic::{AtomicUsize, Ordering},
573 };
574 const THREADS: usize = 8;
575 let gate = Arc::new(TierSetupGate::new());
576 let inside = Arc::new(AtomicUsize::new(0));
577 let overlaps = Arc::new(AtomicUsize::new(0));
578 let entered = Arc::new(AtomicUsize::new(0));
579 let start = Arc::new(Barrier::new(THREADS));
580 let handles: std::vec::Vec<_> = (0..THREADS)
581 .map(|_| {
582 let (gate, inside, overlaps, entered, start) = (
583 gate.clone(),
584 inside.clone(),
585 overlaps.clone(),
586 entered.clone(),
587 start.clone(),
588 );
589 std::thread::spawn(move || {
590 start.wait();
591 let _g = gate.enter();
592 if inside.fetch_add(1, Ordering::SeqCst) != 0 {
593 overlaps.fetch_add(1, Ordering::SeqCst);
594 }
595 entered.fetch_add(1, Ordering::SeqCst);
596 std::thread::sleep(core::time::Duration::from_millis(2));
597 inside.fetch_sub(1, Ordering::SeqCst);
598 })
599 })
600 .collect();
601 for h in handles {
602 h.join().expect("a gate holder panicked");
603 }
604 assert_eq!(
605 entered.load(Ordering::SeqCst),
606 THREADS,
607 "every waiter got through"
608 );
609 assert_eq!(overlaps.load(Ordering::SeqCst), 0, "two holders at once");
610 }
611
612 /// The POSITIVE CONTROL, and the reason it and the two `None` cases share
613 /// one test: `TAKEN` is process-scoped by design, so a second test would
614 /// observe an already-consumed latch and assert nothing — the vacuous shape
615 /// `check-no-vacuous-tests` exists to catch.
616 ///
617 /// A test that only ever saw `take` return `None` would pass against a `take`
618 /// that can never succeed, which is exactly the bug that would make this
619 /// whole module inert while reading as if it worked.
620 ///
621 /// `#[ignore]` because it needs a VIRGIN PROCESS, and that is a property of
622 /// the subject rather than a wish: `TAKEN` is process-global, and
623 /// `spin.rs`'s `default_backing` — reached by every `Executor::open` under
624 /// the `alloc` feature — claims it. Any of this binary's other 365 tests
625 /// that opens an executor therefore consumes the one handout first, and
626 /// this test's own positive control (`take` returning `Some`) becomes
627 /// impossible. Measured: passes alone, fails in the full lane at the
628 /// `!is_taken()` assertion below.
629 ///
630 /// `just check node-std-tests` runs it in its own cargo invocation, which
631 /// is a second process. Same shape and same answer as `boot_report::tests`
632 /// one recipe over, and for the same underlying reason.
633 #[cfg(nros_executor_backing_static)]
634 #[test]
635 #[ignore = "needs a virgin process: TAKEN is a process-global latch that \
636 Executor::open consumes — run via `just check node-std-tests`"]
637 fn the_static_is_handed_out_once_and_only_once() {
638 // Too large for the reservation: refused BEFORE the latch is touched, so
639 // this case cannot consume the one handout.
640 assert!(
641 take(EXECUTOR_BACKING_U64S + 1).is_none(),
642 "a request past the reservation must fall back to the heap"
643 );
644 assert!(
645 !is_taken(),
646 "an over-large request must not claim the latch"
647 );
648
649 let first = take(EXECUTOR_BACKING_U64S).expect("the first taker gets the static");
650 assert_eq!(
651 first.len(),
652 EXECUTOR_BACKING_U64S,
653 "the handout is the requested length, not the whole reservation"
654 );
655 assert!(is_taken());
656
657 assert!(
658 take(1).is_none(),
659 "a second taker must fall back to the heap — two `&'static mut` to \
660 one static is UB, which is the whole reason for the latch"
661 );
662 }
663
664 /// With the reservation off there is no static, so `take` is inert and no
665 /// image carries the bytes.
666 #[cfg(not(nros_executor_backing_static))]
667 #[test]
668 fn the_opt_out_removes_the_reservation_entirely() {
669 assert!(
670 take(1).is_none(),
671 "NROS_EXECUTOR_BACKING_U64S=0 must hand out nothing"
672 );
673 }
674
675 /// issue 1571 — the entry's tier backing goes out ONCE, whole, and each
676 /// slot is exactly one default executor's worth. Its own static, so unlike
677 /// the boot reservation's test it needs no virgin process.
678 #[test]
679 fn tier_backing_is_handed_out_once_and_each_slot_is_one_default_executor() {
680 static TIERS: TierExecutorBacking<3> = TierExecutorBacking::new();
681 let slots = TIERS.take();
682 assert_eq!(slots.len(), 3, "the first taker gets every slot");
683 assert_eq!(
684 core::mem::size_of::<TierExecutorBackingSlot>(),
685 8 * ExecutorSizing::DEFAULT.u64_len(),
686 "a slot is exactly what a DEFAULT-sized executor carves"
687 );
688 assert!(
689 TIERS.take().is_empty(),
690 "a second taker must get nothing — two `&'static mut` to one slot is UB"
691 );
692 }
693
694 /// issue 1598 — a tier's task memory is exactly the declared stack plus the
695 /// stated control-block bound, aligned for a thread stack, and handed out
696 /// ONCE: a second take is empty, never a second task on the same stack.
697 #[test]
698 fn tier_task_memory_is_the_declared_size_and_handed_out_once() {
699 static A: TierTaskMemory<128> = TierTaskMemory::new();
700 static B: TierTaskMemory<32> = TierTaskMemory::new();
701 static SET: TierTaskMemorySet<2> = TierTaskMemorySet::new([A.raw(), B.raw()]);
702 let rows = SET.take();
703 assert_eq!(rows.len(), 2);
704 assert_eq!(rows[0].stack_bytes, 128 * 8);
705 assert_eq!(rows[1].stack_bytes, 32 * 8);
706 assert_eq!(rows[0].tcb_bytes, TIER_TASK_TCB_U64S * 8);
707 assert_eq!(rows[0].stack as usize % 64, 0, "stack is 64-byte aligned");
708 assert_ne!(rows[0].stack, rows[1].stack);
709 assert_eq!(
710 core::mem::size_of::<TierTaskMemory<128>>(),
711 (TIER_TASK_TCB_U64S + 128) * 8,
712 "nothing but the TCB bound and the stack"
713 );
714 assert!(
715 SET.take().is_empty(),
716 "a second take must not alias the rows"
717 );
718 }
719
720 /// issue 1571 — the one refusal: every tier but the boot one needs a slot.
721 #[test]
722 fn tier_backing_refuses_a_block_short_of_the_spawned_tiers() {
723 static TWO: TierExecutorBacking<2> = TierExecutorBacking::new();
724 let two = TWO.take();
725 assert_eq!(check_tier_executor_backing(3, two), Ok(()));
726 assert_eq!(
727 check_tier_executor_backing(1, &[]),
728 Ok(()),
729 "boot tier only"
730 );
731 assert_eq!(
732 check_tier_executor_backing(4, two),
733 Err(TierBackingShort {
734 spawned: 3,
735 slots: 2
736 })
737 );
738 assert_eq!(
739 check_tier_executor_backing(2, &[]),
740 Err(TierBackingShort {
741 spawned: 1,
742 slots: 0
743 }),
744 "a taken-twice (empty) block is refused, not aliased"
745 );
746 }
747}