nros_node/executor/arena_oracle.rs
1//! phase-412 #4 — the arena's HOST ORACLE, held to at compile time.
2//!
3//! `nros-node/build.rs` is the one place that knows what this image's declared
4//! entities cost: it sums the per-kind model over `NROS_ENTITY_COUNT_*` (and the
5//! declared depths) and emits the result as [`arena_model::REQUIRED`] beside
6//! the arena it derives from it (`cargo:arena_size`). Before this module that
7//! requirement stopped at the derivation. An image that STATED
8//! `NROS_EXECUTOR_ARENA_SIZE` below it built, linked, flashed and then died at
9//! the first registration that did not fit, with `NodeError::BufferTooSmall` —
10//! on a board whose only diagnostic channel may be a RAM record read back with
11//! a debugger (issue 1036).
12//!
13//! Here the two numbers meet in one compilation, so the refusal is a build
14//! error that names the knob, both numbers and the shortfall.
15//!
16//! # Which arms can be held, and which cannot
17//!
18//! The arena is a slice of caller-supplied backing, so "the arena" is really
19//! one number per PLACEMENT. A compile-time check needs the arena and the
20//! requirement to be constants in the same compilation; that decides it.
21//!
22//! | arm | arena comes from | checked |
23//! | --- | --- | --- |
24//! | Rust `.bss` `EXECUTOR_BACKING` | `ExecutorSizing::DEFAULT.arena` = `ARENA_SIZE` | compile time, the `ARENA_SIZE` const item below |
25//! | Rust heap, default sizing (`open` / `open_multi` / `open_with_rmw` past the static) | the same `DEFAULT` | compile time, same assertion |
26//! | C `rclc_executor_t::_opaque` | `EXECUTOR_OPAQUE_U64S`, measured from `ExecutorInlineStorage` = `DEFAULT` | compile time, same assertion (`nros-c` compiles after it) |
27//! | C++ `Node::GlobalStorageHolder` / `Executor::storage_` | `CPP_EXECUTOR_OPAQUE_U64S`, measured the same way | compile time, same assertion |
28//! | Rust `open_in` over a caller's `const` sizing | the caller's `ExecutorSizing::arena` | compile time, OPT-IN: [`ExecutorSizing::assert_covers_model`](super::ExecutorSizing::assert_covers_model) |
29//! | Rust heap, per-entry sizing (`open_sized`, `arena_size_for(cbs)`) | a RUNTIME `cbs` handed through `BoardEntry` | NO — runtime |
30//! | Rust `open_in` over a runtime slice / runtime sizing | runtime values | NO — runtime |
31//!
32//! The runtime arms are not faked. Their arena is a value, not a constant, and
33//! an executor there may hold a SUBSET of the image (a tier), so the
34//! image-wide model is not even the right bound. What they get is the
35//! registration-time refusal every arm already has: `arena_alloc` and
36//! `arena_alloc_with_trailing` report the entity, the shortfall and the knob
37//! (`report_arena_exhausted`, issues 0900 / 1036) and write the shortfall to
38//! the boot record.
39//!
40//! # What the model is, and what it is not
41//!
42//! [`REQUIRED`](arena_model::REQUIRED) is the per-kind sum, whose terms
43//! `executor::arena` asserts to be upper bounds on the entry types they stand
44//! for. So an arena at or above it fits what was declared. It is NOT a lower
45//! bound on what an image needs: the terms over-price (issue 1255 prices every
46//! subscription at one image-wide type bound), so an arena stated below the
47//! model may still fit. The refusal is deliberate anyway — a number below the
48//! model is a claim the build cannot check, and the tree's answer to an
49//! over-priced model is to correct the model's INPUTS (declared depths,
50//! `NROS_PUBSUB_QOS_DEPTH`, per-type bounds), which re-derive the arena, rather
51//! than to hand-state a number that goes stale the next time any term moves.
52//!
53//! An image that declares nothing has `REQUIRED == 0`: the derivation then
54//! budgeted a worst case over `NROS_EXECUTOR_MAX_CBS` slots, which is a budget
55//! and not a requirement, and stating less than a budget is the ordinary use of
56//! the knob. Nothing is refused there.
57//!
58//! [`arena_model::REQUIRED`]: crate::config::arena_model::REQUIRED
59
60use crate::config::arena_model;
61
62/// The arm every default-sized backing shares, held in the one compilation
63/// every image makes.
64///
65/// The Rust `.bss` static (`executor::backing`), the heap fallback of the
66/// `alloc` constructors, the C `rclc_executor_t::_opaque` and the C++
67/// `Node::GlobalStorageHolder` storage are all sized from
68/// `ExecutorSizing::DEFAULT`, whose arena IS `ARENA_SIZE`, and each already
69/// refuses at compile time to be smaller than that sizing. So this one item
70/// covers all four.
71///
72/// Here rather than beside `ExecutorSizing` because `storage` is compiled only
73/// with an RMW seam, and this must fire in every build of the crate.
74///
75/// It can fire only when `NROS_EXECUTOR_ARENA_SIZE` is STATED: a derived arena
76/// is the model (unfloored since issue 1971; only an undeclared image's
77/// worst-case budget is floored).
78const _: () = assert_covers(
79 crate::config::ARENA_SIZE,
80 arena_model::REQUIRED,
81 "NROS_EXECUTOR_ARENA_SIZE (Zephyr: CONFIG_NROS_EXECUTOR_ARENA_SIZE)",
82 Remedy::Knob,
83);
84
85/// Bytes `arena` falls short of this image's modelled requirement; `0` when it
86/// covers it, or when the image declared nothing.
87pub const fn shortfall(arena: usize) -> usize {
88 arena_model::REQUIRED.saturating_sub(arena)
89}
90
91/// Refuse, during CONST EVALUATION, an arena below this image's model.
92///
93/// `source` names where `arena` came from — the knob for the default sizing,
94/// the caller's own constant otherwise — because a refusal that does not say
95/// which number to move is the half-diagnostic issue 1036 was about.
96///
97/// Call it from a `const _: () = ...;` item; that is what makes it a build
98/// error. Called at run time it is an ordinary panic with the same text.
99pub const fn assert_covers_model(arena: usize, source: &'static str) {
100 assert_covers(arena, arena_model::REQUIRED, source, Remedy::Caller)
101}
102
103/// Which number the reader has to move, because the two arms differ: the
104/// default arena is a KNOB the build derives when it is left alone, while a
105/// caller's sizing is the caller's own constant and unsetting the knob does
106/// nothing for it (the bench's `arena_size_for(2)` failed with the knob unset).
107#[derive(Clone, Copy)]
108enum Remedy {
109 Knob,
110 Caller,
111}
112
113/// [`assert_covers_model`] against an explicit requirement. Split out so the
114/// comparison and its message are testable without a build that declares
115/// entities.
116const fn assert_covers(arena: usize, required: usize, source: &str, remedy: Remedy) {
117 if arena >= required {
118 return;
119 }
120 let msg = Refusal::new(source, arena, required, remedy);
121 // `panic!("{}", <&str>)` is the one formatted panic const evaluation
122 // accepts, so the numbers are rendered into the buffer first.
123 panic!("{}", msg.as_str())
124}
125
126/// The refusal text, built without `alloc` or `core::fmt` so it can be built in
127/// a `const` context — a build error that said "arena too small" and not by how
128/// much would send the reader to `build.rs` to redo the arithmetic.
129struct Refusal {
130 buf: [u8; Self::CAP],
131 len: usize,
132}
133
134impl Refusal {
135 /// Generous for the longest `source` in the tree plus three 20-digit
136 /// numbers; a longer one truncates rather than failing to build.
137 const CAP: usize = 768;
138
139 const fn new(source: &str, arena: usize, required: usize, remedy: Remedy) -> Self {
140 let head = Self {
141 buf: [0; Self::CAP],
142 len: 0,
143 }
144 .text("nros arena oracle (phase-412): ")
145 .text(source)
146 .text(" = ")
147 .num(arena)
148 .text(" B, but the entities this image declares are modelled at ")
149 .num(required)
150 .text(" B (")
151 .num(required - arena)
152 .text(" B short; nros-node/build.rs, arena_model::REQUIRED). Every entity")
153 .text(" registration past it fails at run time with BufferTooSmall. Give ")
154 .text(source)
155 .text(" at least ")
156 .num(required)
157 .text(" B");
158 let head = match remedy {
159 Remedy::Knob => {
160 head.text(", or leave it unset so the build derives it from the same model.")
161 }
162 Remedy::Caller => head
163 .text(": this is the caller's own sizing, which the knob does not reach")
164 .text(" (arena_size_for(cbs) scales the default by cbs / MAX_CBS; it is")
165 .text(" not the model). Size it from ExecutorSizing::DEFAULT.arena instead."),
166 };
167 head.text(" If the model over-prices this image, fix its inputs (declared QoS")
168 .text(" depths, NROS_PUBSUB_QOS_DEPTH; issue 1255) instead of stating less.")
169 }
170
171 const fn text(mut self, s: &str) -> Self {
172 let b = s.as_bytes();
173 let mut i = 0;
174 while i < b.len() && self.len < Self::CAP {
175 self.buf[self.len] = b[i];
176 self.len += 1;
177 i += 1;
178 }
179 self
180 }
181
182 const fn num(mut self, mut v: usize) -> Self {
183 let mut digits = [0u8; 20];
184 let mut n = 0;
185 loop {
186 digits[n] = b'0' + (v % 10) as u8;
187 n += 1;
188 v /= 10;
189 if v == 0 {
190 break;
191 }
192 }
193 while n > 0 && self.len < Self::CAP {
194 n -= 1;
195 self.buf[self.len] = digits[n];
196 self.len += 1;
197 }
198 self
199 }
200
201 const fn as_str(&self) -> &str {
202 let (head, _) = self.buf.split_at(self.len);
203 match core::str::from_utf8(head) {
204 Ok(s) => s,
205 // Only reachable if a non-ASCII `source` was cut mid-character at
206 // the cap. The numbers are then lost, which is why the cap is large.
207 Err(_) => "nros arena oracle (phase-412): the arena is below the declared model",
208 }
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::{Refusal, Remedy, assert_covers, shortfall};
215
216 /// The numbers the refusal must carry, from the reference island's shape
217 /// (46,272 modelled) against a stated 8,192.
218 ///
219 /// The source is a neutral label, not the knob's name: `config-knob-census`
220 /// reads this crate's sources and counts a call taking a knob-shaped literal
221 /// first as a knob READ. The real knob name in the real build error is
222 /// asserted by `just check node-std-tests`, against a real build.
223 #[test]
224 fn the_refusal_names_the_source_both_numbers_and_the_shortfall() {
225 for remedy in [Remedy::Knob, Remedy::Caller] {
226 let r = Refusal::new("the arena knob", 8_192, 46_272, remedy);
227 let s = r.as_str();
228 assert!(s.contains("the arena knob = 8192 B"), "{s}");
229 assert!(s.contains("modelled at 46272 B"), "{s}");
230 assert!(s.contains("(38080 B short"), "{s}");
231 assert!(s.contains("at least 46272 B"), "{s}");
232 assert!(
233 s.len() < Refusal::CAP,
234 "the message reached the cap and was cut: {s}"
235 );
236 }
237 }
238
239 /// The two arms need different advice, and the wrong advice is worse than
240 /// none: "leave the knob unset" was printed for the bench's own sizing,
241 /// whose build had the knob unset already.
242 #[test]
243 fn only_the_knob_arm_is_told_to_unset_the_knob() {
244 let knob = Refusal::new("K", 1, 2, Remedy::Knob);
245 let caller = Refusal::new("S", 1, 2, Remedy::Caller);
246 assert!(
247 knob.as_str().contains("leave it unset"),
248 "{}",
249 knob.as_str()
250 );
251 assert!(!caller.as_str().contains("unset"), "{}", caller.as_str());
252 assert!(
253 caller.as_str().contains("ExecutorSizing::DEFAULT.arena"),
254 "{}",
255 caller.as_str()
256 );
257 }
258
259 #[test]
260 fn zero_and_extreme_numbers_render() {
261 let s = Refusal::new("x", 0, usize::MAX, Remedy::Caller);
262 assert!(s.as_str().contains("x = 0 B"), "{}", s.as_str());
263 assert!(
264 s.as_str().contains(&std::format!("{} B", usize::MAX)),
265 "{}",
266 s.as_str()
267 );
268 }
269
270 /// The comparison, both sides of the boundary. The panic IS the build
271 /// error when this runs in a `const` item; here it is observable.
272 #[test]
273 fn an_arena_at_the_model_passes_and_one_byte_below_is_refused() {
274 assert_covers(46_272, 46_272, "arena", Remedy::Knob);
275 assert_covers(1 << 20, 46_272, "arena", Remedy::Knob);
276 let refused =
277 std::panic::catch_unwind(|| assert_covers(46_271, 46_272, "arena", Remedy::Knob));
278 let payload = refused.expect_err("one byte under the model must be refused");
279 let text = payload
280 .downcast_ref::<std::string::String>()
281 .map(std::string::String::as_str)
282 .or_else(|| payload.downcast_ref::<&str>().copied())
283 .unwrap_or_default();
284 assert!(text.contains("(1 B short"), "{text}");
285 }
286
287 /// No declaration, no requirement: the worst-case budget is not a claim
288 /// about the image, so nothing an operator states can be short of it.
289 #[test]
290 fn an_image_that_declares_nothing_requires_nothing() {
291 assert_covers(0, 0, "arena", Remedy::Caller);
292 if crate::config::arena_model::REQUIRED == 0 {
293 assert_eq!(shortfall(0), 0);
294 } else {
295 // A test build that declared entities: the derived default must
296 // still cover them, which is the property the const item asserts.
297 assert_eq!(shortfall(crate::config::ARENA_SIZE), 0);
298 }
299 }
300}