v0.1: green-thread actors, supervision, channels, benchmark

Hand-rolled context switching on mmap'd stacks with guard pages,
allocator-driven RDTSC preemption, unbounded MPSC channels, supervision
via per-slot Signal mailboxes, root supervisor as sentinel PID.

Lib + tests + benches clean check/clippy. All 29 tests pass.
Bench: smarm 3.4% over serial baseline, within 160us of tokio
current-thread on prime-counting fan-out.
This commit is contained in:
Claude
2026-05-22 05:01:51 +00:00
commit 0e9d9d7d5f
17 changed files with 1938 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/target
Cargo.lock

23
Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "smarm"
version = "0.1.0"
edition = "2021"
rust-version = "1.95"
[dependencies]
libc = "0.2"
[dev-dependencies]
tokio = { version = "1", features = ["rt", "macros", "sync"] }
[profile.dev]
panic = "unwind"
[profile.release]
panic = "unwind"
lto = "thin"
codegen-units = 1
[[bench]]
name = "primes"
harness = false

134
benches/primes.rs Normal file
View File

@@ -0,0 +1,134 @@
//! Compute-heavy fan-out/fan-in benchmark.
//!
//! Counts primes in [2, N) across W workers (each handling a contiguous
//! slice), then sums the results. Tests pure compute throughput plus the
//! cost of spawn/join/channel. Single-threaded both sides (smarm has only
//! one OS thread; tokio is configured `current_thread`).
//!
//! Run with `cargo bench`.
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Instant;
const N: u64 = 200_000;
const WORKERS: u64 = 16;
const ITERATIONS: u32 = 5;
fn is_prime(n: u64) -> bool {
if n < 2 { return false; }
if n < 4 { return true; }
if n % 2 == 0 { return false; }
let mut i = 3u64;
while i * i <= n {
if n % i == 0 { return false; }
i += 2;
}
true
}
fn count_primes_in(lo: u64, hi: u64) -> u64 {
let mut count = 0u64;
for n in lo..hi {
if is_prime(n) { count += 1; }
}
count
}
fn slice(worker: u64) -> (u64, u64) {
let per = N / WORKERS;
let lo = worker * per;
let hi = if worker + 1 == WORKERS { N } else { (worker + 1) * per };
(lo, hi)
}
fn bench_smarm() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0));
let total2 = total.clone();
let start = Instant::now();
smarm::run(move || {
let mut handles = Vec::new();
for w in 0..WORKERS {
let (lo, hi) = slice(w);
let t = total2.clone();
handles.push(smarm::spawn(move || {
let c = count_primes_in(lo, hi);
t.fetch_add(c, Ordering::Relaxed);
}));
}
for h in handles {
h.join().unwrap();
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
fn bench_tokio() -> (u64, u128) {
let total = Arc::new(AtomicU64::new(0));
let total2 = total.clone();
let rt = tokio::runtime::Builder::new_current_thread()
.build()
.unwrap();
let start = Instant::now();
let local = tokio::task::LocalSet::new();
local.block_on(&rt, async move {
let mut handles = Vec::new();
for w in 0..WORKERS {
let (lo, hi) = slice(w);
let t = total2.clone();
handles.push(tokio::task::spawn_local(async move {
let c = count_primes_in(lo, hi);
t.fetch_add(c, Ordering::Relaxed);
}));
}
for h in handles {
let _ = h.await;
}
});
(total.load(Ordering::Relaxed), start.elapsed().as_micros())
}
fn bench_baseline() -> (u64, u128) {
let mut total = 0u64;
let start = Instant::now();
for w in 0..WORKERS {
let (lo, hi) = slice(w);
total += count_primes_in(lo, hi);
}
(total, start.elapsed().as_micros())
}
fn run_n<F: FnMut() -> (u64, u128)>(name: &str, n: u32, mut f: F) {
let mut times = Vec::new();
let mut last_count = 0;
for _ in 0..n {
let (c, t) = f();
times.push(t);
last_count = c;
}
times.sort();
let median = times[times.len() / 2];
let min = *times.iter().min().unwrap();
let max = *times.iter().max().unwrap();
println!(
"{:>12} | primes: {:>6} | median: {:>8} µs | min: {:>8} µs | max: {:>8} µs",
name, last_count, median, min, max
);
}
fn main() {
println!(
"Counting primes in [2, {}) across {} workers, {} iterations each\n",
N, WORKERS, ITERATIONS
);
println!("{:>12} | {:>15} | {:>16} | {:>15} | {:>15}", "runtime", "primes found", "median", "min", "max");
println!("{}", "-".repeat(80));
run_n("baseline", ITERATIONS, bench_baseline);
run_n("smarm", ITERATIONS, bench_smarm);
run_n("tokio", ITERATIONS, bench_tokio);
}

110
src/actor.rs Normal file
View File

@@ -0,0 +1,110 @@
//! Actor descriptor and trampoline.
//!
//! An `Actor` owns its stack and holds the closure it will run. The
//! `trampoline` is a fixed `extern "C-unwind" fn()` that every actor enters
//! through; it pulls the closure out of a thread-local set by the scheduler
//! immediately before resume, invokes it inside `catch_unwind`, records the
//! outcome, and switches back to the scheduler.
//!
//! Why a thread-local and not, say, passing the closure pointer via a
//! register? Because the first resume goes through `ret`, not `call`, and
//! we have no other channel for parameters. The scheduler sets the
//! thread-local, switches in, the trampoline reads it. After the first
//! resume the closure has been consumed, so subsequent resumes don't need it.
use crate::context::switch_to_scheduler;
use crate::pid::Pid;
use crate::stack::Stack;
use std::any::Any;
use std::cell::{Cell, RefCell};
use std::panic;
/// What an actor produced when it finished. Stored on the actor's slot,
/// drained by `JoinHandle::join` once the slot is marked done.
pub enum Outcome {
Exit,
Panic(Box<dyn Any + Send>),
}
// Thread-locals that the scheduler writes immediately before `switch_to_actor`.
thread_local! {
/// The closure for the actor we're about to resume *for the first time*.
/// Consumed on first entry into the trampoline; `None` thereafter.
static CURRENT_ACTOR_BOX: RefCell<Option<Box<dyn FnOnce() + Send>>> =
const { RefCell::new(None) };
/// The PID of the actor currently executing on this OS thread.
/// Set on every resume so that `self_pid()` works inside actor code.
static CURRENT_PID: Cell<Option<Pid>> = const { Cell::new(None) };
/// Filled by the trampoline when the actor returns (normally or via
/// panic). The scheduler reads this after `switch_to_actor` returns.
static LAST_OUTCOME: RefCell<Option<Outcome>> = const { RefCell::new(None) };
/// Set by the trampoline on completion; reset by the scheduler before
/// each resume so it never sees stale state.
static ACTOR_DONE: Cell<bool> = const { Cell::new(false) };
}
pub fn set_current_actor_box(b: Box<dyn FnOnce() + Send>) {
CURRENT_ACTOR_BOX.with(|c| *c.borrow_mut() = Some(b));
}
pub fn set_current_pid(p: Pid) {
CURRENT_PID.with(|c| c.set(Some(p)));
}
pub fn clear_current_pid() {
CURRENT_PID.with(|c| c.set(None));
}
pub fn current_pid() -> Option<Pid> {
CURRENT_PID.with(|c| c.get())
}
pub fn reset_actor_done() {
ACTOR_DONE.with(|c| c.set(false));
}
pub fn is_actor_done() -> bool {
ACTOR_DONE.with(|c| c.get())
}
pub fn take_last_outcome() -> Option<Outcome> {
LAST_OUTCOME.with(|r| r.borrow_mut().take())
}
/// The function whose address is written as the `ret` target on every actor
/// stack. The compiler must not inline this away. `extern "C-unwind"` permits
/// unwinding to cross the boundary, but `catch_unwind` here means unwinding
/// never actually does.
pub extern "C-unwind" fn trampoline() {
let b = CURRENT_ACTOR_BOX.with(|c| c.borrow_mut().take())
.expect("trampoline entered without a closure set");
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
Ok(()) => Outcome::Exit,
Err(payload) => Outcome::Panic(payload),
};
LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome));
ACTOR_DONE.with(|c| c.set(true));
// Hand control back. The scheduler will tear down our slot and never
// resume us again.
unsafe { switch_to_scheduler() };
// Unreachable. If it isn't, the scheduler has a bug.
unreachable!("scheduler resumed a done actor");
}
/// One actor's worth of state. Owned by the scheduler's slot table.
pub struct Actor {
/// The PID this actor was assigned at spawn time.
pub pid: Pid,
/// The stack the actor runs on. Dropped (munmap'd) when the actor dies.
pub stack: Stack,
/// The saved stack pointer. Updated on every yield.
pub sp: usize,
/// The PID of this actor's supervisor. Used to deliver `Signal` on death.
pub supervisor: Pid,
}

