beck_diag/
depth.rs

1//! The front end's nesting ceiling: a count, and the host stack that count implies.
2//!
3//! The front end recurses over structure the user chose — nested parentheses, nested blocks, a
4//! macro that expands into more of itself, a type inside a type. Every one of those is a host
5//! frame, and until this module existed nothing counted them:
6//! [`docs/42-security-assurance.md`](../../../../../docs/42-security-assurance.md) §42.2 measured an
7//! ~7.6 KB file that aborted `beck check` in a debug build with no span and nothing catchable.
8//!
9//! The bound is a **count**, for the reason
10//! [`docs/adr/0007`](../../../../../docs/adr/0007-evaluator-stack-is-declared-not-discovered.md) gave
11//! for the evaluator's: a stack-headroom budget accepts a program in a release build and refuses it
12//! in a debug one, and a diagnostic that depends on the profile is not a diagnostic.
13//! [`docs/adr/0012`](../../../../../docs/adr/0012-the-front-end-counts-its-own-recursion.md) makes
14//! that argument for the front end.
15//!
16//! It lives in this crate because three crates share it — `beck-syntax` reads, `beck-macro`
17//! expands, `beck-core` checks — and a ceiling with three definitions is three ceilings.
18
19/// How deep the front end will follow user-chosen structure before it refuses.
20///
21/// The number is chosen to be far above anything a person writes and far below anything the stack
22/// notices. SICP's deepest expression is under 20 levels and the corpus's is 11; the parser spends
23/// about 18 KiB per level in an unoptimised build, which `the_ceiling_fits_the_declared_stack`
24/// measures rather than assumes, so this ceiling costs under 5 MiB — inside the 8 MiB a main
25/// thread ordinarily has, and a small fraction of the [`STACK_BYTES`] declared below. A ceiling
26/// nobody legitimately reaches is the point: this bound exists to turn an abort into a message,
27/// not to have an opinion about style.
28pub const MAX_NESTING: u32 = 256;
29
30/// How many statements one block may hold before the front end refuses it.
31///
32/// A **second axis**, and [`64`](../../../../../docs/64-compile-speed-report.md) §64.4 is why it
33/// exists: a body of sequential local bindings is *flat*, so `v0 = …` followed by `v1 = …` sits at
34/// nesting level 2 and [`MAX_NESTING`] never sees it — while the front end recurses once per
35/// binding anyway, because a block is a chain in `Core` whatever it looks like in source. That
36/// report measured the consequence: a debug build aborted at 12,000 bindings and a release build at
37/// 100,000, with no diagnostic, so *which programs compile depended on how the compiler was built*.
38///
39/// It is much larger than [`MAX_NESTING`] because the two axes are not comparable. Nesting 256
40/// levels deep is pathological; writing 256 sequential bindings in one function is merely long, and
41/// a generated or macro-expanded body can be longer still. The number is chosen the way
42/// [`adr/0012`](../../../../../docs/adr/0012-the-front-end-counts-its-own-recursion.md) chose the
43/// other: `the_block_ceiling_fits_the_declared_stack` measures the checker at **6.8 KiB a
44/// statement** in an unoptimised build, so 2,048 of them cost 14 MiB and 28 MiB with the doubled
45/// margin — comfortably inside [`STACK_BYTES`], and with room for a future pass to make a frame
46/// bigger without silently spending the headroom. That test is in `beck-core::check` and fails if
47/// the declaration stops covering the ceiling.
48pub const MAX_BLOCK: u32 = 2048;
49
50/// The host stack the front end needs to reach [`MAX_NESTING`] on every one of its recursions.
51///
52/// Declared rather than discovered, and held to the ceiling by a test in each crate that recurses
53/// (`beck-syntax`, `beck-core`) which *measures* bytes per level and fails if the declaration has
54/// stopped covering it — the pair `beck-eval` has had since `docs/31` §31.3, for the same reason
55/// and against the same failure.
56///
57/// It is deliberately the same 64 MiB `beck_eval::STACK_BYTES` declares, because the two are
58/// consumers of *one* thread: `beck-cli` compiles and evaluates on the stack it dispatches onto,
59/// and `the_front_end_fits_the_stack_the_cli_gives_it` is what keeps the two numbers honest about
60/// each other. They are not summed: a compilation has finished reading before it begins running.
61pub const STACK_BYTES: usize = 64 * 1024 * 1024;
62
63/// Run `f` on a thread that has [`STACK_BYTES`], and give back what it returned.
64///
65/// The counterpart of `beck_eval::on_the_evaluator_stack`, and the answer to "who guarantees the
66/// count is reachable" for the front end. A caller already inside `on_the_evaluator_stack` — which
67/// is every path through `beck-cli` — needs neither, because the two declare the same number.
68pub fn on_the_front_end_stack<T: Send>(f: impl FnOnce() -> T + Send) -> T {
69    std::thread::scope(|scope| {
70        std::thread::Builder::new()
71            .stack_size(STACK_BYTES)
72            .name("beck-front-end".into())
73            .spawn_scoped(scope, f)
74            .expect("a thread for the front end")
75            .join()
76            .unwrap_or_else(|panic| std::panic::resume_unwind(panic))
77    })
78}
79
80/// A recursion counter, held by whatever is recursing.
81///
82/// The discipline is [`enter`](Nesting::enter) at the recursion site and [`leave`](Nesting::leave)
83/// on the way out — at the *site*, not at one grammar rule that seemed to be where nesting comes
84/// from. That is the Scriban lesson (GHSA-p6q4-fgr8-vx4p, §42.2): a limit added at the one
85/// production somebody thought of was bypassed through a different one.
86#[derive(Debug)]
87pub struct Nesting {
88    depth: u32,
89    limit: u32,
90    reported: bool,
91}
92
93impl Default for Nesting {
94    fn default() -> Nesting {
95        Nesting::new()
96    }
97}
98
99impl Nesting {
100    pub fn new() -> Nesting {
101        Nesting::with_limit(MAX_NESTING)
102    }
103
104    /// A counter with a lower ceiling, for a test that would rather not build a 256-deep input.
105    pub fn with_limit(limit: u32) -> Nesting {
106        Nesting {
107            depth: 0,
108            limit,
109            reported: false,
110        }
111    }
112
113    /// A counter for a sub-parse that continues this one, starting at the depth already reached.
114    ///
115    /// A sub-parser over a captured token run is still inside whatever brackets captured it, and a
116    /// counter that started again at zero would be a way in — the same shape of bypass the Scriban
117    /// advisory records.
118    pub fn resumed(&self) -> Nesting {
119        Nesting {
120            depth: self.depth,
121            limit: self.limit,
122            reported: self.reported,
123        }
124    }
125
126    pub fn limit(&self) -> u32 {
127        self.limit
128    }
129
130    pub fn depth(&self) -> u32 {
131        self.depth
132    }
133
134    /// Descend one level. `false` means the ceiling is reached and the caller must not recurse —
135    /// and must not [`leave`](Nesting::leave) either, because nothing was entered.
136    #[must_use]
137    pub fn enter(&mut self) -> bool {
138        #[cfg(feature = "probe")]
139        probe::mark();
140        if self.depth >= self.limit {
141            return false;
142        }
143        self.depth += 1;
144        true
145    }
146
147    pub fn leave(&mut self) {
148        self.depth = self.depth.saturating_sub(1);
149    }
150
151    /// True exactly once per compilation.
152    ///
153    /// One over-deep expression is refused at every level on the way out, and a reader wants the
154    /// count, not one copy of it per level.
155    pub fn should_report(&mut self) -> bool {
156        !std::mem::replace(&mut self.reported, true)
157    }
158
159    /// The note every site prints, so the three of them say the same thing.
160    pub fn note(&self) -> String {
161        self.note_about("levels of nesting")
162    }
163
164    /// The same note, naming the axis being counted.
165    ///
166    /// Two axes reach the same stack — nesting depth and the length of a flat chain
167    /// ([`MAX_BLOCK`]) — and a note that called the second "nesting" would send a reader looking
168    /// for brackets in a function that has none.
169    pub fn note_about(&self, what: &str) -> String {
170        format!(
171            "the front end follows at most {} {what}; this is a fixed count rather \
172             than a reading of the stack, so a program is accepted or refused identically in \
173             every build",
174            self.limit
175        )
176    }
177}
178
179/// The stack-address recorder the ceiling's adequacy is measured with.
180///
181/// Compiled only under the `probe` feature, which nothing but a test enables. It exists because
182/// [`STACK_BYTES`] is a declaration, and a declaration nobody checks is the thing
183/// [`docs/42`](../../../../../docs/42-security-assurance.md) §42.2 found: 64 MiB sized for one
184/// recursive consumer of the stack and already false for another.
185#[cfg(feature = "probe")]
186pub mod probe {
187    use std::cell::Cell;
188
189    thread_local! {
190        static DEEPEST: Cell<usize> = const { Cell::new(usize::MAX) };
191    }
192
193    /// Called from [`super::Nesting::enter`]: record the deepest address the recursion has reached.
194    pub fn mark() {
195        let here = 0u8;
196        let here = std::ptr::addr_of!(here) as usize;
197        DEEPEST.with(|d| {
198            if here < d.get() {
199                d.set(here);
200            }
201        });
202    }
203
204    /// Run `f` and give back the host stack, in bytes, that the recursion inside it spent.
205    ///
206    /// The measurement asserts the stack grows downwards rather than assuming it, because that is
207    /// a property of the platform and not of this code.
208    pub fn stack_spent<T>(f: impl FnOnce() -> T) -> usize {
209        let top = 0u8;
210        let top = std::ptr::addr_of!(top) as usize;
211        DEEPEST.with(|d| d.set(usize::MAX));
212        let _ = f();
213        let deepest = DEEPEST.with(|d| d.get());
214        assert!(
215            deepest < top,
216            "the probe saw no recursion at all, or the host stack does not grow downwards"
217        );
218        top - deepest
219    }
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn a_counter_refuses_at_the_ceiling_and_recovers_on_the_way_out() {
228        let mut n = Nesting::with_limit(3);
229        assert!(n.enter() && n.enter() && n.enter());
230        assert!(
231            !n.enter(),
232            "the fourth level is one past a ceiling of three"
233        );
234        n.leave();
235        assert!(n.enter(), "and leaving makes room again");
236    }
237
238    #[test]
239    fn the_refusal_is_reported_once_however_many_levels_unwind() {
240        let mut n = Nesting::new();
241        assert!(n.should_report());
242        assert!(!n.should_report());
243    }
244}