#![deny(unsafe_code)]
use std::sync::Arc;
use tatara_lisp_eval::vm::{Budget, Chunk, Progress, Vm, VmError};
use tatara_lisp_eval::{ErrorObj, EvalError, Interpreter, Value};
pub mod bisect;
pub mod code;
pub mod image;
pub mod mailbox;
pub mod node;
pub mod pattern;
pub mod receipt;
pub mod scheduling;
#[cfg(target_vendor = "apple")]
mod seam;
pub mod trace;
pub mod violation;
pub use bisect::{bisect, Bisected};
pub use mailbox::{copy_data, deep_copy, install_process_primitives, System};
pub use node::{
install, Carrier, ClockKind, Meter, Node, NodeConfig, CARRIED_WORDS, PROCESS_WORDS, ROOT,
};
pub use receipt::{
Frame, HostIo, Identities, Place, Places, Receipt, ReceiptSink, Source, Subject,
};
pub use scheduling::Scheduling;
pub use trace::{replay, Journal, Replayed, Trace};
pub use violation::{Class, Violation};
#[must_use]
pub fn raised(kind: &str, message: String, data: Value, at: tatara_lisp::Span) -> EvalError {
EvalError::User {
value: Value::Error(Arc::new(ErrorObj {
tag: Arc::from(kind),
message: Arc::from(message),
data: vec![(Value::Keyword(Arc::from("data")), data)],
})),
at,
}
}
#[must_use]
pub fn provided_name(word: &str) -> String {
format!("%provided/{word}")
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Pid(pub u64);
#[derive(Clone, Debug, PartialEq)]
pub enum ExitReason {
Normal,
Crashed(String),
Runaway(String),
Killed(String),
Fatal(String),
}
pub const MEMORY_EXHAUSTED: &str = "memory-exhausted";
#[must_use]
pub fn memory_budget(bound: u64) -> String {
format!("live-byte budget of {bound} exceeded")
}
#[must_use]
pub fn memory_refusal(bound: u64) -> String {
format!("{MEMORY_EXHAUSTED}: {}", memory_budget(bound))
}
#[must_use]
pub fn dispatch_refusal(limit: usize) -> String {
format!("execution budget exhausted: steps per dispatch (limit {limit})")
}
impl ExitReason {
#[must_use]
pub fn memory_exhausted(bound: u64) -> Self {
Self::Runaway(memory_refusal(bound))
}
#[must_use]
pub fn dispatch_exhausted(limit: usize) -> Self {
Self::Runaway(dispatch_refusal(limit))
}
pub fn is_abnormal(&self) -> bool {
!matches!(self, ExitReason::Normal)
}
pub fn is_fatal(&self) -> bool {
matches!(self, ExitReason::Fatal(_))
}
pub fn is_restartable(&self) -> bool {
self.is_abnormal() && !self.is_fatal()
}
}
#[derive(Clone, Debug)]
pub enum ProcState {
Runnable,
Blocked,
Done(Value),
Exited(ExitReason),
}
impl ProcState {
pub fn is_runnable(&self) -> bool {
matches!(self, ProcState::Runnable)
}
pub fn is_blocked(&self) -> bool {
matches!(self, ProcState::Blocked)
}
pub fn is_done(&self) -> bool {
matches!(self, ProcState::Done(_))
}
pub fn done_int(&self) -> Option<i64> {
match self {
ProcState::Done(Value::Int(v)) => Some(*v),
_ => None,
}
}
pub fn exit_reason(&self) -> Option<&ExitReason> {
match self {
ProcState::Exited(r) => Some(r),
_ => None,
}
}
}
pub struct Proc<H> {
pub pid: Pid,
chunk: Arc<Chunk>,
vm: Vm,
interp: Interpreter<H>,
build: Arc<dyn Fn() -> Interpreter<H> + Send + Sync>,
budget: Budget,
pub state: ProcState,
pub restarts: usize,
}
impl<H> Proc<H> {
pub(crate) fn new(
pid: Pid,
chunk: Arc<Chunk>,
budget: Budget,
build: Arc<dyn Fn() -> Interpreter<H> + Send + Sync>,
) -> Self {
let interp = build();
Self {
pid,
chunk,
vm: Vm::with_budget(budget),
interp,
build,
budget,
state: ProcState::Runnable,
restarts: 0,
}
}
pub(crate) fn restart(&mut self) {
self.vm = Vm::with_budget(self.budget);
self.interp = (self.build)();
self.state = ProcState::Runnable;
self.restarts += 1;
}
fn is_runnable(&self) -> bool {
self.state.is_runnable()
}
pub(crate) fn slice(&mut self, host: &mut H) -> Slice
where
H: 'static,
{
let chunk = self.chunk.clone();
match self.vm.step(chunk, &mut self.interp, host) {
Ok(Progress::Yielded) => Slice::Yielded,
Ok(Progress::Blocked) => {
self.state = ProcState::Blocked;
Slice::Blocked
}
Ok(Progress::Done(v)) => {
self.state = ProcState::Done(v.clone());
Slice::Done(v)
}
Err(e) => {
let reason = classify(&e);
self.state = ProcState::Exited(reason.clone());
Slice::Exited(reason)
}
}
}
}
pub(crate) enum Slice {
Yielded,
Blocked,
Done(Value),
Exited(ExitReason),
}
pub trait Readiness {
fn is_ready(&self, pid: Pid) -> bool;
fn any_pending(&self) -> bool;
fn set_current(&mut self, _pid: Pid) {}
fn on_restart(&mut self, _pid: Pid) {}
}
impl Readiness for () {
fn is_ready(&self, _pid: Pid) -> bool {
false
}
fn any_pending(&self) -> bool {
false
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Strategy {
OneForOne,
OneForAll,
RestForOne,
}
impl Strategy {
#[must_use]
pub fn targets(self, exited: usize, len: usize) -> std::ops::Range<usize> {
match self {
Strategy::OneForOne => exited..exited + 1,
Strategy::OneForAll => 0..len,
Strategy::RestForOne => exited..len,
}
}
#[must_use]
pub fn from_keyword(word: &str) -> Option<Self> {
match word {
"one_for_one" => Some(Strategy::OneForOne),
"one_for_all" => Some(Strategy::OneForAll),
"rest_for_one" => Some(Strategy::RestForOne),
_ => None,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum Event {
Exited {
pid: Pid,
reason: ExitReason,
},
Restarted {
pid: Pid,
},
GaveUp {
pid: Pid,
restarts: usize,
},
Blocked {
pid: Pid,
},
Woke {
pid: Pid,
},
Deadlocked {
blocked: Vec<Pid>,
},
}
#[must_use]
pub fn forking<H: 'static>(
prototype: &Arc<Interpreter<H>>,
) -> impl Fn() -> Interpreter<H> + Send + Sync + 'static
where
Interpreter<H>: Send + Sync,
{
let prototype = Arc::clone(prototype);
move || prototype.fork()
}
pub struct Supervisor<H> {
procs: Vec<Proc<H>>,
strategy: Strategy,
max_restarts: usize,
next_pid: u64,
pub events: Vec<Event>,
pub interpreters_built: usize,
}
impl<H: 'static> Supervisor<H> {
pub fn new(strategy: Strategy, max_restarts: usize) -> Self {
Self {
procs: Vec::new(),
strategy,
max_restarts,
next_pid: 1,
events: Vec::new(),
interpreters_built: 0,
}
}
#[allow(clippy::doc_markdown)]
pub fn spawn<F>(&mut self, chunk: Arc<Chunk>, budget: Budget, build: F) -> Pid
where
F: Fn() -> Interpreter<H> + Send + Sync + 'static,
{
let pid = Pid(self.next_pid);
self.next_pid += 1;
self.procs
.push(Proc::new(pid, chunk, budget, Arc::new(build)));
self.interpreters_built += 1;
pid
}
pub fn state_of(&self, pid: Pid) -> Option<&ProcState> {
self.procs.iter().find(|p| p.pid == pid).map(|p| &p.state)
}
pub fn proc_binds(&self, pid: Pid, name: &str) -> Option<bool> {
self.procs
.iter()
.find(|p| p.pid == pid)
.map(|p| p.interp.lookup_global(name).is_some())
}
pub fn restarts_of(&self, pid: Pid) -> Option<usize> {
self.procs.iter().find(|p| p.pid == pid).map(|p| p.restarts)
}
pub fn has_work(&self, host: &H) -> bool
where
H: Readiness,
{
self.procs
.iter()
.any(|p| p.is_runnable() || (p.state.is_blocked() && host.is_ready(p.pid)))
}
pub fn deadlocked(&self, host: &H) -> Vec<Pid>
where
H: Readiness,
{
if host.any_pending() {
return Vec::new();
}
self.procs
.iter()
.filter(|p| p.state.is_blocked())
.map(|p| p.pid)
.collect()
}
pub fn round(&mut self, host: &mut H)
where
H: Readiness,
{
let mut exited: Vec<(usize, ExitReason)> = Vec::new();
for idx in 0..self.procs.len() {
if self.procs[idx].state.is_blocked() && host.is_ready(self.procs[idx].pid) {
self.procs[idx].state = ProcState::Runnable;
let pid = self.procs[idx].pid;
self.events.push(Event::Woke { pid });
}
}
for idx in 0..self.procs.len() {
if !self.procs[idx].is_runnable() {
continue;
}
host.set_current(self.procs[idx].pid);
let pid = self.procs[idx].pid;
match self.procs[idx].slice(host) {
Slice::Yielded => {}
Slice::Blocked => self.events.push(Event::Blocked { pid }),
Slice::Done(_) => self.events.push(Event::Exited {
pid,
reason: ExitReason::Normal,
}),
Slice::Exited(reason) => {
self.events.push(Event::Exited {
pid,
reason: reason.clone(),
});
exited.push((idx, reason));
}
}
}
for (idx, reason) in exited {
if reason.is_restartable() {
self.apply_strategy(idx, host);
}
}
}
pub fn run_to_quiescence(&mut self, host: &mut H, max_rounds: usize) -> usize
where
H: Readiness,
{
let mut rounds = 0;
while self.has_work(host) && rounds < max_rounds {
self.round(host);
rounds += 1;
}
let stuck = self.deadlocked(host);
if !stuck.is_empty() {
self.events.push(Event::Deadlocked { blocked: stuck });
}
rounds
}
fn apply_strategy(&mut self, exited_idx: usize, host: &mut H)
where
H: Readiness,
{
let targets = self.strategy.targets(exited_idx, self.procs.len());
if self.procs[exited_idx].restarts >= self.max_restarts {
let pid = self.procs[exited_idx].pid;
let restarts = self.procs[exited_idx].restarts;
self.events.push(Event::GaveUp { pid, restarts });
return;
}
for idx in targets {
if self.procs[idx]
.state
.exit_reason()
.is_some_and(ExitReason::is_fatal)
{
continue;
}
self.procs[idx].restart();
self.interpreters_built += 1;
let pid = self.procs[idx].pid;
host.on_restart(pid);
self.events.push(Event::Restarted { pid });
}
}
}
pub(crate) fn classify(e: &VmError) -> ExitReason {
match e {
VmError::BudgetExhausted { .. } => ExitReason::Runaway(e.to_string()),
VmError::Eval(fatal @ tatara_lisp_eval::EvalError::Fatal { .. }) => {
ExitReason::Fatal(fatal.short_message())
}
other => ExitReason::Crashed(other.to_string()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tatara_lisp_eval::vm::compile_program;
fn compile(src: &str) -> Arc<Chunk> {
let forms = tatara_lisp::read_spanned(src).expect("parse");
Arc::new(compile_program(&forms).expect("compile"))
}
fn blue(src: &str) -> Arc<Chunk> {
let forms = blue_lang_syntax::parse_program(src).expect("parse blue");
let text = forms
.iter()
.map(|f| f.to_string())
.collect::<Vec<_>>()
.join("\n");
compile(&text)
}
fn build() -> Interpreter<()> {
blue_lang_runtime::interpreter_hostless()
}
fn preemptive(q: usize) -> Budget {
Budget::preemptive(q)
}
#[test]
fn a_runaway_is_contained_and_restarted() {
let mut sup = Supervisor::new(Strategy::OneForOne, 3);
let spin = compile("(define (spin n) (spin (+ n 1))) (spin 0)");
let pid = sup.spawn(
spin,
Budget {
fuel: Some(2_000),
max_depth: None,
quantum: Some(200),
},
build,
);
sup.run_to_quiescence(&mut (), 200);
assert!(
sup.events.iter().any(|e| matches!(
e,
Event::Exited {
reason: ExitReason::Runaway(_),
..
}
)),
"the runaway must exit with a Runaway reason: {:?}",
sup.events
);
assert!(
sup.events
.iter()
.any(|e| matches!(e, Event::Restarted { .. })),
"the supervisor must have restarted it"
);
assert!(sup.restarts_of(pid).unwrap() > 0);
}
#[test]
fn restart_intensity_stops_an_unfixable_child() {
let mut sup = Supervisor::new(Strategy::OneForOne, 2);
let spin = compile("(define (spin n) (spin (+ n 1))) (spin 0)");
sup.spawn(
spin,
Budget {
fuel: Some(500),
max_depth: None,
quantum: Some(100),
},
build,
);
sup.run_to_quiescence(&mut (), 500);
assert!(
sup.events.iter().any(|e| matches!(e, Event::GaveUp { .. })),
"the supervisor must give up: {:?}",
sup.events
);
assert!(!sup.has_work(&()), "nothing should still be runnable");
}
#[test]
fn a_healthy_process_finishes_and_is_not_restarted() {
let mut sup = Supervisor::new(Strategy::OneForOne, 3);
let pid = sup.spawn(blue("1 + 2"), preemptive(50), build);
sup.run_to_quiescence(&mut (), 100);
assert_eq!(sup.state_of(pid).and_then(ProcState::done_int), Some(3));
assert_eq!(
sup.restarts_of(pid),
Some(0),
"a healthy child must not restart"
);
}
#[test]
fn a_long_child_does_not_starve_a_short_one() {
let mut sup = Supervisor::new(Strategy::OneForOne, 3);
let long = sup.spawn(
blue("def loop(n)\n if n == 0\n 1\n else\n loop(n - 1)\n end\nend\nloop(400)"),
preemptive(20),
build,
);
let short = sup.spawn(blue("2 + 3"), preemptive(20), build);
sup.round(&mut ());
assert_eq!(sup.state_of(short).and_then(ProcState::done_int), Some(5));
assert!(
sup.state_of(long).is_some_and(ProcState::is_runnable),
"the long child should still be running"
);
sup.run_to_quiescence(&mut (), 1000);
assert_eq!(sup.state_of(long).and_then(ProcState::done_int), Some(1));
}
fn crashing() -> Arc<Chunk> {
compile("(define (spin n) (spin (+ n 1))) (spin 0)")
}
fn tiny_budget() -> Budget {
Budget {
fuel: Some(60),
max_depth: None,
quantum: Some(1_000),
}
}
#[test]
fn one_for_one_restarts_only_the_child_that_exited() {
let mut sup = Supervisor::new(Strategy::OneForOne, 5);
let a = sup.spawn(blue("1"), preemptive(50), build);
let bad = sup.spawn(crashing(), tiny_budget(), build);
let c = sup.spawn(blue("2"), preemptive(50), build);
sup.round(&mut ());
assert_eq!(sup.restarts_of(bad), Some(1), "the crasher must restart");
assert_eq!(sup.restarts_of(a), Some(0), "a sibling must NOT restart");
assert_eq!(sup.restarts_of(c), Some(0), "a sibling must NOT restart");
}
#[test]
fn one_for_all_restarts_every_child() {
let mut sup = Supervisor::new(Strategy::OneForAll, 5);
let a = sup.spawn(crashing(), tiny_budget(), build);
let b = sup.spawn(blue("1"), preemptive(50), build);
let c = sup.spawn(blue("2"), preemptive(50), build);
sup.round(&mut ());
assert_eq!(sup.restarts_of(a), Some(1));
assert_eq!(
sup.restarts_of(b),
Some(1),
"one_for_all must restart siblings"
);
assert_eq!(
sup.restarts_of(c),
Some(1),
"one_for_all must restart siblings"
);
}
fn breaching_build() -> Interpreter<()> {
let mut interp = build();
interp.register_fn(
"breach",
tatara_lisp_eval::ffi::Arity::Exact(0),
|_: &[Value], _: &mut (), at| {
Err(tatara_lisp_eval::EvalError::fatal(
"capability-breach",
"`read_file` reaches the host (filesystem)",
at,
))
},
);
interp
}
fn breaching() -> Arc<Chunk> {
compile("(try (breach) (catch (e) 0))")
}
#[test]
fn a_fatal_child_is_never_restarted_and_no_sibling_restarts_for_it() {
let mut sup = Supervisor::new(Strategy::OneForAll, 5);
let bad = sup.spawn(breaching(), preemptive(50), breaching_build);
let ok = sup.spawn(blue("1 + 2"), preemptive(50), build);
sup.run_to_quiescence(&mut (), 100);
assert!(
sup.events.iter().any(|e| matches!(
e,
Event::Exited { pid, reason: ExitReason::Fatal(m) } if *pid == bad && m.contains("capability-breach")
)),
"{:?}",
sup.events
);
assert_eq!(
sup.restarts_of(bad),
Some(0),
"a fatal child must not restart"
);
assert_eq!(
sup.restarts_of(ok),
Some(0),
"a sibling must not restart for it"
);
assert_eq!(sup.state_of(ok).and_then(ProcState::done_int), Some(3));
}
#[test]
fn a_fatal_child_is_not_swept_up_when_a_sibling_restarts() {
let mut sup = Supervisor::new(Strategy::OneForAll, 5);
let bad = sup.spawn(breaching(), preemptive(50), breaching_build);
sup.round(&mut ());
let crasher = sup.spawn(crashing(), tiny_budget(), build);
sup.round(&mut ());
assert_eq!(sup.restarts_of(crasher), Some(1));
assert_eq!(
sup.restarts_of(bad),
Some(0),
"one_for_all must leave a fatal child down"
);
assert!(sup
.state_of(bad)
.and_then(ProcState::exit_reason)
.is_some_and(ExitReason::is_fatal));
}
#[test]
fn only_a_fatal_exit_is_abnormal_and_not_restartable() {
let s = String::new;
for (r, restartable) in [
(ExitReason::Normal, false),
(ExitReason::Crashed(s()), true),
(ExitReason::Runaway(s()), true),
(ExitReason::Killed(s()), true),
(ExitReason::Fatal(s()), false),
] {
assert_eq!(r.is_restartable(), restartable, "{r:?}");
assert_eq!(r.is_fatal(), matches!(r, ExitReason::Fatal(_)), "{r:?}");
}
assert!(ExitReason::Fatal(s()).is_abnormal());
}
#[test]
fn rest_for_one_restarts_the_child_and_its_successors_only() {
let mut sup = Supervisor::new(Strategy::RestForOne, 5);
let before = sup.spawn(blue("1"), preemptive(50), build);
let bad = sup.spawn(crashing(), tiny_budget(), build);
let after = sup.spawn(blue("2"), preemptive(50), build);
sup.round(&mut ());
assert_eq!(
sup.restarts_of(before),
Some(0),
"a predecessor must not restart"
);
assert_eq!(sup.restarts_of(bad), Some(1));
assert_eq!(sup.restarts_of(after), Some(1), "a successor must restart");
}
#[test]
fn a_normal_exit_is_not_treated_as_a_crash() {
let mut sup = Supervisor::new(Strategy::OneForAll, 5);
let a = sup.spawn(blue("1"), preemptive(50), build);
let b = sup.spawn(blue("2"), preemptive(50), build);
sup.run_to_quiescence(&mut (), 100);
assert!(
!sup.events
.iter()
.any(|e| matches!(e, Event::Restarted { .. })),
"a normal exit must not trigger one_for_all: {:?}",
sup.events
);
assert_eq!(sup.restarts_of(a), Some(0));
assert_eq!(sup.restarts_of(b), Some(0));
}
#[test]
fn a_crash_does_not_disturb_a_siblings_result() {
let mut sup = Supervisor::new(Strategy::OneForOne, 1);
let _bad = sup.spawn(crashing(), tiny_budget(), build);
let good = sup.spawn(blue("def sum(n, acc)\n if n == 0\n acc\n else\n sum(n - 1, acc + n)\n end\nend\nsum(10, 0)"), preemptive(15), build);
sup.run_to_quiescence(&mut (), 500);
assert_eq!(
sup.state_of(good).and_then(ProcState::done_int),
Some(55),
"the healthy child's answer must survive a sibling's crash"
);
}
#[test]
fn blue_source_runs_under_supervision() {
let mut sup = Supervisor::new(Strategy::OneForOne, 3);
let pid = sup.spawn(
blue(
"def fact(n)\n if n < 2\n 1\n else\n n * fact(n - 1)\n end\nend\nfact(6)",
),
preemptive(30),
build,
);
sup.run_to_quiescence(&mut (), 1000);
assert_eq!(sup.state_of(pid).and_then(ProcState::done_int), Some(720));
}
}