163
src/channel.rs Normal file
View File

@@ -0,0 +1,163 @@
//! Unbounded MPSC channels.
//!
//! Single-threaded scheduler: the inner state is `Rc<RefCell<Inner<T>>>`,
//! not `Arc<Mutex>`. We hand-implement `Send` for `Sender<T>` and
//! `Receiver<T>` when `T: Send`, on the basis that the only way two actor
//! contexts touch the same channel is by being scheduled on the *same* OS
//! thread (v0.1 has exactly one). When we add a second scheduler thread,
//! this lie must be retired: replace `Rc<RefCell>` with `Arc<Mutex>` (or a
//! lock-free queue) and remove the unsafe Send impls.
//!
//! Semantics:
//! - Senders are clonable; the last sender drop closes the channel.
//! - `Receiver::recv` on an empty open channel parks the receiver.
//! - `Receiver::recv` on an empty closed channel returns `Err(RecvError)`.
//! - `Sender::send` on an open channel always succeeds.
//! - `Sender::send` on a closed channel (receiver dropped) returns
//! `Err(SendError(value))`.
//! - When a send pushes to a previously empty queue and a receiver is
//! parked, the receiver is unparked.
use crate::pid::Pid;
use std::cell::RefCell;
use std::collections::VecDeque;
use std::rc::Rc;
pub fn channel<T>() -> (Sender<T>, Receiver<T>) {
let inner = Rc::new(RefCell::new(Inner {
queue: VecDeque::new(),
parked_receiver: None,
senders: 1,
receiver_alive: true,
}));
(Sender { inner: inner.clone() }, Receiver { inner })
}
struct Inner<T> {
queue: VecDeque<T>,
parked_receiver: Option<Pid>,
senders: usize,
receiver_alive: bool,
}
pub struct Sender<T> {
inner: Rc<RefCell<Inner<T>>>,
}
pub struct Receiver<T> {
inner: Rc<RefCell<Inner<T>>>,
}
// SAFETY (v0.1 only): the scheduler is single-threaded. Sender/Receiver can
// be captured into actor closures (which require Send), but they will only
// ever be touched from one OS thread. When multi-threading lands, swap the
// `Rc<RefCell>` for `Arc<Mutex>` and remove these.
unsafe impl<T: Send> Send for Sender<T> {}
unsafe impl<T: Send> Send for Receiver<T> {}
#[derive(Debug, PartialEq, Eq)]
pub struct SendError<T>(pub T);
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub struct RecvError;
impl std::fmt::Display for RecvError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "channel closed")
}
}
impl std::error::Error for RecvError {}
impl<T> Clone for Sender<T> {
fn clone(&self) -> Self {
self.inner.borrow_mut().senders += 1;
Sender { inner: self.inner.clone() }
}
}
impl<T> Drop for Sender<T> {
fn drop(&mut self) {
let unpark = {
let mut g = self.inner.borrow_mut();
g.senders -= 1;
if g.senders == 0 && g.queue.is_empty() {
// Channel closed and drained. Wake the receiver so it can
// see RecvError.
g.parked_receiver.take()
} else {
None
}
};
if let Some(pid) = unpark {
crate::scheduler::unpark(pid);
}
}
}
impl<T> Drop for Receiver<T> {
fn drop(&mut self) {
self.inner.borrow_mut().receiver_alive = false;
}
}
impl<T> Sender<T> {
pub fn send(&self, value: T) -> Result<(), SendError<T>> {
let unpark = {
let mut g = self.inner.borrow_mut();
if !g.receiver_alive {
return Err(SendError(value));
}
g.queue.push_back(value);
// If the receiver is parked, unpark it.
g.parked_receiver.take()
};
if let Some(pid) = unpark {
crate::scheduler::unpark(pid);
}
Ok(())
}
}
impl<T> Receiver<T> {
pub fn recv(&self) -> Result<T, RecvError> {
loop {
// Try to take a message.
{
let mut g = self.inner.borrow_mut();
if let Some(v) = g.queue.pop_front() {
return Ok(v);
}
if g.senders == 0 {
return Err(RecvError);
}
// Empty + open: register and park.
let me = crate::actor::current_pid()
.expect("recv() called outside an actor");
debug_assert!(
g.parked_receiver.is_none(),
"channel has more than one receiver"
);
g.parked_receiver = Some(me);
}
// Release the borrow before parking — the unparker will need it.
crate::scheduler::park_current();
// Loop: the message that woke us might already have been taken
// (it can't, with one receiver, but the senders=0 path can fire
// here too).
}
}
/// Non-blocking. `Ok(Some(v))` if a message was available, `Ok(None)` if
/// the channel is empty but open, `Err(RecvError)` if closed and drained.
pub fn try_recv(&self) -> Result<Option<T>, RecvError> {
let mut g = self.inner.borrow_mut();
if let Some(v) = g.queue.pop_front() {
return Ok(Some(v));
}
if g.senders == 0 {
return Err(RecvError);
}
Ok(None)
}
}

106
src/context.rs Normal file
View File

