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}