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.
244 lines
7.7 KiB
Rust
244 lines
7.7 KiB
Rust
//! 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();
|
|
});
|
|
}
|