diff --git a/README.md b/README.md
index 2058756..75239be 100644
--- a/README.md
+++ b/README.md
@@ -54,6 +54,21 @@ run(|| {
});
```
+## Stopping actors
+
+Two strengths, as in OTP. `request_stop(pid)` is `exit(Pid, kill)`: a cooperative
+hard stop, unwinding at the actor's next observation point. `request_shutdown(pid)`
+is `exit(Pid, shutdown)`: an actor that traps exits (`trap_exit()`, or
+`ctx.trap_exit()` in a gen_server / `cx.trap_exit()` in a gen_statem) receives it
+as a signal — `handle_shutdown` / a `shutdown` row — and may drain before stopping
+itself; one that does not trap is stopped outright. Supervisors trap:
+`request_shutdown(sup)` tears the tree down top-down, each child per its
+`ChildSpec` `Shutdown` policy (`Timeout(d)`, `Infinity`, `BrutalKill`). The run's
+root actor returning means "the program is done": every top-level actor gets a
+`request_shutdown`, and `run()` returns when they are gone. From outside the
+runtime (a signal thread), `Runtime::handle().request_shutdown(pid)` does the
+same. `examples/graceful_shutdown.rs` shows all of it.
+
## Layout
```
diff --git a/ROADMAP.md b/ROADMAP.md
index aa75071..f05ecaa 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -272,6 +272,21 @@ 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.
+- 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
+ non-trapping forest root that spawns before it unwinds leaves that spawn
+ to itself (Erlang: an unlinked spawn is nobody's child).
+
## Invariants & gotchas (respect these across all cycles)
- **Shared mutex is non-reentrant.** `Sender::send` can call `unpark` →
diff --git a/docs/smarm - Deep Dive.html b/docs/smarm - Deep Dive.html
index c5ac302..f772877 100644
--- a/docs/smarm - Deep Dive.html
+++ b/docs/smarm - Deep Dive.html
@@ -1700,7 +1700,7 @@
Panics in terminate()
-
gen_server's terminate() 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. Keep it cheap, non-blocking, non-panicking.
+
gen_server's terminate() 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 (handle_shutdown → Exit, StopHandle::stop, inbox close) runs it outside an unwind, where it may do real work.
Cold locks are leaf locks
diff --git a/examples/graceful_shutdown.rs b/examples/graceful_shutdown.rs
new file mode 100644
index 0000000..9a9031d
--- /dev/null
+++ b/examples/graceful_shutdown.rs
@@ -0,0 +1,142 @@
+//! Graceful shutdown, end to end: a supervised app tree, a server that
+//! drains before it exits, and the two ways the whole thing winds down.
+//!
+//! Stopping an actor comes in two strengths, as in OTP:
+//! - `request_stop(pid)` = `exit(Pid, kill)`: cooperative hard stop,
+//! unwinds at the next observation point.
+//! - `request_shutdown(pid)` = `exit(Pid, shutdown)`: a trapping target gets
+//! an `ExitSignal { reason: Shutdown }` and winds
+//! down on its own terms; a non-trapping one is
+//! stopped outright.
+//!
+//! A supervisor traps exits. `request_shutdown(sup)` runs its ordered
+//! shutdown — children in reverse start order, each per its `ChildSpec`
+//! `Shutdown` policy (`Timeout(d)` default 5s, `Infinity`, `BrutalKill`) —
+//! and the supervisor then returns normally.
+//!
+//! Two triggers are shown:
+//! 1. **Root exit.** The run's root actor returning means "the program is
+//! done": the runtime delivers `request_shutdown` to every top-level actor
+//! (here: the supervisor). Trapping actors may keep running to drain and
+//! end the run when they stop themselves; non-trapping ones are stopped.
+//! 2. **An outside thread** (e.g. a signal handler) driving it via
+//! `RuntimeHandle::request_shutdown` on the supervisor — the root then
+//! just waits for the tree to come down.
+
+use smarm::gen_server::{
+ GenServer, GenServerBuilder, GenServerCtx, ShutdownAction, StopHandle, TimerHandle,
+};
+use smarm::supervisor::{ChildSpec, OneForOne, Restart, Shutdown};
+use smarm::{monitor, request_shutdown, self_pid, sleep, spawn, trap_exit, DownReason};
+use std::thread;
+use std::time::Duration;
+
+/// A server with in-flight work: on shutdown it stops accepting, finishes what
+/// it has (simulated with a ticking timer), then ends itself.
+struct Drainer {
+ pending: u32,
+ stop: Option
>,
+ timer: Option>,
+}
+
+impl GenServer for Drainer {
+ type Call = ();
+ type Reply = ();
+ type Cast = ();
+ type Info = ();
+ type Timer = ();
+
+ fn init(&mut self, ctx: &GenServerCtx) {
+ ctx.trap_exit(); // opt in: shutdown arrives as handle_shutdown
+ self.stop = Some(ctx.stop_handle());
+ self.timer = Some(ctx.timer());
+ }
+ fn handle_call(&mut self, _: ()) {}
+ fn handle_cast(&mut self, _: ()) {}
+ fn handle_shutdown(&mut self) -> ShutdownAction {
+ println!(
+ "drainer: shutdown requested, {} items pending",
+ self.pending
+ );
+ self.timer
+ .as_ref()
+ .unwrap()
+ .tick_every(Duration::from_millis(20), ());
+ ShutdownAction::Continue // keep serving until drained
+ }
+ fn handle_timer(&mut self, _: ()) {
+ self.pending -= 1;
+ if self.pending == 0 {
+ println!("drainer: drained, stopping");
+ self.stop.as_ref().unwrap().stop(); // normal exit
+ }
+ }
+ fn terminate(&mut self) {
+ // Graceful path: this runs on the normal path and may block.
+ println!("drainer: terminate");
+ }
+}
+
+/// 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;
+ }
+ }
+}
+
+fn app_tree() -> OneForOne {
+ OneForOne::new()
+ .child(
+ ChildSpec::new(Restart::Permanent, || {
+ // A plain worker that does not trap: stopped outright on shutdown.
+ loop {
+ sleep(Duration::from_millis(10));
+ }
+ })
+ .shutdown(Shutdown::Timeout(Duration::from_millis(100))),
+ )
+ .child(ChildSpec::new(Restart::Permanent, drainer_child).shutdown(Shutdown::Infinity))
+}
+
+fn main() {
+ println!("--- 1. root exit drives the shutdown ---");
+ smarm::run(|| {
+ spawn(|| app_tree().run());
+ sleep(Duration::from_millis(50)); // the app "runs" for a while
+ // Returning here asks the supervisor to shut down; the run ends when
+ // the tree — drainer included — is gone.
+ });
+
+ println!("--- 2. an outside thread drives the shutdown ---");
+ let rt = smarm::init(smarm::Config::default());
+ let handle = rt.handle(); // Send + Sync; grab it before run
+ rt.run(move || {
+ let sup = spawn(|| app_tree().run());
+ let sup_pid = sup.pid();
+ // Stand-in for a SIGTERM handler thread.
+ thread::spawn(move || {
+ thread::sleep(Duration::from_millis(50));
+ println!("signal thread: requesting shutdown");
+ handle.request_shutdown(sup_pid);
+ });
+ sup.join()
+ .expect("supervisor returns normally after ordered shutdown");
+ println!("supervisor down; root returns");
+ });
+}
diff --git a/examples/named_genserver.rs b/examples/named_genserver.rs
index 27f17ca..468ffd3 100644
--- a/examples/named_genserver.rs
+++ b/examples/named_genserver.rs
@@ -66,6 +66,12 @@ fn main() {
let svc: Option> = whereis_server(COUNTER);
if let Some(svc) = svc {
let _ = svc.call(Query::Get);
+ // A named server is pinned alive by the registry, so dropping refs
+ // does not end it. Stop it explicitly: `shutdown()` asks politely
+ // (a trapping server drains first; this one is stopped outright)
+ // and waits until it is gone. Left running, the root's return
+ // would shut it down the same way — see examples/graceful_shutdown.rs.
+ svc.shutdown();
}
});
}