Extract scheduler responsibilities into a dedicated Runtime component: - src/runtime.rs: New centralized control flow (669 lines) - src/scheduler.rs: Simplified to task queue & preemption management - tests/runtime.rs: Comprehensive runtime test suite - benches/multi_scheduler.rs: Multi-runtime scheduling benchmarks - Improves modularity and enables per-runtime configuration
427 lines
14 KiB
Rust
427 lines
14 KiB
Rust
//! Tests for the multi-scheduler runtime: Config, Runtime::run, and
|
|
//! correctness under genuine parallelism.
|
|
//!
|
|
//! The single-threaded correctness properties (channel ordering, mutex
|
|
//! fairness, timer accuracy, etc.) are already covered by the per-module
|
|
//! tests. This file focuses on what changes when N > 1 scheduler threads
|
|
//! are involved:
|
|
//!
|
|
//! - Config construction and validation
|
|
//! - Runtime::run blocks until all actors finish
|
|
//! - All existing cooperative behaviours hold under multi-threading
|
|
//! - Actors genuinely run on different OS threads
|
|
//! - No lost wakeups under concurrent park/unpark
|
|
//! - No slot leaks under high spawn/join churn
|
|
//! - Panic on one scheduler thread doesn't kill others
|
|
|
|
use smarm::{channel, runtime::{Config, Runtime}, spawn, yield_now, JoinHandle};
|
|
use std::sync::{
|
|
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
|
Arc, Barrier,
|
|
};
|
|
use std::time::Duration;
|
|
use std::collections::HashSet;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Build a runtime with exactly `n` scheduler threads.
|
|
fn rt(n: usize) -> Runtime {
|
|
smarm::runtime::init(Config::exact(n))
|
|
}
|
|
|
|
/// Convenient single-threaded runtime (regression guard).
|
|
fn rt1() -> Runtime { rt(1) }
|
|
|
|
/// Multi-threaded runtime using all available parallelism.
|
|
fn rt_par() -> Runtime {
|
|
smarm::runtime::init(Config::default())
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Config
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn config_exact_overrides_bounds() {
|
|
let c = Config::exact(3);
|
|
assert_eq!(c.resolved_thread_count(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn config_default_clamps_to_available_parallelism() {
|
|
let c = Config::default();
|
|
let n = c.resolved_thread_count();
|
|
let avail = std::thread::available_parallelism()
|
|
.map(|n| n.get())
|
|
.unwrap_or(1);
|
|
// Default min is 1, default max is available_parallelism.
|
|
assert!(n >= 1 && n <= avail);
|
|
}
|
|
|
|
#[test]
|
|
fn config_min_max_clamps() {
|
|
// Force a range that excludes exact: min=2, max=4, available might be >4.
|
|
let c = Config::new(2, 4, None);
|
|
let n = c.resolved_thread_count();
|
|
assert!(n >= 2 && n <= 4, "expected 2..=4, got {n}");
|
|
}
|
|
|
|
#[test]
|
|
fn config_min_1_max_1_is_single_threaded() {
|
|
let c = Config::new(1, 1, None);
|
|
assert_eq!(c.resolved_thread_count(), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Runtime::run — basic lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn runtime_run_executes_closure() {
|
|
let flag = Arc::new(AtomicBool::new(false));
|
|
let f = flag.clone();
|
|
rt(1).run(move || { f.store(true, Ordering::SeqCst); });
|
|
assert!(flag.load(Ordering::SeqCst));
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_run_blocks_until_all_actors_done() {
|
|
// Spawn a chain of actors; the counter should be exactly N when run returns.
|
|
let counter = Arc::new(AtomicU64::new(0));
|
|
let c = counter.clone();
|
|
rt(2).run(move || {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..20 {
|
|
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), 20);
|
|
}
|
|
|
|
#[test]
|
|
fn runtime_can_be_used_multiple_times_sequentially() {
|
|
// Each call to run() is independent.
|
|
let r = rt(2);
|
|
let a = Arc::new(AtomicU64::new(0));
|
|
let b = Arc::new(AtomicU64::new(0));
|
|
let ac = a.clone();
|
|
let bc = b.clone();
|
|
r.run(move || { ac.fetch_add(1, Ordering::SeqCst); });
|
|
r.run(move || { bc.fetch_add(1, Ordering::SeqCst); });
|
|
assert_eq!(a.load(Ordering::SeqCst), 1);
|
|
assert_eq!(b.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Single-threaded regression: exact(1) must behave identically to old run()
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn exact_1_spawn_join_works() {
|
|
let v = Arc::new(AtomicU64::new(0));
|
|
let vc = v.clone();
|
|
rt1().run(move || {
|
|
let h = spawn(move || { vc.store(42, Ordering::SeqCst); });
|
|
h.join().unwrap();
|
|
});
|
|
assert_eq!(v.load(Ordering::SeqCst), 42);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_1_channel_recv_parks_and_wakes() {
|
|
let v = Arc::new(AtomicU64::new(0));
|
|
let vc = v.clone();
|
|
rt1().run(move || {
|
|
let (tx, rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
let val = rx.recv().unwrap();
|
|
vc.store(val, Ordering::SeqCst);
|
|
});
|
|
yield_now();
|
|
tx.send(99).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
assert_eq!(v.load(Ordering::SeqCst), 99);
|
|
}
|
|
|
|
#[test]
|
|
fn exact_1_panic_captured() {
|
|
let saw_err = Arc::new(AtomicBool::new(false));
|
|
let s = saw_err.clone();
|
|
rt1().run(move || {
|
|
let h = spawn(|| panic!("oops"));
|
|
if h.join().is_err() { s.store(true, Ordering::SeqCst); }
|
|
});
|
|
assert!(saw_err.load(Ordering::SeqCst));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Multi-threaded correctness
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn multi_thread_all_actors_complete() {
|
|
let counter = Arc::new(AtomicU64::new(0));
|
|
let c = counter.clone();
|
|
rt_par().run(move || {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..100 {
|
|
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), 100);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_thread_channel_wakeup_across_threads() {
|
|
// Receiver parks; sender runs (potentially on a different OS thread).
|
|
// Verifies no lost wakeup.
|
|
let received = Arc::new(AtomicU64::new(0));
|
|
let rc = received.clone();
|
|
rt_par().run(move || {
|
|
let (tx, rx) = channel::<u64>();
|
|
let h = spawn(move || {
|
|
let v = rx.recv().unwrap();
|
|
rc.store(v, Ordering::SeqCst);
|
|
});
|
|
// Let receiver park.
|
|
yield_now();
|
|
tx.send(7).unwrap();
|
|
h.join().unwrap();
|
|
});
|
|
assert_eq!(received.load(Ordering::SeqCst), 7);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_thread_many_channels_no_lost_wakeups() {
|
|
// N pairs of (sender actor, receiver actor). Each pair exchanges one
|
|
// message. All must complete — any lost wakeup causes a deadlock/timeout.
|
|
const PAIRS: usize = 50;
|
|
let count = Arc::new(AtomicU64::new(0));
|
|
let c = count.clone();
|
|
rt_par().run(move || {
|
|
let mut handles: Vec<JoinHandle> = Vec::new();
|
|
for _ in 0..PAIRS {
|
|
let (tx, rx) = channel::<u64>();
|
|
let cc = c.clone();
|
|
handles.push(spawn(move || {
|
|
let v = rx.recv().unwrap();
|
|
cc.fetch_add(v, Ordering::SeqCst);
|
|
}));
|
|
handles.push(spawn(move || {
|
|
tx.send(1).unwrap();
|
|
}));
|
|
}
|
|
for h in handles { h.join().unwrap(); }
|
|
});
|
|
assert_eq!(count.load(Ordering::SeqCst), PAIRS as u64);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_thread_mutex_contention_no_deadlock() {
|
|
use smarm::Mutex;
|
|
const ACTORS: usize = 20;
|
|
const PER: u64 = 100;
|
|
let total = Arc::new(AtomicU64::new(0));
|
|
let t = total.clone();
|
|
rt_par().run(move || {
|
|
let m: Mutex<u64> = Mutex::new(0);
|
|
let mut handles = Vec::new();
|
|
for _ in 0..ACTORS {
|
|
let mc = m.clone();
|
|
let tc = t.clone();
|
|
handles.push(spawn(move || {
|
|
for _ in 0..PER {
|
|
let mut g = mc.lock_timeout(Duration::from_secs(5)).unwrap();
|
|
*g += 1;
|
|
tc.fetch_add(0, Ordering::SeqCst); // just a memory barrier
|
|
}
|
|
}));
|
|
}
|
|
for h in handles { h.join().unwrap(); }
|
|
let g = m.lock_timeout(Duration::from_secs(1)).unwrap();
|
|
t.store(*g, Ordering::SeqCst);
|
|
});
|
|
assert_eq!(total.load(Ordering::SeqCst), ACTORS as u64 * PER);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_thread_join_across_threads() {
|
|
// Parent joins a child that may run on a different scheduler thread.
|
|
let v = Arc::new(AtomicU64::new(0));
|
|
let vc = v.clone();
|
|
rt_par().run(move || {
|
|
let h = spawn(move || {
|
|
// Do some work to make scheduling interesting.
|
|
for _ in 0..10 { yield_now(); }
|
|
vc.store(1, Ordering::SeqCst);
|
|
});
|
|
h.join().unwrap();
|
|
});
|
|
assert_eq!(v.load(Ordering::SeqCst), 1);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Actors run on distinct OS threads
|
|
//
|
|
// We collect the OS thread IDs that actors execute on. With N schedulers
|
|
// and enough actors, we expect to see more than one thread ID.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn actors_run_on_multiple_os_threads() {
|
|
let thread_ids: Arc<smarm::Mutex<HashSet<u64>>> =
|
|
Arc::new(smarm::Mutex::new(HashSet::new()));
|
|
|
|
rt_par().run({
|
|
let ids = thread_ids.clone();
|
|
move || {
|
|
let mut handles = Vec::new();
|
|
for _ in 0..64 {
|
|
let idc = ids.clone();
|
|
handles.push(spawn(move || {
|
|
let tid = unsafe { libc::syscall(libc::SYS_gettid) as u64 };
|
|
let mut g = idc.lock_timeout(Duration::from_secs(1)).unwrap();
|
|
g.insert(tid);
|
|
}));
|
|
}
|
|
for h in handles { h.join().unwrap(); }
|
|
}
|
|
});
|
|
|
|
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
|
|
|
|
let ids = thread_ids.lock_timeout(Duration::from_secs(1)).unwrap();
|
|
// If we have >1 scheduler threads, we expect >1 OS thread IDs.
|
|
// On a single-CPU machine this may be 1; we just assert ≥ 1.
|
|
assert!(!ids.is_empty());
|
|
if n > 1 {
|
|
// Strongly expect parallelism — not a hard assert since scheduling
|
|
// is non-deterministic, but 64 actors should spread.
|
|
// We log rather than assert to avoid flakiness on loaded CI.
|
|
if ids.len() == 1 {
|
|
eprintln!("WARNING: 64 actors all ran on the same OS thread (flaky on loaded system)");
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Scheduler stats (RFC 000 Layer 1 primitives)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn scheduler_stats_run_queue_len_is_observable() {
|
|
// After spawning actors but before they run, the queue should be non-empty.
|
|
// We can't observe this from inside run() without a snapshot API, but we
|
|
// can verify the stats struct is accessible and returns sane values after
|
|
// run() completes (queue len == 0 at quiescence).
|
|
let r = rt_par();
|
|
r.run(|| {
|
|
for _ in 0..10 { spawn(|| {}); }
|
|
// Don't join — let them drain naturally.
|
|
});
|
|
let stats = r.stats();
|
|
assert_eq!(stats.total_run_queue_len(), 0, "queue should be empty after run()");
|
|
}
|
|
|
|
#[test]
|
|
fn scheduler_stats_thread_count_matches_config() {
|
|
let r = rt(3);
|
|
r.run(|| {});
|
|
assert_eq!(r.stats().scheduler_count(), 3);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Panic isolation: a panicking actor doesn't kill the scheduler thread
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn panic_in_actor_does_not_kill_runtime() {
|
|
let completed = Arc::new(AtomicU64::new(0));
|
|
let c = completed.clone();
|
|
rt_par().run(move || {
|
|
// Spawn a panicker alongside well-behaved actors.
|
|
let bad = spawn(|| panic!("deliberate"));
|
|
let mut good_handles = Vec::new();
|
|
for _ in 0..10 {
|
|
let cc = c.clone();
|
|
good_handles.push(spawn(move || {
|
|
cc.fetch_add(1, Ordering::SeqCst);
|
|
}));
|
|
}
|
|
let _ = bad.join(); // expect Err
|
|
for h in good_handles { h.join().unwrap(); }
|
|
});
|
|
assert_eq!(completed.load(Ordering::SeqCst), 10);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// No slot leaks: rapid spawn/join churn
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn no_slot_leak_under_churn() {
|
|
// Spawn and join many short actors in a loop. If slots leak, the slot
|
|
// table grows unboundedly. We can't directly measure it without an
|
|
// introspection API, but the test at least checks correctness under
|
|
// churn and will OOM if there's a severe leak.
|
|
let counter = Arc::new(AtomicU64::new(0));
|
|
let c = counter.clone();
|
|
rt_par().run(move || {
|
|
for _ in 0..500 {
|
|
let cc = c.clone();
|
|
spawn(move || { cc.fetch_add(1, Ordering::SeqCst); })
|
|
.join()
|
|
.unwrap();
|
|
}
|
|
});
|
|
assert_eq!(counter.load(Ordering::SeqCst), 500);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Ping-pong: channel round-trips between two actors
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn ping_pong_completes() {
|
|
const ROUNDS: u64 = 1_000;
|
|
let final_val = Arc::new(AtomicU64::new(0));
|
|
let fv = final_val.clone();
|
|
rt_par().run(move || {
|
|
let (tx_a, rx_a) = channel::<u64>();
|
|
let (tx_b, rx_b) = channel::<u64>();
|
|
let h_a = spawn(move || {
|
|
tx_a.send(0).unwrap();
|
|
for _ in 0..ROUNDS {
|
|
let v = rx_b.recv().unwrap();
|
|
tx_a.send(v + 1).unwrap();
|
|
}
|
|
});
|
|
let h_b = spawn(move || {
|
|
for _ in 0..=ROUNDS {
|
|
let v = rx_a.recv().unwrap();
|
|
if v < ROUNDS {
|
|
tx_b.send(v).unwrap();
|
|
} else {
|
|
fv.store(v, Ordering::SeqCst);
|
|
}
|
|
}
|
|
});
|
|
h_a.join().unwrap();
|
|
h_b.join().unwrap();
|
|
});
|
|
assert_eq!(final_val.load(Ordering::SeqCst), ROUNDS);
|
|
}
|