@@ -0,0 +1,106 @@
//! Cooperative context switching, x86-64.
//!
//! Two naked-asm functions move execution between a scheduler thread and an
//! actor running on its own mmap'd stack. The compiler cannot do this; the
//! whole point of `#[unsafe(naked)]` is that we control every instruction.
//!
//! `SCHEDULER_SP` and `ACTOR_SP` are thread-locals holding each side's saved
//! stack pointer. `init_actor_stack` builds the initial stack so that the
//! first `switch_to_actor` lands inside the entry function with `rsp % 16 == 8`
//! (the x86-64 ABI requirement at function entry).
use std::cell::Cell;
thread_local! {
static SCHEDULER_SP: Cell<usize> = const { Cell::new(0) };
static ACTOR_SP: Cell<usize> = const { Cell::new(0) };
}
fn get_scheduler_sp() -> usize { SCHEDULER_SP.with(|c| c.get()) }
fn set_scheduler_sp(v: usize) { SCHEDULER_SP.with(|c| c.set(v)) }
pub fn get_actor_sp() -> usize { ACTOR_SP.with(|c| c.get()) }
pub fn set_actor_sp(v: usize) { ACTOR_SP.with(|c| c.set(v)) }
// ---------------------------------------------------------------------------
// Initial stack layout
//
// After alignment, sp = top & ~15 - 8. Then we push (downward) six callee-
// saved register slots and a return address. The first `switch_to_actor`
// pops r15..rbx and `ret`s — landing in `entry` with rsp % 16 == 8.
//
// Layout (high → low), relative to aligned_top = top & ~15:
// aligned_top - 8 : entry ptr ← `ret` target. Post-ret: rsp % 16 == 8.
// aligned_top - 16 : rbx = 0
// aligned_top - 24 : rbp = 0
// aligned_top - 32 : r12 = 0
// aligned_top - 40 : r13 = 0
// aligned_top - 48 : r14 = 0
// aligned_top - 56 : r15 = 0 ← initial rsp
// ---------------------------------------------------------------------------
pub fn init_actor_stack(top: *mut u8, entry: extern "C-unwind" fn()) -> usize {
unsafe {
let mut sp = (top as usize & !15) - 8;
sp -= 8; (sp as *mut usize).write(entry as usize); // ret target
sp -= 8; (sp as *mut usize).write(0); // rbx
sp -= 8; (sp as *mut usize).write(0); // rbp
sp -= 8; (sp as *mut usize).write(0); // r12
sp -= 8; (sp as *mut usize).write(0); // r13
sp -= 8; (sp as *mut usize).write(0); // r14
sp -= 8; (sp as *mut usize).write(0); // r15
sp
}
}
// ---------------------------------------------------------------------------
// Context switch shims
//
// Each shim:
// 1. Pushes the six callee-saved integer registers.
// 2. Snaps rsp into rdi and calls the Rust helper that stores it.
// 3. Calls the Rust helper that returns the *other* side's saved rsp.
// 4. Moves that into rsp.
// 5. Pops the six registers and rets.
//
// XMM registers are NOT saved here. We rely on every yield happening through
// a Rust call site, which means the compiler has spilled any live XMM state
// to the stack before we get here. (This is the same argument the compiler
// uses internally — callee-saved regs are what survive a `call`, and the
// SysV AMD64 ABI says XMM015 are all caller-saved.) If we ever yield from
// a place that isn't a Rust call boundary, this assumption breaks.
// ---------------------------------------------------------------------------
#[unsafe(naked)]
unsafe extern "C" fn switch_to_actor_asm() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_sched_sp}",
"call {get_actor_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_sched_sp = sym set_scheduler_sp,
get_actor_sp = sym get_actor_sp,
);
}
/// Resume the actor whose sp is in `ACTOR_SP`. Returns when the actor yields.
pub unsafe fn switch_to_actor() {
unsafe { switch_to_actor_asm() };
}
#[unsafe(naked)]
pub unsafe extern "C" fn switch_to_scheduler() {
core::arch::naked_asm!(
"push rbx", "push rbp", "push r12", "push r13", "push r14", "push r15",
"mov rdi, rsp",
"call {set_actor_sp}",
"call {get_sched_sp}",
"mov rsp, rax",
"pop r15", "pop r14", "pop r13", "pop r12", "pop rbp", "pop rbx",
"ret",
set_actor_sp = sym set_actor_sp,
get_sched_sp = sym get_scheduler_sp,
);
}

40
src/lib.rs Normal file
View File

@@ -0,0 +1,40 @@
//! # smarm — Silly Marks Abstract Rust Machine
//!
//! Erlang-style green-thread actor concurrency for Rust.
//!
//! v0.1 is single-threaded. One scheduler, one OS thread. The scheduler
//! cooperatively interleaves green-thread actors with hand-rolled context
//! switches. Actors communicate by sending `Send` messages over channels;
//! every actor has a supervisor, which is itself just an actor with a
//! `Receiver<Signal>`.
//!
//! See `LOOM.md` for the design intent and the deferred-for-later list.
pub mod stack;
pub mod context;
pub mod preempt;
pub mod pid;
pub mod actor;
pub mod channel;
pub mod scheduler;
pub mod supervisor;
// ---------------------------------------------------------------------------
// Global allocator
//
// The preempting allocator wraps `System`. While `PREEMPTION_ENABLED` is
// false (the default outside an actor) it adds one branch per allocation
// and no syscalls. The scheduler flips it on per-resume.
// ---------------------------------------------------------------------------
#[global_allocator]
static ALLOCATOR: preempt::PreemptingAllocator = preempt::PreemptingAllocator;
// ---------------------------------------------------------------------------
// Public API re-exports
// ---------------------------------------------------------------------------
pub use channel::{channel, Receiver, RecvError, Sender};
pub use pid::Pid;
pub use scheduler::{run, self_pid, spawn, spawn_under, yield_now, JoinError, JoinHandle};
pub use supervisor::Signal;

38
src/pid.rs Normal file
View File

@@ -0,0 +1,38 @@
//! Process identifiers.
//!
//! A `Pid` is `(index, generation)`. The index is a slot in the scheduler's
//! actor table; the generation increments every time that slot is reused.
//! A stale `Pid` (correct index, wrong generation) is a detectable error,
//! not a silent misdirection — solves the ABA problem without exhausting
//! the PID space.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub struct Pid {
index: u32,
generation: u32,
}
impl Pid {
#[inline]
pub const fn new(index: u32, generation: u32) -> Self {
Self { index, generation }
}
#[inline]
pub const fn index(self) -> u32 { self.index }
#[inline]
pub const fn generation(self) -> u32 { self.generation }
}
impl std::fmt::Debug for Pid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Pid({}.{})", self.index, self.generation)
}
}
impl std::fmt::Display for Pid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "<{}.{}>", self.index, self.generation)
}
}

104
src/preempt.rs Normal file
View File

