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
+49 -1
View File
@@ -79,6 +79,10 @@ impl JoinHandle {
return match o {
Outcome::Exit => Ok(()),
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 => {
@@ -147,7 +151,13 @@ pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHa
let (idx, gen) = s.allocate_slot();
let pid = Pid::new(idx, gen);
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.outstanding_handles = 1;
slot.outcome = None;
@@ -185,11 +195,18 @@ pub fn self_pid() -> Pid {
pub fn yield_now() {
runtime::set_yield_intent(YieldIntent::Yield);
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() {
runtime::set_yield_intent(YieldIntent::Park);
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) {
@@ -220,6 +237,37 @@ pub fn unpark(pid: Pid) {
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
// ---------------------------------------------------------------------------