feat(monitor): demonitor + per-monitor MonitorId (roadmap #5)

Monitors could be installed but never taken down. That gap was about to
bite: the gen_server call timeout we want next is the Erlang dance —
monitor the server, wait for the reply or a Down or a deadline, then
demonitor — and without a way to remove a registration, every timed-out
call would leak a monitor on the server's slot and risk a stale Down
arriving later. So this lands the cleanup primitive before anything
depends on the old shape.

The decision flagged in the roadmap ("decide the monitor API NOW") is
resolved by giving each registration a process-unique MonitorId and
returning it to the caller. monitor() now hands back a
Monitor { id, target, rx } rather than a bare Receiver<Down>: read the
notice from rx as before, and pass &Monitor to demonitor to tear exactly
that registration down. The id comes from a monotonic counter in shared
state, bumped under the same lock that does the registration, so it's
deterministic and never reused — which is what lets demonitor name one of
several monitors on the same target unambiguously. target rode along on
the struct (over the sketched {id, rx}) purely so demonitor can go
straight to the slot instead of scanning every slot for the id.

demonitor returns Option<MonitorId> rather than a bool: Some(id) when a
live registration was found and removed, None when there was nothing left
to remove — it already fired (the registration is drained on finalize),
it was a NoProc, or the slot has been reclaimed. The generation half of
the pid quietly protects that last case: a recycled slot index fails
slot_mut's generation check, so a late demonitor is a clean no-op and can
never strip a different actor's monitor that happens to share the index.

The one piece of real care is reentrancy. Removing a registration drops
the slot's Sender, and Sender::drop can unpark a parked receiver, which
re-enters the shared mutex — which is not reentrant. So demonitor moves
the sender out of the Vec under the lock and lets it drop only after the
lock is released, the same discipline finalize_actor already follows for
its monitor and supervisor sends.

Flushing a Down the target already queued isn't a separate flag; it falls
out of dropping the Monitor. demonitor(&m); drop(m) stops future notices
and discards any queued one — the gen_server-call cleanup in one move.

Storage is now Vec<(MonitorId, Sender<Down>)>. The three slot-reset sites
were left alone on purpose: they clear/rebuild the Vec, which doesn't care
about the element type, so there's no fourth reset obligation. finalize
just destructures (_, m). Because chained_spawn and yield_many register no
monitors, that Vec stays empty on the hot path — taking an empty Vec costs
the same and the notify loop runs zero times — and a before/after general
probe confirmed both medians sit within noise.

Tests cover the three behaviours that matter: a demonitored watcher gets
no Down (its channel closes), demonitoring one of several leaves the
siblings firing normally, and demonitoring after the Down has already
fired reports None.
This commit is contained in:
smarm-dev
2026-06-07 21:51:56 +00:00
parent a7832f4a8c
commit 518103750b
8 changed files with 200 additions and 39 deletions
+3 -3
View File
@@ -42,7 +42,7 @@ fn looping_actor_on_check_is_stopped() {
// Flag the stop before the child is ever resumed; it will observe the
// flag once its check!() loop reaches the amortised preempt check.
request_stop(pid);
let dn = down.recv().expect("monitor channel closed before Down");
let dn = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
@@ -72,7 +72,7 @@ fn parked_on_recv_actor_is_stopped() {
// Let the child run and park in recv() before we request the stop.
yield_now();
request_stop(pid);
let dn = down.recv().expect("monitor channel closed before Down");
let dn = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, pid);
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
@@ -103,7 +103,7 @@ fn no_check_no_alloc_loop_is_not_stopped() {
let pid = h.pid();
let down = monitor(pid);
request_stop(pid); // no observation point => ignored
let dn = down.recv().expect("monitor channel closed before Down");
let dn = down.rx.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Exit) {
s.store(true, Ordering::SeqCst);
}
+2 -2
View File
@@ -57,7 +57,7 @@ fn linked_pair_one_panics_other_is_stopped() {
panic!("boom");
});
let dn = down_b.recv().expect("monitor channel closed before Down");
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
assert_eq!(dn.pid, b, "Down reported the wrong pid");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
@@ -152,7 +152,7 @@ fn link_to_dead_pid_stops_a_nontrapping_caller() {
});
let b = hb.pid();
let down_b = monitor(b);
let dn = down_b.recv().expect("monitor channel closed before Down");
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
+55 -7
View File
@@ -1,10 +1,11 @@
//! Monitor tests: `monitor(pid)` delivers exactly one `Down` describing how
//! the target terminated.
//! the target terminated, and `demonitor(&m)` tears a registration back down.
//!
//! A monitor is unidirectional and one-shot: it never propagates failure to
//! the watcher (that's a link), it just drops a `Down` into the returned
//! channel. These tests pin the three reasons — `Exit`, `Panic`, `NoProc` —
//! and the fan-out case where several monitors watch the same target.
//! the fan-out case where several monitors watch the same target, and that
//! `demonitor` removes exactly the one registration it names.
//!
//! Scheduling note: under the default single-thread runtime the parent runs
//! until it parks, so every `monitor()` call below registers while the target
@@ -13,7 +14,7 @@
//! alive at registration time always produces a real `Down`, never a missed
//! one.
use smarm::{monitor, run, spawn, DownReason};
use smarm::{demonitor, monitor, run, spawn, DownReason};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::Arc;
@@ -25,7 +26,7 @@ fn monitor_sees_normal_exit() {
let h = spawn(|| {});
let pid = h.pid();
let down = monitor(pid);
let d = down.recv().expect("monitor channel closed before Down");
let d = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(d.pid, pid, "Down reported the wrong pid");
if matches!(d.reason, DownReason::Exit) {
o.store(true, Ordering::SeqCst);
@@ -43,7 +44,7 @@ fn monitor_sees_panic() {
let h = spawn(|| panic!("boom"));
let pid = h.pid();
let down = monitor(pid);
let d = down.recv().expect("monitor channel closed before Down");
let d = down.rx.recv().expect("monitor channel closed before Down");
assert_eq!(d.pid, pid);
if matches!(d.reason, DownReason::Panic) {
o.store(true, Ordering::SeqCst);
@@ -66,7 +67,7 @@ fn monitor_already_dead_target_is_noproc() {
// and its generation bumped, so `pid` is now stale.
h.join().unwrap();
let down = monitor(pid);
let d = down.recv().expect("NoProc Down should be delivered immediately");
let d = down.rx.recv().expect("NoProc Down should be delivered immediately");
assert_eq!(d.pid, pid);
if matches!(d.reason, DownReason::NoProc) {
o.store(true, Ordering::SeqCst);
@@ -85,10 +86,57 @@ fn multiple_monitors_all_notified() {
let monitors = [monitor(pid), monitor(pid), monitor(pid)];
let _ = h.join();
for m in monitors {
if matches!(m.recv().unwrap().reason, DownReason::Exit) {
if matches!(m.rx.recv().unwrap().reason, DownReason::Exit) {
c.fetch_add(1, Ordering::SeqCst);
}
}
});
assert_eq!(count.load(Ordering::SeqCst), 3, "every monitor should see the Down");
}
#[test]
fn demonitor_stops_delivery() {
// Demonitoring a live registration removes the slot's only sender for that
// channel; the channel closes, so a later recv() observes Err rather than
// a Down. demonitor reports the id it tore down.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let m = monitor(pid);
assert_eq!(demonitor(&m), Some(m.id), "live registration should be removed");
assert!(m.rx.recv().is_err(), "no Down should arrive after demonitor");
let _ = h.join();
});
}
#[test]
fn demonitor_one_of_many() {
// Three monitors on one target; demonitor the middle. The other two still
// fire; the demonitored channel is closed with no Down. Distinct ids mean
// tearing one down never disturbs its siblings.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let ms = [monitor(pid), monitor(pid), monitor(pid)];
assert_eq!(demonitor(&ms[1]), Some(ms[1].id));
let _ = h.join();
assert!(matches!(ms[0].rx.recv().unwrap().reason, DownReason::Exit));
assert!(matches!(ms[2].rx.recv().unwrap().reason, DownReason::Exit));
assert!(ms[1].rx.recv().is_err(), "demonitored channel should be closed");
});
}
#[test]
fn demonitor_after_fire_is_none() {
// Once the Down has fired, the registration is already drained from the
// slot, so demonitor finds nothing to remove and reports None.
run(|| {
let h = spawn(|| {});
let pid = h.pid();
let m = monitor(pid);
let d = m.rx.recv().expect("Down before close");
assert!(matches!(d.reason, DownReason::Exit));
assert_eq!(demonitor(&m), None, "already-fired monitor has nothing to remove");
let _ = h.join();
});
}