Skip to main content

blue_lang_proc/
lib.rs

1//! Processes and supervision — OTP's structure over blue's scheduler.
2//!
3//! This is the layer that makes *let it crash* a strategy rather than a
4//! slogan, and it is only buildable because two things landed underneath it:
5//!
6//! - **bounded, catchable failure** — a runaway returns a typed
7//!   `VmError::BudgetExhausted` instead of aborting the OS process, so a
8//!   supervisor has something to catch;
9//! - **preemption** — `Vm::step` parks at a quantum with the frame stack
10//!   intact, so one process cannot starve its siblings.
11//!
12//! Both are the *same reduction counter*, read as a total ceiling and as a
13//! per-slice ceiling.
14//!
15//! ## The isolation this does and does not provide
16//!
17//! **Stated first, because overstating it would be the easy mistake.** Each
18//! process gets its **own `Vm`** — its own value stack, frame stack,
19//! handlers and local cells. That is real isolation of *control state*: one
20//! process's crash cannot corrupt another's stack, and a runaway is
21//! contained.
22//!
23//! **Each process also owns its own `Interpreter`**, so it owns its own
24//! globals. A definition or a mutation in one process is invisible to its
25//! siblings, and a restart discards them — which is what makes let-it-crash a
26//! guarantee rather than a hope: the restarted process cannot inherit the
27//! corrupted state that killed it.
28//!
29//! Messages are **deep-copied on send** ([`mailbox::deep_copy`]). `Value` is
30//! `Arc`-based, so a naive send would share structure between processes and
31//! quietly reintroduce the coupling the separate interpreters removed.
32//!
33//! The cost is stated rather than hidden: one interpreter per process means
34//! the primitives and the Lisp stdlib are installed per process, and
35//! [`Supervisor::interpreters_built`] counts it. That is the same trade the
36//! test framework makes for per-test isolation, and for the same reason —
37//! shared state makes order significant.
38//!
39//! This is `sumika` at the *environment* level. It is **not** a per-process
40//! GC heap: allocation still goes through Rust's allocator and reclamation is
41//! still `Arc` refcounting, so there is no independent per-process collection
42//! pause. `theory/BLUE.md` §V records that as the remaining distance.
43//!
44//! ## Strategy names follow `caixa`, deliberately
45//!
46//! `caixa-core`'s `Supervisor` kind already carries `estrategia` with
47//! `OneForOne` / `OneForAll` / `RestForOne` / `SimpleOneForOne`, plus
48//! max-restarts and a restart window. The org rule is one vocabulary per
49//! concept, so [`Strategy`] uses those names rather than inventing a second
50//! set.
51
52use std::sync::Arc;
53
54use tatara_lisp_eval::vm::{Budget, Chunk, Progress, Vm, VmError};
55use tatara_lisp_eval::{Interpreter, Value};
56
57pub mod mailbox;
58pub use mailbox::{deep_copy, install_process_primitives, System};
59
60/// A process identifier.
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub struct Pid(pub u64);
63
64/// Why a process stopped.
65#[derive(Clone, Debug, PartialEq)]
66pub enum ExitReason {
67    /// Ran to completion.
68    Normal,
69    /// A typed runtime failure. Carries the rendered error.
70    Crashed(String),
71    /// The execution budget ran out — a runaway. **This is the case that
72    /// was previously an OS-process abort**, and it is the reason
73    /// supervision is possible at all.
74    Runaway(String),
75}
76
77impl ExitReason {
78    pub fn is_abnormal(&self) -> bool {
79        !matches!(self, ExitReason::Normal)
80    }
81}
82
83/// No `PartialEq`: `tatara_lisp_eval::Value` does not implement it, and
84/// wrapping it in a comparable shim here would invent an equality the
85/// runtime does not define. Callers ask the questions they actually mean —
86/// [`ProcState::is_runnable`], [`ProcState::done_int`].
87#[derive(Clone, Debug)]
88pub enum ProcState {
89    Runnable,
90    /// Parked inside `receive` with an empty mailbox. Becomes `Runnable`
91    /// again the moment a message arrives — a *free* wait, costing no fuel.
92    Blocked,
93    Done(Value),
94    Exited(ExitReason),
95}
96
97impl ProcState {
98    pub fn is_runnable(&self) -> bool {
99        matches!(self, ProcState::Runnable)
100    }
101
102    pub fn is_blocked(&self) -> bool {
103        matches!(self, ProcState::Blocked)
104    }
105
106    pub fn is_done(&self) -> bool {
107        matches!(self, ProcState::Done(_))
108    }
109
110    /// The integer this process produced, if it finished with one.
111    pub fn done_int(&self) -> Option<i64> {
112        match self {
113            ProcState::Done(Value::Int(v)) => Some(*v),
114            _ => None,
115        }
116    }
117
118    pub fn exit_reason(&self) -> Option<&ExitReason> {
119        match self {
120            ProcState::Exited(r) => Some(r),
121            _ => None,
122        }
123    }
124}
125
126/// One process: a program, its own VM, and **its own interpreter**.
127pub struct Proc<H> {
128    pub pid: Pid,
129    /// The code. Retained so a supervisor can restart from a clean VM.
130    chunk: Arc<Chunk>,
131    vm: Vm,
132    /// This process's own globals. The reason a sibling cannot see its
133    /// definitions, and the reason a restart discards them.
134    interp: Interpreter<H>,
135    /// Rebuilds the interpreter on restart. A closure rather than a stored
136    /// clone: `Interpreter` holds registered closures, and a restart must
137    /// produce a *fresh* environment rather than a copy of a possibly-corrupt
138    /// one.
139    build: Arc<dyn Fn() -> Interpreter<H> + Send + Sync>,
140    budget: Budget,
141    pub state: ProcState,
142    /// How many times this process has been restarted.
143    pub restarts: usize,
144}
145
146impl<H> Proc<H> {
147    fn new(
148        pid: Pid,
149        chunk: Arc<Chunk>,
150        budget: Budget,
151        build: Arc<dyn Fn() -> Interpreter<H> + Send + Sync>,
152    ) -> Self {
153        let interp = build();
154        Self {
155            pid,
156            chunk,
157            vm: Vm::with_budget(budget),
158            interp,
159            build,
160            budget,
161            state: ProcState::Runnable,
162            restarts: 0,
163        }
164    }
165
166    /// Discard all control state and start over from the same code.
167    ///
168    /// A fresh `Vm` is the whole restart: there is nothing to unwind,
169    /// because the crashed process's stack was never shared.
170    fn restart(&mut self) {
171        self.vm = Vm::with_budget(self.budget);
172        // A FRESH interpreter, not a saved copy. The whole point of
173        // let-it-crash is that the restarted process cannot inherit the state
174        // that killed it.
175        self.interp = (self.build)();
176        self.state = ProcState::Runnable;
177        self.restarts += 1;
178    }
179
180    fn is_runnable(&self) -> bool {
181        self.state.is_runnable()
182    }
183}
184
185/// What the scheduler needs to know about the host, and nothing more.
186///
187/// The supervisor schedules; it does not know what a mailbox is. It only
188/// asks *"can this parked process proceed now?"* and *"is there anything
189/// pending that could make one proceed?"* — which is exactly enough to wake
190/// a blocked process and to tell a live wait from a deadlock.
191///
192/// Keeping it a trait rather than hard-coding [`System`] means a host with a
193/// different readiness source — a timer, a socket, a market feed — plugs in
194/// without touching the scheduler.
195pub trait Readiness {
196    /// May this parked process now make progress?
197    fn is_ready(&self, pid: Pid) -> bool;
198
199    /// Is there any pending work at all? When every process is parked and
200    /// this is false, nothing will ever change: that is a deadlock, and
201    /// spinning on it forever is the failure mode this question exists to
202    /// prevent.
203    fn any_pending(&self) -> bool;
204
205    /// The scheduler is about to give `pid` a slice. A host whose primitives
206    /// are process-relative — `receive`, `self` — needs this to answer them.
207    /// Default: nothing.
208    fn set_current(&mut self, _pid: Pid) {}
209
210    /// `pid` is about to restart, so its per-process host state should be
211    /// discarded. Default: nothing.
212    fn on_restart(&mut self, _pid: Pid) {}
213}
214
215/// A host with no readiness source of its own. Nothing ever becomes ready,
216/// so a process that parks under `()` is immediately a deadlock — which is
217/// the honest answer, since there is nothing to deliver.
218impl Readiness for () {
219    fn is_ready(&self, _pid: Pid) -> bool {
220        false
221    }
222    fn any_pending(&self) -> bool {
223        false
224    }
225}
226
227/// Restart strategy. Names follow `caixa-core`'s `estrategia`.
228#[derive(Clone, Copy, Debug, PartialEq, Eq)]
229pub enum Strategy {
230    /// Restart only the process that exited.
231    OneForOne,
232    /// Restart every child.
233    OneForAll,
234    /// Restart the exited child and every child declared after it.
235    RestForOne,
236}
237
238/// A record of what the supervisor did, so a caller can assert on
239/// behaviour rather than infer it from final state.
240#[derive(Clone, Debug, PartialEq)]
241pub enum Event {
242    Exited {
243        pid: Pid,
244        reason: ExitReason,
245    },
246    Restarted {
247        pid: Pid,
248    },
249    /// Restart intensity exceeded: the supervisor gave up rather than
250    /// restarting in a tight loop forever.
251    GaveUp {
252        pid: Pid,
253        restarts: usize,
254    },
255    /// A process parked waiting for a message.
256    Blocked {
257        pid: Pid,
258    },
259    /// A parked process was woken by an arriving message.
260    Woke {
261        pid: Pid,
262    },
263    /// Every remaining process is parked and nothing is pending, so no
264    /// message can ever arrive. Reported rather than spun on, because a
265    /// deadlocked switch that *looks* busy is far worse than one that says
266    /// which processes are stuck.
267    Deadlocked {
268        blocked: Vec<Pid>,
269    },
270}
271
272/// A supervisor over an ordered set of children.
273/// A [`Supervisor::spawn`] factory that forks one prototype instead of
274/// rebuilding the standard library per process and per restart.
275///
276/// The isolation guarantee is unchanged, and that is why this is a fork rather
277/// than a shared interpreter. Each child gets a private writable frame, so its
278/// `define`s are invisible to every sibling; the inherited frames sit below its
279/// write floor, so a `set!` reaching one raises `SetSealed` rather than
280/// mutating state a sibling can observe; and a restart is a *new* fork, so the
281/// incarnation that died leaves nothing behind.
282///
283/// ```ignore
284/// let prototype = Arc::new(blue_lang_runtime::interpreter(&mut System::new()));
285/// let pid = sup.spawn(chunk, budget, forking(&prototype));
286/// ```
287///
288/// Build the prototype, install everything on it, *then* fork. A `define` the
289/// prototype runs after a child forks is visible to that child — the frames are
290/// shared, which is the whole point.
291#[must_use]
292pub fn forking<H: 'static>(
293    prototype: &Arc<Interpreter<H>>,
294) -> impl Fn() -> Interpreter<H> + Send + Sync + 'static
295where
296    Interpreter<H>: Send + Sync,
297{
298    let prototype = Arc::clone(prototype);
299    move || prototype.fork()
300}
301
302pub struct Supervisor<H> {
303    procs: Vec<Proc<H>>,
304    strategy: Strategy,
305    /// Maximum restarts of any single child before the supervisor gives up.
306    ///
307    /// Without this a crash-on-start child restarts forever, which is a
308    /// busy loop wearing a supervision tree's clothes.
309    max_restarts: usize,
310    next_pid: u64,
311    pub events: Vec<Event>,
312    /// Interpreters constructed — one per spawn plus one per restart. Reported
313    /// so the cost of per-process isolation is visible rather than hidden.
314    pub interpreters_built: usize,
315}
316
317impl<H: 'static> Supervisor<H> {
318    pub fn new(strategy: Strategy, max_restarts: usize) -> Self {
319        Self {
320            procs: Vec::new(),
321            strategy,
322            max_restarts,
323            next_pid: 1,
324            events: Vec::new(),
325            interpreters_built: 0,
326        }
327    }
328
329    /// Add a child. Order matters for [`Strategy::RestForOne`].
330    ///
331    /// `build` constructs this process's interpreter, and is called again on
332    /// every restart. It is a factory rather than a value because a restart
333    /// must produce a *fresh* environment: handing over one pre-built
334    /// interpreter would mean a crashed process restarts into the globals that
335    /// killed it.
336    ///
337    /// **Prefer [`forking`] to a closure that builds from scratch.** A factory
338    /// calling `interpreter_hostless()` evaluates the whole Lisp standard
339    /// library on every spawn *and every restart* — ~945µs each, for an
340    /// identical result — and a supervision tree under a crash loop pays it
341    /// repeatedly. `forking` gives the same guarantee (a restart gets a clean
342    /// private frame, and cannot write the prototype's globals) for ~29µs.
343    #[allow(clippy::doc_markdown)]
344    pub fn spawn<F>(&mut self, chunk: Arc<Chunk>, budget: Budget, build: F) -> Pid
345    where
346        F: Fn() -> Interpreter<H> + Send + Sync + 'static,
347    {
348        let pid = Pid(self.next_pid);
349        self.next_pid += 1;
350        self.procs
351            .push(Proc::new(pid, chunk, budget, Arc::new(build)));
352        self.interpreters_built += 1;
353        pid
354    }
355
356    pub fn state_of(&self, pid: Pid) -> Option<&ProcState> {
357        self.procs.iter().find(|p| p.pid == pid).map(|p| &p.state)
358    }
359
360    /// Does this process's own environment bind `name`?
361    ///
362    /// Exposed because "a restart discards the environment" is otherwise
363    /// untestable: a counter proves a rebuild was *requested*, not that the old
364    /// bindings are gone. Also useful when debugging a supervision tree.
365    pub fn proc_binds(&self, pid: Pid, name: &str) -> Option<bool> {
366        self.procs
367            .iter()
368            .find(|p| p.pid == pid)
369            .map(|p| p.interp.lookup_global(name).is_some())
370    }
371
372    pub fn restarts_of(&self, pid: Pid) -> Option<usize> {
373        self.procs.iter().find(|p| p.pid == pid).map(|p| p.restarts)
374    }
375
376    /// Is anything still runnable?
377    /// Is there anything to run *right now*?
378    ///
379    /// A blocked process counts only if the host says it is ready. A blocked
380    /// process with nothing pending is not work — it is a deadlock, and
381    /// [`Supervisor::round`] reports it.
382    pub fn has_work(&self, host: &H) -> bool
383    where
384        H: Readiness,
385    {
386        self.procs
387            .iter()
388            .any(|p| p.is_runnable() || (p.state.is_blocked() && host.is_ready(p.pid)))
389    }
390
391    /// Processes parked with nothing to wake them.
392    pub fn deadlocked(&self, host: &H) -> Vec<Pid>
393    where
394        H: Readiness,
395    {
396        if host.any_pending() {
397            return Vec::new();
398        }
399        self.procs
400            .iter()
401            .filter(|p| p.state.is_blocked())
402            .map(|p| p.pid)
403            .collect()
404    }
405
406    /// Run one scheduling round: give every runnable process one quantum,
407    /// then apply the restart strategy to anything that exited abnormally.
408    ///
409    /// Round-robin, so a long-running child cannot starve a short one —
410    /// that is preemption's contribution, and it holds without any child
411    /// cooperating.
412    pub fn round(&mut self, host: &mut H)
413    where
414        H: Readiness,
415    {
416        let mut exited: Vec<(usize, ExitReason)> = Vec::new();
417
418        // Wake first, so a message that arrived during the previous round is
419        // acted on in this one rather than a round later.
420        for idx in 0..self.procs.len() {
421            if self.procs[idx].state.is_blocked() && host.is_ready(self.procs[idx].pid) {
422                self.procs[idx].state = ProcState::Runnable;
423                let pid = self.procs[idx].pid;
424                self.events.push(Event::Woke { pid });
425            }
426        }
427
428        for idx in 0..self.procs.len() {
429            if !self.procs[idx].is_runnable() {
430                continue;
431            }
432            let chunk = self.procs[idx].chunk.clone();
433            // Tell the host whose slice this is, so `receive` and `self`
434            // resolve to the right process.
435            host.set_current(self.procs[idx].pid);
436            // Destructure so the VM and this process's OWN interpreter can be
437            // borrowed at once.
438            let proc = &mut self.procs[idx];
439            let (vm, interp) = (&mut proc.vm, &mut proc.interp);
440            match vm.step(chunk, interp, host) {
441                Ok(Progress::Yielded) => {}
442                Ok(Progress::Blocked) => {
443                    self.procs[idx].state = ProcState::Blocked;
444                    let pid = self.procs[idx].pid;
445                    self.events.push(Event::Blocked { pid });
446                }
447                Ok(Progress::Done(v)) => {
448                    self.procs[idx].state = ProcState::Done(v);
449                    let pid = self.procs[idx].pid;
450                    self.events.push(Event::Exited {
451                        pid,
452                        reason: ExitReason::Normal,
453                    });
454                }
455                Err(e) => {
456                    let reason = classify(&e);
457                    self.procs[idx].state = ProcState::Exited(reason.clone());
458                    let pid = self.procs[idx].pid;
459                    self.events.push(Event::Exited {
460                        pid,
461                        reason: reason.clone(),
462                    });
463                    exited.push((idx, reason));
464                }
465            }
466        }
467
468        for (idx, reason) in exited {
469            if reason.is_abnormal() {
470                self.apply_strategy(idx, host);
471            }
472        }
473    }
474
475    /// Drive rounds until nothing is runnable, or `max_rounds` elapse.
476    ///
477    /// The bound is a test-and-operator safety net, not a semantic: a
478    /// supervision tree that cannot settle should say so rather than spin.
479    pub fn run_to_quiescence(&mut self, host: &mut H, max_rounds: usize) -> usize
480    where
481        H: Readiness,
482    {
483        let mut rounds = 0;
484        while self.has_work(host) && rounds < max_rounds {
485            self.round(host);
486            rounds += 1;
487        }
488        // Settling with processes still parked is a deadlock, not quiescence.
489        // Saying so once, here, is what keeps a stuck system from reading as
490        // a finished one.
491        let stuck = self.deadlocked(host);
492        if !stuck.is_empty() {
493            self.events.push(Event::Deadlocked { blocked: stuck });
494        }
495        rounds
496    }
497
498    fn apply_strategy(&mut self, exited_idx: usize, host: &mut H)
499    where
500        H: Readiness,
501    {
502        let targets: Vec<usize> = match self.strategy {
503            Strategy::OneForOne => vec![exited_idx],
504            Strategy::OneForAll => (0..self.procs.len()).collect(),
505            Strategy::RestForOne => (exited_idx..self.procs.len()).collect(),
506        };
507
508        // Intensity is checked on the CHILD THAT EXITED, not on the
509        // siblings a strategy sweeps up: a sibling restarted as collateral
510        // has not itself misbehaved.
511        if self.procs[exited_idx].restarts >= self.max_restarts {
512            let pid = self.procs[exited_idx].pid;
513            let restarts = self.procs[exited_idx].restarts;
514            self.events.push(Event::GaveUp { pid, restarts });
515            return;
516        }
517
518        for idx in targets {
519            self.procs[idx].restart();
520            self.interpreters_built += 1;
521            let pid = self.procs[idx].pid;
522            // A restarted process starts clean on the host side too. In
523            // particular its mailbox is emptied: the messages in flight were
524            // addressed to the incarnation that died, and replaying them into
525            // the fresh one is how a poison message kills a process forever.
526            host.on_restart(pid);
527            self.events.push(Event::Restarted { pid });
528        }
529    }
530}
531
532fn classify(e: &VmError) -> ExitReason {
533    match e {
534        VmError::BudgetExhausted { .. } => ExitReason::Runaway(e.to_string()),
535        other => ExitReason::Crashed(other.to_string()),
536    }
537}
538
539#[cfg(test)]
540mod tests {
541    use super::*;
542    use tatara_lisp_eval::vm::compile_program;
543
544    fn compile(src: &str) -> Arc<Chunk> {
545        let forms = tatara_lisp::read_spanned(src).expect("parse");
546        Arc::new(compile_program(&forms).expect("compile"))
547    }
548
549    fn blue(src: &str) -> Arc<Chunk> {
550        let forms = blue_lang_syntax::parse_program(src).expect("parse blue");
551        let text = forms
552            .iter()
553            .map(|f| f.to_string())
554            .collect::<Vec<_>>()
555            .join("\n");
556        compile(&text)
557    }
558
559    /// Each process builds its own. Passed as a factory, so a restart gets a
560    /// fresh one.
561    fn build() -> Interpreter<()> {
562        blue_lang_runtime::interpreter_hostless()
563    }
564
565    fn preemptive(q: usize) -> Budget {
566        Budget::preemptive(q)
567    }
568
569    // ---- the payoff: a runaway is contained and restarted -------------
570
571    /// **This is what was impossible before.** A runaway previously aborted
572    /// the OS process, so there was nothing to supervise. Now it exits with
573    /// a typed reason and the supervisor restarts it.
574    #[test]
575    fn a_runaway_is_contained_and_restarted() {
576        let mut sup = Supervisor::new(Strategy::OneForOne, 3);
577        let spin = compile("(define (spin n) (spin (+ n 1))) (spin 0)");
578        let pid = sup.spawn(
579            spin,
580            Budget {
581                fuel: Some(2_000),
582                max_depth: None,
583                quantum: Some(200),
584            },
585            build,
586        );
587        sup.run_to_quiescence(&mut (), 200);
588
589        assert!(
590            sup.events.iter().any(|e| matches!(
591                e,
592                Event::Exited {
593                    reason: ExitReason::Runaway(_),
594                    ..
595                }
596            )),
597            "the runaway must exit with a Runaway reason: {:?}",
598            sup.events
599        );
600        assert!(
601            sup.events
602                .iter()
603                .any(|e| matches!(e, Event::Restarted { .. })),
604            "the supervisor must have restarted it"
605        );
606        assert!(sup.restarts_of(pid).unwrap() > 0);
607    }
608
609    /// And it must eventually GIVE UP rather than restart forever — a
610    /// crash-on-start child otherwise becomes a busy loop in a supervision
611    /// tree's clothes.
612    #[test]
613    fn restart_intensity_stops_an_unfixable_child() {
614        let mut sup = Supervisor::new(Strategy::OneForOne, 2);
615        let spin = compile("(define (spin n) (spin (+ n 1))) (spin 0)");
616        sup.spawn(
617            spin,
618            Budget {
619                fuel: Some(500),
620                max_depth: None,
621                quantum: Some(100),
622            },
623            build,
624        );
625        sup.run_to_quiescence(&mut (), 500);
626
627        assert!(
628            sup.events.iter().any(|e| matches!(e, Event::GaveUp { .. })),
629            "the supervisor must give up: {:?}",
630            sup.events
631        );
632        assert!(!sup.has_work(&()), "nothing should still be runnable");
633    }
634
635    // ---- normal completion -------------------------------------------
636
637    #[test]
638    fn a_healthy_process_finishes_and_is_not_restarted() {
639        let mut sup = Supervisor::new(Strategy::OneForOne, 3);
640        let pid = sup.spawn(blue("1 + 2"), preemptive(50), build);
641        sup.run_to_quiescence(&mut (), 100);
642
643        assert_eq!(sup.state_of(pid).and_then(ProcState::done_int), Some(3));
644        assert_eq!(
645            sup.restarts_of(pid),
646            Some(0),
647            "a healthy child must not restart"
648        );
649    }
650
651    /// Round-robin fairness at the supervisor level: a long child must not
652    /// prevent a short one from finishing.
653    #[test]
654    fn a_long_child_does_not_starve_a_short_one() {
655        let mut sup = Supervisor::new(Strategy::OneForOne, 3);
656        let long = sup.spawn(
657            blue("def loop(n)\n  if n == 0\n    1\n  else\n    loop(n - 1)\n  end\nend\nloop(400)"),
658            preemptive(20),
659            build,
660        );
661        let short = sup.spawn(blue("2 + 3"), preemptive(20), build);
662
663        // One round is enough for the SHORT child; the long one is still going.
664        sup.round(&mut ());
665        assert_eq!(sup.state_of(short).and_then(ProcState::done_int), Some(5));
666        assert!(
667            sup.state_of(long).is_some_and(ProcState::is_runnable),
668            "the long child should still be running"
669        );
670
671        sup.run_to_quiescence(&mut (), 1000);
672        assert_eq!(sup.state_of(long).and_then(ProcState::done_int), Some(1));
673    }
674
675    // ---- the three strategies ------------------------------------------
676
677    fn crashing() -> Arc<Chunk> {
678        compile("(define (spin n) (spin (+ n 1))) (spin 0)")
679    }
680
681    /// Fuel BELOW the quantum, so the crasher exhausts *inside* its first
682    /// slice. With fuel above the quantum it merely yields, and a
683    /// single-round strategy test would see no exit at all.
684    fn tiny_budget() -> Budget {
685        Budget {
686            fuel: Some(60),
687            max_depth: None,
688            quantum: Some(1_000),
689        }
690    }
691
692    #[test]
693    fn one_for_one_restarts_only_the_child_that_exited() {
694        let mut sup = Supervisor::new(Strategy::OneForOne, 5);
695        let a = sup.spawn(blue("1"), preemptive(50), build);
696        let bad = sup.spawn(crashing(), tiny_budget(), build);
697        let c = sup.spawn(blue("2"), preemptive(50), build);
698        sup.round(&mut ());
699
700        assert_eq!(sup.restarts_of(bad), Some(1), "the crasher must restart");
701        assert_eq!(sup.restarts_of(a), Some(0), "a sibling must NOT restart");
702        assert_eq!(sup.restarts_of(c), Some(0), "a sibling must NOT restart");
703    }
704
705    #[test]
706    fn one_for_all_restarts_every_child() {
707        let mut sup = Supervisor::new(Strategy::OneForAll, 5);
708        let a = sup.spawn(crashing(), tiny_budget(), build);
709        let b = sup.spawn(blue("1"), preemptive(50), build);
710        let c = sup.spawn(blue("2"), preemptive(50), build);
711        sup.round(&mut ());
712
713        assert_eq!(sup.restarts_of(a), Some(1));
714        assert_eq!(
715            sup.restarts_of(b),
716            Some(1),
717            "one_for_all must restart siblings"
718        );
719        assert_eq!(
720            sup.restarts_of(c),
721            Some(1),
722            "one_for_all must restart siblings"
723        );
724    }
725
726    /// `rest_for_one` restarts the exited child and everything declared
727    /// AFTER it — the ones that may depend on it — and leaves the earlier
728    /// ones alone.
729    #[test]
730    fn rest_for_one_restarts_the_child_and_its_successors_only() {
731        let mut sup = Supervisor::new(Strategy::RestForOne, 5);
732        let before = sup.spawn(blue("1"), preemptive(50), build);
733        let bad = sup.spawn(crashing(), tiny_budget(), build);
734        let after = sup.spawn(blue("2"), preemptive(50), build);
735        sup.round(&mut ());
736
737        assert_eq!(
738            sup.restarts_of(before),
739            Some(0),
740            "a predecessor must not restart"
741        );
742        assert_eq!(sup.restarts_of(bad), Some(1));
743        assert_eq!(sup.restarts_of(after), Some(1), "a successor must restart");
744    }
745
746    // ---- anti-vacuity --------------------------------------------------
747
748    /// The supervisor must be able to observe a normal exit as normal. If
749    /// everything looked abnormal, the restart tests would pass by always
750    /// restarting.
751    #[test]
752    fn a_normal_exit_is_not_treated_as_a_crash() {
753        let mut sup = Supervisor::new(Strategy::OneForAll, 5);
754        let a = sup.spawn(blue("1"), preemptive(50), build);
755        let b = sup.spawn(blue("2"), preemptive(50), build);
756        sup.run_to_quiescence(&mut (), 100);
757
758        assert!(
759            !sup.events
760                .iter()
761                .any(|e| matches!(e, Event::Restarted { .. })),
762            "a normal exit must not trigger one_for_all: {:?}",
763            sup.events
764        );
765        assert_eq!(sup.restarts_of(a), Some(0));
766        assert_eq!(sup.restarts_of(b), Some(0));
767    }
768
769    /// One process's crash must not corrupt another's control state — the
770    /// isolation claim, tested directly.
771    #[test]
772    fn a_crash_does_not_disturb_a_siblings_result() {
773        let mut sup = Supervisor::new(Strategy::OneForOne, 1);
774        let _bad = sup.spawn(crashing(), tiny_budget(), build);
775        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);
776        sup.run_to_quiescence(&mut (), 500);
777
778        assert_eq!(
779            sup.state_of(good).and_then(ProcState::done_int),
780            Some(55),
781            "the healthy child's answer must survive a sibling's crash"
782        );
783    }
784
785    /// And blue source really does run under supervision — not just
786    /// hand-written tatara-lisp.
787    #[test]
788    fn blue_source_runs_under_supervision() {
789        let mut sup = Supervisor::new(Strategy::OneForOne, 3);
790        let pid = sup.spawn(
791            blue(
792                "def fact(n)\n  if n < 2\n    1\n  else\n    n * fact(n - 1)\n  end\nend\nfact(6)",
793            ),
794            preemptive(30),
795            build,
796        );
797        sup.run_to_quiescence(&mut (), 1000);
798        assert_eq!(sup.state_of(pid).and_then(ProcState::done_int), Some(720));
799    }
800}