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}