Skip to main content

harn_parser/
runtime_stack.rs

1//! The native stack contract for every thread that can run Harn code.
2//!
3//! Harn has two independent stack hazards, each with its own owner:
4//!
5//! * Walking an arbitrarily deep *value* — `x = [x]` in a loop — is made
6//!   stack-size independent by `harn_vm::value::recursion`, which grows the
7//!   native stack on demand and tears values down iteratively.
8//! * Walking an arbitrarily deep *program* — parse, type-check, compile, and
9//!   evaluate all recurse over nested syntax — is not. It relies on the thread
10//!   simply having enough stack, and that is what [`RUNTIME_STACK_SIZE`] is.
11//!
12//! This module owns the second contract because the parser is the lowest crate
13//! that recurses over a program: the module loader, the VM, and every host
14//! depend on it. A thread in any crate that can reach the parser or the VM is
15//! created here, through [`spawn`], [`builder`], or [`scope`]. Rust's 2 MiB
16//! default is never the right answer for such a thread, and a stack overflow
17//! aborts the whole process rather than failing one request.
18//!
19//! `RUST_MIN_STACK` does not substitute. Every CI test lane exports it, which
20//! makes an unsized thread large enough in CI and nowhere else, so a host that
21//! relies on it passes its own tests and aborts the first time a customer runs
22//! a deep enough script. `harn_vm`'s `runtime_stack` tests scan the workspace
23//! and refuse a thread created any other way.
24
25use std::io;
26use std::thread::{Builder, JoinHandle, Scope, ScopedJoinHandle};
27
28/// Native stack a thread needs to parse any source the parser accepts.
29///
30/// Expression precedence levels forward heap-backed nodes to keep large syntax
31/// values out of recursive frames. `make check-parser-stack` checks accepted
32/// and refused expression nesting against a 4 MiB budget in debug and release.
33/// Declarations and other program walks still need stack headroom; Rust's
34/// default thread stack is insufficient for the complete runtime.
35///
36/// The size follows the refusal, not the typical program: the parser must be
37/// able to reach the nesting limit and report it.
38pub const PARSE_STACK_SIZE: usize = 16 * 1024 * 1024;
39
40/// Native stack size a thread needs in order to run Harn code.
41///
42/// Parsing, compilation, and execution walk nested program structure with
43/// recursive frames. The size is set by the deepest descent the runtime
44/// promises to *refuse* rather than the deepest it expects to run. A nested
45/// agent descent costs roughly 2 MiB of native stack per level, so 16 MiB could
46/// carry only seven levels while the nested-execution budget declares eight:
47/// the refusal was undeliverable, and the process aborted on the level that
48/// should have been denied. A bound the stack cannot reach is not a bound.
49pub const RUNTIME_STACK_SIZE: usize = 32 * 1024 * 1024;
50
51// Every thread created here runs the parser too.
52const _: () = assert!(RUNTIME_STACK_SIZE >= PARSE_STACK_SIZE);
53
54/// A thread builder that already holds [`RUNTIME_STACK_SIZE`].
55///
56/// Use it to name a thread or to handle a spawn failure. Do not call
57/// `stack_size` on it: the size is this module's decision.
58pub fn builder() -> Builder {
59    Builder::new().stack_size(RUNTIME_STACK_SIZE)
60}
61
62/// [`std::thread::spawn`] with [`RUNTIME_STACK_SIZE`].
63///
64/// Panics if the OS cannot create the thread, as `std::thread::spawn` does.
65pub fn spawn<F, T>(body: F) -> JoinHandle<T>
66where
67    F: FnOnce() -> T + Send + 'static,
68    T: Send + 'static,
69{
70    builder().spawn(body).expect("failed to spawn thread")
71}
72
73/// [`std::thread::scope`] whose spawned threads hold [`RUNTIME_STACK_SIZE`].
74///
75/// `std::thread::Scope::spawn` always uses the default stack, so a scope from
76/// the standard library cannot hold this contract. This one can.
77pub fn scope<'env, F, T>(body: F) -> T
78where
79    F: for<'scope> FnOnce(RuntimeScope<'scope, 'env>) -> T,
80{
81    std::thread::scope(|inner| body(RuntimeScope { inner }))
82}
83
84/// A scope whose threads hold [`RUNTIME_STACK_SIZE`]. See [`scope`].
85#[derive(Clone, Copy)]
86pub struct RuntimeScope<'scope, 'env: 'scope> {
87    inner: &'scope Scope<'scope, 'env>,
88}
89
90impl<'scope, 'env> RuntimeScope<'scope, 'env> {
91    /// [`std::thread::Scope::spawn`] with [`RUNTIME_STACK_SIZE`].
92    ///
93    /// Panics if the OS cannot create the thread, as the standard library does.
94    pub fn spawn<F, T>(&self, body: F) -> ScopedJoinHandle<'scope, T>
95    where
96        F: FnOnce() -> T + Send + 'scope,
97        T: Send + 'scope,
98    {
99        builder()
100            .spawn_scoped(self.inner, body)
101            .expect("failed to spawn scoped thread")
102    }
103
104    /// Spawn a named scoped thread, reporting a spawn failure to the caller.
105    pub fn spawn_named<F, T>(
106        &self,
107        name: impl Into<String>,
108        body: F,
109    ) -> io::Result<ScopedJoinHandle<'scope, T>>
110    where
111        F: FnOnce() -> T + Send + 'scope,
112        T: Send + 'scope,
113    {
114        builder().name(name.into()).spawn_scoped(self.inner, body)
115    }
116}
117
118/// Run `body` on a thread that holds the [`RUNTIME_STACK_SIZE`] contract.
119///
120/// A caller that drives the VM from a thread it did not create borrows
121/// whatever stack that thread was given. The test harness is where this keeps
122/// happening: a case that builds a Tokio runtime on the libtest thread creates
123/// no thread of its own, so it runs the VM on libtest's stack. That stack is
124/// large enough only because every CI lane exports `RUST_MIN_STACK`, and a
125/// developer machine without it aborts the whole test binary on one ordinary
126/// agent loop (harn#7962). An abort is not a failed case: every later case in
127/// the binary silently never runs.
128///
129/// Panics propagate to the caller unchanged, so a failing assertion inside
130/// `body` still fails its own test.
131pub fn on_vm_stack<R: Send>(body: impl FnOnce() -> R + Send) -> R {
132    scope(|scope| {
133        scope
134            .spawn_named("harn-vm-contract-stack", body)
135            .expect("spawn a thread holding the VM stack contract")
136            .join()
137            .unwrap_or_else(|payload| std::panic::resume_unwind(payload))
138    })
139}
140
141#[cfg(test)]
142mod tests {
143    /// Uses `depth * 16 KiB` of stack, defeating optimization so the frames are
144    /// really allocated.
145    #[inline(never)]
146    fn burn_stack(depth: usize) -> u8 {
147        let mut frame = [0u8; 16 * 1024];
148        frame[depth % frame.len()] = depth as u8;
149        let frame = std::hint::black_box(frame);
150        if depth == 0 {
151            return frame[0];
152        }
153        frame[0].wrapping_add(burn_stack(depth - 1))
154    }
155
156    /// Set on the re-exec'd child so it runs the probe instead of forking again.
157    const PROBE_CHILD: &str = "HARN_RUNTIME_STACK_PROBE_CHILD";
158
159    /// 8 MiB is past Rust's 2 MiB default and under [`super::RUNTIME_STACK_SIZE`].
160    const PROBE_BYTES: usize = 8 * 1024 * 1024;
161
162    /// Every way this module creates a thread survives a probe the default
163    /// stack cannot.
164    ///
165    /// It runs in a re-exec'd child with `RUST_MIN_STACK` cleared, because
166    /// every Rust test lane exports `RUST_MIN_STACK=16777216` and that alone
167    /// makes an unsized thread big enough. Asserting in this process would pass
168    /// with or without the contract.
169    #[test]
170    fn every_spawn_form_holds_the_runtime_stack() {
171        if std::env::var_os(PROBE_CHILD).is_some() {
172            let depth = PROBE_BYTES / (16 * 1024);
173            super::spawn(move || burn_stack(depth))
174                .join()
175                .expect("spawn");
176            super::builder()
177                .name("probe".to_owned())
178                .spawn(move || burn_stack(depth))
179                .expect("builder spawn")
180                .join()
181                .expect("builder");
182            super::scope(|scope| {
183                scope
184                    .spawn(move || burn_stack(depth))
185                    .join()
186                    .expect("scope spawn");
187                scope
188                    .spawn_named("probe", move || burn_stack(depth))
189                    .expect("spawn_named")
190                    .join()
191                    .expect("scope spawn_named");
192            });
193            super::on_vm_stack(move || burn_stack(depth));
194            return;
195        }
196
197        let probe = |child_env: Option<&str>| {
198            let mut command =
199                std::process::Command::new(std::env::current_exe().expect("test executable"));
200            command
201                .args([
202                    "--exact",
203                    "runtime_stack::tests::every_spawn_form_holds_the_runtime_stack",
204                    "--test-threads=1",
205                ])
206                .env_remove("RUST_MIN_STACK");
207            if let Some(value) = child_env {
208                command.env(PROBE_CHILD, value);
209            }
210            command.status().expect("re-exec the probe")
211        };
212        let status = probe(Some("1"));
213        assert!(
214            status.success(),
215            "a runtime_stack thread overflowed {PROBE_BYTES} bytes with RUST_MIN_STACK \
216             unset ({status}), so it took Rust's 2 MiB default"
217        );
218    }
219
220    /// The negative control: the same probe on a default-stack thread dies,
221    /// so the test above is measuring the stack size and not a probe too small
222    /// to matter.
223    #[test]
224    fn the_probe_overflows_a_default_stack() {
225        if std::env::var_os(PROBE_CHILD).is_some() {
226            return;
227        }
228        let status = std::process::Command::new(std::env::current_exe().expect("test executable"))
229            .args([
230                "--exact",
231                "runtime_stack::tests::default_stack_probe_child",
232                "--test-threads=1",
233            ])
234            .env_remove("RUST_MIN_STACK")
235            .env(PROBE_CHILD, "1")
236            .status()
237            .expect("re-exec the default-stack probe");
238        assert!(
239            !status.success(),
240            "the probe survived Rust's 2 MiB default stack, so it proves nothing"
241        );
242    }
243
244    /// Only run as the re-exec'd child of `the_probe_overflows_a_default_stack`.
245    #[test]
246    fn default_stack_probe_child() {
247        if std::env::var_os(PROBE_CHILD).is_none() {
248            return;
249        }
250        let depth = PROBE_BYTES / (16 * 1024);
251        // The thread under test: deliberately the standard library's default.
252        let _ = std::thread::spawn(move || burn_stack(depth)).join();
253    }
254}