Files
smarm/tests/channel.rs
T
smarm 8ff6cf4afd feat(channel): migrate Inner to RawMutex; two-class lock order (Leaf -> Channel)
Phase 2 surfaced the hole this closes: channel guards were held with
preemption enabled, so a timeslice switch inside a channel critical
section could release the pthread mutex from a different OS thread (UB).
RawMutex disables preemption for the guard span and is
cross-thread-release sound by construction; poison goes away with it.

The strict single-class leaf rule cannot survive the migration: finalize
clones the supervisor/trap senders and monitor() clones the Down sender,
all under a cold lock, and those senders live in the slot - the nesting
is structural. The leaf check is therefore generalized to two classes:
Leaf (cold locks, free list, stack pool) and Channel. Order is
Leaf -> Channel, at most one of each; both directions of violation are
debug-asserted at the acquisition site. Channel critical sections call
only the lock-free unpark protocol, so the order is acyclic.

Tests: class-ordering unit tests in raw_mutex (allowed nesting + all
three rejected shapes), plus a multi-scheduler integration test driving
channels through monitor churn and actor death so any ordering
regression trips the debug assert instead of deadlocking.
2026-06-09 22:51:37 +00:00

159 lines
4.8 KiB
Rust

//! 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));
}
#[test]
fn channel_ops_interleaved_with_monitor_churn_multi_thread() {
// Regression for the RawMutex migration: monitor registration clones the
// Down sender under the target's cold (Leaf) lock, which now nests a
// Channel-class lock under it. Debug builds enforce the Leaf -> Channel
// ordering on every acquisition, so driving channels, monitors, and actor
// death concurrently across schedulers makes any ordering regression
// panic here rather than deadlock in the field.
use std::sync::atomic::{AtomicI64, Ordering};
use std::sync::Arc;
let total = Arc::new(AtomicI64::new(0));
let total2 = total.clone();
smarm::init(smarm::Config::exact(4)).run(move || {
let (tx, rx) = channel::<i64>();
let consumer = spawn(move || {
let mut sum = 0;
while let Ok(v) = rx.recv() {
sum += v;
}
OUT.with(|c| c.set(sum)); // not asserted cross-thread; see total
total2.fetch_add(sum, Ordering::Relaxed);
});
let mut handles = Vec::new();
for i in 0..32i64 {
let tx = tx.clone();
handles.push(spawn(move || {
// Short-lived target whose death fires the monitor below.
let t = spawn(move || {
tx.send(i).unwrap();
});
let m = smarm::monitor(t.pid());
t.join().unwrap();
// Down delivery exercises send-from-finalize.
let d = m.rx.recv().unwrap();
assert_eq!(d.reason, smarm::DownReason::Exit);
}));
}
drop(tx);
for h in handles {
h.join().unwrap();
}
consumer.join().unwrap();
});
assert_eq!(total.load(std::sync::atomic::Ordering::Relaxed), (0..32).sum::<i64>());
}