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(); } }); }