feat(cancel): cooperative cancellation via sentinel unwind

request_stop(pid) sets a per-actor flag and wakes a parked target. The
actor realizes the stop as a controlled unwind at its next observation
point (check!()/alloc via maybe_preempt, or the wakeup side of any
blocking park): a StopSentinel panic tears the stack down via the
trampoline's existing catch_unwind, running Drop, and is reported as the
new Outcome::Stopped (distinct from a user Panic).

Death surface kept distinct from Exit: Signal::Stopped(pid) +
DownReason::Stopped, so the supervisor await logic to come can tell a
requested stop apart from a self-termination.

Flag lives on Actor behind Arc<AtomicBool>; the scheduler resume path
takes a raw pointer into it (no per-resume refcount traffic), keeping
yield throughput at baseline.
This commit is contained in:
smarm-dev
2026-06-07 21:51:56 +00:00
parent 09236b8bf4
commit d9a6520a24
8 changed files with 301 additions and 20 deletions
+28 -1
View File
@@ -24,8 +24,22 @@ use std::panic;
pub enum Outcome { pub enum Outcome {
Exit, Exit,
Panic(Box<dyn Any + Send>), Panic(Box<dyn Any + Send>),
/// The actor was cooperatively cancelled via `request_stop`: a sentinel
/// panic unwound its stack (running Drop) and the trampoline recognised
/// the sentinel. Distinct from a user `Panic` — there is no payload to
/// propagate.
Stopped,
} }
/// The payload of the sentinel panic raised at an observation point when an
/// actor has been flagged for cooperative cancellation. Zero-sized; its only
/// job is to be recognisable in the trampoline's `catch_unwind` so the
/// teardown is reported as `Outcome::Stopped` rather than `Outcome::Panic`.
///
/// User code that wraps its own `catch_unwind` can swallow this (cf. Erlang's
/// `catch`); the stop flag stays set, so the next observation point re-raises.
pub(crate) struct StopSentinel;
// Thread-locals that the scheduler writes immediately before `switch_to_actor`. // Thread-locals that the scheduler writes immediately before `switch_to_actor`.
thread_local! { thread_local! {
/// The closure for the actor we're about to resume *for the first time*. /// The closure for the actor we're about to resume *for the first time*.
@@ -84,7 +98,13 @@ pub extern "C-unwind" fn trampoline() {
let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) { let outcome = match panic::catch_unwind(panic::AssertUnwindSafe(b)) {
Ok(()) => Outcome::Exit, Ok(()) => Outcome::Exit,
Err(payload) => Outcome::Panic(payload), Err(payload) => {
if payload.is::<StopSentinel>() {
Outcome::Stopped
} else {
Outcome::Panic(payload)
}
}
}; };
LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome)); LAST_OUTCOME.with(|r| *r.borrow_mut() = Some(outcome));
@@ -107,4 +127,11 @@ pub struct Actor {
pub sp: usize, pub sp: usize,
/// The PID of this actor's supervisor. Used to deliver `Signal` on death. /// The PID of this actor's supervisor. Used to deliver `Signal` on death.
pub supervisor: Pid, pub supervisor: Pid,
/// Cooperative-cancellation flag. `request_stop` sets it (and unparks a
/// parked actor); the actor observes it lock-free at its next observation
/// point — see `preempt::check_cancelled`. Shared via `Arc` so the running
/// actor can poll it without taking the shared mutex. Lives on the `Actor`
/// (constructed fresh at spawn), so unlike a `Slot` field it needs no reset
/// in the slot-recycling paths.
pub stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
} }
+2 -2
View File
@@ -43,8 +43,8 @@ pub use mutex::{LockTimeout, Mutex, MutexGuard};
pub use pid::Pid; pub use pid::Pid;
pub use runtime::{init, Config, Runtime}; pub use runtime::{init, Config, Runtime};
pub use scheduler::{ pub use scheduler::{
block_on_io, run, self_pid, sleep, spawn, spawn_under, wait_readable, wait_writable, block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
yield_now, JoinError, JoinHandle, wait_writable, yield_now, JoinError, JoinHandle,
}; };
pub use supervisor::{ChildSpec, OneForOne, Restart, Signal}; pub use supervisor::{ChildSpec, OneForOne, Restart, Signal};
+2
View File
@@ -47,6 +47,8 @@ pub enum DownReason {
/// The target panicked. The payload is delivered to the actor's joiner, /// The target panicked. The payload is delivered to the actor's joiner,
/// not to monitors. /// not to monitors.
Panic, Panic,
/// The target was cooperatively cancelled via `request_stop`.
Stopped,
/// The target was already gone (finished and reclaimed, or never alive) /// The target was already gone (finished and reclaimed, or never alive)
/// at the moment `monitor()` was called. /// at the moment `monitor()` was called.
NoProc, NoProc,
+53
View File
@@ -27,6 +27,7 @@
use std::alloc::{GlobalAlloc, Layout, System}; use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell; use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
pub const DEFAULT_ALLOC_INTERVAL: u32 = 128; pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU pub const DEFAULT_TIMESLICE_CYCLES: u64 = 300_000; // ≈ 100µs on a 3 GHz CPU
@@ -49,6 +50,54 @@ thread_local! {
/// Per-thread copy of the configured timeslice, written once at /// Per-thread copy of the configured timeslice, written once at
/// scheduler-thread startup. /// scheduler-thread startup.
static CONFIGURED_TIMESLICE_CYCLES: Cell<u64> = const { Cell::new(DEFAULT_TIMESLICE_CYCLES) }; static CONFIGURED_TIMESLICE_CYCLES: Cell<u64> = const { Cell::new(DEFAULT_TIMESLICE_CYCLES) };
/// Raw pointer to the on-CPU actor's cancellation flag, set by the
/// scheduler on resume and reset to null when control returns to it.
/// Null while no actor is running, so observation points are inert on the
/// scheduler's own stack. A raw pointer (not a cloned `Arc`) keeps the
/// resume path free of atomic ref-count traffic; see `check_cancelled` for
/// the safety argument.
static CURRENT_STOP: Cell<*const AtomicBool> = const { Cell::new(std::ptr::null()) };
}
// ---------------------------------------------------------------------------
// Cooperative-cancellation observation
// ---------------------------------------------------------------------------
/// Bind the on-CPU actor's stop flag. Called by the scheduler immediately
/// before `switch_to_actor`. `flag` must point into the resuming actor's
/// `Arc<AtomicBool>` heap box.
pub(crate) fn set_current_stop(flag: *const AtomicBool) {
CURRENT_STOP.with(|c| c.set(flag));
}
/// Unbind the stop flag. Called by the scheduler on the return path from an
/// actor, so the pointer never leaks into the scheduler loop or the next actor.
pub(crate) fn clear_current_stop() {
CURRENT_STOP.with(|c| c.set(std::ptr::null()));
}
/// Observation point for cooperative cancellation. If the on-CPU actor has
/// been flagged for stop, raise the sentinel panic so the trampoline's
/// `catch_unwind` tears the stack down (running Drop) and reports
/// `Outcome::Stopped`, distinct from a user `Panic`. A no-op (one TLS load,
/// one relaxed atomic load) on the common path.
///
/// Called from `maybe_preempt` (amortised, the `check!()`/alloc path) and from
/// the wakeup side of every blocking park (`park_current`/`yield_now`), which
/// is past the prep-to-park window — so it can never lose a wakeup.
#[inline]
pub fn check_cancelled() {
let p = CURRENT_STOP.with(|c| c.get());
// SAFETY: `p` is either null (no actor on-CPU — the scheduler clears it on
// every return) or a pointer into the on-CPU actor's `Arc<AtomicBool>`
// heap box. That box outlives the run: an actor's slot is not finalized or
// reclaimed while the actor is on-CPU (finalize runs only after it yields
// back), and the Arc keeps the box alive at a fixed address even if the
// slot table Vec reallocates underneath it.
if !p.is_null() && unsafe { (*p).load(Ordering::Relaxed) } {
std::panic::panic_any(crate::actor::StopSentinel);
}
} }
/// Called once per scheduler thread at startup (before any actor runs). /// Called once per scheduler thread at startup (before any actor runs).
@@ -122,6 +171,10 @@ pub fn maybe_preempt() {
let n = c.get(); let n = c.get();
if n == 0 { if n == 0 {
c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get())); c.set(CONFIGURED_ALLOC_INTERVAL.with(|i| i.get()));
// Cooperative cancellation shares the amortised cadence with the
// timeslice check. Observe a pending stop first: if we are being
// cancelled there is no point yielding, we unwind instead.
check_cancelled();
if PREEMPTION_ENABLED.with(|e| e.get()) { if PREEMPTION_ENABLED.with(|e| e.get()) {
let start = TIMESLICE_START.with(|s| s.get()); let start = TIMESLICE_START.with(|s| s.get());
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) { if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {
+28 -15
View File
@@ -44,7 +44,7 @@ use crate::supervisor::Signal;
use crate::timer::Timers; use crate::timer::Timers;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
@@ -542,6 +542,10 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>), Signal::Panic(pid, Box::new(()) as Box<dyn std::any::Any + Send>),
DownReason::Panic, DownReason::Panic,
), ),
// Cooperative cancellation: kept distinct from a normal Exit so a
// supervisor's await logic (roadmap #2) can tell "I stopped it" apart
// from "it finished on its own".
Outcome::Stopped => (Outcome::Stopped, Signal::Stopped(pid), DownReason::Stopped),
}; };
let (waiters, supervisor_pid, monitors, recycled_stack) = inner.with_shared(|s| { let (waiters, supervisor_pid, monitors, recycled_stack) = inner.with_shared(|s| {
@@ -730,7 +734,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
// None with all_clear=false — idle, wait // None with all_clear=false — idle, wait
// ---------------------------------------------------------------- // ----------------------------------------------------------------
enum PopResult { enum PopResult {
Work(Pid, usize, Option<Closure>), Work(Pid, usize, *const AtomicBool, Option<Closure>),
Idle { Idle {
next_deadline: Option<std::time::Instant>, next_deadline: Option<std::time::Instant>,
io_outstanding: u32, io_outstanding: u32,
@@ -738,23 +742,30 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
}, },
AllDone, AllDone,
} }
// SAFETY: the raw pointer is the actor's stack pointer, valid for the // SAFETY: the raw pointers are (a) the actor's stack pointer and (b) a
// lifetime of the actor's Slot. It is only dereferenced by // pointer into the actor's `Arc<AtomicBool>` stop flag, both valid for
// switch_to_actor() on this same thread immediately after this block. // the lifetime of the actor's Slot. They are only used by this same
unsafe impl Send for PopResult {} // closure is Send; usize sp is plain data // thread immediately after this block (switch_to_actor / set_current_stop).
unsafe impl Send for PopResult {} // closure is Send; usize sp + stop ptr are plain data
let pop = inner.with_shared(|s| { let pop = inner.with_shared(|s| {
let len = s.run_queue.len() as u64; let len = s.run_queue.len() as u64;
stats.run_queue_len.store(len, Ordering::Relaxed); stats.run_queue_len.store(len, Ordering::Relaxed);
if let Some(pid) = s.run_queue.pop_front() { if let Some(pid) = s.run_queue.pop_front() {
// Happy path: got a PID, grab SP and optional first closure. // Happy path: got a PID, grab SP, the stop-flag pointer, and
let sp = s.slot(pid) // optional first closure. We hand out a raw pointer into the
.and_then(|slot| slot.actor.as_ref().map(|a| a.sp)); // actor's `Arc<AtomicBool>` rather than cloning the Arc: the
match sp { // box stays alive and at a fixed address for as long as the
Some(sp) => { // actor is on-CPU (it can't be finalized until it yields back),
// so the scheduler's resume path pays no atomic ref-count cost.
let info = s.slot(pid)
.and_then(|slot| slot.actor.as_ref()
.map(|a| (a.sp, std::sync::Arc::as_ptr(&a.stop))));
match info {
Some((sp, stop)) => {
let closure = s.pop_pending_closure(pid); let closure = s.pop_pending_closure(pid);
PopResult::Work(pid, sp, closure) PopResult::Work(pid, sp, stop, closure)
} }
None => { None => {
// Stale PID (actor already finalized). Treat as idle // Stale PID (actor already finalized). Treat as idle
@@ -792,10 +803,10 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
} }
}); });
let (pid, sp, first_closure) = match pop { let (pid, sp, stop_flag, first_closure) = match pop {
PopResult::Work(pid, sp, closure) => { PopResult::Work(pid, sp, stop, closure) => {
crate::te!(crate::trace::Event::Dequeue(pid)); crate::te!(crate::trace::Event::Dequeue(pid));
(pid, sp, closure) (pid, sp, stop, closure)
} }
PopResult::AllDone => return, PopResult::AllDone => return,
PopResult::Idle { next_deadline, io_outstanding, wake_fd } => { PopResult::Idle { next_deadline, io_outstanding, wake_fd } => {
@@ -841,6 +852,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
set_actor_sp(sp); set_actor_sp(sp);
set_current_pid(pid); set_current_pid(pid);
crate::preempt::set_current_stop(stop_flag);
reset_actor_done(); reset_actor_done();
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield)); YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
crate::preempt::reset_timeslice(); crate::preempt::reset_timeslice();
@@ -852,6 +864,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
PREEMPTION_ENABLED.with(|c| c.set(false)); PREEMPTION_ENABLED.with(|c| c.set(false));
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed); stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
clear_current_pid(); clear_current_pid();
crate::preempt::clear_current_stop();
let intent = YIELD_INTENT.with(|c| c.get()); let intent = YIELD_INTENT.with(|c| c.get());
let new_sp = get_actor_sp(); let new_sp = get_actor_sp();
+49 -1
View File
@@ -79,6 +79,10 @@ impl JoinHandle {
return match o { return match o {
Outcome::Exit => Ok(()), Outcome::Exit => Ok(()),
Outcome::Panic(p) => Err(JoinError { payload: p }), Outcome::Panic(p) => Err(JoinError { payload: p }),
// A cooperative stop carries no panic payload to
// propagate; the *reason* is observable via monitors
// (DownReason::Stopped). join() therefore reports Ok.
Outcome::Stopped => Ok(()),
}; };
} }
None => { None => {
@@ -147,7 +151,13 @@ pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHa
let (idx, gen) = s.allocate_slot(); let (idx, gen) = s.allocate_slot();
let pid = Pid::new(idx, gen); let pid = Pid::new(idx, gen);
let slot = &mut s.slots[idx as usize]; let slot = &mut s.slots[idx as usize];
slot.actor = Some(crate::actor::Actor { pid, stack, sp, supervisor }); slot.actor = Some(crate::actor::Actor {
pid,
stack,
sp,
supervisor,
stop: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
});
slot.state = crate::runtime::State::Runnable; slot.state = crate::runtime::State::Runnable;
slot.outstanding_handles = 1; slot.outstanding_handles = 1;
slot.outcome = None; slot.outcome = None;
@@ -185,11 +195,18 @@ pub fn self_pid() -> Pid {
pub fn yield_now() { pub fn yield_now() {
runtime::set_yield_intent(YieldIntent::Yield); runtime::set_yield_intent(YieldIntent::Yield);
unsafe { crate::context::switch_to_scheduler() }; unsafe { crate::context::switch_to_scheduler() };
// Observation point: we may have been resumed only to be cancelled.
crate::preempt::check_cancelled();
} }
pub fn park_current() { pub fn park_current() {
runtime::set_yield_intent(YieldIntent::Park); runtime::set_yield_intent(YieldIntent::Park);
unsafe { crate::context::switch_to_scheduler() }; unsafe { crate::context::switch_to_scheduler() };
// Observation point on the wakeup side of every blocking primitive
// (recv/sleep/mutex/io/join). Past the prep-to-park window, so this never
// races a wakeup: a stop unparks us, we resume here, and unwind out of
// whatever blocking call parked us — running Drop along the way.
crate::preempt::check_cancelled();
} }
pub fn unpark(pid: Pid) { pub fn unpark(pid: Pid) {
@@ -220,6 +237,37 @@ pub fn unpark(pid: Pid) {
let _ = result; let _ = result;
} }
/// Request cooperative cancellation of `pid`.
///
/// Sets the actor's stop flag and, if it is parked, wakes it so it observes
/// the flag promptly. The actor realises the stop as a controlled unwind at
/// its next observation point (`check!()`/allocation, or the wakeup side of a
/// blocking park), terminating with `Outcome::Stopped`.
///
/// This is best-effort and cooperative: an actor that never reaches an
/// observation point — a tight loop with no `check!()`, no allocation, and no
/// blocking op — cannot be stopped, exactly as it cannot be preempted. A no-op
/// if `pid` is already gone.
pub fn request_stop(pid: Pid) {
let _ = try_with_runtime(|inner| {
inner.with_shared(|s| {
if let Some(slot) = s.slot_mut(pid) {
if let Some(actor) = slot.actor.as_ref() {
actor.stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
// Wake a parked target so it reaches its post-park observation
// point now. A Runnable target will observe on its next yield;
// a Done target has nothing to stop.
if matches!(slot.state, crate::runtime::State::Parked) {
slot.state = crate::runtime::State::Runnable;
s.run_queue.push_back(pid);
crate::te!(crate::trace::Event::Enqueue(pid));
}
}
})
});
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// NoPreempt // NoPreempt
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+6
View File
@@ -16,6 +16,10 @@ pub enum Signal {
Exit(Pid), Exit(Pid),
/// The child panicked. Payload is whatever `panic!` was called with. /// The child panicked. Payload is whatever `panic!` was called with.
Panic(Pid, Box<dyn Any + Send>), Panic(Pid, Box<dyn Any + Send>),
/// The child was cooperatively cancelled via `request_stop`. Carries no
/// payload — there is nothing to propagate. Kept distinct from `Exit` so a
/// supervisor can distinguish a stop it requested from a self-termination.
Stopped(Pid),
} }
impl std::fmt::Debug for Signal { impl std::fmt::Debug for Signal {
@@ -23,6 +27,7 @@ impl std::fmt::Debug for Signal {
match self { match self {
Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid), Signal::Exit(pid) => write!(f, "Signal::Exit({:?})", pid),
Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid), Signal::Panic(pid, _) => write!(f, "Signal::Panic({:?}, ..)", pid),
Signal::Stopped(pid) => write!(f, "Signal::Stopped({:?})", pid),
} }
} }
} }
@@ -32,6 +37,7 @@ impl Signal {
match self { match self {
Signal::Exit(p) => *p, Signal::Exit(p) => *p,
Signal::Panic(p, _) => *p, Signal::Panic(p, _) => *p,
Signal::Stopped(p) => *p,
} }
} }
} }
+132
View File
@@ -0,0 +1,132 @@
//! Cooperative cancellation tests (roadmap item #1, the keystone).
//!
//! `request_stop(pid)` flags an actor for cancellation. The actor realizes the
//! stop as a *controlled unwind*: at the next observation point (a `check!()`
//! / allocation via `maybe_preempt`, or the wakeup side of any blocking park)
//! a dedicated sentinel panic is raised, the trampoline's `catch_unwind` tears
//! the stack down — running Drop guards — and reports `Outcome::Stopped`,
//! distinct from a user `Panic`. Monitors see `DownReason::Stopped`.
//!
//! These cases are deterministic under the single-thread runtime: the parent
//! runs until it parks, so the relative order of `request_stop`, the child
//! reaching its observation point, and the monitor `Down` is fixed.
use smarm::{channel, monitor, request_stop, run, spawn, yield_now, DownReason};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Sets its flag when dropped — used to prove the cancellation unwind runs
/// Drop guards rather than leaking the stack.
struct DropFlag(Arc<AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
#[test]
fn looping_actor_on_check_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let h = spawn(move || {
let _g = DropFlag(d);
// Tight loop whose only observation point is check!().
loop {
smarm::check!();
}
});
let pid = h.pid();
let down = monitor(pid);
// Flag the stop before the child is ever resumed; it will observe the
// flag once its check!() loop reaches the amortised preempt check.
request_stop(pid);
let dn = down.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run during the cancellation unwind");
}
#[test]
fn parked_on_recv_actor_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let h = spawn(move || {
let _g = DropFlag(d);
let (tx, rx) = channel::<u8>();
// Keep a sender alive so the channel stays open and recv() parks
// indefinitely rather than returning Err.
let _keep = tx;
let _ = rx.recv(); // parks here until the stop unwinds us out
});
let pid = h.pid();
let down = monitor(pid);
// Let the child run and park in recv() before we request the stop.
yield_now();
request_stop(pid);
let dn = down.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(saw_stopped.load(Ordering::SeqCst), "expected DownReason::Stopped");
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run on cancellation of a parked actor");
}
#[test]
fn no_check_no_alloc_loop_is_not_stopped() {
// Documents the inherent gap: an actor that never reaches an observation
// point (no check!(), no allocation, no blocking op) cannot be stopped —
// the same limitation as preemption. Here the child runs a bounded,
// allocation-free arithmetic loop, so the stop flagged before it runs is
// silently never honored and the actor exits normally.
let saw_exit = Arc::new(AtomicBool::new(false));
let s = saw_exit.clone();
run(move || {
let h = spawn(|| {
let mut x: u64 = 0;
for i in 0..2_000_000u64 {
x = x.wrapping_add(i ^ (x >> 1));
}
std::hint::black_box(x);
});
let pid = h.pid();
let down = monitor(pid);
request_stop(pid); // no observation point => ignored
let dn = down.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Exit) {
s.store(true, Ordering::SeqCst);
}
let _ = h.join();
});
assert!(
saw_exit.load(Ordering::SeqCst),
"a loop with no observation points must exit normally, never Stopped"
);
}
#[test]
fn join_on_stopped_actor_returns_ok() {
// A cooperative stop carries no panic payload to propagate, so join()
// reports Ok(()); the *fact* of the stop is observable via monitors
// (DownReason::Stopped), which is the channel that carries termination
// reason.
run(|| {
let h = spawn(|| loop {
smarm::check!();
});
let pid = h.pid();
request_stop(pid);
assert!(h.join().is_ok(), "join on a stopped actor returns Ok(())");
});
}