blue_lang_runtime/lib.rs
1//! The blue runtime — **one** definition of what a blue program runs against.
2//!
3//! Before this crate existed, every consumer hand-rolled its own
4//! `Interpreter::new()` + `install_primitives(...)` pair — three of them, in
5//! three crates — and they had silently drifted: none of them loaded the Lisp
6//! stdlib. So `6 % 3` lowered correctly to `(mod 6 3)`, `mod` was genuinely
7//! defined, and the program still died with `unbound symbol: mod`, because
8//! the definition lived in a stdlib nobody loaded.
9//!
10//! That is the duplication tax in its usual shape: the bug is not in any one
11//! copy, it is in there *being* copies. One function now owns the answer.
12//!
13//! ## Layers
14//!
15//! A blue interpreter is built in two layers, and both are required:
16//!
17//! 1. **Rust primitives** — arithmetic, comparison, list ops, I/O.
18//! 2. **The full tatara stdlib** — primitives, higher-order functions
19//! (`map`/`filter`/`fold`), maps, channels, fibers, type-check, and
20//! everything tatara defines in tatara-lisp itself
21//! (`mod`, `rem`, `first`, `inc`, `even?`, the actor and transducer
22//! helpers, …). Loading it is not optional garnish: blue's own operator
23//! lowering depends on it.
24//! 3. **blue's own core** ([`stdlib`]) — strings and number conversion, which
25//! tatara-lisp does not have in any form. A Ruby-surface language without
26//! `length` or `upcase` is not usable, and the semantics are Ruby's
27//! (characters, not bytes), which is why they are blue's and not the
28//! substrate's.
29//! 4. **JSON** ([`json`]) and **cryptography** ([`crypto`]) — pure
30//! computation, installed unconditionally, so the wasm consumer keeps them.
31//! 5. **The host layer** (`sys`, behind the `sys` feature) — every name in it
32//! is a host import.
33//! 6. **Processes** ([`attach_node`]) — `spawn`, `send`, `receive` and the
34//! rest of `blue_lang_proc::node`'s words, bound to a node of the
35//! interpreter's own, so the program is that node's root process.
36
37pub mod alloc;
38#[cfg(feature = "sys")]
39pub mod audio;
40pub mod collection;
41pub mod crypto;
42pub mod dispatch;
43#[cfg(feature = "sys")]
44pub mod display;
45pub mod doc_sources;
46pub mod docs;
47pub mod domain;
48#[cfg(feature = "sys")]
49pub mod entropy;
50pub mod erase;
51pub mod errors;
52pub mod host_surface;
53pub mod hosted;
54pub mod inputs;
55pub mod json;
56pub mod literal;
57pub mod memory;
58pub mod messages;
59#[cfg(feature = "sys")]
60pub mod net;
61pub mod nix;
62pub mod observe;
63pub mod paths;
64pub mod pipeline;
65pub mod project_rules;
66#[cfg(feature = "sys")]
67pub mod provide;
68pub mod receipt;
69pub mod session;
70pub mod spread;
71pub mod stdlib;
72#[cfg(feature = "sys")]
73pub mod storage;
74#[cfg(feature = "sys")]
75pub mod sys;
76#[cfg(feature = "sys")]
77pub mod threshold;
78pub mod timings;
79pub mod uses;
80#[cfg(feature = "sys")]
81pub mod wire;
82pub mod xml;
83
84pub use erase::{erase_types, to_sexps};
85pub use inputs::{declarations, install_input_primitives, Declaration, InputError, Inputs};
86pub use pipeline::{
87 parse, parse_tree_with_depth, parse_with_depth, run, run_with_inputs, Run, RunError,
88};
89pub use stdlib::install_blue_stdlib;
90
91pub use blue_lang_proc::{Frame, HostIo, Identities, Places, Receipt};
92
93use tatara_lisp_eval::{install_full_stdlib_with, Interpreter};
94
95/// Build an interpreter with the complete blue runtime installed.
96///
97/// This is the *only* sanctioned way to obtain one. A caller that builds an
98/// `Interpreter` directly gets a partial runtime, and the failure shows up as
99/// an unbound symbol at the far end of a program.
100pub fn interpreter<H: 'static>(host: &mut H) -> Interpreter<H> {
101 let mut interp = Interpreter::new();
102 // The FULL substrate, not just `install_primitives`.
103 //
104 // blue called `install_primitives` + `install_lisp_stdlib_with` and got
105 // neither `install_hof` nor `install_map` — so `map`, `filter`, `fold` and
106 // every map literal were UNBOUND SYMBOLS. A language with no higher-order
107 // functions, in a workspace whose whole surface is Ruby's.
108 //
109 // Same shape as the stdlib gap this crate was created to fix: the substrate
110 // has layers, and naming them one at a time is how one gets missed. Call
111 // the composed installer.
112 install_full_stdlib_with(&mut interp, host);
113 // Layer 3: blue's own core — strings and number conversion, which
114 // tatara-lisp does not carry at all. See `stdlib` for why the
115 // character-counting semantics are blue's rather than the substrate's.
116 stdlib::install_blue_stdlib(&mut interp);
117 // Layer 4: blue's JSON surface — parse, stringify, read. Pure
118 // computation, no host imports, so it is installed unconditionally; the
119 // `wasm32-unknown-unknown` consumer keeps it. See `json` for why objects
120 // arrive as alists rather than Maps.
121 json::install_json_stdlib(&mut interp);
122 // Layer 4b: cryptography — BLAKE3 and Ed25519. Pure for the same reason
123 // JSON is: a deterministic function of its arguments, with the keypair's
124 // seed supplied by the caller rather than drawn from an entropy source,
125 // so it lowers to no import and the wasm consumer keeps it. See `crypto`.
126 crypto::install_crypto_stdlib(&mut interp);
127 nix::install_nix_stdlib(&mut interp);
128 xml::install_xml_stdlib(&mut interp);
129 // Layer 5: the host-side system surface — process, filesystem, env,
130 // clock, network. Feature-gated: every sys primitive is a host import, so the
131 // wasm consumer (which builds with `sys` OFF) keeps its zero-host-import
132 // surface by construction. Only the CLI turns it on.
133 #[cfg(feature = "sys")]
134 sys::install_sys_stdlib(&mut interp);
135 // Layer 6: processes. Bound here so every name oracle built from this
136 // interpreter (the check stage, the LSP, `blue reference`) knows them.
137 attach_node(&mut interp);
138 // Last, so installing the stdlib is not metered against the program.
139 apply_execution_bounds(&mut interp);
140 interp
141}
142
143/// Give `interp` a process node of its own: its program becomes the node's
144/// root, and `spawn` starts processes in this node and no other.
145///
146/// [`interpreter_hostless`] calls it on every fork, so two programs never
147/// share processes. A door that forks an interpreter per unit of work (a test
148/// per fork) calls it on each fork for the same reason.
149pub fn attach_node<H: 'static>(interp: &mut Interpreter<H>) {
150 dispatch::install(interp);
151 blue_lang_proc::install(interp, blue_lang_proc::Node::new(node_config()));
152}
153
154/// [`attach_node`] for a program that runs under `frame`: every process it
155/// starts is held to the same frame, so a host name the program may not call
156/// is refused in its children too. Without this a spawned child would run on
157/// the full runtime and a frame would be one `spawn` away from meaningless.
158pub fn attach_node_in_frame<H: 'static>(interp: &mut Interpreter<H>, frame: &blue_lang_waku::Waku) {
159 attach_node_held(interp, &Hold::from(frame.clone()));
160}
161
162/// [`attach_node`] for a program its project bounds: every process it
163/// starts runs under `bounds` met with the process's, and gives `account`
164/// of them as [`attach_node_accounted`] does with no hold.
165pub fn attach_node_bounded<H: 'static>(
166 interp: &mut Interpreter<H>,
167 bounds: ExecutionBounds,
168 account: &Account,
169) {
170 let mut config = node_config_under(execution_bounds().meet(bounds));
171 config.host_io = account.host_io.clone();
172 config.identities = account.identities.clone();
173 config.places = account.places.clone();
174 dispatch::install(interp);
175 blue_lang_proc::install(interp, blue_lang_proc::Node::new(config));
176}
177
178/// [`attach_node_in_frame`] with the hold's providers bound in every child.
179pub fn attach_node_held<H: 'static>(interp: &mut Interpreter<H>, hold: &Hold) {
180 attach_node_accounted(interp, Some(hold), &Account::default());
181}
182
183/// Give `interp` a node whose processes are held to `hold`, when there is
184/// one, and give `account` of them: each child's host calls tally into its
185/// ledger, its identities name each trace's and receipt's definition, and
186/// with a receipts directory every held process writes a receipt there when
187/// an incarnation ends.
188pub fn attach_node_accounted<H: 'static>(
189 interp: &mut Interpreter<H>,
190 hold: Option<&Hold>,
191 account: &Account,
192) {
193 let mut config = node_config_under(execution_bounds_under(hold));
194 config.host_io = account.host_io.clone();
195 config.identities = account.identities.clone();
196 config.places = account.places.clone();
197 if let Some(hold) = hold {
198 config.frame = Some(hold.frame());
199 config.receipts = account.receipts.clone().map(receipt::sink);
200 config.count_steps = account.steps.is_some();
201 config.state_root = Some(json::state_root);
202 let held = hold.clone();
203 let io = account.host_io.clone();
204 config.child = std::sync::Arc::new(move || {
205 let mut child = child_interpreter();
206 held.bind(&mut child, "this process's frame", &io);
207 child
208 });
209 }
210 dispatch::install(interp);
211 blue_lang_proc::install(interp, blue_lang_proc::Node::new(config));
212}
213
214/// The account a run gives of itself besides its value, filled while it runs
215/// and read after it ends, whether it returned or raised.
216#[derive(Clone, Default)]
217pub struct Account {
218 /// Host calls through granted host names, per process.
219 pub host_io: HostIo,
220 /// Each definition's `def_hash`, and the run's under [`receipt::RUN`].
221 pub identities: Identities,
222 /// How the identities are computed from the program, once it is lowered.
223 /// Without one, no trace or receipt names a `def_hash`, and no trace
224 /// names the file, line and column of a breaching call.
225 pub identify: Option<Identify>,
226 /// Each definition's file, read from the program when it identifies.
227 pub places: Places,
228 /// Where each held process writes its receipt; none are written without.
229 pub receipts: Option<std::path::PathBuf>,
230 pub steps: Option<std::sync::Arc<std::sync::atomic::AtomicU64>>,
231}
232
233impl Account {
234 /// Name `program`'s definitions in this account's identities, computed
235 /// by its `identify` from the `lowered` tree when first asked, with the
236 /// entry's `main` under [`receipt::RUN`]. `false`, and nothing deferred,
237 /// for an account that does not identify.
238 pub fn defer_identities(
239 &self,
240 program: &uses::ResolvedProgram,
241 lowered: std::sync::Arc<Vec<tatara_lisp::Spanned>>,
242 main: Option<String>,
243 ) -> bool {
244 let Some(identify) = self.identify else {
245 return false;
246 };
247 self.identities.defer(identify(program, lowered, main));
248 true
249 }
250}
251
252/// The table of a lowered program's identities, computed when first asked.
253pub type Identified =
254 Box<dyn FnOnce() -> std::collections::BTreeMap<String, String> + Send + 'static>;
255
256/// Given a checked program, its lowered tree and the key of the entry's
257/// `main` if it has one: each definition key with its `def_hash`, and
258/// [`receipt::RUN`] with the run's, computed only when first asked.
259/// `blue-lang-mondou` computes it; this crate only calls it.
260pub type Identify = fn(
261 &uses::ResolvedProgram,
262 std::sync::Arc<Vec<tatara_lisp::Spanned>>,
263 Option<String>,
264) -> Identified;
265
266/// What a run is held to: the frame it may reach the host through, the
267/// capabilities pure providers stand in for, and the bounds its project
268/// declares beside the process's.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub struct Hold {
271 pub frame: blue_lang_waku::Waku,
272 pub provisions: blue_lang_waku::Provisions,
273 /// The project's own bounds (`bounds(...)` in its Bluefile), met with
274 /// the process's ([`Hold::execution_bounds`]), so they only narrow.
275 /// `None` runs under the process's bounds alone.
276 pub bounds: Option<ExecutionBounds>,
277}
278
279impl From<blue_lang_waku::Waku> for Hold {
280 fn from(frame: blue_lang_waku::Waku) -> Self {
281 Self {
282 frame,
283 provisions: blue_lang_waku::Provisions::default(),
284 bounds: None,
285 }
286 }
287}
288
289/// The name a scoped provider binds the host name `word` under. No blue
290/// source can write it, so only the references [`Hold::rebind_scoped`] moves
291/// onto it reach the provider.
292#[must_use]
293pub fn provided_name(word: &str) -> String {
294 blue_lang_proc::provided_name(word)
295}
296
297impl Hold {
298 /// Every host capability neither the frame grants nor a whole-program
299 /// provider stands in for. A scoped provider's capability stays here:
300 /// code outside its packages gets the frame's answer.
301 #[must_use]
302 pub fn withheld(&self) -> Vec<blue_lang_waku::Capability> {
303 let reach = self.provisions.widen(&self.frame.reach);
304 blue_lang_waku::Capability::host_effects()
305 .into_iter()
306 .filter(|cap| !reach.grants(*cap))
307 .collect()
308 }
309
310 /// Every host capability a call can reach the host through: granted by
311 /// the frame, and no whole-program provider standing in for it. A scoped
312 /// provider's capability the frame grants is here: code outside its
313 /// packages reaches the host.
314 #[must_use]
315 pub fn granted(&self) -> Vec<blue_lang_waku::Capability> {
316 blue_lang_waku::Capability::host_effects()
317 .into_iter()
318 .filter(|cap| {
319 self.frame.reach.grants(*cap)
320 && self.provisions.get(*cap).is_none_or(|p| p.scope.is_some())
321 })
322 .collect()
323 }
324
325 /// Every host name neither the frame grants nor a whole-program provider
326 /// stands in for. A scoped provider's names stay here: code outside its
327 /// packages gets the frame's answer.
328 #[must_use]
329 pub fn withheld_host_names(&self) -> Vec<(&'static str, blue_lang_waku::Capability)> {
330 names_of(self.withheld())
331 }
332
333 /// Every host name a call can reach the host through.
334 #[must_use]
335 pub fn granted_host_names(&self) -> Vec<(&'static str, blue_lang_waku::Capability)> {
336 names_of(self.granted())
337 }
338
339 /// The bounds a run held here runs under: the process's, met with the
340 /// project's.
341 #[must_use]
342 pub fn execution_bounds(&self) -> ExecutionBounds {
343 let process = execution_bounds();
344 self.bounds.map_or(process, |own| process.meet(own))
345 }
346
347 /// The hold as an artifact names it.
348 #[must_use]
349 pub fn frame(&self) -> Frame {
350 Frame {
351 granted: self.granted(),
352 provided: self.provisions.iter().cloned().collect(),
353 withheld: self.withheld(),
354 }
355 }
356
357 /// The frame block of a receipt for a run held here that made `host_io`
358 /// host calls: exactly `{"granted":[…],"withheld":[…],"host_io":n}`.
359 #[must_use]
360 pub fn frame_summary(&self, host_io: u64) -> serde_json::Value {
361 self.frame().block(host_io)
362 }
363
364 /// Bind the hold into `interp`: a breach for every withheld host name,
365 /// then each whole-program provider over its capability's names and each
366 /// scoped one under [`provided_name`], then a shim over every granted
367 /// host name that tallies each call into `io`. A withheld or provided
368 /// name gets no shim, so a pure frame tallies nothing. A whole-program
369 /// clock provider is also the clock the host's Flash rate reads.
370 pub fn bind<H: 'static>(&self, interp: &mut Interpreter<H>, who: &'static str, io: &HostIo) {
371 if let Some(bounds) = self.bounds {
372 bounds.narrow(interp);
373 }
374 refuse_host_names(interp, &self.withheld_host_names(), who);
375 #[cfg(feature = "sys")]
376 if let Some(clock) = provide::provide(interp, &self.provisions) {
377 if self
378 .granted()
379 .contains(&blue_lang_waku::Capability::CommFlash)
380 {
381 wire::host_flash_on(interp, clock);
382 }
383 }
384 count_host_names(interp, &self.granted_host_names(), io);
385 }
386
387 /// Move each scoped provider's host names, where a file of a package it
388 /// serves writes them in `forms`, onto [`provided_name`]. A provider
389 /// serves the packages its `for:` names and every package they `use`, by
390 /// the imports `program` resolved; `forms` is `program`'s evaluated tree,
391 /// top-level form for top-level form. Returns the host names the entry
392 /// file may not write: [`Hold::withheld_host_names`] less those a scoped
393 /// provider serves the entry's own package.
394 pub fn rebind_scoped(
395 &self,
396 program: &uses::ResolvedProgram,
397 forms: &mut [tatara_lisp::Spanned],
398 ) -> Vec<(&'static str, blue_lang_waku::Capability)> {
399 let served: Vec<(
400 blue_lang_waku::Capability,
401 std::collections::BTreeSet<String>,
402 )> = self
403 .provisions
404 .scoped()
405 .map(|(cap, named)| (cap, program.reached_from(named)))
406 .collect();
407 let serves = |package: Option<&str>, cap: blue_lang_waku::Capability| {
408 package.is_some_and(|p| served.iter().any(|(c, s)| *c == cap && s.contains(p)))
409 };
410 if !served.is_empty() {
411 uses::rename_written(program, forms, &|package, name| {
412 let cap = blue_lang_waku::Provision::capability_of(name)?;
413 serves(package, cap).then(|| provided_name(name))
414 });
415 }
416 let entry = program
417 .file(uses::ResolvedProgram::ENTRY)
418 .and_then(|f| f.package.as_deref());
419 self.withheld_host_names()
420 .into_iter()
421 .filter(|(_, cap)| !serves(entry, *cap))
422 .collect()
423 }
424}
425
426fn names_of(
427 caps: Vec<blue_lang_waku::Capability>,
428) -> Vec<(&'static str, blue_lang_waku::Capability)> {
429 caps.into_iter()
430 .flat_map(|cap| cap.names().into_iter().map(move |n| (n, cap)))
431 .collect()
432}
433
434/// Shim each of `granted` that `interp` defines so a call that reaches it
435/// tallies one host call into `io`. The shim is registered under its own
436/// name and the host name rebound to it, so the original stays reachable by
437/// its name and reports its own arity errors.
438fn count_host_names<H: 'static>(
439 interp: &mut Interpreter<H>,
440 granted: &[(&'static str, blue_lang_waku::Capability)],
441 io: &HostIo,
442) {
443 for &(name, _) in granted {
444 let Some(original @ tatara_lisp_eval::Value::NativeFn(_)) = interp.lookup_global(name)
445 else {
446 continue;
447 };
448 let tatara_lisp_eval::Value::NativeFn(nf) = &original else {
449 continue;
450 };
451 let arity = nf.arity;
452 let io = io.clone();
453 let shim = format!("%io:{name}");
454 interp.register_higher_order_fn(
455 shim.clone(),
456 tatara_lisp_eval::ffi::Arity::Any,
457 move |args: &[tatara_lisp_eval::Value],
458 host: &mut H,
459 caller: &tatara_lisp_eval::ffi::Caller<H>,
460 span| {
461 if arity.check(args.len()).is_ok() {
462 io.tally();
463 }
464 caller.apply_value(&original, args.to_vec(), host, span)
465 },
466 );
467 if let Some(v) = interp.lookup_global(&shim) {
468 interp.define_global(name, v);
469 }
470 }
471}
472
473/// Every host name `frame` does not grant, with its capability.
474#[must_use]
475pub fn ungranted_host_names(
476 frame: &blue_lang_waku::Waku,
477) -> Vec<(&'static str, blue_lang_waku::Capability)> {
478 Hold::from(frame.clone()).withheld_host_names()
479}
480
481/// The kind of the fatal error a capability breach raises: the node's, so a
482/// crash trace classifies the breach this crate raises.
483pub const BREACH: &str = blue_lang_proc::violation::BREACH;
484
485/// The exit status a run that ended on a capability breach returns:
486/// `EX_NOPERM` (sysexits.h). `blue run` and an appliance both answer it.
487pub const BREACH_EXIT: u8 = 77;
488
489/// The fatal error a call to `name` raises when `who` does not grant `cap`.
490/// No `rescue` catches it: the run ends.
491#[must_use]
492pub fn breach(
493 name: &str,
494 cap: blue_lang_waku::Capability,
495 who: &str,
496 at: tatara_lisp::Span,
497) -> tatara_lisp_eval::EvalError {
498 blue_lang_proc::violation::breach(name, cap, who, at)
499}
500
501/// Is `e` a capability breach?
502#[must_use]
503pub fn is_breach(e: &tatara_lisp_eval::EvalError) -> bool {
504 matches!(e, tatara_lisp_eval::EvalError::Fatal { kind, .. } if &**kind == BREACH)
505}
506
507/// Bind each of `refused` that `interp` defines to a breach naming `who`.
508pub fn refuse_host_names<H: 'static>(
509 interp: &mut Interpreter<H>,
510 refused: &[(&'static str, blue_lang_waku::Capability)],
511 who: &'static str,
512) {
513 refuse_host_names_noting(interp, refused, who, |_, _, _| {});
514}
515
516/// [`refuse_host_names`], calling `note` with the host, the capability and
517/// the name before each breach is raised.
518pub fn refuse_host_names_noting<H: 'static>(
519 interp: &mut Interpreter<H>,
520 refused: &[(&'static str, blue_lang_waku::Capability)],
521 who: &'static str,
522 note: fn(&mut H, blue_lang_waku::Capability, &'static str),
523) {
524 for &(name, cap) in refused {
525 if interp.lookup_global(name).is_none() {
526 continue;
527 }
528 interp.register_fn(
529 name,
530 tatara_lisp_eval::ffi::Arity::Any,
531 move |_: &[tatara_lisp_eval::Value], host: &mut H, span| {
532 note(host, cap, name);
533 Err(breach(name, cap, who, span))
534 },
535 );
536 }
537}
538
539/// The node every interpreter's processes run under: the execution bounds
540/// per incarnation, a scheduling quantum, and the host's clock when there is
541/// a host (a node with no host has a virtual clock: time passes only when
542/// every process waits).
543fn node_config() -> blue_lang_proc::NodeConfig {
544 node_config_under(execution_bounds())
545}
546
547fn node_config_under(b: ExecutionBounds) -> blue_lang_proc::NodeConfig {
548 let mut config = blue_lang_proc::NodeConfig::new(std::sync::Arc::new(child_interpreter));
549 config.budget = tatara_lisp_eval::vm::Budget {
550 fuel: b.max_steps,
551 max_depth: Some(b.max_call_depth),
552 quantum: Some(blue_lang_proc::node::DEFAULT_QUANTUM),
553 };
554 config.meter = Some(memory::METER);
555 config.dispatch_steps = b.max_dispatch_steps;
556 #[cfg(feature = "sys")]
557 {
558 config.carrier = Some(wire::CARRIER);
559 }
560 config.clock = if cfg!(feature = "sys") {
561 blue_lang_proc::ClockKind::Real
562 } else {
563 blue_lang_proc::ClockKind::Virtual
564 };
565 config
566}
567
568/// A fresh interpreter for a child process: the hostless base, forked. The
569/// node adds its child words to it.
570fn child_interpreter() -> Interpreter<()> {
571 let mut interp = match HOSTLESS_BASE.lock() {
572 Ok(base) => base.fork(),
573 Err(_) => interpreter(&mut ()),
574 };
575 apply_execution_bounds(&mut interp);
576 interp
577}
578
579/// The execution bounds every interpreter this crate builds runs under.
580static EXECUTION_BOUNDS: std::sync::OnceLock<ExecutionBounds> = std::sync::OnceLock::new();
581
582/// How much a blue program may do before it is refused: nested calls,
583/// evaluation steps and live bytes.
584///
585/// Each is a BOUND in the sense `blue-lang-cli`'s `config` module admits: a
586/// terminating program under bounds above its cost returns the same value, and
587/// one that exceeds either is refused with a catchable `depth-exceeded` or
588/// `fuel-exhausted` error naming the limit and the function — never a process
589/// abort, which is what exceeding the host stack was before tatara-lisp-eval
590/// 0.3.63. The walker and the VM run under the same bounds and refuse with the
591/// same message. The two memory bounds refuse with a fatal `memory-exhausted`
592/// ([`memory`]); a process past one exits a runaway.
593#[derive(Debug, Clone, Copy, PartialEq, Eq)]
594pub struct ExecutionBounds {
595 /// Nested (non-tail) calls alive at once. Tail calls do not count.
596 pub max_call_depth: usize,
597 /// Evaluation steps per run; `None` is unbounded.
598 pub max_steps: Option<usize>,
599 /// Live bytes one evaluation of code its caller wrote may hold (`blue
600 /// run`, an appliance, each test of `blue test`); `None` is unbounded.
601 /// Each process it spawns is held to the same bound, on its own account.
602 pub max_alloc_bytes: Option<u64>,
603 /// Live bytes one evaluation may hold at a door that runs code it did not
604 /// write: a session (`eval`, `repl`, `serve`), live evaluation in the
605 /// editor, a project rule, a Bluefile. `None` is unbounded.
606 pub guest_max_alloc_bytes: Option<u64>,
607 pub max_dispatch_steps: Option<usize>,
608}
609
610impl ExecutionBounds {
611 /// Depth at tatara-lisp-eval's `DEFAULT_MAX_DEPTH`, steps unbounded.
612 ///
613 /// The two defaults differ for one reason: whether a program that works
614 /// today keeps working. A recursion past 100k frames never worked — it
615 /// aborted the process — so bounding depth removes no behaviour. A run
616 /// longer than any finite step count did work (simulations, tools on
617 /// nodes), so a default step bound would remove it; for a few hours of
618 /// 2026-09-29 blue shipped 50M (~3 s) and did exactly that. A host that
619 /// runs code it did not write, or a test that must see a runaway end,
620 /// sets `max_steps` itself.
621 ///
622 /// Memory follows the same test, door by door: a run holding more than
623 /// any finite count worked, so `max_alloc_bytes` is unbounded; a door
624 /// that runs code it did not write never promised that, so
625 /// `guest_max_alloc_bytes` is [`memory::GUEST_MAX_ALLOC_BYTES`].
626 pub const DEFAULT: Self = Self {
627 max_call_depth: tatara_lisp_eval::vm::DEFAULT_MAX_DEPTH,
628 max_steps: None,
629 max_alloc_bytes: None,
630 guest_max_alloc_bytes: Some(memory::GUEST_MAX_ALLOC_BYTES),
631 max_dispatch_steps: None,
632 };
633
634 /// No bound at all: what [`Self::meet`] leaves any bounds as, so a
635 /// project that declares one bound states it over this.
636 pub const UNBOUNDED: Self = Self {
637 max_call_depth: usize::MAX,
638 max_steps: None,
639 max_alloc_bytes: None,
640 guest_max_alloc_bytes: None,
641 max_dispatch_steps: None,
642 };
643
644 /// The lesser of each bound in `self` and `other`, where `None` is no
645 /// bound: neither side can widen what the other bounds.
646 #[must_use]
647 pub fn meet(self, other: Self) -> Self {
648 Self {
649 max_call_depth: self.max_call_depth.min(other.max_call_depth),
650 max_steps: lesser(self.max_steps, other.max_steps),
651 max_alloc_bytes: lesser(self.max_alloc_bytes, other.max_alloc_bytes),
652 guest_max_alloc_bytes: lesser(self.guest_max_alloc_bytes, other.guest_max_alloc_bytes),
653 max_dispatch_steps: lesser(self.max_dispatch_steps, other.max_dispatch_steps),
654 }
655 }
656
657 /// Hold `interp` to these bounds as well as its own budget: its depth
658 /// becomes the lesser of the two, and its steps the lesser of
659 /// `max_steps` and the steps its budget has left, counted from here.
660 pub fn narrow<H: 'static>(self, interp: &mut Interpreter<H>) {
661 let budget = interp.budget();
662 let left = budget
663 .fuel
664 .map(|fuel| fuel.saturating_sub(interp.steps_spent()));
665 let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
666 fuel: lesser(left, self.max_steps),
667 max_depth: lesser(budget.max_depth, Some(self.max_call_depth)),
668 quantum: budget.quantum,
669 });
670 }
671}
672
673fn lesser<T: Ord>(a: Option<T>, b: Option<T>) -> Option<T> {
674 match (a, b) {
675 (Some(a), Some(b)) => Some(a.min(b)),
676 (a, None) => a,
677 (None, b) => b,
678 }
679}
680
681/// The bounds a run held to `hold` runs under; the process's without one.
682#[must_use]
683pub fn execution_bounds_under(hold: Option<&Hold>) -> ExecutionBounds {
684 hold.map_or_else(execution_bounds, Hold::execution_bounds)
685}
686
687/// Name the bounds for this process. The CLI calls this once with the resolved
688/// `BlueConfig`, before any interpreter is built; an embedder that never calls
689/// it runs under [`ExecutionBounds::DEFAULT`]. Only the first call counts,
690/// like `sys::set_program_args`.
691pub fn set_execution_bounds(bounds: ExecutionBounds) {
692 let _ = EXECUTION_BOUNDS.set(bounds);
693}
694
695/// The bounds interpreters are built with.
696#[must_use]
697pub fn execution_bounds() -> ExecutionBounds {
698 EXECUTION_BOUNDS
699 .get()
700 .copied()
701 .unwrap_or(ExecutionBounds::DEFAULT)
702}
703
704fn apply_execution_bounds<H: 'static>(interp: &mut Interpreter<H>) {
705 let b = execution_bounds();
706 // No quantum, so the tree-walker refuses nothing of this budget.
707 let _ = interp.set_budget(tatara_lisp_eval::vm::Budget {
708 fuel: b.max_steps,
709 max_depth: Some(b.max_call_depth),
710 quantum: None,
711 });
712}
713
714/// The prebuilt hostless substrate, built once per process.
715///
716/// **Measured 2026-08-01, and this is the entire reason the fork exists.**
717/// Profiling a trivial blue program found the run dominated by ONE thing:
718///
719/// ```text
720/// parse 9.6 µs
721/// check 1.6 µs
722/// interpreter_hostless() 5 820 µs <- 98.4% of the run
723/// ```
724///
725/// Splitting that further: `Interpreter::new()` is 24 µs and
726/// `install_full_stdlib_with` is the rest — the stdlib was being rebuilt from
727/// scratch on every single `run()`, and blue's whole surface (`map`, `filter`,
728/// string ops) lives in it, so no program could avoid the cost.
729///
730/// `fork` already existed upstream for exactly this, and blue simply was not
731/// using it. tatara-lisp-eval's own `fork_cost.rs` opens with *"cheapness is
732/// the entire reason it exists"* — the capability was built, documented and
733/// guarded, and the consumer kept paying full price beside it.
734///
735/// | | per call |
736/// |---|---|
737/// | rebuild (`Interpreter::new` + `install_full_stdlib_with`) | **9.03 ms** |
738/// | `fork()` of a prebuilt base | **0.102 ms** |
739///
740/// **88× on the dominant cost.** This is purgatory's computation-layer clause
741/// made real: the stdlib is the most-reused computation in the language, and
742/// it was being discarded and recomputed rather than resurrected.
743///
744/// Isolation is `fork`'s contract, not an assumption made here — its
745/// correctness gates live upstream in `fork.rs`, and `fork_cost.rs` guards
746/// against a future change that deep-copies (which would keep every
747/// correctness test green while silently restoring the cost this removes).
748static HOSTLESS_BASE: std::sync::LazyLock<std::sync::Mutex<Interpreter<()>>> =
749 std::sync::LazyLock::new(|| {
750 std::sync::Mutex::new(timings::stage(timings::Stage::Runtime, || {
751 interpreter(&mut ())
752 }))
753 });
754
755/// The common host-free case.
756///
757/// Forks the process-wide base rather than rebuilding the stdlib. Falls back to
758/// a full build if the lock is poisoned: a poisoned lock means another thread
759/// panicked mid-fork, and answering a correct-but-slow interpreter beats
760/// propagating someone else's panic into an unrelated caller.
761pub fn interpreter_hostless() -> Interpreter<()> {
762 let mut interp = match HOSTLESS_BASE.lock() {
763 Ok(base) => base.fork(),
764 Err(_) => interpreter(&mut ()),
765 };
766 // Again after the fork: the base may have been built before the bounds
767 // were named, and a fork inherits whatever its base had.
768 apply_execution_bounds(&mut interp);
769 // A node per fork: the base's own node would be shared by every program.
770 attach_node(&mut interp);
771 interp
772}
773
774/// Lift spanless forms into what the evaluator eats.
775///
776/// **No longer on the `blue run` path, and that is the point.**
777/// [`erase::erase_types`] now takes and returns `Spanned`, so
778/// `pipeline::run_in_surface` hands the evaluator the *parser's own* tree with
779/// the author's positions on it — there is nothing left to lift. This
780/// function survives for a caller that genuinely holds spanless `Sexp` and
781/// has no position to preserve, and as the subject of the two tests below
782/// that pin why the hop it replaced was unsafe.
783///
784/// `Interpreter::eval_program` takes `&[Spanned]`; blue's stages produced
785/// `Sexp`. Two callers bridged that gap by *printing the tree and reading it
786/// back* — `forms.map(ToString::to_string).join("\n")` into
787/// `tatara_lisp::read_spanned`. Both stopped, and the round trip is gone from
788/// the pipeline.
789///
790/// **Why it had to go.** Printing a tree we already hold and re-parsing it puts
791/// the reader's lexer between blue and its own output, for nothing — and the
792/// printer and the reader are **not inverses**. `Atom::Str`'s `Display` escapes
793/// its payload and its own docs explain at length why; the `Atom::Symbol` arm
794/// is a bare `write_str`. So a symbol whose text carries a separator prints as
795/// several tokens and reads back as several symbols: a well-formed tree with a
796/// different meaning, no error raised. Measured 2026-08-02 —
797/// `pipeline::tests::the_round_trip_is_not_the_identity_in_general` pins which
798/// separators are silent and which are loud. The only thing that kept this from
799/// biting was blue happening not to emit those bytes. A stage that never
800/// serialises cannot be mis-read.
801///
802/// **What is given up: spans.** The old path's spans pointed into the
803/// re-printed lisp text, a buffer no human ever wrote and no diagnostic could
804/// usefully cite — they were positions in blue's own output, not in the
805/// author's source. `Span::synthetic` says the same thing honestly.
806///
807/// **Carrying real blue-source spans through erasure was the separate piece of
808/// work, and it is done — but NOT the way this doc predicted.** It said the
809/// work "would start from `Spanned::from_sexp_at`". It did not, and could not:
810/// `from_sexp_at` is a *lift*, stamping ONE span across a whole subtree, so
811/// starting there would have replaced a tree of synthetic spans with a tree of
812/// identical wrong ones. The actual shape was to stop projecting in the first
813/// place — erasure walks `Spanned` and keeps each node's own span, because it
814/// only ever deletes (see [`erase`]). **A pointer at the nearest-looking API is
815/// how a reader is sent to the wrong starting point**; the note is corrected
816/// here rather than deleted, because the wrong lead was load-bearing enough to
817/// be worth naming.
818#[must_use]
819pub fn lower_to_spanned(forms: &[tatara_lisp::Sexp]) -> Vec<tatara_lisp::Spanned> {
820 forms
821 .iter()
822 .map(tatara_lisp::Spanned::from_sexp_synthetic)
823 .collect()
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use tatara_lisp_eval::Value;
830
831 fn eval(src: &str) -> Value {
832 let forms = tatara_lisp::read_spanned(src).expect("read");
833 let mut interp = interpreter_hostless();
834 interp.eval_program(&forms, &mut ()).expect("eval")
835 }
836
837 /// Every layer is present. A test that only checked layer 1 is exactly
838 /// what let the stdlib gap survive.
839 #[test]
840 fn both_layers_are_installed() {
841 // Layer 1: a Rust primitive.
842 assert!(matches!(eval("(+ 1 2)"), Value::Int(3)));
843 // Layer 2: a stdlib definition, which is the layer that was missing.
844 assert!(matches!(eval("(mod 7 3)"), Value::Int(1)));
845 assert!(matches!(eval("(inc 41)"), Value::Int(42)));
846 assert!(matches!(eval("(first (list 9 8))"), Value::Int(9)));
847 // Layer 3: blue's own core — string and number conversion.
848 assert!(matches!(eval(r#"(length "héllo")"#), Value::Int(5)));
849 // Layer 4: the JSON surface.
850 assert!(matches!(
851 eval(r#"(json_parse "{\"outcome\":\"ok\"}")"#),
852 Value::List(_)
853 ));
854 // Layer 4b: cryptography.
855 assert!(matches!(eval(r#"(blake3_hex "")"#), Value::Str(_)));
856 }
857
858 #[cfg(feature = "sys")]
859 mod held {
860 use super::super::*;
861 use blue_lang_waku::{Capability, Provided, Provision, Provisions};
862 use tatara_lisp_eval::Value;
863
864 fn held(host: &[Capability], provided: &[Provided]) -> (Hold, Interpreter<()>, HostIo) {
865 let mut provisions = Provisions::default();
866 for p in provided {
867 provisions.add(p.clone()).expect("one provider each");
868 }
869 let hold = Hold {
870 frame: session::frame_granting(host),
871 provisions,
872 bounds: None,
873 };
874 let mut interp = interpreter_hostless();
875 let io = HostIo::new();
876 hold.bind(&mut interp, "this test's frame", &io);
877 (hold, interp, io)
878 }
879
880 fn call(interp: &mut Interpreter<()>, src: &str) -> Result<Value, String> {
881 let forms = tatara_lisp::read_spanned(src).expect("read");
882 interp
883 .eval_program(&forms, &mut ())
884 .map_err(|e| e.to_string())
885 }
886
887 #[test]
888 fn a_pure_hold_shims_no_name_so_nothing_can_tally() {
889 let (hold, mut interp, io) = held(&[], &[]);
890 assert!(hold.granted_host_names().is_empty());
891 for (name, _) in hold.withheld_host_names() {
892 assert!(
893 interp.lookup_global(&format!("%io:{name}")).is_none(),
894 "`{name}` is withheld, so it has no counting shim"
895 );
896 }
897 assert!(call(&mut interp, "(getenv \"HOME\")").is_err());
898 assert!(call(&mut interp, "(now_ms)").is_err());
899 assert_eq!(io.total(), 0);
900 }
901
902 #[test]
903 fn a_granted_name_tallies_each_call_and_a_provided_or_withheld_one_none() {
904 let (hold, mut interp, io) = held(
905 &[Capability::Environment],
906 &[Provision::DeterministicStepped {
907 at_ms: 10,
908 step_ms: 1,
909 }
910 .into()],
911 );
912 assert_eq!(hold.granted(), [Capability::Environment]);
913 call(&mut interp, "(getenv \"HOME\")").expect("granted");
914 call(&mut interp, "(argv)").expect("granted");
915 assert_eq!(io.total(), 2, "two calls through granted names");
916 assert_eq!(
917 call(&mut interp, "(now_ms)").expect("provided").to_string(),
918 "10"
919 );
920 assert!(call(&mut interp, "(read_file \"/etc/hosts\")").is_err());
921 assert_eq!(io.total(), 2, "a provider and a breach reach no host");
922 let wrong = call(&mut interp, "(getenv)").expect_err("arity");
923 assert!(
924 wrong.contains("getenv"),
925 "the original names itself: {wrong}"
926 );
927 assert_eq!(
928 io.total(),
929 2,
930 "a call refused for its arity reached no host"
931 );
932 }
933
934 #[test]
935 fn an_unrestricted_hold_shims_every_host_name_but_a_provided_one() {
936 let hold = Hold {
937 frame: blue_lang_waku::Waku::top(),
938 provisions: {
939 let mut p = Provisions::default();
940 p.add(Provision::NullSink).expect("one");
941 p
942 },
943 bounds: None,
944 };
945 assert!(hold.withheld().is_empty());
946 assert!(!hold.granted().contains(&Capability::FileSystem));
947 assert_eq!(hold.granted().len(), Capability::host_effects().len() - 1);
948 let f = hold.frame();
949 assert_eq!(f.provided, [Provided::from(Provision::NullSink)]);
950 assert_eq!(f.granted, hold.granted());
951 }
952
953 #[test]
954 fn a_scoped_provider_binds_its_alias_uncounted_and_leaves_the_host_name_counted() {
955 let scoped = Provided {
956 provision: Provision::NullSink,
957 scope: Some(std::collections::BTreeSet::from(["kazu".to_string()])),
958 };
959 let (hold, mut interp, io) =
960 held(&[Capability::FileSystem], std::slice::from_ref(&scoped));
961 assert_eq!(
962 hold.granted(),
963 [Capability::FileSystem, Capability::StorageAppend]
964 );
965 assert_eq!(hold.frame().provided, [scoped]);
966 let alias = provided_name("read_file");
967 assert!(
968 interp.lookup_global(&alias).is_some(),
969 "the scoped alias is bound"
970 );
971 let _ = call(&mut interp, &format!("({alias} \"/etc/hosts\")"));
972 assert_eq!(io.total(), 0, "the provider reaches no host");
973 call(&mut interp, "(read_file \"/etc/hosts\")").expect("granted");
974 assert_eq!(io.total(), 1, "code outside the scope reaches the host");
975 }
976
977 #[test]
978 fn a_host_flash_grant_reads_the_whole_program_stepped_clock_for_its_rate() {
979 let (_, mut interp, _) = held(
980 &[Capability::CommFlash],
981 &[Provision::DeterministicStepped {
982 at_ms: 0,
983 step_ms: 0,
984 }
985 .into()],
986 );
987 let big = "x".repeat(1279);
988 call(
989 &mut interp,
990 &format!("(begin (define w (flash_open \"127.0.0.1:0\")) (define big \"{big}\"))"),
991 )
992 .expect("granted");
993 for _ in 0..12 {
994 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)").expect("within the burst");
995 }
996 let refused =
997 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)").expect_err("past it");
998 assert!(
999 refused.contains(":rate_limited") && refused.contains(":wait_ms 16"),
1000 "{refused}"
1001 );
1002 call(&mut interp, "(sleep_ms 16)").expect("provided");
1003 assert_eq!(
1004 call(&mut interp, "(flash_put w \"127.0.0.1:9\" big)")
1005 .expect("a virtual sleep refills the host's bucket")
1006 .to_string(),
1007 "1280"
1008 );
1009 }
1010
1011 #[test]
1012 fn the_frame_summary_is_exactly_granted_withheld_and_host_io() {
1013 let (hold, _, _) = held(&[Capability::Clock], &[]);
1014 assert_eq!(
1015 blue_lang_proc::receipt::canonical(&hold.frame_summary(4)),
1016 r#"{"granted":["clock"],"host_io":4,"withheld":["process","filesystem","environment","network","entropy","comm-flash","comm-scavenger","mpc-threshold","storage-append","audio-out","audio-in","display"]}"#
1017 );
1018 }
1019
1020 #[test]
1021 fn each_sense_is_its_own_hold_and_a_provider_answers_it_uncounted() {
1022 let (hold, mut interp, io) = held(&[Capability::AudioOut], &[]);
1023 assert_eq!(hold.granted(), vec![Capability::AudioOut]);
1024 call(&mut interp, "(audio_out_format)").expect("granted");
1025 assert_eq!(io.total(), 1, "the format query is a host call, tallied");
1026 for (word, label) in [
1027 (
1028 "(capture_audio 1 (hash-map :rate 8000 :channels 1))",
1029 "audio-in",
1030 ),
1031 ("(audio_in_format)", "audio-in"),
1032 ("(canvas_open (hash-map :width 1 :height 1))", "display"),
1033 ] {
1034 let e = call(&mut interp, word).expect_err(word);
1035 assert!(e.contains(label), "{word}: {e}");
1036 }
1037
1038 let framebuffer: Provided = Provision::Framebuffer.into();
1039 let (hold, mut interp, io) = held(&[], std::slice::from_ref(&framebuffer));
1040 assert!(hold.withheld().contains(&Capability::AudioOut));
1041 assert_eq!(
1042 call(
1043 &mut interp,
1044 "(begin (define c (canvas_open (hash-map :width 2 :height 2))) (canvas_fill_rect c 0 0 1 1 \"#abcdef\") (canvas_present c) (canvas_pixel c 0 0))"
1045 )
1046 .expect("provided")
1047 .to_string(),
1048 "\"#abcdef\""
1049 );
1050 assert_eq!(io.total(), 0, "a provided word is not a host call");
1051 let e = call(
1052 &mut interp,
1053 "(play_audio (hash-map :rate 8000 :channels 1 :samples (list)))",
1054 )
1055 .expect_err("withheld");
1056 assert!(e.contains("audio-out"), "{e}");
1057 }
1058
1059 #[test]
1060 fn a_filesystem_hold_holds_the_log_words_and_a_storage_append_hold_holds_only_them() {
1061 let (hold, mut interp, io) = held(&[Capability::FileSystem], &[]);
1062 assert_eq!(
1063 hold.granted(),
1064 vec![Capability::FileSystem, Capability::StorageAppend]
1065 );
1066 assert!(
1067 call(&mut interp, "(read_log \"/nonexistent/blue/held.log\")")
1068 .expect_err("absent")
1069 .contains("no such log")
1070 );
1071 assert_eq!(io.total(), 1, "the log word is shimmed and tallied");
1072
1073 let (hold, mut interp, _) = held(&[Capability::StorageAppend], &[]);
1074 assert_eq!(hold.granted(), vec![Capability::StorageAppend]);
1075 assert!(hold.withheld().contains(&Capability::FileSystem));
1076 for word in [
1077 "(write_file \"x\" \"y\")",
1078 "(rm \"x\")",
1079 "(append_file \"x\" \"y\")",
1080 ] {
1081 let e = call(&mut interp, word).expect_err(word);
1082 assert!(e.contains("filesystem"), "{word}: {e}");
1083 }
1084 }
1085 }
1086
1087 /// Anti-vacuity: a bare interpreter really does LACK layer 2, so the test
1088 /// above is measuring the runtime's contribution and not a property the
1089 /// interpreter has for free.
1090 #[test]
1091 fn a_bare_interpreter_lacks_the_stdlib() {
1092 let forms = tatara_lisp::read_spanned("(mod 7 3)").expect("read");
1093 let mut bare = Interpreter::new();
1094 tatara_lisp_eval::install_primitives(&mut bare);
1095 assert!(
1096 bare.eval_program(&forms, &mut ()).is_err(),
1097 "if a bare interpreter already resolved `mod`, this crate would be measuring nothing"
1098 );
1099 }
1100}