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, MailboxConfig, MailboxFullReason};
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    /// Memory + overflow contract applied to **every** flow mailbox
41    /// spawned by this runtime (bytecode `Spawn` and host `spawn`).
42    /// See [`MailboxConfig`] / `docs/mailbox.md`.
43    pub mailbox: MailboxConfig,
44}
45
46impl Default for RuntimeConfig {
47    fn default() -> Self {
48        RuntimeConfig {
49            workers: num_cpus::get().max(1),
50            quantum: DEFAULT_QUANTUM,
51            mailbox: MailboxConfig::DEFAULT,
52        }
53    }
54}
55
56/// State shared by every worker thread and the timer thread. Everything in
57/// here is either internally synchronized (`Injector`, `Directory`,
58/// `CapTable`, `TimerWheel`, the `RuntimeMetrics` atomics) or immutable after
59/// construction (`stealers`, `quantum`) — there is no top-level lock
60/// covering the whole runtime, by design: a global lock is exactly what an
61/// M:N scheduler exists to avoid.
62pub struct Shared {
63    pub(crate) injector: Injector<Box<Flow>>,
64    pub(crate) stealers: Vec<Stealer<Box<Flow>>>,
65    /// FlowId → mailbox (delivery after Cap resolution).
66    pub(crate) directory: Directory,
67    /// CapId → { FlowId, rights } (bytecode Send/Ask addressing — FlowCap).
68    pub(crate) caps: super::capability::CapTable,
69    pub(crate) timer: Arc<TimerWheel>,
70    pub(crate) notify: (Mutex<()>, Condvar),
71    pub(crate) metrics: RuntimeMetrics,
72    pub(crate) shutdown: AtomicBool,
73    pub(crate) quantum: u32,
74    pub(crate) mailbox: MailboxConfig,
75}
76
77/// A running Byteflow runtime: worker pool + timer thread over one shared
78/// [`Chunk`].
79///
80/// Owns M:N scheduling for **flows** (spawn, yield, sleep, mailboxes,
81/// FlowCap resolution, supervised restarts). Intentionally *not* here yet:
82/// JIT, native quotas, distribution across machines.
83///
84/// Construct with [`Runtime::new`] (no natives) or
85/// [`Runtime::with_natives`] when the chunk uses `CallNative` /
86/// [`crate::std_native_table`].
87pub struct Runtime {
88    shared: Arc<Shared>,
89    chunk: Arc<Chunk>,
90    natives: Arc<NativeTable>,
91    workers: Vec<JoinHandle<()>>,
92    timer_thread: Option<JoinHandle<()>>,
93}
94
95impl Runtime {
96    /// Convenience constructor for chunks that never call out through
97    /// `Opcode::CallNative`. Equivalent to
98    /// `Runtime::with_natives(chunk, NativeTable::empty())`.
99    ///
100    /// Returns [`SpawnError`] instead of panicking: verify failures and OS
101    /// thread-spawn refusals are category-A errors (see
102    /// [`docs::error_model`](crate::docs::error_model)).
103    pub fn new(chunk: Chunk) -> Result<Self, SpawnError> {
104        Self::with_config(chunk, RuntimeConfig::default())
105    }
106
107    /// Construct a runtime whose flows can call into `natives` via
108    /// `Opcode::CallNative` — the host FFI boundary.
109    pub fn with_natives(chunk: Chunk, natives: Arc<NativeTable>) -> Result<Self, SpawnError> {
110        Self::with_natives_and_config(chunk, natives, RuntimeConfig::default())
111    }
112
113    pub fn with_config(chunk: Chunk, config: RuntimeConfig) -> Result<Self, SpawnError> {
114        Self::with_natives_and_config(chunk, NativeTable::empty(), config)
115    }
116
117    /// Verify `chunk`, spawn the worker pool + timer thread, and return a
118    /// live [`Runtime`].
119    ///
120    /// Failures here mean the runtime was **never** started (no orphan
121    /// threads): either the bytecode is invalid
122    /// ([`SpawnError::VerifyFailed`]) or the OS refused a thread
123    /// ([`SpawnError::ThreadSpawnFailed`]).
124    pub fn with_natives_and_config(
125        chunk: Chunk,
126        natives: Arc<NativeTable>,
127        config: RuntimeConfig,
128    ) -> Result<Self, SpawnError> {
129        crate::bytecode::verify(&chunk).map_err(|e| SpawnError::VerifyFailed(e.to_string()))?;
130        let chunk = Arc::new(chunk);
131        let workers_n = config.workers.max(1);
132
133        let locals: Vec<LocalDeque<Box<Flow>>> =
134            (0..workers_n).map(|_| LocalDeque::new_fifo()).collect();
135        let stealers: Vec<Stealer<Box<Flow>>> = locals.iter().map(|l| l.stealer()).collect();
136
137        let shared = Arc::new(Shared {
138            injector: Injector::new(),
139            stealers,
140            directory: Directory::new(),
141            caps: super::capability::CapTable::new(),
142            timer: TimerWheel::new(),
143            notify: (Mutex::new(()), Condvar::new()),
144            metrics: RuntimeMetrics::default(),
145            shutdown: AtomicBool::new(false),
146            quantum: config.quantum,
147            mailbox: config.mailbox,
148        });
149
150        let mut workers = Vec::with_capacity(workers_n);
151        for local in locals {
152            let shared = shared.clone();
153            let handle = std::thread::Builder::new()
154                .name("byteflow-worker".into())
155                .spawn(move || worker::run_worker(shared, local))
156                .map_err(|e| SpawnError::ThreadSpawnFailed(e.to_string()))?;
157            workers.push(handle);
158        }
159
160        let shared_timer = shared.clone();
161        let timer_thread = std::thread::Builder::new()
162            .name("byteflow-timer".into())
163            .spawn(move || {
164                shared_timer
165                    .timer
166                    .clone()
167                    .drive(&shared_timer.injector, &shared_timer.notify)
168            })
169            .map_err(|e| SpawnError::ThreadSpawnFailed(e.to_string()))?;
170
171        Ok(Runtime {
172            shared,
173            chunk,
174            natives,
175            workers,
176            timer_thread: Some(timer_thread),
177        })
178    }
179
180    /// Spawn a top-level flow starting at `function` in this runtime's
181    /// chunk, returning a [`FlowHandle`] the caller can `.join()`.
182    ///
183    /// Returns [`SpawnError::BadFunction`] if `function` is out of range.
184    pub fn spawn(&self, function: u32, args: &[Value]) -> Result<FlowHandle, SpawnError> {
185        spawn_on(
186            &self.shared,
187            &self.chunk,
188            &self.natives,
189            function,
190            args,
191            RestartPolicy::Never,
192            None,
193        )
194    }
195
196    /// A cheap, `Send + Sync` handle that can spawn processes into this
197    /// runtime from any thread, independent of `Runtime`'s own lifetime
198    /// bookkeeping (worker `JoinHandle`s). Used by [`super::supervisor::Supervisor`].
199    pub fn spawner(&self) -> RuntimeSpawner {
200        RuntimeSpawner { shared: self.shared.clone(), chunk: self.chunk.clone(), natives: self.natives.clone() }
201    }
202
203    /// A [`super::supervisor::Supervisor`] bound to this runtime, ready to
204    /// take supervised children (design notes §15).
205    pub fn supervisor(&self) -> Result<super::supervisor::Supervisor, SpawnError> {
206        super::supervisor::Supervisor::new(self.spawner())
207    }
208
209    /// Look up a function by name in the runtime's chunk — convenience for
210    /// callers that built their chunk with [`crate::bytecode::ChunkBuilder`]
211    /// and don't want to thread raw indices through their own code.
212    pub fn function_index(&self, name: &str) -> Option<u32> {
213        self.chunk.functions.iter().position(|f| f.name == name).map(|i| i as u32)
214    }
215
216    pub fn metrics(&self) -> RuntimeMetricsSnapshot {
217        self.shared.metrics.snapshot()
218    }
219
220    /// Number of flows currently registered in the directory — i.e.
221    /// alive (running, ready, sleeping, or waiting), not counting ones that
222    /// have already completed or failed.
223    pub fn live_flows(&self) -> usize {
224        self.shared.directory.len()
225    }
226
227    pub fn worker_count(&self) -> usize {
228        self.workers.len()
229    }
230
231    /// Deliver an **Atomic Hop** (`Value::Message`) to `target` from the
232    /// embedder (not from bytecode).
233    ///
234    /// # Host trust boundary
235    ///
236    /// This path takes a [`FlowId`] directly — **no Cap required**. The host
237    /// is trusted; bytecode must use `Value::Cap` via `Opcode::Send` /
238    /// `Ask`. Host-injected messages are not re-stamped (`sender` /
239    /// `reply_cap` stay as built). Bare scalars are rejected
240    /// ([`SendError::NotAHop`]).
241    pub fn send(&self, target: FlowId, message: Value) -> Result<(), SendError> {
242        if message.as_message().is_none() {
243            return Err(SendError::NotAHop {
244                got: message.type_name(),
245            });
246        }
247        let mailbox = match self.shared.directory.lookup(target) {
248            Ok(Some(m)) => m,
249            Ok(None) => return Err(SendError::NoSuchFlow(target)),
250            Err(e) => {
251                super::error::report_fault(e);
252                return Err(SendError::NoSuchFlow(target));
253            }
254        };
255        match mailbox.push(message.clone()) {
256            Ok(Ok(Delivery::Queued | Delivery::QueuedDropOldest | Delivery::DroppedNewest)) => {
257                Ok(())
258            }
259            Ok(Ok(Delivery::Handoff(mut flow))) => {
260                if let Some(dest) = flow.last_receive_dest {
261                    let _ = flow.vm.resume_with(dest, message);
262                }
263                self.shared.injector.push(flow);
264                wake_workers(&self.shared);
265                Ok(())
266            }
267            Ok(Err(full)) => Err(SendError::MailboxFull {
268                flow: target,
269                reason: full.reason(),
270            }),
271            Err(e) => {
272                super::error::report_fault(e);
273                Err(SendError::NoSuchFlow(target))
274            }
275        }
276    }
277
278    /// Stop accepting new scheduling work and join every worker + the timer
279    /// thread. Processes that are mid-quantum are allowed to reach their
280    /// next natural suspension point; this does **not** forcibly abort
281    /// running bytecode (there is no safe way to do that to an OS thread
282    /// mid-instruction — see design notes §11 on why preemption here is
283    /// cooperative/budgeted rather than signal-based).
284    pub fn shutdown(mut self) {
285        self.shared.shutdown.store(true, Ordering::Release);
286        self.shared.timer.shutdown();
287        {
288            let (lock, cvar) = &self.shared.notify;
289            match super::sync_lock::lock(lock, "Runtime::shutdown") {
290                Ok(_g) => cvar.notify_all(),
291                Err(e) => super::error::report_fault(e),
292            }
293        }
294        for w in self.workers.drain(..) {
295            let _ = w.join();
296        }
297        if let Some(t) = self.timer_thread.take() {
298            let _ = t.join();
299        }
300    }
301}
302
303/// A convenience Pid constructor for embedders that stored a raw `u64`
304/// (e.g. round-tripped through `Value::Pid`) and need a [`FlowId`] to
305/// call APIs that take one.
306pub fn flow_id_from_u64(raw: u64) -> FlowId {
307    FlowId(raw)
308}
309
310/// Why [`Runtime::send`] could not deliver a hop.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum SendError {
313    NoSuchFlow(FlowId),
314    /// Atomic Hop rule: only [`crate::Value::Message`] may cross `Send`.
315    NotAHop { got: &'static str },
316    /// Target inbox is at one of its logical bounds
317    /// ([`OverflowPolicy::Reject`](crate::OverflowPolicy::Reject)). `reason` says which — see
318    /// [`MailboxFullReason`].
319    MailboxFull {
320        flow: FlowId,
321        reason: MailboxFullReason,
322    },
323}
324
325impl std::fmt::Display for SendError {
326    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
327        match self {
328            SendError::NoSuchFlow(id) => write!(f, "no live flow {id}"),
329            SendError::NotAHop { got } => {
330                write!(f, "atomic hop requires Value::Message, got {got}")
331            }
332            SendError::MailboxFull { flow, reason } => {
333                write!(f, "mailbox full for {flow} ({reason})")
334            }
335        }
336    }
337}
338
339impl std::error::Error for SendError {}
340
341/// Shared machinery behind `Runtime::spawn` and `RuntimeSpawner::spawn`
342/// (and, transitively, `Supervisor`): build a fresh `Flow` (VM +
343/// mailbox + completion channel), register it in the directory, and push
344/// it onto the global injector for any worker to pick up.
345///
346/// Returns [`SpawnError`] on bad function index / VM init / directory
347/// poison — never panics. Bytecode `Opcode::Spawn` that fails here turns
348/// into `FlowOutcome::Failed` for the *parent* (see `worker`).
349pub(crate) fn spawn_on(
350    shared: &Arc<Shared>,
351    chunk: &Arc<Chunk>,
352    natives: &Arc<NativeTable>,
353    function: u32,
354    args: &[Value],
355    restart_policy: RestartPolicy,
356    supervisor: Option<SupervisorLink>,
357) -> Result<FlowHandle, SpawnError> {
358    let id = super::process::next_flow_id();
359    let vm = Vm::new(chunk.clone(), natives.clone(), function, args)?;
360    let mailbox = Arc::new(Mailbox::with_config(shared.mailbox));
361    if let Err(e) = shared.directory.register(id, mailbox.clone()) {
362        super::error::report_fault(e);
363        return Err(SpawnError::VmInit(
364            "directory register failed (poisoned lock)".into(),
365        ));
366    }
367    let (tx, rx) = super::oneshot::channel();
368    let mut flow = Box::new(Flow::new(id, vm, mailbox, restart_policy, tx));
369    flow.supervisor = supervisor;
370    RuntimeMetrics::inc(&shared.metrics.processes_spawned);
371    shared.injector.push(flow);
372    wake_workers(shared);
373    Ok(FlowHandle { id, receiver: rx })
374}
375
376pub(crate) fn wake_workers(shared: &Shared) {
377    let (lock, cvar) = &shared.notify;
378    match super::sync_lock::lock(lock, "wake_workers") {
379        Ok(_g) => cvar.notify_one(),
380        Err(e) => super::error::report_fault(e),
381    }
382}
383
384/// A `Send + Sync`, freely cloneable capability to spawn processes into a
385/// [`Runtime`], detached from the `Runtime` value itself. Exists because
386/// [`super::supervisor::Supervisor`] needs to respawn processes from a
387/// background monitor thread whose lifetime isn't tied to the `Runtime`
388/// object's own (which owns non-`Sync` `JoinHandle`s for its workers).
389#[derive(Clone)]
390pub struct RuntimeSpawner {
391    pub(crate) shared: Arc<Shared>,
392    pub(crate) chunk: Arc<Chunk>,
393    pub(crate) natives: Arc<NativeTable>,
394}
395
396impl RuntimeSpawner {
397    pub fn spawn(
398        &self,
399        function: u32,
400        args: &[Value],
401        restart_policy: RestartPolicy,
402    ) -> Result<FlowHandle, SpawnError> {
403        spawn_on(
404            &self.shared,
405            &self.chunk,
406            &self.natives,
407            function,
408            args,
409            restart_policy,
410            None,
411        )
412    }
413
414    pub(crate) fn spawn_linked(
415        &self,
416        function: u32,
417        args: &[Value],
418        restart_policy: RestartPolicy,
419        supervisor: SupervisorLink,
420    ) -> Result<FlowHandle, SpawnError> {
421        spawn_on(
422            &self.shared,
423            &self.chunk,
424            &self.natives,
425            function,
426            args,
427            restart_policy,
428            Some(supervisor),
429        )
430    }
431
432    pub fn metrics(&self) -> RuntimeMetricsSnapshot {
433        self.shared.metrics.snapshot()
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::bytecode::{ChunkBuilder, Opcode, Value};
441    use crate::scheduler::FlowOutcome;
442    use std::time::Duration;
443
444    fn add_chunk() -> Chunk {
445        let mut b = ChunkBuilder::new("test");
446        b.begin_function("main", 0, 2);
447        b.emit_load_imm(0, 41);
448        b.emit_load_imm(1, 1);
449        b.emit_binop(Opcode::Add, 0, 0, 1);
450        b.emit_return(0);
451        b.finish()
452    }
453
454    /// Sleeps `millis` inside the flow, then returns 7. The sleep is what
455    /// makes "still running" an observable state from the host thread.
456    fn sleep_then_return_chunk(millis: i32) -> Chunk {
457        let mut b = ChunkBuilder::new("test");
458        b.begin_function("main", 0, 2);
459        b.emit_load_imm(0, millis);
460        b.emit_sleep(0);
461        b.emit_load_imm(0, 7);
462        b.emit_return(0);
463        b.finish()
464    }
465
466    /// The host thread must be able to ask "done yet?" and to wait under a
467    /// bound *it* chooses, instead of surrendering itself to `join` for
468    /// however long the bytecode decides to take.
469    #[test]
470    fn polling_and_bounded_waits_never_commit_the_host_thread() -> Result<(), Box<dyn std::error::Error>>
471    {
472        const FLOW_SLEEP: i32 = 150;
473        let rt = Runtime::with_config(
474            sleep_then_return_chunk(FLOW_SLEEP),
475            RuntimeConfig {
476                workers: 1,
477                quantum: 1_000,
478                mailbox: MailboxConfig::DEFAULT,
479            },
480        )?;
481        let handle = rt.spawn(0, &[])?;
482
483        // The flow cannot possibly be finished yet: it has to be picked up
484        // and then sleep. A poll must say so without waiting.
485        if let Some(outcome) = handle.try_join() {
486            rt.shutdown();
487            return Err(format!("try_join answered too early: {outcome:?}").into());
488        }
489
490        // A bound well below the flow's sleep must expire and hand control
491        // back, not block until the flow happens to finish.
492        if let Some(outcome) = handle.join_timeout(Duration::from_millis(20)) {
493            rt.shutdown();
494            return Err(format!("join_timeout answered too early: {outcome:?}").into());
495        }
496
497        // A generous bound collects the real outcome through the same
498        // (non-consuming) handle.
499        let outcome = handle.join_timeout(Duration::from_secs(10));
500        rt.shutdown();
501        match outcome {
502            Some(FlowOutcome::Completed(Value::Int(7))) => Ok(()),
503            other => Err(format!("unexpected outcome: {other:?}").into()),
504        }
505    }
506
507    #[test]
508    fn spawn_and_join_add() -> Result<(), Box<dyn std::error::Error>> {
509        let rt = Runtime::with_config(
510            add_chunk(),
511            RuntimeConfig {
512                workers: 1,
513                quantum: 1_000,
514                mailbox: MailboxConfig::DEFAULT,
515            },
516        )?;
517        let outcome = rt.spawn(0, &[])?.join();
518        rt.shutdown();
519        match outcome {
520            FlowOutcome::Completed(Value::Int(42)) => Ok(()),
521            other => Err(format!("unexpected outcome: {other:?}").into()),
522        }
523    }
524}