Files
smarm/tests/link.rs
T
smarm-dev 518103750b 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.
2026-06-07 21:51:56 +00:00

224 lines
7.9 KiB
Rust

//! Link + trap_exit tests (roadmap item #3).
//!
//! A *link* is bidirectional and persistent (contrast a monitor, which is
//! unidirectional and one-shot). When a linked actor dies *abnormally* — a
//! panic or a cooperative `request_stop` — the death propagates to the peer:
//!
//! - a peer that has NOT trapped exits is cooperatively `request_stop`'d, so
//! a crash fate-shares across the link set (Erlang's "let it crash");
//! - a peer that HAS called `trap_exit()` instead receives an [`ExitSignal`]
//! *message* on its trap inbox and survives.
//!
//! A *normal* exit never propagates — not even to a trapping peer. Linking an
//! already-dead pid delivers an immediate exit signal (`NoProc`): a message to
//! a trapping caller, a `request_stop` to a non-trapping one.
//!
//! These cases are deterministic under the single-thread runtime: an actor
//! runs until it parks/yields, so the relative order of link setup, the
//! triggering death, and the propagated signal is fixed.
use smarm::{
channel, link, monitor, run, self_pid, spawn, trap_exit, unlink, yield_now, DownReason,
};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// Sets its flag when dropped — proves a propagated stop unwound the peer's
/// stack (running Drop) rather than leaking it.
struct DropFlag(Arc<AtomicBool>);
impl Drop for DropFlag {
fn drop(&mut self) {
self.0.store(true, Ordering::SeqCst);
}
}
/// Abnormal death of one linked actor cooperatively stops its non-trapping
/// peer, and the peer's Drop guards run during the propagated unwind.
#[test]
fn linked_pair_one_panics_other_is_stopped() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
// B parks on recv (kept open by a retained sender) so it is alive and
// suspended when the propagated stop arrives.
let hb = spawn(move || {
let _g = DropFlag(d);
let (tx, rx) = channel::<u8>();
let _keep = tx;
let _ = rx.recv(); // parks until the link-propagated stop unwinds us
});
let b = hb.pid();
let down_b = monitor(b);
// A links B, then panics. The panic propagates along the link to B.
let ha = spawn(move || {
link(b);
panic!("boom");
});
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);
}
drop(ha);
let _ = hb.join();
});
assert!(
saw_stopped.load(Ordering::SeqCst),
"peer should be Stopped by the propagated abnormal exit"
);
assert!(
dropped.load(Ordering::SeqCst),
"peer's Drop guard must run during the propagated cancellation unwind"
);
}
/// A trapping peer receives an abnormal death as an `ExitSignal` message and
/// keeps running instead of being torn down.
#[test]
fn trap_exit_turns_abnormal_death_into_a_message() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let h = spawn(move || {
let inbox = trap_exit();
// Spawn the peer from here so we control ordering: it is enqueued
// behind us and runs once we park on `inbox.recv()`.
let ha = spawn(|| panic!("peer down"));
let a = ha.pid();
link(a);
// Parks here; A then runs, panics, and the trap message wakes us.
let sig = inbox.recv().expect("trap inbox closed without a signal");
if sig.from == a && matches!(sig.reason, DownReason::Panic) {
o.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
ok.load(Ordering::SeqCst),
"trapping peer should receive ExitSignal{{from, Panic}} and survive"
);
}
/// A normal exit never propagates — a trapping linked peer sees no message and
/// stays alive.
#[test]
fn normal_exit_does_not_propagate() {
let empty = Arc::new(AtomicBool::new(false));
let e = empty.clone();
run(move || {
let h = spawn(move || {
let inbox = trap_exit();
let ha = spawn(|| { /* exits normally, immediately */ });
let a = ha.pid();
link(a);
yield_now(); // let A run to completion and finalize
// A exited normally: nothing should have landed on the inbox.
if let Ok(None) = inbox.try_recv() {
e.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
empty.load(Ordering::SeqCst),
"normal exit must not deliver any ExitSignal to a trapping peer"
);
}
/// Linking an already-dead pid from a non-trapping actor stops the caller
/// (no silent no-op).
#[test]
fn link_to_dead_pid_stops_a_nontrapping_caller() {
let dropped = Arc::new(AtomicBool::new(false));
let saw_stopped = Arc::new(AtomicBool::new(false));
let (d, s) = (dropped.clone(), saw_stopped.clone());
run(move || {
let ha = spawn(|| {});
let a = ha.pid();
ha.join().unwrap(); // A finishes and is reclaimed → `a` is now stale
let hb = spawn(move || {
let _g = DropFlag(d);
link(a); // dead target, not trapping → request_stop(self)
loop {
smarm::check!(); // observation point to realize the stop
}
});
let b = hb.pid();
let down_b = monitor(b);
let dn = down_b.rx.recv().expect("monitor channel closed before Down");
if matches!(dn.reason, DownReason::Stopped) {
s.store(true, Ordering::SeqCst);
}
let _ = hb.join();
});
assert!(
saw_stopped.load(Ordering::SeqCst),
"linking a dead pid (non-trapping) should stop the caller"
);
assert!(dropped.load(Ordering::SeqCst), "Drop guard must run");
}
/// Linking an already-dead pid from a trapping actor delivers an immediate
/// `NoProc` exit message instead of killing it.
#[test]
fn link_to_dead_pid_messages_a_trapping_caller() {
let ok = Arc::new(AtomicBool::new(false));
let o = ok.clone();
run(move || {
let ha = spawn(|| {});
let a = ha.pid();
ha.join().unwrap(); // `a` is now stale
let hb = spawn(move || {
let inbox = trap_exit();
link(a); // dead target, trapping → immediate NoProc message
let sig = inbox.recv().expect("trap inbox closed without a signal");
if sig.from == a && matches!(sig.reason, DownReason::NoProc) {
o.store(true, Ordering::SeqCst);
}
});
let _ = hb.join();
});
assert!(
ok.load(Ordering::SeqCst),
"linking a dead pid (trapping) should deliver a NoProc ExitSignal"
);
}
/// `unlink` removes the relationship in both directions: an abnormal death no
/// longer reaches the formerly-linked peer.
#[test]
fn unlink_prevents_propagation() {
let survived = Arc::new(AtomicBool::new(false));
let sv = survived.clone();
run(move || {
let h = spawn(move || {
let b = self_pid();
let inbox = trap_exit();
let ha = spawn(move || {
link(b);
unlink(b);
panic!("boom"); // abnormal, but the link is gone
});
yield_now(); // let A link, unlink, and panic
// Unlinked before death → no ExitSignal should have arrived.
if let Ok(None) = inbox.try_recv() {
sv.store(true, Ordering::SeqCst);
}
drop(ha);
});
let _ = h.join();
});
assert!(
survived.load(Ordering::SeqCst),
"unlinked peer must not receive a propagated exit"
);
}