Skip to main content

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}