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