Skip to main content

nros/
runtime_storage.rs

1//! Caller-supplied storage for the component pool — phase-391 W5, step 2.
2//!
3//! Mirrors [`nros_node::ExecutorSizing`] one layer up, and
4//! for the same stated reason: **public + non-generic**, the "C/C++ is a thin
5//! wrapper" principle. The entry, the macro and the FFI seam supply these as
6//! plain `usize`s rather than as const generics C cannot name —
7//! `node_runtime` carries nine `extern "C"` sites and backs
8//! `__nros_component_<pkg>_install`.
9//!
10//! This module is INERT on its own: it computes how large a backing must be.
11//! The pool that carves one is W5 step 3; until then nothing calls
12//! [`RuntimeSizing::u64_len`] except its tests.
13//!
14//! Ungated deliberately — the arithmetic is useful for sizing a `static`
15//! whether or not the runtime module that consumes it is compiled in.
16
17use crate::config::{COMPONENT_SLOT_BYTES, MAX_COMPONENTS};
18
19/// Per-image component-pool sizing — how many components the runtime can hold
20/// and how much erased storage each one's `TypedSlot<C>` gets.
21///
22/// `slot_bytes` is a BYTE budget rather than a type because the pool is
23/// heterogeneous (`TypedSlot<C>` is generic over `C`) and the FFI seam cannot
24/// name a generic. A component whose slot does not fit is a registration
25/// error, not a compile error.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct RuntimeSizing {
28    /// Component pool slots.
29    pub components: usize,
30    /// Bytes of erased slot storage per component.
31    pub slot_bytes: usize,
32}
33
34impl RuntimeSizing {
35    /// The build-time default, from the `NROS_RUNTIME_*` knobs.
36    pub const DEFAULT: Self = Self {
37        components: MAX_COMPONENTS,
38        slot_bytes: COMPONENT_SLOT_BYTES,
39    };
40
41    /// `u64` words a backing must hold for this sizing.
42    ///
43    /// `u64` because the backing is 8-aligned, which covers every field the
44    /// pool stores without hand-aligning — the same reason
45    /// `executor_storage_u64_len` uses it.
46    pub const fn u64_len(&self) -> usize {
47        // Each slot is its erased storage rounded up to the 8-byte alignment
48        // the backing already guarantees.
49        let per_slot = self.slot_bytes.div_ceil(8);
50        per_slot * self.components
51    }
52}
53
54impl Default for RuntimeSizing {
55    fn default() -> Self {
56        Self::DEFAULT
57    }
58}
59
60/// A carved component-pool slot: `slot_bytes` of 8-aligned storage for one
61/// type-erased `TypedSlot<C>`.
62///
63/// The slot outlives every closure that dispatches through it because the
64/// BACKING is `'static` — which is the point of W5, and why the
65/// `Arc<ComponentCell>` refcount that currently proves that lifetime becomes
66/// unnecessary.
67pub struct Slot<'s> {
68    storage: &'s mut [core::mem::MaybeUninit<u8>],
69}
70
71impl Slot<'_> {
72    /// Bytes available for the erased slot value.
73    pub fn capacity(&self) -> usize {
74        self.storage.len()
75    }
76
77    /// Whether `T` fits this slot, in both size and alignment.
78    ///
79    /// A `false` here is a REGISTRATION error, not a compile error: the pool is
80    /// heterogeneous and the FFI seam cannot name a generic. Callers surface it
81    /// as "raise NROS_RUNTIME_COMPONENT_SLOT_BYTES".
82    pub fn fits<T>(&self) -> bool {
83        self.storage.len() >= core::mem::size_of::<T>()
84            && (self.storage.as_ptr() as usize).is_multiple_of(core::mem::align_of::<T>())
85    }
86
87    /// Pointer to the slot's storage, for an in-place write of `TypedSlot<C>`.
88    pub fn as_mut_ptr(&mut self) -> *mut u8 {
89        self.storage.as_mut_ptr() as *mut u8
90    }
91}
92
93/// Carve `backing` into `sizing.components` slots of `sizing.slot_bytes`.
94///
95/// # Panics
96///
97/// If `backing` is too small, naming both sizes. Fail-loud on EVERY profile,
98/// not `debug_assert!` — embedded release builds strip debug assertions, and a
99/// short backing is silent memory corruption rather than a wrong answer. Same
100/// reasoning as `executor::storage::carve` (issue #131, where a stale config
101/// mirror surfaced as a `jalr -> 0`).
102pub fn carve(
103    backing: &mut [core::mem::MaybeUninit<u64>],
104    sizing: RuntimeSizing,
105) -> impl Iterator<Item = Slot<'_>> {
106    let need = sizing.u64_len();
107    assert!(
108        backing.len() >= need,
109        "component pool backing too small: {} u64 words < {} required for {} slot(s) \
110         of {} bytes — size it with RuntimeSizing::u64_len()",
111        backing.len(),
112        need,
113        sizing.components,
114        sizing.slot_bytes,
115    );
116    let len_bytes = backing.len() * 8;
117    // SAFETY: reinterpreting `MaybeUninit<u64>` as `MaybeUninit<u8>` reads no
118    // value and only WIDENS the alignment guarantee; the length scales by 8.
119    let bytes: &mut [core::mem::MaybeUninit<u8>] = unsafe {
120        core::slice::from_raw_parts_mut(
121            backing.as_mut_ptr() as *mut core::mem::MaybeUninit<u8>,
122            len_bytes,
123        )
124    };
125    let per_slot = (sizing.slot_bytes.div_ceil(8) * 8).max(1);
126    bytes
127        .chunks_exact_mut(per_slot)
128        .take(sizing.components)
129        .map(|storage| Slot { storage })
130}
131
132#[cfg(test)]
133mod tests {
134    extern crate alloc;
135    use super::*;
136
137    #[test]
138    fn default_comes_from_the_knobs() {
139        assert_eq!(RuntimeSizing::DEFAULT.components, MAX_COMPONENTS);
140        assert_eq!(RuntimeSizing::DEFAULT.slot_bytes, COMPONENT_SLOT_BYTES);
141    }
142
143    #[test]
144    fn u64_len_rounds_each_slot_up_not_the_total() {
145        // 12 bytes is 1.5 words; each SLOT rounds to 2, so 3 slots need 6 —
146        // not `(12 * 3) / 8 = 4.5 -> 5`. Rounding the total would under-size
147        // every slot after the first.
148        let s = RuntimeSizing {
149            components: 3,
150            slot_bytes: 12,
151        };
152        assert_eq!(s.u64_len(), 6);
153    }
154
155    #[test]
156    fn zero_components_needs_no_backing() {
157        let s = RuntimeSizing {
158            components: 0,
159            slot_bytes: 512,
160        };
161        assert_eq!(s.u64_len(), 0);
162    }
163
164    #[test]
165    fn carve_yields_exactly_the_requested_slots() {
166        let s = RuntimeSizing {
167            components: 3,
168            slot_bytes: 16,
169        };
170        let mut backing = [core::mem::MaybeUninit::<u64>::uninit(); 6];
171        let slots: alloc::vec::Vec<_> = carve(&mut backing, s).collect();
172        assert_eq!(slots.len(), 3, "one slot per component, no more");
173        for sl in &slots {
174            assert!(sl.capacity() >= 16, "each slot holds its byte budget");
175        }
176    }
177
178    #[test]
179    fn carve_slots_do_not_overlap() {
180        let s = RuntimeSizing {
181            components: 2,
182            slot_bytes: 8,
183        };
184        let mut backing = [core::mem::MaybeUninit::<u64>::uninit(); 2];
185        let mut slots: alloc::vec::Vec<_> = carve(&mut backing, s).collect();
186        let a = slots[0].as_mut_ptr() as usize;
187        let b = slots[1].as_mut_ptr() as usize;
188        // Overlapping slots are the corruption this wave exists to avoid.
189        assert!(b >= a + 8, "slot 1 starts at or after the end of slot 0");
190    }
191
192    #[test]
193    #[should_panic(expected = "component pool backing too small")]
194    fn carve_refuses_a_short_backing() {
195        // Negative control: one word short must PANIC, not hand out slots that
196        // run past the end. Verified to fail when the assert is removed.
197        let s = RuntimeSizing {
198            components: 4,
199            slot_bytes: 64,
200        };
201        let mut backing = [core::mem::MaybeUninit::<u64>::uninit(); 31];
202        let _ = carve(&mut backing, s).count();
203    }
204
205    #[test]
206    fn a_type_too_large_for_its_slot_is_refused_not_truncated() {
207        let s = RuntimeSizing {
208            components: 1,
209            slot_bytes: 8,
210        };
211        let mut backing = [core::mem::MaybeUninit::<u64>::uninit(); 1];
212        let slot = carve(&mut backing, s).next().unwrap();
213        assert!(slot.fits::<u64>(), "8 bytes fits an 8-byte slot");
214        assert!(
215            !slot.fits::<[u64; 4]>(),
216            "32 bytes does not fit an 8-byte slot"
217        );
218    }
219
220    #[test]
221    fn the_default_sizing_is_not_accidentally_empty() {
222        // Guards the case this campaign keeps hitting: a figure that passes
223        // because nothing is in it.
224        assert!(RuntimeSizing::DEFAULT.u64_len() > 0);
225    }
226}