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