feat(gen_server,gen_statem): lifetime is the actor's — refs are addresses; inline named run

Root cause behind the "pin the endpoint" gotcha and the trapping-wrapper
pattern: a gen_server had two lifetime authorities — its refs (last one
dropped → inbox closes → exit) and, when supervised, its supervisor. OTP has
one: a process lives until it stops, is shut down, or is killed; a pid is an
address. Root exit now shutting down every forest root removes the reason the
ref-governed idiom existed (a forgotten server no longer hangs the run), so
adopt the one rule:

- The server/machine loop holds one inbox sender for its life; the inbox
  never closes. GenServerRef / GenStatemRef are addresses. Explicit close is
  `shutdown()`; a forgotten one is swept at root exit.
- `NamedGenServerBuilder::run()` / `gen_statem::run_named(name, m)` run the
  loop inline as the current actor: a server is a direct ChildSpec child,
  gets the supervisor's shutdown as handle_shutdown / a shutdown row, re-binds
  its name on restart, and is addressed by name. The wrapper in
  examples/graceful_shutdown.rs is gone.
- gen_statem gains GenStatemName + whereis_machine/send/call/shutdown by name
  (parity with gen_server); the macro gets `Sm::new`.
- Root-exit sweep records `Event::RootSweep { target, trapping }` under
  smarm-trace ("root_sweep shutdown|stopped"): unsupervised leftovers are
  visible rather than silently owned-by-refs.
- Named start() name-clash path stops the spawned actor instead of relying
  on ref drop.

