harn_vm/runtime_stack.rs
1//! The native stack contract for threads that drive the Harn VM.
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 [`crate::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//! The second contract lives entirely in the hosts: it holds only if every
13//! thread that ends up running the VM asks for the size. Getting it wrong is
14//! unusually expensive, because a stack overflow aborts the process instead of
15//! failing one request, so this module also carries the structural check that
16//! keeps new hosts honest.
17
18/// Native stack size a thread needs in order to drive the Harn VM.
19///
20/// Compilation and execution walk nested program structure with recursive
21/// frames, which can exceed Rust's 2 MiB default thread stack. A host that
22/// runs the VM on a thread it spawns must request this size explicitly.
23///
24/// Relying on the ambient default is not safe, and neither is relying on
25/// `RUST_MIN_STACK`: that variable is set by the CI test lanes but not by any
26/// shipped binary, so a host that depends on it passes its own tests and then
27/// aborts the whole process — a stack overflow is not a catchable panic — the
28/// first time a customer runs a deep enough script.
29pub const RUNTIME_STACK_SIZE: usize = 16 * 1024 * 1024;
30
31#[cfg(test)]
32mod tests {
33 /// How much source to read past a spawn before deciding what it does.
34 const WINDOW: usize = 600;
35
36 /// Spawning forms that create a thread with the ambient default stack
37 /// unless the call site says otherwise.
38 const SPAWNS: [&str; 2] = ["std::thread::spawn(", "thread::Builder::new()"];
39
40 /// Building a current-thread Tokio runtime on a freshly spawned thread is
41 /// what "this thread is about to drive the VM" looks like across the
42 /// workspace: it is how every serve transport, the orchestrator's ACP
43 /// worker, and the CLI's scaffold and test workers are shaped.
44 const DRIVES_VM: &str = "tokio::runtime::Builder";
45
46 /// Either idiom for honoring the contract: the inline
47 /// `.stack_size(..._STACK_SIZE)` that `harn-cli` uses, or a helper such as
48 /// `harn-serve`'s `vm_thread` that applies it centrally (those call sites
49 /// match neither spawn form, so they never reach this check).
50 const HONORS_CONTRACT: &str = "stack_size(";
51
52 /// The source a spawn is judged on: everything from the spawn up to the
53 /// next attributed item, at any indentation.
54 ///
55 /// Without the cut, a one-line spawn reads the runtime built by the
56 /// *following* function and reports a thread that does no VM work.
57 #[expect(
58 clippy::string_slice,
59 reason = "len is a sum of whole split_inclusive line lengths"
60 )]
61 fn spawn_body(window: &str) -> &str {
62 match window
63 .split_inclusive('\n')
64 .take_while(|line| !line.trim_start().starts_with("#["))
65 .map(str::len)
66 .sum::<usize>()
67 {
68 0 => "",
69 len => &window[..len],
70 }
71 }
72
73 /// Every thread in the workspace that builds a Tokio runtime to drive the
74 /// VM must ask for [`super::RUNTIME_STACK_SIZE`].
75 ///
76 /// This is deliberately a *workspace* scan rather than a per-crate one.
77 /// The same defect shipped simultaneously in `harn-serve`, `harn-cli`, and
78 /// `harn-vm` (harn#6165) precisely because each crate's own tests could
79 /// only see that crate — and because every Rust test lane here exports
80 /// `RUST_MIN_STACK=16777216`, which makes an unsized spawn large enough in
81 /// CI and nowhere else.
82 ///
83 /// Scope is `crates/`, which is every workspace member and so every shipped
84 /// host. `bench/` is out, and builds its runtimes on the current thread
85 /// rather than a spawned one, so there is nothing there to catch.
86 ///
87 /// What this does *not* catch, so nobody over-trusts it: a thread that
88 /// drives the VM without building a Tokio runtime on itself. Of the ~76
89 /// non-test spawn sites in the workspace this judges only the ~10 shaped
90 /// like a transport or worker. `run_dap_adapter`, the counterfactual plan
91 /// runner, and the connector worker loop all drive the VM through a plain
92 /// function call and were found by reading, not by this scan. Deciding
93 /// those needs a call graph; recognizing the idiom that actually recurs
94 /// does not, and that idiom is where every instance so far has lived.
95 #[test]
96 fn vm_driving_threads_ask_for_the_runtime_stack() {
97 let crates_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
98 .parent()
99 .expect("harn-vm lives below crates");
100
101 let mut offenders = Vec::new();
102 let mut scanned = 0usize;
103 for entry in walkdir::WalkDir::new(crates_dir)
104 .into_iter()
105 .filter_map(Result::ok)
106 .filter(|entry| {
107 entry.file_type().is_file()
108 && entry.path().extension().and_then(std::ffi::OsStr::to_str) == Some("rs")
109 && entry
110 .path()
111 .components()
112 .any(|component| component.as_os_str() == "src")
113 && entry.file_name() != "runtime_stack.rs"
114 })
115 {
116 scanned += 1;
117 let source = std::fs::read_to_string(entry.path()).expect("read Rust source");
118 for pattern in SPAWNS {
119 for (offset, _) in source.match_indices(pattern) {
120 let end = (offset + WINDOW).min(source.len());
121 // A window can land mid-codepoint; the next match still
122 // covers this file and every marker here is ASCII.
123 let Some(window) = source.get(offset..end) else {
124 continue;
125 };
126 let window = spawn_body(window);
127 if window.contains(DRIVES_VM) && !window.contains(HONORS_CONTRACT) {
128 #[expect(
129 clippy::string_slice,
130 reason = "offset is a match_indices offset on source"
131 )]
132 let line = 1 + source[..offset].matches('\n').count();
133 offenders.push(format!("{}:{line}", entry.path().display()));
134 }
135 }
136 }
137 }
138
139 assert!(scanned > 100, "scan found only {scanned} sources to check");
140 assert!(
141 offenders.is_empty(),
142 "these threads build a Tokio runtime to drive the VM but take Rust's \
143 2 MiB default stack, so a deep script aborts the process instead of \
144 failing one request. Give them harn_vm::RUNTIME_STACK_SIZE — inline \
145 via `.stack_size(..)`, or through a helper like harn-serve's \
146 `vm_thread`:\n {}",
147 offenders.join("\n ")
148 );
149 }
150}