feat(safety): phase 5 — loom model checking + invariant audit/assertion sweep
State machine extracted to src/slot_state.rs as a standalone unit (StateWord: publish_queued / try_claim / yield_return / park_return / unpark / set_done / reclaim, plus the Status view for cold paths). runtime.rs keeps the protocol rationale and consumes the mechanism; every transition self-asserts its precondition per the assert-the-invariants rule. src/sync_shim.rs: std vs loom indirection (atomics + UnsafeCell with the with/with_mut access API) for the two loom-modeled modules. Loom models run the PRODUCTION code, not a replica: - slot_state: no-lost-wakeup (park vs unpark), two-unparkers-one-enqueue, stale-unpark-never-hits-reused-slot (the ABA theorem), unpark-vs-claim. - run_queue: mpmc exactly-once through a lap wraparound, push/pop race, striped two-producer drain. RUSTFLAGS="--cfg loom" cargo test --lib --release — 7 models, all pass. RawMutex deliberately not loom-modeled (futexes can't be; textbook mutex3 with stress + unwind coverage). Assertion sweep (invariants now checked at the point of reliance): - enqueue debug-asserts the word reads EXACTLY (gen, Queued) — the at-most-once-enqueued invariant the ring capacity proof leans on. - RawMutex enforces the leaf rule mechanically: debug-build thread-local held-count, panics at the acquisition that violates it. - live_actors underflow (double finalize) asserted. - StateWord transition preconditions asserted (yield/park/done/reclaim/ publish/claim). Audit: with_runtime, RawMutex guards, run-queue ops, and trace::record all gate preemption (and thereby the stop sentinel) for their span; trace was already self-gating. Sole remaining exception is the channel std MutexGuard — the documented first post-v0.5 fast-follow. Review fixes to the branched-in work: - slot_state::status_for mapped (matching gen, Vacant) to Stale instead of unreachable!: Pid::new is public, so a forged/never-issued pid (e.g. monitor(Pid::new(5,0)) on a fresh slab) could reach it — "no such actor" is the correct total answer; issued pids still can't get there. - Tightened enqueue's assert from Status::Live to the exact (gen, Queued) word its own comment argues for. Validated: 22 suites in debug (all asserts + leaf counter live) for rq-mutex/rq-mpmc/rq-striped, release for rq-mutex, smarm-trace build + stress, loom 7/7.
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
//! The per-slot scheduling state machine, as a standalone unit.
|
||||
//!
|
||||
//! One atomic word packs `(generation << 32) | state`; every transition is a
|
||||
//! CAS on the packed word, so the generation check is atomic with the
|
||||
//! transition — no ABA, no acting on a recycled slot. The diagram and the
|
||||
//! full protocol rationale live in `runtime.rs`; this module is the
|
||||
//! mechanism, factored out so that:
|
||||
//!
|
||||
//! - loom can model-check the production transitions directly (see the
|
||||
//! `loom_tests` module; built with `RUSTFLAGS="--cfg loom"`), and
|
||||
//! - every method asserts the precondition it relies on (`debug_assert!` —
|
||||
//! these are hot paths), per the assert-the-invariants house rule.
|
||||
//!
|
||||
//! Atomics come from `sync_shim` (std normally, `loom::sync` under
|
||||
//! `cfg(loom)`).
|
||||
|
||||
use crate::sync_shim::{AtomicU64, Ordering};
|
||||
|
||||
pub(crate) const ST_VACANT: u64 = 0;
|
||||
pub(crate) const ST_QUEUED: u64 = 1;
|
||||
pub(crate) const ST_RUNNING: u64 = 2;
|
||||
pub(crate) const ST_RUNNING_NOTIFIED: u64 = 3;
|
||||
pub(crate) const ST_PARKED: u64 = 4;
|
||||
pub(crate) const ST_DONE: u64 = 5;
|
||||
|
||||
#[inline]
|
||||
pub(crate) const fn pack(gen: u32, st: u64) -> u64 {
|
||||
((gen as u64) << 32) | st
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) const fn word_gen(w: u64) -> u32 {
|
||||
(w >> 32) as u32
|
||||
}
|
||||
#[inline]
|
||||
pub(crate) const fn word_state(w: u64) -> u64 {
|
||||
w & 0xFF
|
||||
}
|
||||
|
||||
/// What an unpark amounted to. The caller owns the side effects (enqueue,
|
||||
/// trace events) — this module is pure state.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(crate) enum Unpark {
|
||||
/// Parked → Queued: the caller must enqueue the pid.
|
||||
Enqueue,
|
||||
/// Running → RunningNotified: the scheduler's park-return will re-queue.
|
||||
Notified,
|
||||
/// Stale generation, already queued/notified, done, or vacant.
|
||||
Noop,
|
||||
}
|
||||
|
||||
/// A pid's-eye view of the slot, for cold paths that hold the slot lock.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub(crate) enum Status {
|
||||
/// The generation no longer matches: the slot was reclaimed (and possibly
|
||||
/// reused) — the pid is stale.
|
||||
Stale,
|
||||
/// The actor terminated; its outcome is (or was) in the slot.
|
||||
Done,
|
||||
/// Alive in some scheduling state (Queued / Running / Notified / Parked).
|
||||
Live,
|
||||
}
|
||||
|
||||
pub(crate) struct StateWord(AtomicU64);
|
||||
|
||||
impl StateWord {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self(AtomicU64::new(pack(0, ST_VACANT)))
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn load(&self) -> u64 {
|
||||
self.0.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn generation(&self) -> u32 {
|
||||
word_gen(self.load())
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn status_for(&self, gen: u32) -> Status {
|
||||
let w = self.load();
|
||||
if word_gen(w) != gen {
|
||||
return Status::Stale;
|
||||
}
|
||||
match word_state(w) {
|
||||
ST_DONE => Status::Done,
|
||||
// A matching generation on a Vacant slot is unreachable for any
|
||||
// ISSUED pid — reclaim bumps the generation in the very store
|
||||
// that vacates, and install publishes Queued before the pid
|
||||
// escapes. But `Pid::new` is public, so a forged / never-issued
|
||||
// pid (e.g. `Pid::new(5, 0)` against a fresh slab) can land
|
||||
// here; for those, "no such actor" is the correct total answer.
|
||||
ST_VACANT => Status::Stale,
|
||||
_ => Status::Live,
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn-side publish: Vacant → Queued. The caller owns the vacant slot
|
||||
/// exclusively (it popped the index from the free list), so this is a
|
||||
/// plain Release store; it is the moment the actor becomes visible to
|
||||
/// pops, unparks, and stops.
|
||||
pub(crate) fn publish_queued(&self, gen: u32) {
|
||||
debug_assert_eq!(
|
||||
self.load(),
|
||||
pack(gen, ST_VACANT),
|
||||
"publish over a non-vacant slot"
|
||||
);
|
||||
self.0.store(pack(gen, ST_QUEUED), Ordering::Release);
|
||||
}
|
||||
|
||||
/// Scheduler pop-side claim: Queued → Running. `false` means the popped
|
||||
/// pid is stale — by the at-most-once-enqueued invariant, a generation
|
||||
/// mismatch is the only possible failure (asserted).
|
||||
#[must_use]
|
||||
pub(crate) fn try_claim(&self, gen: u32) -> bool {
|
||||
match self.0.compare_exchange(
|
||||
pack(gen, ST_QUEUED),
|
||||
pack(gen, ST_RUNNING),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => true,
|
||||
Err(actual) => {
|
||||
debug_assert_ne!(
|
||||
word_gen(actual),
|
||||
gen,
|
||||
"queued pid found in unexpected state {} — double enqueue?",
|
||||
word_state(actual)
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Yield return path: Running | RunningNotified → Queued. A notification
|
||||
/// that arrived mid-run coalesces into the re-queue. Caller must enqueue.
|
||||
pub(crate) fn yield_return(&self, gen: u32) {
|
||||
let prev = self.0.swap(pack(gen, ST_QUEUED), Ordering::AcqRel);
|
||||
debug_assert!(
|
||||
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(prev) == gen,
|
||||
"yield return from invalid word {prev:#x}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Park return path. `true` = actually parked. `false` = an unpark landed
|
||||
/// in the prep-to-park window (RunningNotified); the word is already back
|
||||
/// to Queued and the caller must enqueue — the lost-wakeup window, closed.
|
||||
#[must_use]
|
||||
pub(crate) fn park_return(&self, gen: u32) -> bool {
|
||||
match self.0.compare_exchange(
|
||||
pack(gen, ST_RUNNING),
|
||||
pack(gen, ST_PARKED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
) {
|
||||
Ok(_) => true,
|
||||
Err(actual) => {
|
||||
debug_assert_eq!(
|
||||
actual,
|
||||
pack(gen, ST_RUNNING_NOTIFIED),
|
||||
"park return from invalid word {actual:#x}"
|
||||
);
|
||||
self.0.store(pack(gen, ST_QUEUED), Ordering::Release);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The unpark protocol — the one way anything outside the scheduler makes
|
||||
/// an actor runnable. See [`Unpark`] for the caller's obligations.
|
||||
#[must_use]
|
||||
pub(crate) fn unpark(&self, gen: u32) -> Unpark {
|
||||
loop {
|
||||
let w = self.load();
|
||||
if word_gen(w) != gen {
|
||||
return Unpark::Noop;
|
||||
}
|
||||
match word_state(w) {
|
||||
ST_PARKED => {
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, ST_QUEUED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Unpark::Enqueue;
|
||||
}
|
||||
}
|
||||
ST_RUNNING => {
|
||||
if self
|
||||
.0
|
||||
.compare_exchange(
|
||||
w,
|
||||
pack(gen, ST_RUNNING_NOTIFIED),
|
||||
Ordering::AcqRel,
|
||||
Ordering::Acquire,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
return Unpark::Notified;
|
||||
}
|
||||
}
|
||||
_ => return Unpark::Noop, // Queued | Notified | Done | Vacant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize: Running | RunningNotified → Done. Called by the scheduler
|
||||
/// that just ran the actor to completion (so those are the only legal
|
||||
/// prior states), under the slot's cold lock so join's check-or-register
|
||||
/// is linearized against it.
|
||||
pub(crate) fn set_done(&self, gen: u32) {
|
||||
let prev = self.0.swap(pack(gen, ST_DONE), Ordering::AcqRel);
|
||||
debug_assert!(
|
||||
matches!(word_state(prev), ST_RUNNING | ST_RUNNING_NOTIFIED)
|
||||
&& word_gen(prev) == gen,
|
||||
"finalize from invalid word {prev:#x}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Reclaim: Done → Vacant(gen + 1). The generation bump IS the reclaim:
|
||||
/// every stale pid is dead from this store onwards. Caller holds the cold
|
||||
/// lock and has verified eligibility (asserted).
|
||||
pub(crate) fn reclaim(&self, gen: u32) {
|
||||
debug_assert_eq!(
|
||||
self.load(),
|
||||
pack(gen, ST_DONE),
|
||||
"reclaim of a non-Done slot"
|
||||
);
|
||||
self.0
|
||||
.store(pack(gen.wrapping_add(1), ST_VACANT), Ordering::Release);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// loom model tests — RUSTFLAGS="--cfg loom" cargo test --lib --release
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(all(test, loom))]
|
||||
mod loom_tests {
|
||||
use super::*;
|
||||
use loom::sync::atomic::{AtomicBool, AtomicUsize};
|
||||
use loom::sync::Arc;
|
||||
use loom::thread;
|
||||
use std::sync::atomic::Ordering as O;
|
||||
|
||||
/// THE lost-wakeup theorem. A waiter registers a condition check then
|
||||
/// parks (as every parking site does); a waker sets the condition then
|
||||
/// unparks. In every interleaving the waiter must end up runnable —
|
||||
/// parked-forever-with-condition-set must be unreachable.
|
||||
#[test]
|
||||
fn no_lost_wakeup_park_vs_unpark() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0)); // scheduler claimed: actor Running
|
||||
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let enqueues = Arc::new(AtomicUsize::new(0));
|
||||
|
||||
// Waker: make the condition true, then unpark.
|
||||
let w = word.clone();
|
||||
let r = ready.clone();
|
||||
let e = enqueues.clone();
|
||||
let waker = thread::spawn(move || {
|
||||
r.store(true, O::SeqCst);
|
||||
if w.unpark(0) == Unpark::Enqueue {
|
||||
e.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
// Waiter (as the scheduler executes it): re-check the condition,
|
||||
// park only if still false; a Notified park-return re-queues.
|
||||
let parked = if ready.load(O::SeqCst) {
|
||||
false // condition already visible: doesn't park at all
|
||||
} else if word.park_return(0) {
|
||||
true
|
||||
} else {
|
||||
enqueues.fetch_add(1, O::SeqCst); // notified → re-queued
|
||||
false
|
||||
};
|
||||
|
||||
waker.join().unwrap();
|
||||
|
||||
let w = word.load();
|
||||
if parked {
|
||||
// Parked is only a FINAL state if the waker's unpark moved it
|
||||
// back to Queued (+ one enqueue). Parked-and-stays-parked
|
||||
// would be the lost wakeup.
|
||||
assert_eq!(word_state(w), ST_QUEUED, "lost wakeup: parked forever");
|
||||
assert_eq!(enqueues.load(O::SeqCst), 1);
|
||||
} else {
|
||||
// Never more than one enqueue (at-most-once-enqueued).
|
||||
assert!(enqueues.load(O::SeqCst) <= 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Two concurrent unparkers, one parked actor: exactly one wins the
|
||||
/// enqueue (at-most-once), regardless of interleaving.
|
||||
#[test]
|
||||
fn two_unparkers_one_enqueue() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
assert!(word.park_return(0)); // actor parked
|
||||
|
||||
let enqueues = Arc::new(AtomicUsize::new(0));
|
||||
let mut hs = Vec::new();
|
||||
for _ in 0..2 {
|
||||
let w = word.clone();
|
||||
let e = enqueues.clone();
|
||||
hs.push(thread::spawn(move || {
|
||||
if w.unpark(0) == Unpark::Enqueue {
|
||||
e.fetch_add(1, O::SeqCst);
|
||||
}
|
||||
}));
|
||||
}
|
||||
for h in hs {
|
||||
h.join().unwrap();
|
||||
}
|
||||
assert_eq!(enqueues.load(O::SeqCst), 1);
|
||||
assert_eq!(word_state(word.load()), ST_QUEUED);
|
||||
});
|
||||
}
|
||||
|
||||
/// The ABA theorem: a stale-generation unpark racing reclaim + reuse can
|
||||
/// never touch the slot's new occupant.
|
||||
#[test]
|
||||
fn stale_unpark_never_hits_reused_slot() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
// Gen-0 actor runs to completion.
|
||||
word.publish_queued(0);
|
||||
assert!(word.try_claim(0));
|
||||
|
||||
let w = word.clone();
|
||||
let stale = thread::spawn(move || w.unpark(0));
|
||||
|
||||
// Scheduler: finalize, reclaim, and a new spawn reuses the slot.
|
||||
word.set_done(0);
|
||||
word.reclaim(0);
|
||||
word.publish_queued(1);
|
||||
|
||||
// The stale unpark may have squeezed in only while gen 0 was
|
||||
// still Running (→ Notified) — in which case set_done's swap
|
||||
// absorbed it — or it observed Done/Vacant/gen-1 and no-op'd.
|
||||
// Either way it must never claim an enqueue.
|
||||
assert_ne!(stale.join().unwrap(), Unpark::Enqueue);
|
||||
// And the new occupant is exactly where its spawn put it.
|
||||
assert_eq!(word.load(), pack(1, ST_QUEUED));
|
||||
});
|
||||
}
|
||||
|
||||
/// Unpark racing the claim itself: whatever the interleaving, the actor
|
||||
/// is Running or RunningNotified afterwards and nobody enqueued (it was
|
||||
/// never parked).
|
||||
#[test]
|
||||
fn unpark_vs_claim_coalesces() {
|
||||
loom::model(|| {
|
||||
let word = Arc::new(StateWord::new());
|
||||
word.publish_queued(0);
|
||||
|
||||
let w = word.clone();
|
||||
let unparker = thread::spawn(move || w.unpark(0));
|
||||
|
||||
assert!(word.try_claim(0)); // the entry is ours; claim must win
|
||||
let r = unparker.join().unwrap();
|
||||
assert_ne!(r, Unpark::Enqueue);
|
||||
let st = word_state(word.load());
|
||||
assert!(matches!(st, ST_RUNNING | ST_RUNNING_NOTIFIED));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user