Skip to main content

byteflow/scheduler/
runtime.rs

1use std::sync::atomic::{AtomicBool, Ordering};
2use std::sync::{Arc, Condvar, Mutex};
3use std::thread::JoinHandle;
4
5use crate::bytecode::{Chunk, Value};
6use crate::vm::{NativeTable, Vm};
7use crossbeam_deque::{Injector, Stealer, Worker as LocalDeque};
8
9use super::directory::Directory;
10use super::error::SpawnError;
11use super::handle::FlowHandle;
12use super::mailbox::{Delivery, Mailbox};
13use super::metrics::{RuntimeMetrics, RuntimeMetricsSnapshot};
14use super::process::{Flow, FlowId, RestartPolicy};
15use super::supervisor::SupervisorLink;
16use super::timer::TimerWheel;
17use super::worker;
18
19/// Default instruction budget per scheduling turn (design notes §10).
20/// Chosen as a middle ground: large enough that the per-yield bookkeeping
21/// cost is amortized over meaningful work, small enough that a
22/// pathological `loop {}` in one flow can't visibly stall the others —
23/// at 10k simple instructions/turn and even a conservative tens-of-millions
24/// of instructions/sec per core, worst-case added latency for a sibling
25/// flow is sub-millisecond.
26pub const DEFAULT_QUANTUM: u32 = 10_000;
27
28/// Tunables for [`Runtime::new`]. Everything has a sensible default via
29/// [`RuntimeConfig::default`] so the common case is `Runtime::new(chunk)`.
30#[derive(Clone, Debug)]
31pub struct RuntimeConfig {
32    /// Number of worker OS threads. Defaults to the number of logical CPUs
33    /// — one worker per core is the right starting point for a CPU-bound
34    /// M:N scheduler; embedders running alongside other CPU-heavy work on
35    /// the same machine may want fewer.
36    pub workers: usize,
37    /// Instructions a flow runs before being preempted back to the
38    /// scheduler even if it never hits `Yield`.
39    pub quantum: u32,
40}
41
42impl Default for RuntimeConfig {
43    fn default() -> Self {
44        RuntimeConfig { workers: num_cpus::get().max(1), quantum: DEFAULT_QUANTUM }
45    }
46}
47
48/// State shared by every worker thread and the timer thread. Everything in
49/// here is either internally synchronized (`Injector`, `Directory`,
50/// `CapTable`, `TimerWheel`, the `RuntimeMetrics` atomics) or immutable after
51/// construction (`stealers`, `quantum`) — there is no top-level lock
52/// covering the whole runtime, by design: a global lock is exactly what an
53/// M:N scheduler exists to avoid.
54pub struct Shared {
55    pub(crate) injector: Injector<Box<Flow>>,
56    pub(crate) stealers: Vec<Stealer<Box<Flow>>>,
57    /// FlowId → mailbox (delivery after Cap resolution).
58    pub(crate) directory: Directory,
59    /// CapId → { FlowId, rights } (bytecode Send/Ask addressing — FlowCap).
60    pub(crate) caps: super::capability::CapTable,
61    pub(crate) timer: Arc<TimerWheel>,
62    pub(crate) notify: (Mutex<()>, Condvar),
63    pub(crate) metrics: RuntimeMetrics,
64    pub(crate) shutdown: AtomicBool,
65    pub(crate) quantum: u32,
66}
67
68/// A running Byteflow runtime: worker pool + timer thread over one shared
69/// [`Chunk`].
70///
71/// Owns M:N scheduling for **flows** (spawn, yield, sleep, mailboxes,
72/// FlowCap resolution, supervised restarts). Intentionally *not* here yet:
73/// JIT, native quotas, distribution across machines.
74///
75/// Construct with [`Runtime::new`] (no natives) or
76/// [`Runtime::with_natives`] when the chunk uses `CallNative` /
77/// [`crate::std_native_table`].
78pub struct Runtime {
79    shared: Arc<Shared>,
80    chunk: Arc<Chunk>,
81    natives: Arc<NativeTable>,
82    workers: Vec<JoinHandle<()>>,
83    timer_thread: Option<JoinHandle<()>>,
84}
85
86impl Runtime {
87    /// Convenience constructor for chunks that never call out through
88    /// `Opcode::CallNative`. Equivalent to
89    /// `Runtime::with_natives(chunk, NativeTable::empty())`.
90    ///
91    /// Returns [`SpawnError`] instead of panicking: verify failures and OS
92    /// thread-spawn refusals are category-A errors (see
93    /// [`super::error`]).
94    pub fn new(chunk: Chunk) -> Result<Self, SpawnError> {
95        Self::with_config(chunk, RuntimeConfig::default())
96    }
97
98    /// Construct a runtime whose flows can call into `natives` via
99    /// `Opcode::CallNative` — the host FFI boundary.
100    pub fn with_natives(chunk: Chunk, natives: Arc<NativeTable>) -> Result<Self, SpawnError> {
101        Self::with_natives_and_config(chunk, natives, RuntimeConfig::default())
102    }
103
104    pub fn with_config(chunk: Chunk, config: RuntimeConfig) -> Result<Self, SpawnError> {
105        Self::with_natives_and_config(chunk, NativeTable::empty(), config)
106    }
107
108    /// Verify `chunk`, spawn the worker pool + timer thread, and return a
109    /// live [`Runtime`].
110    ///
111    /// Failures here mean the runtime was **never** started (no orphan
112    /// threads): either the bytecode is invalid
113    /// ([`SpawnError::VerifyFailed`]) or the OS refused a thread
114    /// ([`SpawnError::ThreadSpawnFailed`]).
115    pub fn with_natives_and_config(
116        chunk: Chunk,
117        natives: Arc<NativeTable>,
118        config: RuntimeConfig,
119    ) -> Result<Self, SpawnError> {
120        crate::bytecode::verify(&chunk).map_err(|e| SpawnError::VerifyFailed(e.to_string()))?;
121        let chunk = Arc::new(chunk);
122        let workers_n = config.workers.max(1);
123
124        let locals: Vec<LocalDeque<Box<Flow>>> =
125            (0..workers_n).map(|_| LocalDeque::new_fifo()).collect();
126        let stealers: Vec<Stealer<Box<Flow>>> = locals.iter().map(|l| l.stealer()).collect();
127
128        let shared = Arc::new(Shared {
129            injector: Injector::new(),
130            stealers,
131            directory: Directory::new(),
132            caps: super::capability::CapTable::new(),
133            timer: TimerWheel::new(),
134            notify: (Mutex::new(()), Condvar::new()),
135            metrics: RuntimeMetrics::default(),
136            shutdown: AtomicBool::new(false),
137            quantum: config.quantum,
138        });
139
140        let mut workers = Vec::with_capacity(workers_n);
141        for local in locals {
142            let shared = shared.clone();
143            let handle = std::thread::Builder::new()
144                .name("byteflow-worker".into())
145                .spawn(move || worker::run_worker(shared, local))
146                .map_err(|e| SpawnError::ThreadSpawnFailed(e.to_string()))?;
147            workers.push(handle);
148        }
149
150        let shared_timer = shared.clone();
151        let timer_thread = std::thread::Builder::new()
152            .name("byteflow-timer".into())
153            .spawn(move || {
154                shared_timer
155                    .timer
156                    .clone()
157                    .drive(&shared_timer.injector, &shared_timer.notify)
158            })
159            .map_err(|e| SpawnError::ThreadSpawnFailed(e.to_string()))?;
160
161        Ok(Runtime {
162            shared,
163            chunk,
164            natives,
165            workers,
166            timer_thread: Some(timer_thread),
167        })
168    }
169
170    /// Spawn a top-level flow starting at `function` in this runtime's
171    /// chunk, returning a [`FlowHandle`] the caller can `.join()`.
172    ///
173    /// Returns [`SpawnError::BadFunction`] if `function` is out of range.
174    pub fn spawn(&self, function: u32, args: &[Value]) -> Result<FlowHandle, SpawnError> {
175        spawn_on(
176            &self.shared,
177            &self.chunk,
178            &self.natives,
179            function,
180            args,
181            RestartPolicy::Never,
182            None,
183        )
184    }
185
186    /// A cheap, `Send + Sync` handle that can spawn processes into this
187    /// runtime from any thread, independent of `Runtime`'s own lifetime
188    /// bookkeeping (worker `JoinHandle`s). Used by [`super::supervisor::Supervisor`].
189    pub fn spawner(&self) -> RuntimeSpawner {
190        RuntimeSpawner { shared: self.shared.clone(), chunk: self.chunk.clone(), natives: self.natives.clone() }
191    }
192
193    /// A [`super::supervisor::Supervisor`] bound to this runtime, ready to
194    /// take supervised children (design notes §15).
195    pub fn supervisor(&self) -> Result<super::supervisor::Supervisor, SpawnError> {
196        super::supervisor::Supervisor::new(self.spawner())
197    }
198
199    /// Look up a function by name in the runtime's chunk — convenience for
200    /// callers that built their chunk with [`crate::bytecode::ChunkBuilder`]
201    /// and don't want to thread raw indices through their own code.
202    pub fn function_index(&self, name: &str) -> Option<u32> {
203        self.chunk.functions.iter().position(|f| f.name == name).map(|i| i as u32)
204    }
205
206    pub fn metrics(&self) -> RuntimeMetricsSnapshot {
207        self.shared.metrics.snapshot()
208    }
209
210    /// Number of flows currently registered in the directory — i.e.
211    /// alive (running, ready, sleeping, or waiting), not counting ones that
212    /// have already completed or failed.
213    pub fn live_flows(&self) -> usize {
214        self.shared.directory.len()
215    }
216
217    pub fn worker_count(&self) -> usize {
218        self.workers.len()
219    }
220
221    /// Deliver an **Atomic Hop** (`Value::Message`) to `target` from the
222    /// embedder (not from bytecode).
223    ///
224    /// # Host trust boundary
225    ///
226    /// This path takes a [`FlowId`] directly — **no Cap required**. The host
227    /// is trusted; bytecode must use `Value::Cap` via `Opcode::Send` /
228    /// `Ask`. Host-injected messages are not re-stamped (`sender` /
229    /// `reply_cap` stay as built). Bare scalars are rejected
230    /// ([`SendError::NotAHop`]).
231    pub fn send(&self, target: FlowId, message: Value) -> Result<(), SendError> {
232        if message.as_message().is_none() {
233            return Err(SendError::NotAHop {
234                got: message.type_name(),
235            });
236        }
237        let mailbox = match self.shared.directory.lookup(target) {
238            Ok(Some(m)) => m,
239            Ok(None) => return Err(SendError::NoSuchFlow(target)),
240            Err(e) => {
241                super::error::report_fault(e);
242                return Err(SendError::NoSuchFlow(target));
243            }
244        };
245        match mailbox.push(message.clone()) {
246            Ok(Delivery::Queued) => Ok(()),
247            Ok(Delivery::Handoff(mut flow)) => {
248                if let Some(dest) = flow.last_receive_dest {
249                    let _ = flow.vm.resume_with(dest, message);
250                }
251                self.shared.injector.push(flow);
252                wake_workers(&self.shared);
253                Ok(())
254            }
255            Err(e) => {
256                super::error::report_fault(e);
257                Err(SendError::NoSuchFlow(target))
258            }
259        }
260    }
261
262    /// Stop accepting new scheduling work and join every worker + the timer
263    /// thread. Processes that are mid-quantum are allowed to reach their
264    /// next natural suspension point; this does **not** forcibly abort
265    /// running bytecode (there is no safe way to do that to an OS thread
266    /// mid-instruction — see design notes §11 on why preemption here is
267    /// cooperative/budgeted rather than signal-based).
268    pub fn shutdown(mut self) {
269        self.shared.shutdown.store(true, Ordering::Release);
270        self.shared.timer.shutdown();
271        {
272            let (lock, cvar) = &self.shared.notify;
273            match super::sync_lock::lock(lock, "Runtime::shutdown") {
274                Ok(_g) => cvar.notify_all(),
275                Err(e) => super::error::report_fault(e),
276            }
277        }
278        for w in self.workers.drain(..) {
279            let _ = w.join();
280        }
281        if let Some(t) = self.timer_thread.take() {
282            let _ = t.join();
283        }
284    }
285}
286
287/// A convenience Pid constructor for embedders that stored a raw `u64`
288/// (e.g. round-tripped through `Value::Pid`) and need a [`FlowId`] to
289/// call APIs that take one.
290pub fn flow_id_from_u64(raw: u64) -> FlowId {
291    FlowId(raw)
292}
293
294/// Why [`Runtime::send`] could not deliver a hop.
295#[derive(Debug, Clone, PartialEq, Eq)]
296pub enum SendError {
297    NoSuchFlow(FlowId),
298    /// Atomic Hop rule: only [`crate::Value::Message`] may cross `Send`.
299    NotAHop { got: &'static str },
300}
301
302impl std::fmt::Display for SendError {
303    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
304        match self {
305            SendError::NoSuchFlow(id) => write!(f, "no live flow {id}"),
306            SendError::NotAHop { got } => {
307                write!(f, "atomic hop requires Value::Message, got {got}")
308            }
309        }
310    }
311}
312
313impl std::error::Error for SendError {}
314
315/// Shared machinery behind `Runtime::spawn` and `RuntimeSpawner::spawn`
316/// (and, transitively, `Supervisor`): build a fresh `Flow` (VM +
317/// mailbox + completion channel), register it in the directory, and push
318/// it onto the global injector for any worker to pick up.
319///
320/// Returns [`SpawnError`] on bad function index / VM init / directory
321/// poison — never panics. Bytecode `Opcode::Spawn` that fails here turns
322/// into `FlowOutcome::Failed` for the *parent* (see `worker`).
323pub(crate) fn spawn_on(
324    shared: &Arc<Shared>,
325    chunk: &Arc<Chunk>,
326    natives: &Arc<NativeTable>,
327    function: u32,
328    args: &[Value],
329    restart_policy: RestartPolicy,
330    supervisor: Option<SupervisorLink>,
331) -> Result<FlowHandle, SpawnError> {
332    let id = super::process::next_flow_id();
333    let vm = Vm::new(chunk.clone(), natives.clone(), function, args)?;
334    let mailbox = Arc::new(Mailbox::new());
335    if let Err(e) = shared.directory.register(id, mailbox.clone()) {
336        super::error::report_fault(e);
337        return Err(SpawnError::VmInit(
338            "directory register failed (poisoned lock)".into(),
339        ));
340    }
341    let (tx, rx) = super::oneshot::channel();
342    let mut flow = Box::new(Flow::new(id, vm, mailbox, restart_policy, tx));
343    flow.supervisor = supervisor;
344    RuntimeMetrics::inc(&shared.metrics.processes_spawned);
345    shared.injector.push(flow);
346    wake_workers(shared);
347    Ok(FlowHandle { id, receiver: rx })
348}
349
350pub(crate) fn wake_workers(shared: &Shared) {
351    let (lock, cvar) = &shared.notify;
352    match super::sync_lock::lock(lock, "wake_workers") {
353        Ok(_g) => cvar.notify_one(),
354        Err(e) => super::error::report_fault(e),
355    }
356}
357
358/// A `Send + Sync`, freely cloneable capability to spawn processes into a
359/// [`Runtime`], detached from the `Runtime` value itself. Exists because
360/// [`super::supervisor::Supervisor`] needs to respawn processes from a
361/// background monitor thread whose lifetime isn't tied to the `Runtime`
362/// object's own (which owns non-`Sync` `JoinHandle`s for its workers).
363#[derive(Clone)]
364pub struct RuntimeSpawner {
365    pub(crate) shared: Arc<Shared>,
366    pub(crate) chunk: Arc<Chunk>,
367    pub(crate) natives: Arc<NativeTable>,
368}
369
370impl RuntimeSpawner {
371    pub fn spawn(
372        &self,
373        function: u32,
374        args: &[Value],
375        restart_policy: RestartPolicy,
376    ) -> Result<FlowHandle, SpawnError> {
377        spawn_on(
378            &self.shared,
379            &self.chunk,
380            &self.natives,
381            function,
382            args,
383            restart_policy,
384            None,
385        )
386    }
387
388    pub(crate) fn spawn_linked(
389        &self,
390        function: u32,
391        args: &[Value],
392        restart_policy: RestartPolicy,
393        supervisor: SupervisorLink,
394    ) -> Result<FlowHandle, SpawnError> {
395        spawn_on(
396            &self.shared,
397            &self.chunk,
398            &self.natives,
399            function,
400            args,
401            restart_policy,
402            Some(supervisor),
403        )
404    }
405
406    pub fn metrics(&self) -> RuntimeMetricsSnapshot {
407        self.shared.metrics.snapshot()
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::bytecode::{ChunkBuilder, Opcode, Value};
415    use crate::scheduler::FlowOutcome;
416
417    fn add_chunk() -> Chunk {
418        let mut b = ChunkBuilder::new("test");
419        b.begin_function("main", 0, 2);
420        b.emit_load_imm(0, 41);
421        b.emit_load_imm(1, 1);
422        b.emit_binop(Opcode::Add, 0, 0, 1);
423        b.emit_return(0);
424        b.finish()
425    }
426
427    #[test]
428    fn spawn_and_join_add() {
429        let rt = Runtime::with_config(
430            add_chunk(),
431            RuntimeConfig {
432                workers: 1,
433                quantum: 1_000,
434            },
435        )
436        .expect("runtime");
437        let outcome = rt.spawn(0, &[]).expect("spawn").join();
438        rt.shutdown();
439        match outcome {
440            FlowOutcome::Completed(Value::Int(42)) => {}
441            other => panic!("unexpected outcome: {other:?}"),
442        }
443    }
444}