@@ -0,0 +1,104 @@
//! Allocator-driven preemption.
//!
//! A `GlobalAlloc` wrapper counts allocations. Every `ALLOC_INTERVAL`-th
//! allocation it reads RDTSC and, if the actor's timeslice has expired,
//! calls `switch_to_scheduler` to yield.
//!
//! All state is thread-local. The scheduler enables preemption on resume
//! and disables it on the return path, so the scheduler can never preempt
//! itself.
//!
//! TSC frequency is machine-dependent; `TIMESLICE_CYCLES` is a constant
//! calibrated for ~100µs on a 3 GHz CPU. A real implementation would
//! measure it at startup. For v0.1 the constant suffices.
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
const ALLOC_INTERVAL: u32 = 128;
const TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
thread_local! {
/// While `false`, the allocator hook is a no-op.
pub static PREEMPTION_ENABLED: Cell<bool> = const { Cell::new(false) };
/// Countdown to next RDTSC check. Reset to `ALLOC_INTERVAL` on resume.
static ALLOC_COUNT: Cell<u32> = const { Cell::new(ALLOC_INTERVAL) };
/// RDTSC value written by the scheduler on every actor resume.
static TIMESLICE_START: Cell<u64> = const { Cell::new(0) };
}
/// Arm the timeslice. Called by the scheduler on every resume.
pub fn reset_timeslice() {
ALLOC_COUNT.with(|c| c.set(ALLOC_INTERVAL));
TIMESLICE_START.with(|c| c.set(rdtsc()));
}
#[inline(always)]
pub fn rdtsc() -> u64 {
unsafe {
// SAFETY: x86-64 only. `lfence` serialises the instruction stream so
// we don't measure time before prior instructions retire.
core::arch::asm!("lfence", options(nostack, nomem, preserves_flags));
core::arch::x86_64::_rdtsc()
}
}
pub struct PreemptingAllocator;
unsafe impl GlobalAlloc for PreemptingAllocator {
#[inline]
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
maybe_preempt();
unsafe { System.alloc(layout) }
}
#[inline]
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
unsafe { System.dealloc(ptr, layout) }
}
#[inline]
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
maybe_preempt();
unsafe { System.alloc_zeroed(layout) }
}
#[inline]
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
maybe_preempt();
unsafe { System.realloc(ptr, layout, new_size) }
}
}
#[inline(always)]
fn maybe_preempt() {
ALLOC_COUNT.with(|c| {
let n = c.get();
if n == 0 {
c.set(ALLOC_INTERVAL);
if PREEMPTION_ENABLED.with(|e| e.get()) {
let start = TIMESLICE_START.with(|s| s.get());
if rdtsc().saturating_sub(start) > TIMESLICE_CYCLES {
// SAFETY: reachable only inside an actor (the scheduler
// sets PREEMPTION_ENABLED on resume and clears it on
// return). The scheduler stack is therefore valid.
unsafe { crate::context::switch_to_scheduler() };
}
}
} else {
c.set(n - 1);
}
});
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
/// Force-expire the timeslice so the next RDTSC check preempts.
pub fn expire_timeslice_for_test() {
TIMESLICE_START.with(|c| c.set(0));
ALLOC_COUNT.with(|c| c.set(0));
}

529
src/scheduler.rs Normal file
View File

@@ -0,0 +1,529 @@
//! The single-threaded scheduler.
//!
//! There is one global scheduler per OS thread, stored in a thread-local.
//! `run(initial)` initialises it, spawns the initial actor, drives the loop
//! until the run queue is empty, then tears it down.
//!
//! Slot table: a `Vec<Slot>` indexed by `Pid::index()`, with a free list of
//! reusable indices. Each slot has a `generation` counter that increments
//! every time the slot is freed; `Pid` carries the generation it was minted
//! with, so a stale PID has a mismatching generation and is detected on
//! lookup.
//!
//! Run queue: a `VecDeque<Pid>` of runnable actors. The state of an actor
//! is implicit in slot.state: `Runnable` means it's either in the queue or
//! currently executing; `Parked` means it's waiting for something to unpark
//! it (channel send, join completion, …); `Done` means it has finished and
//! is awaiting reaping.
//!
//! Joining: `JoinHandle::join()` parks the calling actor and registers it
//! on the target slot's `waiters` list. When the target actor finishes,
//! the scheduler reaps the slot and unparks every waiter, passing them the
//! outcome via a side channel (the target's `outcome` field, drained on
//! the joiner side).
use crate::actor::{
clear_current_pid, current_pid, is_actor_done, reset_actor_done,
set_current_actor_box, set_current_pid, take_last_outcome, trampoline, Actor, Outcome,
};
use crate::channel::Sender;
use crate::context::{get_actor_sp, init_actor_stack, set_actor_sp, switch_to_actor};
use crate::pid::Pid;
use crate::preempt::PREEMPTION_ENABLED;
use crate::stack::Stack;
use crate::supervisor::Signal;
use std::cell::RefCell;
use std::collections::VecDeque;
// ---------------------------------------------------------------------------
// Configuration
// ---------------------------------------------------------------------------
const ACTOR_STACK_SIZE: usize = 64 * 1024;
// ---------------------------------------------------------------------------
// Per-actor slot
// ---------------------------------------------------------------------------
enum State {
/// Either in the run queue or currently executing.
Runnable,
/// Removed from the queue, waiting for `unpark()`.
Parked,
/// The actor has finished. Slot persists until the last `JoinHandle`
/// has been joined (or dropped). Then the slot is freed.
Done,
}
struct Slot {
/// Bumped every time this slot is freed and re-used. A `Pid` with a
/// non-matching generation is stale.
generation: u32,
/// `None` when the slot is free. `Some` otherwise.
actor: Option<Actor>,
state: State,
/// PIDs waiting in `JoinHandle::join`.
waiters: Vec<Pid>,
/// The outcome the actor produced, captured when it finished.
/// Drained by `JoinHandle::join`.
outcome: Option<Outcome>,
/// If this slot is a supervisor, the sender into its `Signal` mailbox.
/// Cloned out and used when one of its children dies.
supervisor_channel: Option<Sender<Signal>>,
/// Number of `JoinHandle`s still outstanding for this actor. The slot
/// is reclaimed only when the actor is done AND outstanding_handles == 0.
outstanding_handles: u32,
}
impl Slot {
fn vacant() -> Self {
Self {
generation: 0,
actor: None,
state: State::Done,
waiters: Vec::new(),
outcome: None,
supervisor_channel: None,
outstanding_handles: 0,
}
}
}
// ---------------------------------------------------------------------------
// Scheduler state
// ---------------------------------------------------------------------------
struct SchedulerState {
slots: Vec<Slot>,
free_list: Vec<u32>,
run_queue: VecDeque<Pid>,
/// The root supervisor's PID. Children spawned at the top level are
/// supervised by this. Set by `run()`.
root_pid: Option<Pid>,
}
impl SchedulerState {
fn new() -> Self {
Self {
slots: Vec::new(),
free_list: Vec::new(),
run_queue: VecDeque::new(),
root_pid: None,
}
}
/// Allocate a slot; return its (index, generation).
fn allocate_slot(&mut self) -> (u32, u32) {
if let Some(idx) = self.free_list.pop() {
let s = &mut self.slots[idx as usize];
(idx, s.generation)
} else {
let idx = self.slots.len() as u32;
self.slots.push(Slot::vacant());
(idx, 0)
}
}
fn slot(&self, pid: Pid) -> Option<&Slot> {
let s = self.slots.get(pid.index() as usize)?;
if s.generation == pid.generation() { Some(s) } else { None }
}
fn slot_mut(&mut self, pid: Pid) -> Option<&mut Slot> {
let s = self.slots.get_mut(pid.index() as usize)?;
if s.generation == pid.generation() { Some(s) } else { None }
}
}
thread_local! {
static SCHED: RefCell<Option<SchedulerState>> = const { RefCell::new(None) };
}
fn with_sched<R>(f: impl FnOnce(&mut SchedulerState) -> R) -> R {
SCHED.with(|c| {
let mut g = c.borrow_mut();
let s = g.as_mut().expect("scheduler not running");
f(s)
})
}
/// Same as `with_sched` but returns `None` when there's no scheduler instead
/// of panicking. Used on cleanup paths (channel sender drop during shutdown,
/// for example).
fn try_with_sched<R>(f: impl FnOnce(&mut SchedulerState) -> R) -> Option<R> {
SCHED.with(|c| {
let mut g = c.borrow_mut();
g.as_mut().map(f)
})
}
// ---------------------------------------------------------------------------
// JoinHandle
// ---------------------------------------------------------------------------
#[derive(Debug)]
pub struct JoinError {
/// Whatever `panic!` was called with.
pub payload: Box<dyn std::any::Any + Send>,
}
pub struct JoinHandle {
pid: Pid,
/// `false` once `join()` has been called and the handle has consumed
/// its outcome. Prevents the Drop impl from double-decrementing.
consumed: bool,
}
impl JoinHandle {
pub fn pid(&self) -> Pid { self.pid }
/// Block the calling actor until the target completes. Returns
/// `Ok(())` on normal exit, `Err(JoinError)` if the target panicked.
pub fn join(mut self) -> Result<(), JoinError> {
let me = current_pid().expect("join() called outside an actor");
loop {
let outcome = with_sched(|s| {
let slot = s.slot_mut(self.pid)
.expect("join: target slot has been reused");
if matches!(slot.state, State::Done) {
Some(slot.outcome.take().expect("Done slot must have an outcome"))
} else {
slot.waiters.push(me);
None
}
});
match outcome {
Some(o) => {
self.consumed = true;
self.decrement_handle_count();
return match o {
Outcome::Exit => Ok(()),
Outcome::Panic(p) => Err(JoinError { payload: p }),
};
}
None => park_current(),
}
}
}
fn decrement_handle_count(&mut self) {
with_sched(|s| {
let should_reclaim = match s.slot_mut(self.pid) {
Some(slot) => {
slot.outstanding_handles = slot.outstanding_handles.saturating_sub(1);
matches!(slot.state, State::Done) && slot.outstanding_handles == 0
}
None => false,
};
if should_reclaim {
reclaim_slot(s, self.pid);
}
});
}
}
impl Drop for JoinHandle {
fn drop(&mut self) {
if !self.consumed {
self.decrement_handle_count();
}
}
}
// ---------------------------------------------------------------------------
// Slot reclamation
// ---------------------------------------------------------------------------
fn reclaim_slot(s: &mut SchedulerState, pid: Pid) {
let idx = pid.index();
let slot = &mut s.slots[idx as usize];
// Bump generation so any stale PIDs from now on miss.
slot.generation = slot.generation.wrapping_add(1);
// Drop the actor (its stack with it).
slot.actor = None;
slot.outcome = None;
slot.waiters.clear();
slot.supervisor_channel = None;
slot.state = State::Done; // semantically vacant; allocator checks free_list
slot.outstanding_handles = 0;
s.free_list.push(idx);
}
// ---------------------------------------------------------------------------
// spawn / spawn_under / self_pid
// ---------------------------------------------------------------------------
/// Spawn `f` as a child of the currently-executing actor.
/// Outside an actor (only legal from `run()`'s initial setup), the child's
/// supervisor is the root supervisor.
pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle {
let parent = current_pid()
.or_else(|| with_sched(|s| s.root_pid))
.expect("spawn() before run()");
spawn_under(parent, f)
}
/// Spawn `f` with `supervisor` as its parent. The supervisor will receive
/// a `Signal` on its registered channel when the child terminates.
pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle {
let pid = with_sched(|s| {
let (idx, gen) = s.allocate_slot();
let pid = Pid::new(idx, gen);
let stack = Stack::new(ACTOR_STACK_SIZE)
.expect("stack allocation failed");
let sp = init_actor_stack(stack.top(), trampoline);
let slot = &mut s.slots[idx as usize];
slot.actor = Some(Actor { pid, stack, sp, supervisor });
slot.state = State::Runnable;
slot.outstanding_handles = 1;
slot.outcome = None;
slot.waiters.clear();
slot.supervisor_channel = None;
s.run_queue.push_back(pid);
pid
});
// Stash the closure where `schedule_loop` will find it before the first
// resume.
PENDING_CLOSURES.with(|c| {
c.borrow_mut().push((pid, Box::new(f) as Closure));
});
JoinHandle { pid, consumed: false }
}
type Closure = Box<dyn FnOnce() + Send>;
thread_local! {
/// Closures awaiting their first resume. Keyed by the PID the scheduler
/// allocated for them in `spawn_under`. The scheduler pops from here in
/// `pop_pending_closure` right before each first resume.
static PENDING_CLOSURES: RefCell<Vec<(Pid, Closure)>> = const { RefCell::new(Vec::new()) };
}
fn pop_pending_closure(pid: Pid) -> Option<Closure> {
PENDING_CLOSURES.with(|c| {
let mut v = c.borrow_mut();
v.iter().position(|(p, _)| *p == pid).map(|i| v.swap_remove(i).1)
})
}
pub fn self_pid() -> Pid {
current_pid().expect("self_pid() called outside an actor")
}
// ---------------------------------------------------------------------------
// yield_now / park / unpark
// ---------------------------------------------------------------------------
/// Cooperative yield. The current actor goes to the back of the run queue.
pub fn yield_now() {
// Mark ourselves as needing to be re-queued, then yield.
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
unsafe { crate::context::switch_to_scheduler() };
}
/// Park the current actor (remove it from the run queue until `unpark`).
pub fn park_current() {
YIELD_INTENT.with(|c| c.set(YieldIntent::Park));
unsafe { crate::context::switch_to_scheduler() };
}
/// Wake a parked actor. If the actor isn't parked (already runnable or done)
/// this is a no-op — that's important; channel and join can both fire
/// spurious unparks under some orderings and we want them to be cheap.
/// Also a no-op if the scheduler isn't running (covers channel-sender drop
/// during runtime teardown).
pub fn unpark(pid: Pid) {
try_with_sched(|s| {
if let Some(slot) = s.slot_mut(pid) {
if matches!(slot.state, State::Parked) {
slot.state = State::Runnable;
s.run_queue.push_back(pid);
}
}
});
}
/// What an actor wants the scheduler to do when control returns from it.
#[derive(Copy, Clone)]
enum YieldIntent {
/// Re-queue (yield_now or preemption).
Yield,
/// Remove from the run queue (waiting for unpark).
Park,
}
thread_local! {
static YIELD_INTENT: std::cell::Cell<YieldIntent> = const { std::cell::Cell::new(YieldIntent::Yield) };
}
// ---------------------------------------------------------------------------
// Supervisor channel registration
// ---------------------------------------------------------------------------
/// Register `sender` as the mailbox for signals about children supervised
/// by `pid`. Idempotent; later calls overwrite.
pub fn register_supervisor_channel(pid: Pid, sender: Sender<Signal>) {
with_sched(|s| {
if let Some(slot) = s.slot_mut(pid) {
slot.supervisor_channel = Some(sender);
} else {
panic!("register_supervisor_channel: pid {:?} not found", pid);
}
});
}
// ---------------------------------------------------------------------------
// run() — the runtime entry point
// ---------------------------------------------------------------------------
/// Boot the runtime, spawn `initial` as a child of the root supervisor,
/// drive the scheduler until the run queue is empty, tear down.
///
/// The root supervisor is a *sentinel* PID, not a real actor. Signals
/// addressed to it are dropped on the floor — that's what "process exits"
/// means in the spec when nothing escalates further. User code that wants
/// real supervision spawns its own supervisor actor and uses `spawn_under`.
pub fn run<F: FnOnce() + Send + 'static>(initial: F) {
SCHED.with(|c| {
assert!(c.borrow().is_none(), "smarm::run() called recursively");
let mut state = SchedulerState::new();
state.root_pid = Some(ROOT_PID);
*c.borrow_mut() = Some(state);
});
let initial_handle = spawn(initial);
schedule_loop();
// Drop the handle BEFORE the scheduler is torn down — its Drop impl
// calls `with_sched` to decrement the outstanding-handle count.
drop(initial_handle);
// Take the SchedulerState out of the thread-local BEFORE dropping it.
// Dropping it while still inside SCHED.with's RefCell borrow would
// re-enter (via channel senders' Drop → unpark → try_with_sched).
let state = SCHED.with(|c| c.borrow_mut().take());
drop(state);
PENDING_CLOSURES.with(|c| c.borrow_mut().clear());
}
/// Reserved sentinel pid for the root supervisor. Never allocated to a
/// real actor; lookups return `None`; signals are dropped.
pub const ROOT_PID: Pid = Pid::new(u32::MAX, u32::MAX);
fn schedule_loop() {
loop {
let pid = match with_sched(|s| s.run_queue.pop_front()) {
Some(p) => p,
None => return,
};
// Look up sp; skip stale or already-reaped pids.
let sp = match with_sched(|s| {
s.slot(pid).and_then(|slot| slot.actor.as_ref().map(|a| a.sp))
}) {
Some(sp) => sp,
None => continue,
};
// If this is a first resume, move the pending closure to the
// thread-local the trampoline reads.
if let Some(b) = pop_pending_closure(pid) {
set_current_actor_box(b);
}
set_actor_sp(sp);
set_current_pid(pid);
reset_actor_done();
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
crate::preempt::reset_timeslice();
PREEMPTION_ENABLED.with(|c| c.set(true));
unsafe { switch_to_actor() };
PREEMPTION_ENABLED.with(|c| c.set(false));
clear_current_pid();
let intent = YIELD_INTENT.with(|c| c.get());
let new_sp = get_actor_sp();
if is_actor_done() {
let outcome = take_last_outcome().unwrap_or(Outcome::Exit);
finalize_actor(pid, outcome);
} else {
with_sched(|s| {
if let Some(slot) = s.slot_mut(pid) {
if let Some(actor) = slot.actor.as_mut() {
actor.sp = new_sp;
}
match intent {
YieldIntent::Yield => {
slot.state = State::Runnable;
s.run_queue.push_back(pid);
}
YieldIntent::Park => {
slot.state = State::Parked;
}
}
}
});
}
}
}
fn finalize_actor(pid: Pid, outcome: Outcome) {
// Joiners get the typed Result with the panic payload. The supervisor
// gets an informational `Signal::Panic` with an empty payload — its job
// is policy (restart/escalate), not forensics. Users who need the
// payload in supervision can plumb their own channel.
let (joiner_outcome, sup_signal) = match outcome {
Outcome::Exit => (Outcome::Exit, Signal::Exit(pid)),
Outcome::Panic(payload) => (
Outcome::Panic(payload),
Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>),
),
};
// Stash outcome, mark Done, collect waiters, drop the actor stack.
let (waiters, supervisor_pid) = with_sched(|s| {
let slot = s.slot_mut(pid).expect("finalize_actor: slot vanished");
let sup = slot.actor.as_ref().map(|a| a.supervisor);
slot.outcome = Some(joiner_outcome);
slot.state = State::Done;
slot.actor = None;
let w = std::mem::take(&mut slot.waiters);
(w, sup)
});
// Deliver to supervisor (best-effort; ignore SendError).
if let Some(sup) = supervisor_pid {
let sender = with_sched(|s| {
s.slot(sup).and_then(|slot| slot.supervisor_channel.clone())
});
if let Some(sender) = sender {
let _ = sender.send(sup_signal);
}
}
// Unpark joiners.
for joiner in waiters {
unpark(joiner);
}
// Reclaim if no outstanding handles.
with_sched(|s| {
let should_reclaim = match s.slot(pid) {
Some(slot) => slot.outstanding_handles == 0,
None => false,
};
if should_reclaim {
reclaim_slot(s, pid);
}
});
}

