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
+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() {