Skip to main content

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}