89
src/stack.rs Normal file
View File

@@ -0,0 +1,89 @@
//! mmap-based growable stack with a guard page below.
//!
//! Layout (low → high address):
//! [ guard page (PROT_NONE) | stack region ]
//! ^ top() — initial stack pointer
//!
//! Stacks grow downward. Overflow lands in the guard page → SIGSEGV.
use std::io;
pub struct Stack {
/// Bottom of the entire mmap'd region (start of guard page).
base: *mut u8,
/// Total mmap'd size: guard_size + stack_size.
total_size: usize,
/// Usable stack size (excluding guard page).
stack_size: usize,
}
// Stack owns its memory; safe to send across threads.
unsafe impl Send for Stack {}
impl Stack {
/// Allocate a new stack. `stack_size` is the usable region; one page is
/// added below as a guard page. Both are rounded up to the page size.
pub fn new(stack_size: usize) -> io::Result<Self> {
let page = page_size();
let stack_size = round_up(stack_size, page);
let guard_size = page;
let total_size = guard_size + stack_size;
let base = unsafe {
libc::mmap(
std::ptr::null_mut(),
total_size,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_PRIVATE | libc::MAP_ANONYMOUS,
-1,
0,
)
};
if base == libc::MAP_FAILED {
return Err(io::Error::last_os_error());
}
let base = base as *mut u8;
let ret = unsafe {
libc::mprotect(base as *mut libc::c_void, guard_size, libc::PROT_NONE)
};
if ret != 0 {
let err = io::Error::last_os_error();
unsafe { libc::munmap(base as *mut libc::c_void, total_size) };
return Err(err);
}
Ok(Self { base, total_size, stack_size })
}
/// 16-byte-aligned top of the usable region.
pub fn top(&self) -> *mut u8 {
let raw_top = self.base as usize + self.total_size;
(raw_top & !15) as *mut u8
}
/// Pointer to the bottom of the usable region (just above the guard page).
pub fn usable_base(&self) -> *mut u8 {
unsafe { self.base.add(page_size()) }
}
pub fn stack_size(&self) -> usize {
self.stack_size
}
}
impl Drop for Stack {
fn drop(&mut self) {
unsafe {
libc::munmap(self.base as *mut libc::c_void, self.total_size);
}
}
}
fn page_size() -> usize {
unsafe { libc::sysconf(libc::_SC_PAGESIZE) as usize }
}
fn round_up(n: usize, align: usize) -> usize {
(n + align - 1) & !(align - 1)
}

