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}