Skip to main content

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