Tests: tests/gen_server_lifetime.rs, tests/gen_statem_lifetime.rs,
tests/root_sweep_trace.rs (feature-gated); three existing tests that used
drop-closes-inbox now use shutdown(). Docs/README/ROADMAP/Deep Dive updated.
This commit is contained in:
Claude (sandbox)
2026-08-19 17:47:31 +00:00
parent 849a424c8e
commit 415effb2e9
15 changed files with 715 additions and 97 deletions
+8
View File
@@ -69,6 +69,14 @@ root actor returning means "the program is done": every top-level actor gets a
runtime (a signal thread), `Runtime::handle().request_shutdown(pid)` does the
same. `examples/graceful_shutdown.rs` shows all of it.
A gen_server or gen_statem lives until it stops, is shut down, or is killed; its
refs are addresses — dropping them never ends it (a forgotten one is swept at
root exit; with `--features smarm-trace` each such sweep is a `root_sweep`
trace line). The supervised shape is `GenServerBuilder::named(N).run()` /
`gen_statem::run_named(N, m)`: the server runs inline as the `ChildSpec` child
itself, so the supervisor's shutdown reaches it directly, a restart re-binds the
name, and the program addresses it by name.
## Layout
```
+5 -8
View File
@@ -273,14 +273,11 @@ outright — `join` what you need finished. No forcing sweep follows.
---
### Open items from the graceful-shutdown work (not scheduled)
- **gen_server / gen_statem as a direct supervised child.** A `ChildSpec` start
fn is `Fn()`, and `GenServerBuilder::start` spawns a *new* actor and hands
back a ref, so a supervised server today needs a trapping wrapper actor that
starts it `under(self_pid())`, forwards the shutdown and waits
(`examples/graceful_shutdown.rs::drainer_child`). An inline
`GenServerBuilder::run()` (loop as the current actor; ref handed out via a
name or a start callback) would make the primary OTP use case direct. Needed
by urus's endpoint child.
- ~~gen_server / gen_statem as a direct supervised child.~~ Done: lifetime is
the actor's (refs are addresses, the loop holds an inbox sender);
`NamedGenServerBuilder::run` / `gen_statem::run_named` run the loop inline as
the `ChildSpec` child; the root-exit sweep traces each leftover as
`root_sweep` under `smarm-trace`.
- Supervisor `Live` drop-guard sweep is `request_stop` (kill propagates as
kill); OTP would deliver a trappable `killed`. Chosen for boundedness.
- A root-exit shutdown reaches only actors live *at that instant*; a
+4 -3
View File
@@ -1620,8 +1620,9 @@
wait: <code>select</code> priority is <strong>Down arms › Watcher arm › info channels (declaration order) ›
inbox</strong>, rebuilt each turn. A hot inbox can't starve a death notice or a system message;
conversely a hot info channel <em>can</em> starve the inbox — deliberately. A closed info arm is
silently dropped from the set; a closed <em>inbox</em> (every <code>ServerRef</code> gone) is graceful
shutdown.</p>
silently dropped from the set. The inbox never closes — the loop holds one sender for its whole
life, so a <code>GenServerRef</code> is an address, not an owner: the server ends only by
<code>StopHandle::stop</code>, a shutdown, a hard stop, or a panic.</p>
<h3>Death needs no monitor</h3>
<p>Server death detection falls out of channel closure. Already dead → the inbox is closed and
@@ -1700,7 +1701,7 @@
</div>
<div class="module-card">
<div class="module-name" style="color:var(--red)">Panics in <code>terminate()</code></div>
<p>gen_server's <code>terminate()</code> runs from a drop guard, possibly mid-unwind. A panic inside it during an unwind is a double panic → process abort, no supervision tree to save you. On the panic and hard-stop paths keep it cheap, non-blocking, non-panicking. Only the graceful path (<code>handle_shutdown → Exit</code>, <code>StopHandle::stop</code>, inbox close) runs it outside an unwind, where it may do real work.</p>
<p>gen_server's <code>terminate()</code> runs from a drop guard, possibly mid-unwind. A panic inside it during an unwind is a double panic → process abort, no supervision tree to save you. On the panic and hard-stop paths keep it cheap, non-blocking, non-panicking. Only the graceful path (<code>handle_shutdown → Exit</code>, <code>StopHandle::stop</code>) runs it outside an unwind, where it may do real work.</p>
</div>
<div class="module-card">
<div class="module-name" style="color:var(--yellow)">Cold locks are leaf locks</div>
+22 -25
View File
@@ -24,10 +24,11 @@
//! just waits for the tree to come down.
use smarm::gen_server::{
GenServer, GenServerBuilder, GenServerCtx, ShutdownAction, StopHandle, TimerHandle,
GenServer, GenServerBuilder, GenServerCtx, GenServerName, ShutdownAction, StopHandle,
TimerHandle,
};
use smarm::supervisor::{ChildSpec, OneForOne, Restart, Shutdown};
use smarm::{monitor, request_shutdown, self_pid, sleep, spawn, trap_exit, DownReason};
use smarm::{sleep, spawn};
use std::thread;
use std::time::Duration;
@@ -77,28 +78,9 @@ impl GenServer for Drainer {
}
}
/// A supervised child wrapping the server. (A gen_server is not yet directly
/// usable as a `ChildSpec` start fn; the wrapper traps, forwards the shutdown,
/// and waits for the server to finish. See ROADMAP "open items".)
fn drainer_child() {
let inbox = trap_exit();
let srv = GenServerBuilder::new(Drainer {
pending: 3,
stop: None,
timer: None,
})
.under(self_pid())
.start();
let mon = monitor(srv.pid());
// Wait for our shutdown, forward it, wait for the server.
while let Ok(sig) = inbox.recv() {
if sig.reason == DownReason::Shutdown {
request_shutdown(srv.pid());
let _ = mon.rx.recv();
return;
}
}
}
/// The server's name: how the rest of the app reaches it (and the only handle
/// that survives a restart).
const DRAINER: GenServerName<Drainer> = GenServerName::new("drainer");
fn app_tree() -> OneForOne {
OneForOne::new()
@@ -111,7 +93,22 @@ fn app_tree() -> OneForOne {
})
.shutdown(Shutdown::Timeout(Duration::from_millis(100))),
)
.child(ChildSpec::new(Restart::Permanent, drainer_child).shutdown(Shutdown::Infinity))
// A gen_server is a direct child: `named(N).run()` runs the loop as
// the child actor itself, so the supervisor's shutdown arrives as
// `handle_shutdown` and a restart re-binds the name.
.child(
ChildSpec::new(Restart::Permanent, || {
GenServerBuilder::new(Drainer {
pending: 3,
stop: None,
timer: None,
})
.named(DRAINER)
.run()
.expect("drainer name is free");
})
.shutdown(Shutdown::Infinity),
)
}
fn main() {
+79 -29
View File
@@ -127,8 +127,8 @@
//! - [`GenServer::init`] runs once before the first message. Use it to start
//! timers or set up monitors; see the [`GenServerCtx`] it receives.
//! - [`GenServer::terminate`] runs when the server is about to exit. It fires
//! on every exit path (all `GenServerRef`s dropped, a handler panic, a
//! cooperative stop, or a graceful shutdown), not only on clean shutdown.
//! on every exit path (a self-stop, a graceful shutdown, a cooperative hard
//! stop, or a handler panic), not only on clean shutdown.
//! Keep it non-panicking: on the panic and hard-stop paths it runs
//! mid-unwind, where a second panic aborts the process and where it must
//! not block (any park re-observes the stop). Only on the graceful path
@@ -136,13 +136,22 @@
//!
//! ## When the server stops
//!
//! The server runs as long as at least one [`GenServerRef`] exists. When the last
//! one is dropped, the inbox closes and the loop exits normally. It can also
//! end itself: clone a [`StopHandle`] from [`GenServerCtx::stop_handle`] in
//! `init` and call [`StopHandle::stop`] from any handler — the loop breaks
//! after the current message and exits *normally* (OTP's `{stop, normal}`).
//! This is distinct from `request_stop(self_pid())`, which is an abnormal
//! `Stopped` and gets a `Transient` child restarted.
//! A server lives until it stops, is shut down, or is killed — as an OTP
//! process does. A [`GenServerRef`] is an *address*: cloning and dropping it
//! never changes the server's lifetime, and a ref that nobody holds is not a
//! leak — a forgotten server idles until the run ends, when the root-exit
//! shutdown (see [`Runtime::run`](crate::Runtime::run)) takes it down with
//! every other unsupervised actor. Anything meant to live long should be
//! supervised (see *Supervised servers* below); [`start`] / [`start_under`]
//! are for scripts, tests and short-lived helpers, and the explicit close is
//! [`GenServerRef::shutdown`].
//!
//! A server can end itself: clone a [`StopHandle`] from
//! [`GenServerCtx::stop_handle`] in `init` and call [`StopHandle::stop`] from
//! any handler — the loop breaks after the current message and exits
//! *normally* (OTP's `{stop, normal}`). This is distinct from
//! `request_stop(self_pid())`, which is an abnormal `Stopped` and gets a
//! `Transient` child restarted.
//!
//! ## Graceful shutdown
//!
@@ -193,9 +202,23 @@
//! Names and registration: a server can be given a static name so other
//! actors can reach it without holding a `GenServerRef`. Use
//! [`GenServerBuilder::named`] to register on start, and the free functions
//! [`call`], [`cast`], and [`whereis_server`] to address it by name. Registered
//! servers are a natural fit for supervision; see `supervisor` for how to
//! build a tree that restarts servers on failure.
//! [`call`], [`cast`], and [`whereis_server`] to address it by name.
//!
//! ## Supervised servers
//!
//! The supervised shape is [`NamedGenServerBuilder::run`]: it runs the loop
//! **inline, as the current actor**, so the closure of a
//! [`ChildSpec`](crate::supervisor::ChildSpec) *is* the server — the
//! supervisor's shutdown arrives as [`GenServer::handle_shutdown`], a restart
//! runs the factory again and re-binds the name, and the rest of the program
//! addresses it by name (a held ref would go stale on restart anyway).
//!
//! ```ignore
//! const COUNTER: GenServerName<Counter> = GenServerName::new("counter");
//! OneForOne::new().child(ChildSpec::new(Restart::Permanent, || {
//! GenServerBuilder::new(Counter::default()).named(COUNTER).run().unwrap();
//! }));
//! ```
//!
//! ## Limitations
//!
@@ -317,9 +340,9 @@ enum Envelope<G: GenServer> {
Cast(G::Cast),
}
/// A clonable handle to a running server. Cloning yields another sender to the
/// same inbox; the server lives until the last `GenServerRef` is dropped, at which
/// point its inbox closes and the loop exits normally.
/// A clonable handle to a running server: an *address*, not an owner. Cloning
/// yields another sender to the same inbox; dropping refs never ends the
/// server (see the module docs, *When the server stops*).
pub struct GenServerRef<G: GenServer> {
tx: Sender<Envelope<G>>,
pid: Pid,
@@ -423,9 +446,8 @@ impl<G: GenServer> GenServerRef<G> {
/// a supervisor uses its child's [`Shutdown`](crate::supervisor::Shutdown)
/// policy to bound it.
///
/// This is the right teardown for a server kept alive by a registered
/// [`GenServerName`], where dropping every external `GenServerRef` is not enough
/// to close the inbox. Like all cooperative cancellation, it is best-effort:
/// This is the explicit close: dropping refs never ends a server. Like
/// all cooperative cancellation, it is best-effort:
/// a server wedged in a tight loop with no observation point cannot be
/// stopped this way. Panics if called outside `Runtime::run()`.
pub fn shutdown(&self) {
@@ -807,9 +829,10 @@ impl<G: GenServer> GenServerBuilder<G> {
self
}
/// Spawn the server actor and hand back its [`GenServerRef`]. The server's
/// lifetime is governed by its refs, not by joining, so the backing join
/// handle is dropped.
/// Spawn the server actor and hand back its [`GenServerRef`] (an address;
/// the server's lifetime is its own, see the module docs). The backing
/// join handle is dropped. For a supervised server use
/// [`named`](Self::named) + [`NamedGenServerBuilder::run`] instead.
pub fn start(self) -> GenServerRef<G> {
self.spawn_server()
}
@@ -837,13 +860,14 @@ impl<G: GenServer> GenServerBuilder<G> {
supervisor,
stack_opts,
} = self;
let keep = tx.clone();
let handle = match supervisor {
Some(sup) => crate::scheduler::spawn_under_with(sup, stack_opts, move || {
server_loop::<G>(rx, state, infos)
server_loop::<G>(keep, rx, state, infos)
}),
None => crate::scheduler::spawn_with(stack_opts, move || {
server_loop::<G>(keep, rx, state, infos)
}),
None => {
crate::scheduler::spawn_with(stack_opts, move || server_loop::<G>(rx, state, infos))
}
};
GenServerRef {
tx,
@@ -931,19 +955,38 @@ impl<G: GenServer> NamedGenServerBuilder<G> {
/// The inbox sender is published under the name **from the parent side,
/// before this returns**, so a by-name `call` / `cast` resolves the instant
/// `start()` returns — no race with the server body. On a name clash the
/// just-spawned server is wound down (its only ref is dropped, closing the
/// inbox), so a failed bind leaks no actor.
/// just-spawned server is stopped, so a failed bind leaks no actor.
pub fn start(self) -> Result<GenServerRef<G>, RegisterError> {
let NamedGenServerBuilder { builder, name } = self;
let server = builder.spawn_server();
match register_with::<Envelope<G>>(server.pid, name, server.tx.clone()) {
Ok(()) => Ok(server),
Err(e) => {
drop(server); // inbox closes → loop exits gracefully
crate::scheduler::request_stop(server.pid); // never ran init
Err(e)
}
}
}
/// Run the server **inline, as the current actor**, bound to its name.
/// This is the supervised shape: the closure of a
/// [`ChildSpec`](crate::supervisor::ChildSpec) *is* the server, so the
/// supervisor's shutdown reaches it as [`GenServer::handle_shutdown`], a
/// restart runs the factory again and re-binds the name, and clients
/// address it by name ([`call`], [`cast`], [`whereis_server`]). Returns
/// when the server exits; [`RegisterError::NameTaken`] (before `init`) if
/// the name is held by a different live actor.
///
/// `under` / `stack_opts` are spawn options and do not apply here — the
/// actor already exists.
pub fn run(self) -> Result<(), RegisterError> {
let NamedGenServerBuilder { builder, name } = self;
let GenServerBuilder { state, infos, .. } = builder;
let (tx, rx) = channel::<Envelope<G>>();
register_with::<Envelope<G>>(crate::scheduler::self_pid(), name, tx.clone())?;
server_loop::<G>(tx, rx, state, infos);
Ok(())
}
}
/// Resolve a [`GenServerName`] to a [`GenServerRef`] when you want a handle to hold or
@@ -1002,10 +1045,17 @@ pub fn start_under<G: GenServer>(supervisor: Pid, state: G) -> GenServerRef<G> {
}
fn server_loop<G: GenServer>(
keep: Sender<Envelope<G>>,
rx: Receiver<Envelope<G>>,
state: G,
mut infos: Vec<Receiver<G::Info>>,
) {
// The loop holds one inbox sender for its whole life: the inbox never
// closes, so refs are addresses and the server's lifetime is the actor's
// (stop handle, shutdown, stop, panic). The `Disconnected` arms below are
// defensive only.
let _keep = keep;
// Drop guard — owns the server state and the timer registry.
//
// Why a guard rather than code after the loop:
@@ -1117,7 +1167,7 @@ fn server_loop<G: GenServer>(
guard.0.handle_idle();
reset_idle(&mut idle_deadline);
}
// All ServerRefs dropped → inbox closed → shutdown.
// Defensive: the loop holds a sender, so unreachable.
Err(RecvTimeoutError::Disconnected) => break,
}
}
+120 -16
View File
@@ -71,8 +71,12 @@
//!
//! ## Stopping, shutdown, and exits
//!
//! A machine runs while a [`GenStatemRef`] exists; dropping the last one
//! closes the inbox and ends it normally. It can also end itself: any row
//! A machine lives until it stops, is shut down, or is killed; a
//! [`GenStatemRef`] is an address, and dropping refs never ends it (the
//! gen_server rule — see its *When the server stops*). The supervised shape
//! is [`run_named`], which runs the machine inline as the current actor so it
//! is a direct `ChildSpec` child addressed by [`GenStatemName`]. A machine
//! can end itself: any row
//! body may call [`cx.stop()`](Cx::stop) (or use the `stop` tail keyword,
//! sugar for `{ cx.stop(); prev }`) — the loop breaks after that event and the
//! actor exits *normally* (OTP's `{stop, normal}`; a `Transient` child is not
@@ -94,6 +98,7 @@ use crate::channel::{channel, select, Receiver, Selectable, Sender};
use crate::link::ExitSignal;
use crate::monitor::{monitor, DownReason};
use crate::pid::Pid;
use crate::registry::{register_with, resolve_named_sender, RegisterError};
use crate::scheduler::{cancel_timer, request_shutdown, send_after_to};
use crate::timer::TimerId;
use std::collections::{HashMap, VecDeque};
@@ -162,11 +167,12 @@ pub trait Machine: Send + 'static {
None
}
/// Runs as the machine actor exits, on any exit path (inbox closed, a
/// `stop`, a handler panic, a hard stop). Like `gen_server::terminate`:
/// on the panic and hard-stop paths it runs mid-unwind — do not panic or
/// park there; only on the normal path (`stop`, inbox close) may it do real
/// work. The macro's optional `terminate { … }` block generates it.
/// Runs as the machine actor exits, on any exit path (a `stop`, a
/// graceful shutdown, a handler panic, a hard stop). Like
/// `gen_server::terminate`: on the panic and hard-stop paths it runs
/// mid-unwind — do not panic or park there; only on the normal path
/// (`stop`, shutdown rows) may it do real work. The macro's optional
/// `terminate { … }` block generates it.
fn terminate(&mut self) {}
}
@@ -460,8 +466,7 @@ pub enum SendError {
}
/// A clonable handle to a running machine. Cloning yields another sender to the
/// same inbox; the machine lives until the last `GenStatemRef` is dropped, at which
/// point its inbox closes and the loop exits.
/// same inbox. An address, not an owner: dropping refs never ends the machine.
pub struct GenStatemRef<M: Machine> {
tx: Sender<M::Ev>,
pid: Pid,
@@ -528,7 +533,8 @@ impl<M: Machine> GenStatemRef<M> {
/// Spawn `machine` as an actor and hand back its [`GenStatemRef`]. Shape mirrors
/// `gen_server::start`: make the inbox, spawn the loop, return the ref; the
/// backing join handle is dropped (lifetime is governed by refs, not joining).
/// backing join handle is dropped (the machine's lifetime is its own). For a
/// supervised machine use [`run_named`].
///
/// Panics if called outside `Runtime::run()`.
pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
@@ -543,16 +549,104 @@ pub fn spawn<M: Machine>(machine: M) -> GenStatemRef<M> {
/// Panics if called outside `Runtime::run()`.
pub fn spawn_with<M: Machine>(opts: crate::scheduler::SpawnOpts, machine: M) -> GenStatemRef<M> {
let (tx, rx) = channel::<M::Ev>();
let handle = crate::scheduler::spawn_with(opts, move || statem_loop(rx, machine));
let keep = tx.clone();
let handle = crate::scheduler::spawn_with(opts, move || statem_loop(keep, rx, machine));
GenStatemRef {
tx,
pid: handle.pid(),
}
}
/// A typed, static name for a gen_statem, used to address a machine through
/// the registry without holding a [`GenStatemRef`]. Mirrors
/// [`GenServerName`](crate::gen_server::GenServerName): declare it as a
/// constant and bind it with [`run_named`].
pub struct GenStatemName<M> {
name: &'static str,
_marker: PhantomData<fn() -> M>,
}
impl<M> GenStatemName<M> {
/// Bind a static string as a machine name.
#[inline]
pub const fn new(name: &'static str) -> Self {
Self {
name,
_marker: PhantomData,
}
}
/// The underlying registry key.
#[inline]
pub const fn as_str(self) -> &'static str {
self.name
}
}
impl<M> Copy for GenStatemName<M> {}
impl<M> Clone for GenStatemName<M> {
fn clone(&self) -> Self {
*self
}
}
/// Run `machine` **inline, as the current actor**, bound to `name`. The
/// supervised shape: the closure of a
/// [`ChildSpec`](crate::supervisor::ChildSpec) *is* the machine, so the
/// supervisor's shutdown reaches it as a `shutdown` row, a restart runs the
/// factory again and re-binds the name, and clients address it by name
/// ([`send`], [`call`], [`whereis_machine`]). Returns when the machine exits;
/// [`RegisterError::NameTaken`] (before `on_start`) if the name is held by a
/// different live actor. Mirrors
/// [`NamedGenServerBuilder::run`](crate::gen_server::NamedGenServerBuilder::run).
pub fn run_named<M: Machine>(name: GenStatemName<M>, machine: M) -> Result<(), RegisterError> {
let (tx, rx) = channel::<M::Ev>();
register_with::<M::Ev>(crate::scheduler::self_pid(), name.as_str(), tx.clone())?;
statem_loop(tx, rx, machine);
Ok(())
}
/// Resolve a [`GenStatemName`] to a [`GenStatemRef`]; `None` if no live
/// machine holds the name. Panics if called outside `Runtime::run()`.
pub fn whereis_machine<M: Machine>(name: GenStatemName<M>) -> Option<GenStatemRef<M>> {
resolve_named_sender::<M::Ev>(name.as_str()).map(|(pid, tx)| GenStatemRef { tx, pid })
}
/// Push an event to the machine registered under `name`, resolving per send.
/// [`SendError::Down`] if no live machine holds the name.
pub fn send<M: Machine>(name: GenStatemName<M>, ev: M::Ev) -> Result<(), SendError> {
match whereis_machine(name) {
Some(m) => m.send(ev),
None => Err(SendError::Down),
}
}
/// Synchronous request-reply to the machine registered under `name`,
/// resolving per call (a machine restarted under the same name is reached
/// transparently). [`CallError::Down`] if no live machine holds the name.
pub fn call<M, T, F>(name: GenStatemName<M>, make: F) -> Result<T, CallError>
where
M: Machine,
T: Send + 'static,
F: FnOnce(Reply<T>) -> M::Ev,
{
match whereis_machine(name) {
Some(m) => m.call(make),
None => Err(CallError::Down),
}
}
/// Shut down the machine registered under `name` and wait for it (see
/// [`GenStatemRef::shutdown`]). A no-op if no live machine holds the name.
pub fn shutdown<M: Machine>(name: GenStatemName<M>) {
if let Some(m) = whereis_machine(name) {
m.shutdown();
}
}
/// The machine actor body: `on_start`, then one `handle` per event until the
/// inbox closes (all refs dropped), a row resolves to `stop`, or the actor is
/// stopped from outside.
/// row resolves to `stop`, a shutdown row stops it, or the actor is stopped
/// from outside.
///
/// Intake arms are selected each iteration in priority order — **exits**
/// (only when trapping) above **timers** above the **inbox** — so a shutdown
@@ -566,7 +660,11 @@ pub fn spawn_with<M: Machine>(opts: crate::scheduler::SpawnOpts, machine: M) ->
/// it back ([`Step::Postponed`]) for the queue; a `handle` that transitions
/// ([`Step::Transitioned`]) triggers a [`replay`] of the queue in the new
/// state, ahead of the next intake.
fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, machine: M) {
fn statem_loop<M: Machine>(keep: Sender<M::Ev>, rx: Receiver<M::Ev>, machine: M) {
// One inbox sender lives with the loop: the inbox never closes, refs are
// addresses, the machine's lifetime is the actor's (stop row, shutdown,
// stop, panic). The `Disconnected` inbox arm below is defensive only.
let _keep = keep;
// Drop guard — owns the machine and the timer registry, so `terminate`
// fires on every exit path (clean close, `stop`, a handler panic, a hard
// stop) and the timer drain is sequenced before it. Same shape as
@@ -685,7 +783,7 @@ fn statem_loop<M: Machine>(rx: Receiver<M::Ev>, machine: M) {
match rx.try_recv() {
Ok(Some(ev)) => dispatch(&mut guard.0, &mut cx, &mut postpone, ev),
Ok(None) => debug_assert!(false, "ready inbox was empty"),
// All GenStatemRefs dropped → inbox closed → normal exit.
// Defensive: the loop holds a sender, so unreachable.
Err(_) => break,
}
}
@@ -991,8 +1089,14 @@ macro_rules! gen_statem {
}
impl $sm {
/// The machine value, for [`gen_statem::run_named`]
/// (`$crate::gen_statem::run_named`) or `spawn`.
fn new(init: $State, data: $Data) -> $sm {
$sm { state: init, data }
}
fn start(init: $State, data: $Data) -> $crate::gen_statem::GenStatemRef<$sm> {
$crate::gen_statem::spawn($sm { state: init, data })
$crate::gen_statem::spawn($sm::new(init, data))
}
#[allow(unused_variables)]
+1 -1
View File
@@ -63,7 +63,7 @@ pub use gen_server::{
ShutdownAction, StopHandle, TimerHandle, Watcher,
};
pub use gen_statem::{
CallError as GenStatemCallError, Cx, GenStatemRef, Machine, Reply, Resolution,
CallError as GenStatemCallError, Cx, GenStatemName, GenStatemRef, Machine, Reply, Resolution,
SendError as GenStatemSendError,
};
pub use introspect::{
+12 -1
View File
@@ -1965,7 +1965,18 @@ fn shutdown_forest_roots(inner: &Arc<RuntimeInner>, root: Pid) {
.slot_at(parent)
.is_some_and(|ps| ps.is_live_for(parent));
if !parent_live {
crate::scheduler::request_shutdown_inner(inner, pid, root);
// `_probe` so the trace can say what each leftover was: under
// `smarm-trace` every swept actor is a `root_sweep` line — the
// visibility that makes "a forgotten actor costs only a slot, never
// a hung run" a checkable claim rather than a hope.
let found = crate::scheduler::request_shutdown_inner_probe(inner, pid, root);
#[cfg_attr(not(feature = "smarm-trace"), allow(unused_variables))]
if let Some(trapping) = found {
crate::te!(crate::trace::Event::RootSweep {
target: pid,
trapping
});
}
}
}
}
+17 -2
View File
@@ -662,6 +662,17 @@ pub fn request_shutdown<A>(pid: Pid<A>) {
// cold lock (generation-verified), then acts outside the lock: a trap send
// may unpark the receiver, and `request_stop_inner` re-takes the lock.
pub(crate) fn request_shutdown_inner(inner: &RuntimeInner, pid: Pid, from: Pid) {
request_shutdown_inner_probe(inner, pid, from);
}
/// [`request_shutdown_inner`], reporting what it found: `Some(true)` if the
/// target was trapping (got the signal), `Some(false)` if it was stopped
/// outright, `None` if there was nothing live at `pid`.
pub(crate) fn request_shutdown_inner_probe(
inner: &RuntimeInner,
pid: Pid,
from: Pid,
) -> Option<bool> {
let trap = match inner.slot_at(pid) {
Some(slot) => {
let cold = slot.cold.lock();
@@ -679,9 +690,13 @@ pub(crate) fn request_shutdown_inner(inner: &RuntimeInner, pid: Pid, from: Pid)
from,
reason: crate::monitor::DownReason::Shutdown,
});
Some(true)
}
Some(None) => request_stop_inner(inner, pid),
None => {}
Some(None) => {
request_stop_inner(inner, pid);
Some(false)
}
None => None,
}
}
+24 -2
View File
@@ -46,17 +46,32 @@ mod inner {
#[derive(Clone, Debug)]
pub enum Event {
// Actor lifecycle
Spawn { parent: Pid, child: Pid },
Spawn {
parent: Pid,
child: Pid,
},
Resume(Pid),
Yield(Pid),
Park(Pid),
Done(Pid),
/// Root exit found a live forest root (an actor nobody supervises)
/// and delivered `request_shutdown` to it. `trapping` says whether it
/// got the chance to drain (true) or was stopped outright (false).
/// Every such line is an actor whose lifetime was nobody's business
/// but the runtime's — the way to *see* unsupervised leftovers.
RootSweep {
target: Pid,
trapping: bool,
},
// Wakeup paths
UnparkDirect(Pid), // unpark() saw Parked -> re-queued immediately
UnparkDeferred(Pid), // unpark() saw Runnable -> set pending_unpark flag
UnparkFlagConsumed(Pid), // scheduler saw flag on Park -> re-queued instead
// Channel
Send { sender: Pid, receiver: Option<Pid> },
Send {
sender: Pid,
receiver: Option<Pid>,
},
RecvPark(Pid),
RecvWake(Pid),
// Queue
@@ -253,6 +268,13 @@ mod inner {
Event::Yield(p) => ("yield".into(), p.index()),
Event::Park(p) => ("park".into(), p.index()),
Event::Done(p) => ("done".into(), p.index()),
Event::RootSweep { target, trapping } => (
format!(
"root_sweep {}",
if *trapping { "shutdown" } else { "stopped" }
),
target.index(),
),
Event::UnparkDirect(p) => ("unpark_direct".into(), p.index()),
Event::UnparkDeferred(p) => ("unpark_deferred".into(), p.index()),
Event::UnparkFlagConsumed(p) => ("unpark_flag_consumed".into(), p.index()),
+6 -6
View File
@@ -88,7 +88,7 @@ impl GenServer for Lifecycle {
}
}
// init -> handle_call -> (drop last ref closes inbox) -> terminate.
// init -> handle_call -> shutdown -> terminate.
#[test]
fn init_and_terminate_run() {
let log = Arc::new(Mutex::new(Vec::new()));
@@ -96,9 +96,9 @@ fn init_and_terminate_run() {
run(move || {
let server = start(Lifecycle { log: log2 });
server.call(()).unwrap();
// Dropping the only ref closes the inbox; the server breaks out of its
// recv loop and runs terminate. run() will not return until it has.
drop(server);
// Refs are addresses: dropping one does not end the server. The
// explicit close does, and waits for terminate.
server.shutdown();
});
assert_eq!(*log.lock().unwrap(), vec!["init", "call", "terminate"]);
}
@@ -702,8 +702,8 @@ fn no_timer_survives_exit() {
let _ = server.call(()).unwrap(); // sync: periodic armed
smarm::sleep(Duration::from_millis(45)); // a couple of ticks
let mon = smarm::monitor(server.pid());
drop(server); // inbox closes → loop exits → guard drains timers
// Clean Down ⇒ the loop returned without the no-leak assert aborting.
server.shutdown(); // loop exits → guard drains timers
// Clean Down ⇒ the loop returned without the no-leak assert aborting.
assert!(mon.rx.recv().is_ok());
let at_exit = f_read.lock().unwrap().len();
smarm::sleep(Duration::from_millis(90)); // would be several more ticks
+243
View File
@@ -0,0 +1,243 @@
//! gen_server lifetime is the actor's, not its refs' (OTP: a pid is an
//! address, a process lives until it stops, is shut down, or is killed).
//!
//! - Dropping the last `GenServerRef` does NOT end the server. It ends via
//! `StopHandle::stop`, `request_shutdown` / `GenServerRef::shutdown`,
//! `request_stop`, or a handler panic.
//! - `GenServerBuilder::named(N).run()` runs the loop inline as the *current*
//! actor, so a server is a direct `ChildSpec` child: the supervisor's
//! shutdown reaches it as `handle_shutdown`, a restart re-binds the name,
//! and by-name `call`/`cast` reach whichever incarnation is live.
use smarm::gen_server::{
self, GenServer, GenServerBuilder, GenServerCtx, GenServerName, ShutdownAction, StopHandle,
};
use smarm::registry::RegisterError;
use smarm::supervisor::{ChildSpec, OneForOne, Restart, Shutdown};
use smarm::{monitor, request_shutdown, run, sleep, spawn, DownReason};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Default, Clone)]
struct Log(Arc<Mutex<Vec<String>>>);
impl Log {
fn push(&self, s: impl Into<String>) {
self.0.lock().unwrap().push(s.into());
}
fn get(&self) -> Vec<String> {
self.0.lock().unwrap().clone()
}
}
struct Counter {
log: Log,
n: u64,
trap: bool,
stop: Option<StopHandle<Counter>>,
}
enum Call {
Get,
}
enum Cast {
Inc,
Stop,
}
impl GenServer for Counter {
type Call = Call;
type Reply = u64;
type Cast = Cast;
type Info = ();
type Timer = ();
fn init(&mut self, ctx: &GenServerCtx<Self>) {
if self.trap {
ctx.trap_exit();
}
self.stop = Some(ctx.stop_handle());
self.log.push("init");
}
fn handle_call(&mut self, Call::Get: Call) -> u64 {
self.n
}
fn handle_cast(&mut self, c: Cast) {
match c {
Cast::Inc => self.n += 1,
Cast::Stop => self.stop.as_ref().unwrap().stop(),
}
}
fn handle_shutdown(&mut self) -> ShutdownAction {
self.log.push("handle_shutdown");
ShutdownAction::Exit
}
fn terminate(&mut self) {
self.log.push("terminate");
}
}
fn counter(log: &Log, trap: bool) -> Counter {
Counter {
log: log.clone(),
n: 0,
trap,
stop: None,
}
}
// ---------------------------------------------------------------------------
// Refs are addresses: dropping the last one does not end the server.
// ---------------------------------------------------------------------------
#[test]
fn dropping_last_ref_does_not_end_server() {
let log = Log::default();
let l = log.clone();
run(move || {
let srv = gen_server::start(counter(&l, true));
let pid = srv.pid();
srv.cast(Cast::Inc).unwrap();
assert_eq!(srv.call(Call::Get).unwrap(), 1);
let mon = monitor(pid);
drop(srv);
sleep(Duration::from_millis(30));
assert!(
mon.rx.try_recv().unwrap().is_none(),
"server must outlive its last ref"
);
assert_eq!(l.get(), vec!["init"], "terminate must not have run");
// Explicit teardown still works, and is what ends it.
request_shutdown(pid);
let down = mon.rx.recv().unwrap();
assert_eq!(down.reason, DownReason::Exit);
});
assert_eq!(log.get(), vec!["init", "handle_shutdown", "terminate"]);
}
#[test]
fn ref_shutdown_is_the_explicit_close() {
let log = Log::default();
let l = log.clone();
run(move || {
let srv = gen_server::start(counter(&l, true));
srv.call(Call::Get).unwrap(); // sync: init (and trap_exit) has run
srv.shutdown(); // graceful, waits
assert_eq!(l.get(), vec!["init", "handle_shutdown", "terminate"]);
});
}
#[test]
fn forgotten_server_is_shut_down_at_root_exit() {
// A ref-less server is not a hung run: root exit shuts it down.
let log = Log::default();
let l = log.clone();
run(move || {
let srv = gen_server::start(counter(&l, false));
drop(srv);
});
assert_eq!(log.get(), vec!["init", "terminate"]);
}
// ---------------------------------------------------------------------------
// Inline run: a gen_server as a direct ChildSpec child.
// ---------------------------------------------------------------------------
const COUNTER: GenServerName<Counter> = GenServerName::new("lifetime-counter");
#[test]
fn named_run_is_a_direct_supervised_child_and_gets_shutdown() {
let log = Log::default();
let l = log.clone();
run(move || {
let l2 = l.clone();
let sup = spawn(move || {
let l3 = l2.clone();
OneForOne::new()
.child(
ChildSpec::new(Restart::Permanent, move || {
GenServerBuilder::new(counter(&l3, true))
.named(COUNTER)
.run()
.expect("name free");
})
.shutdown(Shutdown::Infinity),
)
.run();
});
sleep(Duration::from_millis(10));
gen_server::cast(COUNTER, Cast::Inc).unwrap();
assert_eq!(gen_server::call(COUNTER, Call::Get).unwrap(), 1);
request_shutdown(sup.pid());
sup.join()
.expect("ordered shutdown, supervisor returns normally");
assert_eq!(l.get(), vec!["init", "handle_shutdown", "terminate"]);
assert!(gen_server::whereis_server(COUNTER).is_none());
});
}
#[test]
fn named_run_child_restarts_and_rebinds_name() {
let log = Log::default();
let inits = Arc::new(AtomicUsize::new(0));
let l = log.clone();
let i = inits.clone();
run(move || {
let l2 = l.clone();
let i2 = i.clone();
let sup = spawn(move || {
let l3 = l2.clone();
let i3 = i2.clone();
OneForOne::new()
.child(ChildSpec::new(Restart::Permanent, move || {
i3.fetch_add(1, Ordering::SeqCst);
GenServerBuilder::new(counter(&l3, false))
.named(COUNTER)
.run()
.expect("name free on (re)start");
}))
.run();
});
sleep(Duration::from_millis(10));
gen_server::cast(COUNTER, Cast::Inc).unwrap();
assert_eq!(gen_server::call(COUNTER, Call::Get).unwrap(), 1);
// Normal self-exit → Permanent restarts it, fresh state, same name.
gen_server::cast(COUNTER, Cast::Stop).unwrap();
sleep(Duration::from_millis(30));
assert_eq!(i.load(Ordering::SeqCst), 2, "restarted once");
assert_eq!(gen_server::call(COUNTER, Call::Get).unwrap(), 0);
request_shutdown(sup.pid());
sup.join().unwrap();
});
assert_eq!(log.get(), vec!["init", "terminate", "init", "terminate"]);
}
#[test]
fn named_run_name_clash_fails_before_init() {
let log = Log::default();
let l = log.clone();
run(move || {
let first = GenServerBuilder::new(counter(&l, false))
.named(COUNTER)
.start()
.unwrap();
let l2 = l.clone();
let res = Arc::new(Mutex::new(None));
let r2 = res.clone();
let first_pid = first.pid();
spawn(move || {
let r = GenServerBuilder::new(counter(&l2, false))
.named(COUNTER)
.run();
*r2.lock().unwrap() = Some(r);
})
.join()
.unwrap();
assert_eq!(
*res.lock().unwrap(),
Some(Err(RegisterError::NameTaken { holder: first_pid }))
);
assert_eq!(l.get(), vec!["init"], "clashing server never ran init");
first.shutdown();
});
}
+10 -4
View File
@@ -196,12 +196,18 @@ fn linked_peer_death_reaches_handle_exit() {
r.cast(Cast::Note("still-serving")).unwrap();
sleep(Duration::from_millis(20));
a.store(true, Ordering::SeqCst);
let mon = monitor(r.pid());
drop(r); // inbox closes → clean exit
let _ = mon.rx.recv(); // don't let the root-exit sweep race terminate
r.shutdown(); // graceful; waits for terminate
});
assert!(alive.load(Ordering::SeqCst));
assert_eq!(log.get(), vec!["handle_exit", "still-serving", "terminate"]);
assert_eq!(
log.get(),
vec![
"handle_exit",
"still-serving",
"handle_shutdown",
"terminate"
]
);
}
#[test]
+128
View File
@@ -0,0 +1,128 @@
//! gen_statem lifetime parity with gen_server: a machine lives until it
//! stops, is shut down, or is killed — its refs are addresses. And
//! `gen_statem::run_named` runs a machine inline as the current actor, so it
//! is a direct `ChildSpec` child addressed by name.
use smarm::gen_statem::{self, GenStatemName, Reply};
use smarm::supervisor::{ChildSpec, OneForOne, Restart, Shutdown};
use smarm::{monitor, request_shutdown, run, sleep, spawn, DownReason};
use std::sync::{Arc, Mutex};
use std::time::Duration;
#[derive(Default, Clone)]
struct Log(Arc<Mutex<Vec<&'static str>>>);
impl Log {
fn push(&self, e: &'static str) {
self.0.lock().unwrap().push(e);
}
fn get(&self) -> Vec<&'static str> {
self.0.lock().unwrap().clone()
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum S {
On,
}
struct D {
log: Log,
trap: bool,
n: u64,
}
enum Cast {
Inc,
StopNow,
}
enum Call {
Get(Reply<u64>),
}
smarm::gen_statem! {
machine: Sm { state: S, data: D };
event: Ev { cast: Cast, call: Call, info: () };
context(data, prev, cx);
enter {
S::On => { data.log.push("enter"); if data.trap { cx.trap_exit() } },
}
on S::On => {
cast Cast::Inc => { data.n += 1; prev },
cast Cast::StopNow => stop,
call Call::Get(r) => { r.reply(data.n); prev },
shutdown => { data.log.push("shutdown"); cx.stop(); prev },
state_timeout => unhandled,
timeout _ => unhandled,
}
terminate { data.log.push("terminate"); }
}
fn d(log: &Log, trap: bool) -> D {
D {
log: log.clone(),
trap,
n: 0,
}
}
#[test]
fn dropping_last_ref_does_not_end_machine() {
let log = Log::default();
let l = log.clone();
run(move || {
let m = Sm::start(S::On, d(&l, true));
let pid = m.pid();
m.send(Ev::Cast(Cast::Inc)).unwrap();
assert_eq!(m.call(|r| Ev::Call(Call::Get(r))).unwrap(), 1);
let mon = monitor(pid);
drop(m);
sleep(Duration::from_millis(30));
assert!(
mon.rx.try_recv().unwrap().is_none(),
"machine must outlive its refs"
);
assert_eq!(l.get(), vec!["enter"]);
request_shutdown(pid);
assert_eq!(mon.rx.recv().unwrap().reason, DownReason::Exit);
});
assert_eq!(log.get(), vec!["enter", "shutdown", "terminate"]);
}
const SM: GenStatemName<Sm> = GenStatemName::new("lifetime-sm");
#[test]
fn run_named_is_a_direct_supervised_child() {
let log = Log::default();
let l = log.clone();
run(move || {
let l2 = l.clone();
let sup = spawn(move || {
let l3 = l2.clone();
OneForOne::new()
.child(
ChildSpec::new(Restart::Permanent, move || {
gen_statem::run_named(SM, Sm::new(S::On, d(&l3, true))).expect("name free");
})
.shutdown(Shutdown::Infinity),
)
.run();
});
sleep(Duration::from_millis(10));
gen_statem::send(SM, Ev::Cast(Cast::Inc)).unwrap();
assert_eq!(gen_statem::call(SM, |r| Ev::Call(Call::Get(r))).unwrap(), 1);
// Normal self-exit → Permanent restart → fresh data, same name.
gen_statem::send(SM, Ev::Cast(Cast::StopNow)).unwrap();
sleep(Duration::from_millis(30));
assert_eq!(gen_statem::call(SM, |r| Ev::Call(Call::Get(r))).unwrap(), 0);
request_shutdown(sup.pid());
sup.join().unwrap();
assert!(gen_statem::whereis_machine(SM).is_none());
});
assert_eq!(
log.get(),
vec!["enter", "terminate", "enter", "shutdown", "terminate"]
);
}
+36
View File
@@ -0,0 +1,36 @@
//! Under `smarm-trace`, every actor the root-exit sweep reaches is recorded
//! as a `root_sweep` event — the way to *see* unsupervised leftovers. One test
//! per binary: the trace file is process-global.
#![cfg(feature = "smarm-trace")]
use smarm::gen_server::{self, GenServer, GenServerCtx};
use smarm::run;
struct Quiet;
impl GenServer for Quiet {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn init(&mut self, _: &GenServerCtx<Self>) {}
fn handle_call(&mut self, _: ()) {}
fn handle_cast(&mut self, _: ()) {}
}
#[test]
fn forgotten_server_shows_up_as_root_sweep() {
let path = std::env::temp_dir().join(format!("smarm_root_sweep_{}.json", std::process::id()));
std::env::set_var("SMARM_TRACE_FILE", &path);
run(|| {
let srv = gen_server::start(Quiet);
srv.call(()).unwrap();
drop(srv); // forgotten: nobody supervises it, nobody holds it
});
let trace = std::fs::read_to_string(&path).expect("trace file written");
let _ = std::fs::remove_file(&path);
assert!(
trace.contains("root_sweep stopped"),
"expected a root_sweep line for the non-trapping leftover; got:\n{trace}"
);
}