//! 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::(); 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::(); 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 = Vec::new(); for _ in 0..PAIRS { let (tx, rx) = channel::(); 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 = 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>> = 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::(); let (tx_b, rx_b) = channel::(); 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); }