37
src/supervisor.rs Normal file
View File

@@ -0,0 +1,37 @@
//! Supervision signals.
//!
//! Every actor has a supervisor, which is itself just an actor with a
//! `Receiver<Signal>`. When a child actor terminates, the scheduler sends
//! a `Signal` on the supervisor's channel. The supervisor decides what to
//! do — restart, escalate, ignore.
//!
//! For v0.1 there is no built-in restart-intensity cap. That's policy and
//! lives in user code; library is mechanism only.
use crate::pid::Pid;
use std::any::Any;
pub enum Signal {
/// The child exited normally.
Exit(Pid),
/// The child panicked. Payload is whatever `panic!` was called with.
Panic(Pid, Box<dyn Any + Send>),
}
impl std::fmt::Debug for Signal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid),
Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid),
}
}
}
impl Signal {
pub fn pid(&self) -> Pid {
match self {
Signal::Exit(p) => *p,
Signal::Panic(p, _) => *p,
}
}
}

110
tests/channel.rs Normal file
View File

@@ -0,0 +1,110 @@
//! Channel tests. These run under the scheduler because `recv()` needs to
//! be able to park, which requires a live runtime.
use smarm::{channel, run, spawn};
use std::cell::Cell;
thread_local! {
static OUT: Cell<i64> = const { Cell::new(0) };
}
#[test]
fn send_then_recv_same_actor() {
OUT.with(|c| c.set(0));
run(|| {
let (tx, rx) = channel::<i64>();
tx.send(42).unwrap();
let v = rx.recv().unwrap();
OUT.with(|c| c.set(v));
});
assert_eq!(OUT.with(|c| c.get()), 42);
}
#[test]
fn recv_parks_until_send_from_other_actor() {
OUT.with(|c| c.set(0));
run(|| {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
// This actor blocks on an empty channel.
let v = rx.recv().unwrap();
OUT.with(|c| c.set(v));
});
// Parent runs, then yields to let the child block,
// then sends, then joins.
smarm::yield_now();
tx.send(7).unwrap();
h.join().unwrap();
});
assert_eq!(OUT.with(|c| c.get()), 7);
}
#[test]
fn multiple_messages_arrive_in_order() {
let captured: std::sync::Arc<std::sync::Mutex<Vec<i64>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let cap2 = captured.clone();
run(move || {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
for _ in 0..3 {
let v = rx.recv().unwrap();
cap2.lock().unwrap().push(v);
}
});
for v in 1..=3i64 {
tx.send(v).unwrap();
}
h.join().unwrap();
});
assert_eq!(*captured.lock().unwrap(), vec![1, 2, 3]);
}
#[test]
fn cloned_senders_both_deliver() {
let captured: std::sync::Arc<std::sync::Mutex<Vec<i64>>> =
std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let cap2 = captured.clone();
run(move || {
let (tx, rx) = channel::<i64>();
let tx2 = tx.clone();
let h = spawn(move || {
for _ in 0..2 {
let v = rx.recv().unwrap();
cap2.lock().unwrap().push(v);
}
});
tx.send(10).unwrap();
tx2.send(20).unwrap();
h.join().unwrap();
});
let mut got = captured.lock().unwrap().clone();
got.sort();
assert_eq!(got, vec![10, 20]);
}
#[test]
fn recv_returns_err_when_all_senders_dropped() {
let saw_err: std::sync::Arc<std::sync::atomic::AtomicBool> =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let saw_err2 = saw_err.clone();
run(move || {
let (tx, rx) = channel::<i64>();
let h = spawn(move || {
// Receiver waits; no message will ever come.
if rx.recv().is_err() {
saw_err2.store(true, std::sync::atomic::Ordering::SeqCst);
}
});
smarm::yield_now();
drop(tx); // last sender gone; rx.recv must return Err.
h.join().unwrap();
});
assert!(saw_err.load(std::sync::atomic::Ordering::SeqCst));
}

