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, floored.
77const _: () = assert_covers(
78 crate::config::ARENA_SIZE,
79 arena_model::REQUIRED,
80 "NROS_EXECUTOR_ARENA_SIZE (Zephyr: CONFIG_NROS_EXECUTOR_ARENA_SIZE)",
81 Remedy::Knob,
82);
83
84/// Bytes `arena` falls short of this image's modelled requirement; `0` when it
85/// covers it, or when the image declared nothing.
86pub const fn shortfall(arena: usize) -> usize {
87 arena_model::REQUIRED.saturating_sub(arena)
88}
89
90/// Refuse, during CONST EVALUATION, an arena below this image's model.
91///
92/// `source` names where `arena` came from — the knob for the default sizing,
93/// the caller's own constant otherwise — because a refusal that does not say
94/// which number to move is the half-diagnostic issue 1036 was about.
95///
96/// Call it from a `const _: () = ...;` item; that is what makes it a build
97/// error. Called at run time it is an ordinary panic with the same text.
98pub const fn assert_covers_model(arena: usize, source: &'static str) {
99 assert_covers(arena, arena_model::REQUIRED, source, Remedy::Caller)
100}
101
102/// Which number the reader has to move, because the two arms differ: the
103/// default arena is a KNOB the build derives when it is left alone, while a
104/// caller's sizing is the caller's own constant and unsetting the knob does
105/// nothing for it (the bench's `arena_size_for(2)` failed with the knob unset).
106#[derive(Clone, Copy)]
107enum Remedy {
108 Knob,
109 Caller,
110}
111
112/// [`assert_covers_model`] against an explicit requirement. Split out so the
113/// comparison and its message are testable without a build that declares
114/// entities.
115const fn assert_covers(arena: usize, required: usize, source: &str, remedy: Remedy) {
116 if arena >= required {
117 return;
118 }
119 let msg = Refusal::new(source, arena, required, remedy);
120 // `panic!("{}", <&str>)` is the one formatted panic const evaluation
121 // accepts, so the numbers are rendered into the buffer first.
122 panic!("{}", msg.as_str())
123}
124
125/// The refusal text, built without `alloc` or `core::fmt` so it can be built in
126/// a `const` context — a build error that said "arena too small" and not by how
127/// much would send the reader to `build.rs` to redo the arithmetic.
128struct Refusal {
129 buf: [u8; Self::CAP],
130 len: usize,
131}
132
133impl Refusal {
134 /// Generous for the longest `source` in the tree plus three 20-digit
135 /// numbers; a longer one truncates rather than failing to build.
136 const CAP: usize = 768;
137
138 const fn new(source: &str, arena: usize, required: usize, remedy: Remedy) -> Self {
139 let head = Self {
140 buf: [0; Self::CAP],
141 len: 0,
142 }
143 .text("nros arena oracle (phase-412): ")
144 .text(source)
145 .text(" = ")
146 .num(arena)
147 .text(" B, but the entities this image declares are modelled at ")
148 .num(required)
149 .text(" B (")
150 .num(required - arena)
151 .text(" B short; nros-node/build.rs, arena_model::REQUIRED). Every entity")
152 .text(" registration past it fails at run time with BufferTooSmall. Give ")
153 .text(source)
154 .text(" at least ")
155 .num(required)
156 .text(" B");
157 let head = match remedy {
158 Remedy::Knob => {
159 head.text(", or leave it unset so the build derives it from the same model.")
160 }
161 Remedy::Caller => head
162 .text(": this is the caller's own sizing, which the knob does not reach")
163 .text(" (arena_size_for(cbs) scales the default by cbs / MAX_CBS; it is")
164 .text(" not the model). Size it from ExecutorSizing::DEFAULT.arena instead."),
165 };
166 head.text(" If the model over-prices this image, fix its inputs (declared QoS")
167 .text(" depths, NROS_PUBSUB_QOS_DEPTH; issue 1255) instead of stating less.")
168 }
169
170 const fn text(mut self, s: &str) -> Self {
171 let b = s.as_bytes();
172 let mut i = 0;
173 while i < b.len() && self.len < Self::CAP {
174 self.buf[self.len] = b[i];
175 self.len += 1;
176 i += 1;
177 }
178 self
179 }
180
181 const fn num(mut self, mut v: usize) -> Self {
182 let mut digits = [0u8; 20];
183 let mut n = 0;
184 loop {
185 digits[n] = b'0' + (v % 10) as u8;
186 n += 1;
187 v /= 10;
188 if v == 0 {
189 break;
190 }
191 }
192 while n > 0 && self.len < Self::CAP {
193 n -= 1;
194 self.buf[self.len] = digits[n];
195 self.len += 1;
196 }
197 self
198 }
199
200 const fn as_str(&self) -> &str {
201 let (head, _) = self.buf.split_at(self.len);
202 match core::str::from_utf8(head) {
203 Ok(s) => s,
204 // Only reachable if a non-ASCII `source` was cut mid-character at
205 // the cap. The numbers are then lost, which is why the cap is large.
206 Err(_) => "nros arena oracle (phase-412): the arena is below the declared model",
207 }
208 }
209}
210
211#[cfg(test)]
212mod tests {
213 use super::{Refusal, Remedy, assert_covers, shortfall};
214
215 /// The numbers the refusal must carry, from the reference island's shape
216 /// (46,272 modelled) against a stated 8,192.
217 ///
218 /// The source is a neutral label, not the knob's name: `config-knob-census`
219 /// reads this crate's sources and counts a call taking a knob-shaped literal
220 /// first as a knob READ. The real knob name in the real build error is
221 /// asserted by `just check node-std-tests`, against a real build.
222 #[test]
223 fn the_refusal_names_the_source_both_numbers_and_the_shortfall() {
224 for remedy in [Remedy::Knob, Remedy::Caller] {
225 let r = Refusal::new("the arena knob", 8_192, 46_272, remedy);
226 let s = r.as_str();
227 assert!(s.contains("the arena knob = 8192 B"), "{s}");
228 assert!(s.contains("modelled at 46272 B"), "{s}");
229 assert!(s.contains("(38080 B short"), "{s}");
230 assert!(s.contains("at least 46272 B"), "{s}");
231 assert!(
232 s.len() < Refusal::CAP,
233 "the message reached the cap and was cut: {s}"
234 );
235 }
236 }
237
238 /// The two arms need different advice, and the wrong advice is worse than
239 /// none: "leave the knob unset" was printed for the bench's own sizing,
240 /// whose build had the knob unset already.
241 #[test]
242 fn only_the_knob_arm_is_told_to_unset_the_knob() {
243 let knob = Refusal::new("K", 1, 2, Remedy::Knob);
244 let caller = Refusal::new("S", 1, 2, Remedy::Caller);
245 assert!(
246 knob.as_str().contains("leave it unset"),
247 "{}",
248 knob.as_str()
249 );
250 assert!(!caller.as_str().contains("unset"), "{}", caller.as_str());
251 assert!(
252 caller.as_str().contains("ExecutorSizing::DEFAULT.arena"),
253 "{}",
254 caller.as_str()
255 );
256 }
257
258 #[test]
259 fn zero_and_extreme_numbers_render() {
260 let s = Refusal::new("x", 0, usize::MAX, Remedy::Caller);
261 assert!(s.as_str().contains("x = 0 B"), "{}", s.as_str());
262 assert!(
263 s.as_str().contains(&std::format!("{} B", usize::MAX)),
264 "{}",
265 s.as_str()
266 );
267 }
268
269 /// The comparison, both sides of the boundary. The panic IS the build
270 /// error when this runs in a `const` item; here it is observable.
271 #[test]
272 fn an_arena_at_the_model_passes_and_one_byte_below_is_refused() {
273 assert_covers(46_272, 46_272, "arena", Remedy::Knob);
274 assert_covers(1 << 20, 46_272, "arena", Remedy::Knob);
275 let refused =
276 std::panic::catch_unwind(|| assert_covers(46_271, 46_272, "arena", Remedy::Knob));
277 let payload = refused.expect_err("one byte under the model must be refused");
278 let text = payload
279 .downcast_ref::<std::string::String>()
280 .map(std::string::String::as_str)
281 .or_else(|| payload.downcast_ref::<&str>().copied())
282 .unwrap_or_default();
283 assert!(text.contains("(1 B short"), "{text}");
284 }
285
286 /// No declaration, no requirement: the worst-case budget is not a claim
287 /// about the image, so nothing an operator states can be short of it.
288 #[test]
289 fn an_image_that_declares_nothing_requires_nothing() {
290 assert_covers(0, 0, "arena", Remedy::Caller);
291 if crate::config::arena_model::REQUIRED == 0 {
292 assert_eq!(shortfall(0), 0);
293 } else {
294 // A test build that declared entities: the derived default must
295 // still cover them, which is the property the const item asserts.
296 assert_eq!(shortfall(crate::config::ARENA_SIZE), 0);
297 }
298 }
299}