feat(runtime): root exit is graceful shutdown of the forest roots

The RFC 014 root-exit sweep hard-stopped every live slot once nothing was
runnable. That deferral privileged queued work over parked-with-a-pending-
wake work (a sleeper was killed, a queued cast was drained) and any attempt
to widen the notion of pending wake (timers, fd readiness) re-wedges the
run on the periodic-timer daemon the sweep exists to end.

Root exit now means "the program is done": finalize_actor delivers
request_shutdown to every forest root — each live actor whose parent is
the run (ROOT_PID) or is dead — synchronously, before the live-count
decrement. Supervisors cascade per child Shutdown policy; trapping actors
may Continue/drain with working timers and end the run when they stop
themselves; non-trapping actors are stopped outright. No forcing sweep.

Removes root_exited/root_swept, Pop::RootDrain and the idle-verdict
condition; adds tests/root_exit.rs.
This commit is contained in:
Claude (sandbox)
2026-08-19 07:11:46 +00:00
parent 9c8f59ca53
commit 250f31265b
4 changed files with 359 additions and 58 deletions
+55 -53
View File
@@ -904,17 +904,10 @@ pub(crate) struct RuntimeInner {
pub(crate) live_actors: AtomicU32,
/// Packed `(index << 32 | generation)` of the run's root (initial) actor,
/// or `u64::MAX` (the ROOT_PID sentinel) before one is set. When this actor
/// finalizes it flags `root_exited`; the scheduler's idle verdict then
/// stops the remaining (parked-forever) actors. Set once per `run()`, right
/// after the initial spawn.
/// finalizes, `finalize_actor` runs the root-exit shutdown (see
/// `shutdown_forest_roots`). Set once per `run()`, right after the initial
/// spawn.
pub(crate) root_bits: AtomicU64,
/// Set when the root actor finalizes; read by the scheduler's idle verdict
/// to trigger the one-shot teardown sweep. Reset per `run()`.
pub(crate) root_exited: AtomicBool,
/// Guards the teardown sweep to fire at most once per run (a parked-forever
/// remainder that survives the sweep falls through to the normal idle wait
/// rather than busy-spinning). Reset per `run()`.
pub(crate) root_swept: AtomicBool,
/// Timer heap. Independent lock: never nested with any other.
pub(crate) timers: Mutex<Timers>,
/// IO subsystem. `None` between runs. Lock order: io before everything.
@@ -1004,8 +997,6 @@ impl RuntimeInner {
free: RawMutex::new(free),
live_actors: AtomicU32::new(0),
root_bits: AtomicU64::new(u64::MAX),
root_exited: AtomicBool::new(false),
root_swept: AtomicBool::new(false),
timers: Mutex::new(timers),
io: Mutex::new(None),
next_monitor_id: AtomicU64::new(0),
@@ -1338,10 +1329,9 @@ impl Runtime {
// requires a running runtime in the thread-local).
RUNTIME.with(|r| *r.borrow_mut() = Some(self.inner.clone()));
let initial_handle = crate::scheduler::spawn(f);
// The initial actor is the run's root: when it exits, remaining actors
// are stopped so the run winds down (see finalize_actor / schedule_loop).
self.inner.root_exited.store(false, Ordering::Relaxed);
self.inner.root_swept.store(false, Ordering::Relaxed);
// The initial actor is the run's root: its exit means "the program is
// done" — every remaining top-level actor is asked to shut down (see
// `finalize_actor` / `shutdown_forest_roots`).
self.inner.set_root(initial_handle.pid());
// Launch N-1 extra scheduler threads, named `smarm-sched-{slot}` so
@@ -1916,14 +1906,12 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
// Reclaim if no outstanding handles (re-verified inside).
reclaim_slot(inner, pid);
// Root-exit teardown is DEFERRED to the scheduler's idle verdict, not done
// here: stopping eagerly would cut off actors that still have queued work
// (they'd unwind on the stop before draining their mailbox). Flagging it
// instead lets the run queue drain naturally first; only the parked-forever
// remainder (e.g. a server pinned alive by a registered name) is then
// stopped, once nothing runnable is left. See `schedule_loop`.
// Root exit = the program is done. Ask every top-level survivor to shut
// down, right here, before the live-count decrement below: any wake this
// produces is then ordered before `live_actors` can be observed at its
// decremented value, same as every other wakeup finalize issues.
if inner.is_root(pid) {
inner.root_exited.store(true, Ordering::Release);
shutdown_forest_roots(inner, pid);
}
// The decrement is LAST: every wakeup this finalize produced (joiners,
@@ -1934,16 +1922,51 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
debug_assert!(prev >= 1, "live_actors underflow — double finalize");
}
/// Cooperatively stop every live actor — the root-exit teardown sweep, run from
/// `schedule_loop` once the run queue is empty after the root has exited. Each
/// [`request_stop_inner`](crate::scheduler::request_stop_inner) re-verifies the
/// target under its cold lock, so the racy per-slot generation read is safe: a
/// vacant, dead, or reused slot no-ops. The swept actors unpark, unwind at their
/// next observation point, and finalize, dropping `live_actors` to zero.
fn stop_live_actors(inner: &Arc<RuntimeInner>) {
/// The root-exit shutdown. Delivers [`request_shutdown`](crate::request_shutdown)
/// to every **forest root**: each live actor whose recorded parent
/// (`Actor::supervisor` — the spawner for a plain `spawn`, the supervisor for
/// `spawn_under`) is the run itself (`ROOT_PID`) or is no longer live. Actors
/// under a live parent are not addressed — that parent is responsible for
/// them: a supervisor traps and runs its ordered, policy-driven shutdown; a
/// bare parent that dies takes non-trapping children with it via the next
/// pass of this same rule only if it dies *now*, so a parent that outlives
/// this scan and later dies leaves its subtree to itself (Erlang semantics: an
/// unlinked spawn is nobody's child).
///
/// Semantics per target follow `request_shutdown`: a trapping actor receives
/// `ExitSignal { from: root, reason: Shutdown }` and may finish work — drain,
/// keep its timers ticking, then stop itself; a non-trapping one is stopped
/// outright. There is no second, forcing sweep: an actor that traps and never
/// stops keeps the run alive by design (put it under a supervisor with a
/// `Shutdown::Timeout` policy if that is not wanted). Runs once, on the root's
/// finalize path, so it races only against actors that are still running —
/// each `request_shutdown_inner` re-verifies its target under the cold lock,
/// so a slot that dies or is reused mid-scan is a no-op.
fn shutdown_forest_roots(inner: &Arc<RuntimeInner>, root: Pid) {
for idx in 0..inner.slots.len() as u32 {
let pid = Pid::new(idx, inner.slots[idx as usize].generation());
crate::scheduler::request_stop_inner(inner, pid);
let slot = &inner.slots[idx as usize];
let pid = Pid::new(idx, slot.generation());
if pid == root {
continue;
}
// Read the parent under the cold lock (generation-verified); act
// outside it — `request_shutdown_inner` sends and may unpark.
let parent = {
let cold = slot.cold.lock();
if slot.generation() != pid.generation() {
continue;
}
match cold.actor.as_ref() {
Some(a) => a.supervisor,
None => continue,
}
};
let parent_live = inner
.slot_at(parent)
.is_some_and(|ps| ps.is_live_for(parent));
if !parent_live {
crate::scheduler::request_shutdown_inner(inner, pid, root);
}
}
}
@@ -2027,9 +2050,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
Got(Pid),
Idle,
AllDone,
/// Root has exited and nothing is runnable: stop the parked-forever
/// remainder, then re-pop. Fires at most once per run.
RootDrain,
}
// 2a. RFC 005: drain this thread's wake slot before touching the
@@ -2079,15 +2099,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
let live = inner.live_actors.load(Ordering::Acquire);
if live == 0 && io_out == 0 {
Pop::AllDone
} else if inner.root_exited.load(Ordering::Acquire)
&& !inner.root_swept.swap(true, Ordering::AcqRel)
{
// Root gone and nothing runnable — the live remainder
// are parked-forever daemons (Queued actors with pending
// work drained before the queue emptied). Stop them so
// the run can end. One-shot: a survivor falls through to
// the idle wait below on the next pass.
Pop::RootDrain
} else {
Pop::Idle
}
@@ -2115,13 +2126,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
inner.coord.wake_all();
return;
}
Pop::RootDrain => {
// Root has exited and nothing is runnable: stop the
// parked-forever remainder, then loop back to re-pop the
// now-runnable (stopping) actors.
stop_live_actors(inner);
continue;
}
Pop::Idle => {
// Something is still in flight. Park on our own futex
// until a producer wakes us (enqueue tail), a deadline
@@ -2152,8 +2156,6 @@ fn schedule_loop(inner: &Arc<RuntimeInner>, slot_idx: usize) {
|| (inner.live_actors.load(Ordering::Acquire) == 0
&& inner.io_outstanding.load(Ordering::Acquire) == 0
&& inner.io_fd_waiters.load(Ordering::Acquire) == 0)
|| (inner.root_exited.load(Ordering::Acquire)
&& !inner.root_swept.load(Ordering::Acquire))
|| inner.coord.deadline_due()
});
if tk_deadline.is_some() {