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
+53
View File
@@ -27,6 +27,7 @@
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
use std::sync::atomic::{AtomicBool, Ordering};
pub const DEFAULT_ALLOC_INTERVAL: u32 = 128;
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
/// scheduler-thread startup.
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).
@@ -122,6 +171,10 @@ pub fn maybe_preempt() {
let n = c.get();
if n == 0 {
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()) {
let start = TIMESLICE_START.with(|s| s.get());
if rdtsc().saturating_sub(start) > CONFIGURED_TIMESLICE_CYCLES.with(|t| t.get()) {