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}