Skip to main content

byteflow/scheduler/
supervisor.rs

1use std::collections::{HashMap, VecDeque};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, Condvar, Mutex};
4use std::thread::JoinHandle;
5use std::time::{Duration, Instant};
6
7use crate::bytecode::Value;
8
9use super::error::{report_fault, SpawnError};
10use super::handle::FlowHandle;
11use super::process::{FlowId, FlowOutcome, RestartPolicy};
12use super::runtime::RuntimeSpawner;
13use super::sync_lock;
14
15/// How many restarts OTP-style supervisors allow inside a sliding window
16/// before giving up (design notes §15). Three-in-five-seconds is the
17/// classic default: enough to absorb a flaky child, tight enough that a
18/// crash loop cannot spin the runtime forever.
19const DEFAULT_MAX_RESTARTS: u32 = 3;
20const DEFAULT_MAX_PERIOD: Duration = Duration::from_secs(5);
21
22/// A child the supervisor should start (and possibly restart).
23///
24/// `function` is an index into the runtime's chunk — the same number
25/// [`super::runtime::Runtime::spawn`] takes. Args are cloned on every
26/// restart so a child always comes back with the original call.
27#[derive(Clone, Debug)]
28pub struct ChildSpec {
29    pub name: String,
30    pub function: u32,
31    pub args: Vec<Value>,
32    pub restart: RestartPolicy,
33}
34
35impl ChildSpec {
36    pub fn new(name: impl Into<String>, function: u32) -> Self {
37        ChildSpec {
38            name: name.into(),
39            function,
40            args: Vec::new(),
41            restart: RestartPolicy::OnFailure,
42        }
43    }
44
45    pub fn args(mut self, args: Vec<Value>) -> Self {
46        self.args = args;
47        self
48    }
49
50    pub fn restart(mut self, restart: RestartPolicy) -> Self {
51        self.restart = restart;
52        self
53    }
54}
55
56/// Tunables for [`Supervisor::with_config`].
57///
58/// Unlike a full OTP supervisor this does **not** implement one-for-all /
59/// rest-for-one: those strategies require aborting sibling processes, and
60/// Byteflow's preemption is cooperative (see [`super::runtime::Runtime::shutdown`]).
61/// v0 is one-for-one — only the child that exited is considered for restart.
62#[derive(Clone, Debug)]
63pub struct SupervisorConfig {
64    /// Restarts allowed inside [`Self::max_period`]. The initial start does
65    /// not count; only respawns do. Hitting this cap sets
66    /// [`Supervisor::intensity_exceeded`] and further restarts are refused.
67    pub max_restarts: u32,
68    pub max_period: Duration,
69}
70
71impl Default for SupervisorConfig {
72    fn default() -> Self {
73        SupervisorConfig {
74            max_restarts: DEFAULT_MAX_RESTARTS,
75            max_period: DEFAULT_MAX_PERIOD,
76        }
77    }
78}
79
80struct ChildExit {
81    id: FlowId,
82    outcome: FlowOutcome,
83}
84
85struct LiveChild {
86    spec: ChildSpec,
87}
88
89struct Inner {
90    spawner: RuntimeSpawner,
91    config: SupervisorConfig,
92    events: Mutex<VecDeque<ChildExit>>,
93    cvar: Condvar,
94    children: Mutex<HashMap<FlowId, LiveChild>>,
95    restart_times: Mutex<VecDeque<Instant>>,
96    intensity_exceeded: AtomicBool,
97    shutdown: AtomicBool,
98}
99
100/// Cheap, `Clone` handle the worker uses to hand a terminal outcome back
101/// without taking a lock on the supervisor's child table (the drive loop
102/// is the only writer of that table).
103#[derive(Clone)]
104pub(crate) struct SupervisorLink {
105    inner: Arc<Inner>,
106}
107
108impl SupervisorLink {
109    pub(crate) fn notify(&self, id: FlowId, outcome: FlowOutcome) {
110        match sync_lock::lock(&self.inner.events, "SupervisorLink::notify") {
111            Ok(mut events) => {
112                events.push_back(ChildExit { id, outcome });
113                self.inner.cvar.notify_one();
114            }
115            Err(e) => report_fault(e),
116        }
117    }
118}
119
120/// Host-side child restarter (design notes §15-16).
121///
122/// A `Supervisor` is **not** a bytecode Flow. It is a dedicated OS
123/// thread plus a table of [`ChildSpec`]s. When a supervised flow
124/// becomes `FlowState::Failed` (or completes, under
125/// [`RestartPolicy::Always`]), the worker delivers the
126/// [`FlowOutcome`] here instead of letting the fault take anything
127/// else down. The supervisor then consults the child's
128/// [`RestartPolicy`] and, if intensity allows, respawns it under a
129/// fresh [`FlowId`] — Pids are never reused (see
130/// [`super::process::FlowId`]).
131///
132/// Constructed from a [`RuntimeSpawner`] so it does not have to own the
133/// runtime's worker `JoinHandle`s.
134pub struct Supervisor {
135    inner: Arc<Inner>,
136    thread: Option<JoinHandle<()>>,
137}
138
139impl Supervisor {
140    pub fn new(spawner: RuntimeSpawner) -> Result<Self, SpawnError> {
141        Self::with_config(spawner, SupervisorConfig::default())
142    }
143
144    /// Start the dedicated supervisor OS thread. Thread-spawn failure is
145    /// [`SpawnError::ThreadSpawnFailed`] — same category-A surface as
146    /// [`super::runtime::Runtime::new`], not a panic.
147    pub fn with_config(spawner: RuntimeSpawner, config: SupervisorConfig) -> Result<Self, SpawnError> {
148        let inner = Arc::new(Inner {
149            spawner,
150            config,
151            events: Mutex::new(VecDeque::new()),
152            cvar: Condvar::new(),
153            children: Mutex::new(HashMap::new()),
154            restart_times: Mutex::new(VecDeque::new()),
155            intensity_exceeded: AtomicBool::new(false),
156            shutdown: AtomicBool::new(false),
157        });
158        let drive_inner = inner.clone();
159        let thread = std::thread::Builder::new()
160            .name("byteflow-supervisor".into())
161            .spawn(move || drive(drive_inner))
162            .map_err(|e| SpawnError::ThreadSpawnFailed(e.to_string()))?;
163        Ok(Supervisor {
164            inner,
165            thread: Some(thread),
166        })
167    }
168
169    /// Spawn `spec` and start supervising it. The returned handle is for
170    /// this incarnation only — a restart allocates a new Pid and a new
171    /// completion channel.
172    pub fn start_child(&self, spec: ChildSpec) -> Result<FlowHandle, SpawnError> {
173        spawn_child(&self.inner, spec)
174    }
175
176    pub fn live_children(&self) -> usize {
177        match sync_lock::lock(&self.inner.children, "Supervisor::live_children") {
178            Ok(g) => g.len(),
179            Err(e) => {
180                report_fault(e);
181                0
182            }
183        }
184    }
185
186    /// `true` once more than [`SupervisorConfig::max_restarts`] respawns
187    /// landed inside the intensity window. Remaining children keep
188    /// running; we just stop bringing them back (no safe abort of a
189    /// mid-quantum flow).
190    pub fn intensity_exceeded(&self) -> bool {
191        self.inner.intensity_exceeded.load(Ordering::Acquire)
192    }
193
194    /// Stop the drive thread. Does not terminate live children — they
195    /// belong to the runtime, not to us.
196    pub fn shutdown(mut self) {
197        self.inner.shutdown.store(true, Ordering::Release);
198        self.inner.cvar.notify_all();
199        if let Some(t) = self.thread.take() {
200            let _ = t.join();
201        }
202    }
203}
204
205fn spawn_child(inner: &Arc<Inner>, spec: ChildSpec) -> Result<FlowHandle, SpawnError> {
206    let link = SupervisorLink {
207        inner: inner.clone(),
208    };
209    // Hold the table across spawn so a child that faults in its first
210    // quantum cannot notify us before its row exists (the drive loop
211    // takes this same lock in `handle_exit`, so the event waits).
212    let mut children = match sync_lock::lock(&inner.children, "spawn_child") {
213        Ok(c) => c,
214        Err(e) => {
215            report_fault(e);
216            return Err(SpawnError::VmInit(
217                "supervisor child table poisoned".into(),
218            ));
219        }
220    };
221    let handle = inner.spawner.spawn_linked(
222        spec.function,
223        &spec.args,
224        spec.restart,
225        link,
226    )?;
227    children.insert(handle.id(), LiveChild { spec });
228    Ok(handle)
229}
230
231fn should_restart(policy: RestartPolicy, outcome: &FlowOutcome) -> bool {
232    match policy {
233        RestartPolicy::Always => true,
234        RestartPolicy::OnFailure => matches!(outcome, FlowOutcome::Failed(_)),
235        RestartPolicy::Never => false,
236    }
237}
238
239fn intensity_hit(inner: &Inner) -> bool {
240    let now = Instant::now();
241    let mut times = match sync_lock::lock(&inner.restart_times, "intensity_hit") {
242        Ok(t) => t,
243        Err(e) => {
244            report_fault(e);
245            return true;
246        }
247    };
248    times.push_back(now);
249    let window_start = match now.checked_sub(inner.config.max_period) {
250        Some(t) => t,
251        None => now,
252    };
253    loop {
254        match times.front() {
255            Some(t) if *t < window_start => {
256                times.pop_front();
257            }
258            _ => break,
259        }
260    }
261    if times.len() as u32 > inner.config.max_restarts {
262        inner.intensity_exceeded.store(true, Ordering::Release);
263        true
264    } else {
265        false
266    }
267}
268
269fn drive(inner: Arc<Inner>) {
270    loop {
271        if inner.shutdown.load(Ordering::Acquire) {
272            return;
273        }
274        let exit = {
275            let mut events = match sync_lock::lock(&inner.events, "supervisor::drive") {
276                Ok(e) => e,
277                Err(e) => {
278                    report_fault(e);
279                    return;
280                }
281            };
282            loop {
283                if inner.shutdown.load(Ordering::Acquire) {
284                    return;
285                }
286                if let Some(exit) = events.pop_front() {
287                    break exit;
288                }
289                match sync_lock::wait_timeout(
290                    &inner.cvar,
291                    events,
292                    Duration::from_millis(100),
293                    "supervisor::wait",
294                ) {
295                    Ok((guard, _)) => events = guard,
296                    Err(e) => {
297                        report_fault(e);
298                        return;
299                    }
300                }
301            }
302        };
303        handle_exit(&inner, exit);
304    }
305}
306
307fn handle_exit(inner: &Arc<Inner>, exit: ChildExit) {
308    let spec = {
309        let mut children = match sync_lock::lock(&inner.children, "handle_exit") {
310            Ok(c) => c,
311            Err(e) => {
312                report_fault(e);
313                return;
314            }
315        };
316        match children.remove(&exit.id) {
317            Some(live) => live.spec,
318            None => return,
319        }
320    };
321
322    if !should_restart(spec.restart, &exit.outcome) {
323        return;
324    }
325    if inner.intensity_exceeded.load(Ordering::Acquire) || intensity_hit(inner) {
326        return;
327    }
328
329    let _ = spawn_child(inner, spec);
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::time::{Duration, Instant};
336
337    use crate::bytecode::{Chunk, ChunkBuilder, Value};
338    use crate::scheduler::runtime::{Runtime, RuntimeConfig};
339
340    fn trap_chunk() -> Chunk {
341        let mut b = ChunkBuilder::new("trap");
342        b.begin_function("boom", 0, 1);
343        b.emit_trap(1);
344        b.finish()
345    }
346
347    fn ok_chunk() -> Chunk {
348        let mut b = ChunkBuilder::new("ok");
349        b.begin_function("main", 0, 1);
350        b.emit_load_imm(0, 7);
351        b.emit_return(0);
352        b.finish()
353    }
354
355    fn tiny_runtime(chunk: Chunk) -> Result<Runtime, crate::scheduler::SpawnError> {
356        Runtime::with_config(
357            chunk,
358            RuntimeConfig {
359                workers: 1,
360                quantum: 1_000,
361                mailbox: super::super::mailbox::MailboxConfig::DEFAULT,
362            },
363        )
364    }
365
366    fn wait_until(mut pred: impl FnMut() -> bool) {
367        let start = Instant::now();
368        while !pred() {
369            assert!(
370                start.elapsed() < Duration::from_secs(2),
371                "supervisor test timed out"
372            );
373            std::thread::sleep(Duration::from_millis(5));
374        }
375    }
376
377    #[test]
378    fn on_failure_does_not_restart_a_clean_exit() -> Result<(), Box<dyn std::error::Error>> {
379        let rt = tiny_runtime(ok_chunk())?;
380        let sup = Supervisor::new(rt.spawner())?;
381        let outcome = sup
382            .start_child(ChildSpec::new("main", 0).restart(RestartPolicy::OnFailure))?
383            .join();
384        wait_until(|| sup.live_children() == 0);
385        let spawned = rt.metrics().processes_spawned;
386        sup.shutdown();
387        rt.shutdown();
388        assert!(matches!(outcome, FlowOutcome::Completed(_)));
389        assert_eq!(spawned, 1);
390        Ok(())
391    }
392
393    #[test]
394    fn on_failure_restarts_until_intensity() -> Result<(), Box<dyn std::error::Error>> {
395        let rt = tiny_runtime(trap_chunk())?;
396        let sup = Supervisor::with_config(
397            rt.spawner(),
398            SupervisorConfig {
399                max_restarts: 2,
400                max_period: Duration::from_secs(5),
401            },
402        )?;
403        let _first = sup
404            .start_child(ChildSpec::new("boom", 0).restart(RestartPolicy::OnFailure))?;
405        wait_until(|| sup.intensity_exceeded() && rt.metrics().processes_failed >= 3);
406        let spawned = rt.metrics().processes_spawned;
407        let failed = rt.metrics().processes_failed;
408        sup.shutdown();
409        rt.shutdown();
410        // initial start + 2 restarts, then intensity refuses the 3rd restart
411        assert_eq!(spawned, 3);
412        assert_eq!(failed, 3);
413        Ok(())
414    }
415
416    #[test]
417    fn always_restarts_a_clean_exit_until_intensity() -> Result<(), Box<dyn std::error::Error>> {
418        let rt = tiny_runtime(ok_chunk())?;
419        let sup = Supervisor::with_config(
420            rt.spawner(),
421            SupervisorConfig {
422                max_restarts: 2,
423                max_period: Duration::from_secs(5),
424            },
425        )?;
426        let _ = sup
427            .start_child(ChildSpec::new("main", 0).restart(RestartPolicy::Always))?;
428        wait_until(|| sup.intensity_exceeded() && rt.metrics().processes_completed >= 3);
429        let spawned = rt.metrics().processes_spawned;
430        sup.shutdown();
431        rt.shutdown();
432        assert_eq!(spawned, 3);
433        Ok(())
434    }
435
436    #[test]
437    fn policy_table() {
438        let ok = FlowOutcome::Completed(Value::Unit);
439        let fail = FlowOutcome::Failed("boom".into());
440        assert!(should_restart(RestartPolicy::Always, &ok));
441        assert!(should_restart(RestartPolicy::Always, &fail));
442        assert!(!should_restart(RestartPolicy::OnFailure, &ok));
443        assert!(should_restart(RestartPolicy::OnFailure, &fail));
444        assert!(!should_restart(RestartPolicy::Never, &ok));
445        assert!(!should_restart(RestartPolicy::Never, &fail));
446    }
447}