Lift OTP's `exit(Pid, shutdown)` + child-spec `shutdown` wholesale.
scheduler / runtime
- `request_shutdown(pid)`: the polite stop. A target trapping exits gets an
`ExitSignal { reason: DownReason::Shutdown }` on its trap inbox and keeps
running; a non-trapping target is stopped as by `request_stop`, which is
now documented as the hard stop (`exit(Pid, kill)`). Dead pid: no-op.
- `RuntimeHandle::request_shutdown` for the off-runtime (signal thread) path;
`from == ROOT_PID` there.
- `DownReason::Shutdown` — appears only in ExitSignal, never in Down (a
complying target exits *normally*).
supervisor
- `ChildSpec::shutdown(Shutdown::{BrutalKill, Timeout(d), Infinity})`,
default Timeout(5s). Every supervisor-initiated stop (ordered shutdown and
OneForAll/RestForOne sibling cycling) is: request_shutdown → await the
child's Signal up to the grace → request_stop → await. Sequential, reverse
start order.
- The supervisor traps exits; a Shutdown ExitSignal runs the ordered
shutdown and `run()` returns normally, so `request_shutdown(root_sup)`
tears a whole tree down top-down with each child's grace period.
- FIX: a hard `request_stop` on a supervisor previously orphaned its
children (the ordered shutdown lived after the loop, and the unwind
skipped it). `Live` (the by_pid map) now carries a drop guard that
fire-and-forget hard-stops live children when unwinding.
gen_server
- `GenServerCtx::trap_exit()` opt-in in `init`; the trap inbox becomes arm 0
of the loop's select. Shutdown ExitSignal → `handle_shutdown() ->
ShutdownAction::{Exit, Continue}` (default Exit: loop breaks, `terminate`
runs on the normal path and may block). Other ExitSignals →
`handle_exit(sig)`.
- `GenServerCtx::stop_handle() -> StopHandle`, `stop()` ends the server
after the current message with a *normal* exit — the missing
`{stop, normal, State}`; `request_stop(self_pid())` was the only self-exit
and it is abnormal (Transient restarts it).
- `GenServerRef::shutdown()` / `gen_server::shutdown(name)` now go through
`request_shutdown`.
Tests: tests/shutdown.rs, tests/supervisor_shutdown.rs,
tests/gen_server_shutdown.rs. Full suite green; fmt + clippy --lib clean.
123 lines
4.2 KiB
Rust
123 lines
4.2 KiB
Rust
//! Graceful shutdown — `request_shutdown` (OTP `exit(Pid, shutdown)`).
|
|
//!
|
|
//! `request_stop` is `exit(Pid, kill)`: an uncatchable unwind at the target's
|
|
//! next observation point. `request_shutdown` is the polite form:
|
|
//! - a target that is NOT trapping exits is stopped exactly as by
|
|
//! `request_stop` (OTP's rule: don't trap, you die);
|
|
//! - a target that IS trapping receives an `ExitSignal { reason: Shutdown }`
|
|
//! on its trap inbox and keeps running — it is expected to wind down and
|
|
//! exit normally on its own.
|
|
|
|
use smarm::{monitor, request_shutdown, run, self_pid, sleep, spawn, trap_exit, DownReason, Pid};
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::{mpsc, Arc};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
const WATCHDOG: Duration = Duration::from_secs(10);
|
|
|
|
#[test]
|
|
fn request_shutdown_stops_a_non_trapping_actor() {
|
|
run(|| {
|
|
let h = spawn(|| sleep(Duration::from_secs(3600)));
|
|
let mon = monitor(h.pid());
|
|
request_shutdown(h.pid());
|
|
let down = mon.rx.recv().expect("down");
|
|
assert_eq!(down.reason, DownReason::Stopped);
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn request_shutdown_is_a_message_to_a_trapping_actor() {
|
|
let unwound = Arc::new(AtomicBool::new(false));
|
|
let u = unwound.clone();
|
|
run(move || {
|
|
struct Unwound(Arc<AtomicBool>);
|
|
impl Drop for Unwound {
|
|
fn drop(&mut self) {
|
|
if std::thread::panicking() {
|
|
self.0.store(true, Ordering::SeqCst);
|
|
}
|
|
}
|
|
}
|
|
let (tx, rx) = smarm::channel::<(Pid, DownReason)>();
|
|
let (ready_tx, ready_rx) = smarm::channel::<()>();
|
|
let h = spawn(move || {
|
|
let _g = Unwound(u);
|
|
let inbox = trap_exit();
|
|
let _ = ready_tx.send(());
|
|
let sig = inbox.recv().expect("exit signal");
|
|
let _ = tx.send((sig.from, sig.reason));
|
|
// Keep doing work after the request: shutdown is advisory.
|
|
sleep(Duration::from_millis(20));
|
|
});
|
|
// Trapping is set by the target itself; a request that beats it is a
|
|
// plain stop (same window as OTP's exit-before-process_flag).
|
|
ready_rx.recv().expect("ready");
|
|
let me = self_pid();
|
|
let mon = monitor(h.pid());
|
|
request_shutdown(h.pid());
|
|
let (from, reason) = rx.recv().expect("relayed");
|
|
assert_eq!(from, me);
|
|
assert_eq!(reason, DownReason::Shutdown);
|
|
let down = mon.rx.recv().expect("down");
|
|
assert_eq!(
|
|
down.reason,
|
|
DownReason::Exit,
|
|
"target exited normally, not stopped"
|
|
);
|
|
});
|
|
assert!(
|
|
!unwound.load(Ordering::SeqCst),
|
|
"trapping target must not be unwound"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn request_shutdown_on_dead_pid_is_a_no_op() {
|
|
run(|| {
|
|
let h = spawn(|| {});
|
|
let pid = h.pid();
|
|
let _ = h.join();
|
|
request_shutdown(pid); // must not panic
|
|
});
|
|
}
|
|
|
|
#[test]
|
|
fn handle_request_shutdown_from_foreign_thread() {
|
|
let rt = smarm::init(smarm::Config::exact(2));
|
|
let handle = rt.handle();
|
|
|
|
let (pid_tx, pid_rx) = mpsc::channel::<Pid>();
|
|
let requester = thread::spawn(move || {
|
|
let pid = pid_rx.recv().expect("pid");
|
|
thread::sleep(Duration::from_millis(50));
|
|
handle.request_shutdown(pid);
|
|
});
|
|
|
|
let (done_tx, done_rx) = mpsc::channel();
|
|
thread::spawn(move || {
|
|
rt.run(move || {
|
|
let (tx, rx) = smarm::channel::<DownReason>();
|
|
let (ready_tx, ready_rx) = smarm::channel::<()>();
|
|
let h = spawn(move || {
|
|
let inbox = trap_exit();
|
|
let _ = ready_tx.send(());
|
|
let sig = inbox.recv().expect("exit signal");
|
|
let _ = tx.send(sig.reason);
|
|
});
|
|
ready_rx.recv().expect("ready");
|
|
pid_tx.send(h.pid()).expect("send pid");
|
|
let reason = rx.recv().expect("relayed");
|
|
assert_eq!(reason, DownReason::Shutdown);
|
|
let _ = h.join();
|
|
});
|
|
let _ = done_tx.send(());
|
|
});
|
|
|
|
done_rx
|
|
.recv_timeout(WATCHDOG)
|
|
.expect("run did not return: foreign-thread request_shutdown never reached the target");
|
|
requester.join().expect("requester thread");
|
|
}
|