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 -15
View File
@@ -44,7 +44,7 @@ use crate::supervisor::Signal;
use crate::timer::Timers;
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::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>),
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| {
@@ -730,7 +734,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
// None with all_clear=false — idle, wait
// ----------------------------------------------------------------
enum PopResult {
Work(Pid, usize, Option<Closure>),
Work(Pid, usize, *const AtomicBool, Option<Closure>),
Idle {
next_deadline: Option<std::time::Instant>,
io_outstanding: u32,
@@ -738,23 +742,30 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
},
AllDone,
}
// SAFETY: the raw pointer is the actor's stack pointer, valid for the
// lifetime of the actor's Slot. It is only dereferenced by
// switch_to_actor() on this same thread immediately after this block.
unsafe impl Send for PopResult {} // closure is Send; usize sp is plain data
// SAFETY: the raw pointers are (a) the actor's stack pointer and (b) a
// pointer into the actor's `Arc<AtomicBool>` stop flag, both valid for
// the lifetime of the actor's Slot. They are only used by this same
// 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 len = s.run_queue.len() as u64;
stats.run_queue_len.store(len, Ordering::Relaxed);
if let Some(pid) = s.run_queue.pop_front() {
// Happy path: got a PID, grab SP and optional first closure.
let sp = s.slot(pid)
.and_then(|slot| slot.actor.as_ref().map(|a| a.sp));
match sp {
Some(sp) => {
// Happy path: got a PID, grab SP, the stop-flag pointer, and
// optional first closure. We hand out a raw pointer into the
// actor's `Arc<AtomicBool>` rather than cloning the Arc: the
// box stays alive and at a fixed address for as long as the
// 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);
PopResult::Work(pid, sp, closure)
PopResult::Work(pid, sp, stop, closure)
}
None => {
// 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 {
PopResult::Work(pid, sp, closure) => {
let (pid, sp, stop_flag, first_closure) = match pop {
PopResult::Work(pid, sp, stop, closure) => {
crate::te!(crate::trace::Event::Dequeue(pid));
(pid, sp, closure)
(pid, sp, stop, closure)
}
PopResult::AllDone => return,
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_current_pid(pid);
crate::preempt::set_current_stop(stop_flag);
reset_actor_done();
YIELD_INTENT.with(|c| c.set(YieldIntent::Yield));
crate::preempt::reset_timeslice();
@@ -852,6 +864,7 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot: usize) {
PREEMPTION_ENABLED.with(|c| c.set(false));
stats.current_pid_index.store(u32::MAX, Ordering::Relaxed);
clear_current_pid();
crate::preempt::clear_current_stop();
let intent = YIELD_INTENT.with(|c| c.get());
let new_sp = get_actor_sp();