137
tests/context.rs Normal file
View File

@@ -0,0 +1,137 @@
//! Low-level context-switch tests. These poke `init_actor_stack` and the
//! naked asm shims directly — no scheduler involved.
use smarm::context::{
get_actor_sp, init_actor_stack, set_actor_sp, switch_to_actor, switch_to_scheduler,
};
use smarm::stack::Stack;
use std::cell::Cell;
thread_local! {
static LOG: Cell<u64> = const { Cell::new(0) };
}
fn log(v: u64) { LOG.with(|c| c.set(c.get() | v)); }
fn get_log() -> u64 { LOG.with(|c| c.get()) }
fn reset_log() { LOG.with(|c| c.set(0)); }
extern "C-unwind" fn actor_simple() {
log(0x1);
unsafe { switch_to_scheduler() };
}
#[test]
fn actor_runs_and_returns_to_scheduler() {
reset_log();
let stack = Stack::new(64 * 1024).unwrap();
let sp = init_actor_stack(stack.top(), actor_simple);
set_actor_sp(sp);
unsafe { switch_to_actor() };
assert_eq!(get_log(), 0x1);
}
extern "C-unwind" fn actor_two_steps() {
log(0x1);
unsafe { switch_to_scheduler() };
log(0x2);
unsafe { switch_to_scheduler() };
}
#[test]
fn actor_yields_and_resumes() {
reset_log();
let stack = Stack::new(64 * 1024).unwrap();
let sp = init_actor_stack(stack.top(), actor_two_steps);
set_actor_sp(sp);
unsafe { switch_to_actor() };
assert_eq!(get_log(), 0x1, "after first resume");
unsafe { switch_to_actor() };
assert_eq!(get_log(), 0x1 | 0x2, "after second resume");
}
// Callee-saved registers must survive a yield.
use std::sync::OnceLock;
static REG_BEFORE: OnceLock<[u64; 4]> = OnceLock::new();
static REG_AFTER: OnceLock<[u64; 4]> = OnceLock::new();
extern "C-unwind" fn actor_reg_check() {
unsafe {
let s0: u64 = 0xAAAA_BBBB_0000_0001;
let s1: u64 = 0xCCCC_DDDD_0000_0002;
let s2: u64 = 0xEEEE_FFFF_0000_0003;
let s3: u64 = 0x1111_2222_0000_0004;
core::arch::asm!(
"mov r12, {s0}", "mov r13, {s1}", "mov r14, {s2}", "mov r15, {s3}",
s0 = in(reg) s0, s1 = in(reg) s1, s2 = in(reg) s2, s3 = in(reg) s3,
out("r12") _, out("r13") _, out("r14") _, out("r15") _,
);
REG_BEFORE.set([s0, s1, s2, s3]).ok();
switch_to_scheduler();
let a0: u64; let a1: u64; let a2: u64; let a3: u64;
core::arch::asm!(
"mov {a0}, r12", "mov {a1}, r13", "mov {a2}, r14", "mov {a3}, r15",
a0 = out(reg) a0, a1 = out(reg) a1, a2 = out(reg) a2, a3 = out(reg) a3,
);
REG_AFTER.set([a0, a1, a2, a3]).ok();
switch_to_scheduler();
}
}
#[test]
fn callee_saved_registers_survive_yield() {
let stack = Stack::new(64 * 1024).unwrap();
let sp = init_actor_stack(stack.top(), actor_reg_check);
set_actor_sp(sp);
unsafe { switch_to_actor(); switch_to_actor(); }
assert_eq!(REG_BEFORE.get().copied().unwrap(), REG_AFTER.get().copied().unwrap());
}
// Two actors, independent stacks.
thread_local! {
static A_VAL: Cell<u64> = const { Cell::new(0) };
static B_VAL: Cell<u64> = const { Cell::new(0) };
}
extern "C-unwind" fn actor_a() {
A_VAL.with(|c| c.set(0xAAAA));
unsafe { switch_to_scheduler() };
let v = A_VAL.with(|c| c.get());
A_VAL.with(|c| c.set(if v == 0xAAAA { 0xA00D } else { 0xDEAD }));
unsafe { switch_to_scheduler() };
}
extern "C-unwind" fn actor_b() {
B_VAL.with(|c| c.set(0xBBBB));
unsafe { switch_to_scheduler() };
let v = B_VAL.with(|c| c.get());
B_VAL.with(|c| c.set(if v == 0xBBBB { 0xB00D } else { 0xDEAD }));
unsafe { switch_to_scheduler() };
}
#[test]
fn two_actors_dont_corrupt_each_other() {
let stack_a = Stack::new(64 * 1024).unwrap();
let stack_b = Stack::new(64 * 1024).unwrap();
let sp_a = init_actor_stack(stack_a.top(), actor_a);
let sp_b = init_actor_stack(stack_b.top(), actor_b);
set_actor_sp(sp_a); unsafe { switch_to_actor() };
let sp_a = get_actor_sp();
set_actor_sp(sp_b); unsafe { switch_to_actor() };
let sp_b = get_actor_sp();
set_actor_sp(sp_a); unsafe { switch_to_actor() };
set_actor_sp(sp_b); unsafe { switch_to_actor() };
assert_eq!(A_VAL.with(|c| c.get()), 0xA00D);
assert_eq!(B_VAL.with(|c| c.get()), 0xB00D);
}

22
tests/pid.rs Normal file
View File

@@ -0,0 +1,22 @@
use smarm::pid::Pid;
#[test]
fn pid_equality() {
assert_eq!(Pid::new(0, 0), Pid::new(0, 0));
assert_ne!(Pid::new(0, 0), Pid::new(0, 1));
assert_ne!(Pid::new(0, 0), Pid::new(1, 0));
}
#[test]
fn pid_accessors() {
let p = Pid::new(42, 7);
assert_eq!(p.index(), 42);
assert_eq!(p.generation(), 7);
}
#[test]
fn pid_debug_is_useful() {
let p = Pid::new(3, 5);
let s = format!("{:?}", p);
assert!(s.contains('3') && s.contains('5'), "got: {}", s);
}

171
tests/scheduler.rs Normal file
View File

@@ -0,0 +1,171 @@
//! End-to-end scheduler tests: spawning, joining, panic delivery,
//! yield_now, self_pid.
use smarm::{channel, run, self_pid, spawn, spawn_under, yield_now, Signal};
use std::cell::Cell;
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
// ---------------------------------------------------------------------------
// Single root actor runs to completion
// ---------------------------------------------------------------------------
#[test]
fn root_actor_runs() {
let captured = Arc::new(AtomicI64::new(0));
let c = captured.clone();
run(move || { c.store(99, Ordering::SeqCst); });
assert_eq!(captured.load(Ordering::SeqCst), 99);
}
// ---------------------------------------------------------------------------
// Spawn child, join it
// ---------------------------------------------------------------------------
#[test]
fn spawn_and_join_returns_exit() {
let captured = Arc::new(AtomicI64::new(0));
let c = captured.clone();
run(move || {
let h = spawn(move || { c.store(7, Ordering::SeqCst); });
let res = h.join();
assert!(res.is_ok(), "join returned {:?}", res);
});
assert_eq!(captured.load(Ordering::SeqCst), 7);
}
// ---------------------------------------------------------------------------
// yield_now lets a sibling run
// ---------------------------------------------------------------------------
#[test]
fn yield_now_interleaves_actors() {
let log: Arc<std::sync::Mutex<Vec<u8>>> = Arc::new(std::sync::Mutex::new(Vec::new()));
let l1 = log.clone();
let l2 = log.clone();
run(move || {
let h1 = spawn(move || {
l1.lock().unwrap().push(1);
yield_now();
l1.lock().unwrap().push(3);
});
let h2 = spawn(move || {
l2.lock().unwrap().push(2);
yield_now();
l2.lock().unwrap().push(4);
});
h1.join().unwrap();
h2.join().unwrap();
});
// Both actors get their first step before either second step. Exact order
// is FIFO: 1, 2, then 3, 4.
assert_eq!(*log.lock().unwrap(), vec![1, 2, 3, 4]);
}
// ---------------------------------------------------------------------------
// self_pid returns this actor's pid inside the actor
// ---------------------------------------------------------------------------
#[test]
fn self_pid_is_stable_within_an_actor() {
let pid_cell: Arc<std::sync::Mutex<Option<smarm::Pid>>> =
Arc::new(std::sync::Mutex::new(None));
let p2 = pid_cell.clone();
run(move || {
let h = spawn(move || {
let me = self_pid();
yield_now();
assert_eq!(me, self_pid(), "self_pid changed across yield");
*p2.lock().unwrap() = Some(me);
});
h.join().unwrap();
});
assert!(pid_cell.lock().unwrap().is_some());
}
// ---------------------------------------------------------------------------
// Panic is captured; join returns Err; supervisor receives Signal::Panic
// ---------------------------------------------------------------------------
#[test]
fn panicking_child_returns_join_error() {
let saw_err = Arc::new(std::sync::atomic::AtomicBool::new(false));
let s = saw_err.clone();
run(move || {
let h = spawn(|| panic!("kaboom"));
if h.join().is_err() {
s.store(true, Ordering::SeqCst);
}
});
assert!(saw_err.load(Ordering::SeqCst));
}
#[test]
fn supervisor_receives_panic_signal() {
let saw_panic_signal = Arc::new(std::sync::atomic::AtomicBool::new(false));
let s = saw_panic_signal.clone();
run(move || {
// Build a supervisor actor with its own mailbox.
let (sig_tx, sig_rx) = channel::<Signal>();
let sup_handle = spawn(move || {
// Wait for exactly one signal.
let sig = sig_rx.recv().unwrap();
if let Signal::Panic(_, _) = sig {
s.store(true, Ordering::SeqCst);
}
});
// Tell the runtime: when I spawn the next child, route signals here.
let sup_pid = sup_handle.pid();
smarm::scheduler::register_supervisor_channel(sup_pid, sig_tx);
let child = spawn_under(sup_pid, || panic!("oops"));
let _ = child.join();
sup_handle.join().unwrap();
});
assert!(saw_panic_signal.load(Ordering::SeqCst));
}
// ---------------------------------------------------------------------------
// Multiple children, all complete, parent gets back control
// ---------------------------------------------------------------------------
#[test]
fn many_children_all_complete() {
let counter = Arc::new(AtomicI64::new(0));
let c = counter.clone();
run(move || {
let mut handles = Vec::new();
for _ in 0..10 {
let cc = c.clone();
handles.push(spawn(move || {
cc.fetch_add(1, Ordering::SeqCst);
}));
}
for h in handles {
h.join().unwrap();
}
});
assert_eq!(counter.load(Ordering::SeqCst), 10);
}
// ---------------------------------------------------------------------------
// Repeated yield_now inside an actor with no other actors completes
// ---------------------------------------------------------------------------
#[test]
fn yield_alone_terminates() {
thread_local! {
static N: Cell<i32> = const { Cell::new(0) };
}
N.with(|c| c.set(0));
run(|| {
for _ in 0..5 {
N.with(|c| c.set(c.get() + 1));
yield_now();
}
});
assert_eq!(N.with(|c| c.get()), 5);
}

123
tests/stack.rs Normal file
View File

@@ -0,0 +1,123 @@
//! Stack allocator tests.
//!
//! Covers allocation, alignment, read/write across the usable region, and
//! (via subprocess) that the guard page actually SIGSEGVs.
use smarm::stack::Stack;
#[test]
fn top_is_16_byte_aligned() {
let s = Stack::new(64 * 1024).unwrap();
assert_eq!(s.top() as usize % 16, 0);
}
#[test]
fn top_is_within_allocation() {
let s = Stack::new(64 * 1024).unwrap();
let top = s.top() as usize;
let base = s.usable_base() as usize;
assert!(top > base);
assert!(top <= base + s.stack_size());
}
#[test]
fn write_and_read_top_of_stack() {
let s = Stack::new(64 * 1024).unwrap();
let sentinel: u64 = 0xDEAD_BEEF_CAFE_1234;
unsafe {
let ptr = s.top().sub(8) as *mut u64;
ptr.write_volatile(sentinel);
assert_eq!(ptr.read_volatile(), sentinel);
}
}
#[test]
fn write_and_read_bottom_of_usable_region() {
let s = Stack::new(64 * 1024).unwrap();
let sentinel: u64 = 0x0102_0304_0506_0708;
unsafe {
let ptr = s.usable_base() as *mut u64;
ptr.write_volatile(sentinel);
assert_eq!(ptr.read_volatile(), sentinel);
}
}
#[test]
fn small_stack_allocates() {
assert!(Stack::new(4096).is_ok());
}
#[test]
fn large_stack_allocates() {
assert!(Stack::new(8 * 1024 * 1024).is_ok());
}
#[test]
fn stack_size_at_least_requested() {
let s = Stack::new(64 * 1024).unwrap();
assert!(s.stack_size() >= 64 * 1024);
}
// ---------------------------------------------------------------------------
// Guard page SIGSEGV tests — subprocess-based.
// ---------------------------------------------------------------------------
use std::env;
use std::process::Command;
fn run_as_child_if_requested() {
match env::var("SMARM_SUBTEST").as_deref() {
Ok("guard_page_direct") => {
let s = Stack::new(64 * 1024).unwrap();
unsafe {
let guard_ptr = s.usable_base().sub(1);
guard_ptr.write_volatile(0xAB);
}
std::process::exit(0);
}
Ok("stack_overflow") => {
let s = Stack::new(64 * 1024).unwrap();
unsafe {
let mut ptr = s.top().sub(1);
let stop = s.usable_base().sub(1);
while ptr >= stop {
ptr.write_volatile(0xFF);
ptr = ptr.sub(1);
}
}
std::process::exit(0);
}
_ => {}
}
}
fn spawn_subtest(name: &str) -> std::process::ExitStatus {
let exe = env::current_exe().unwrap();
Command::new(exe)
.env("SMARM_SUBTEST", name)
.args(["--test-threads=1", "--quiet"])
.status()
.expect("failed to spawn subprocess")
}
#[test]
fn guard_page_causes_sigsegv() {
run_as_child_if_requested();
let status = spawn_subtest("guard_page_direct");
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(11), "expected SIGSEGV, got: {:?}", status);
}
}
#[test]
fn stack_overflow_causes_sigsegv() {
run_as_child_if_requested();
let status = spawn_subtest("stack_overflow");
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
assert_eq!(status.signal(), Some(11), "expected SIGSEGV, got: {:?}", status);
}
}