feat(link): bidirectional links + trap_exit (roadmap #3)
Add Erlang-style process links so an abnormal death fate-shares across a link set, with trap_exit to convert that into a message instead. - Slot.links: Vec<Pid>, bidirectional; reset in all three lifecycle sites. - Actor.trap: Option<Sender<ExitSignal>>, fresh per spawn (a restarted child starts un-trapped; no fourth reset site). - link/unlink free functions on self_pid(); trap_exit() -> Receiver<ExitSignal> (a dedicated inbox, distinct from the monitor Down channel). - finalize_actor clears reverse links under the lock (always; keeps the cascade acyclic), then propagates abnormal deaths post-lock: trapping peer gets an ExitSignal message and survives, non-trapping peer is request_stop'd. Normal exit never propagates. Dead-pid link delivers an immediate NoProc (message if trapping, else request_stop(self) -- never a silent no-op). - ExitSignal reuses DownReason and carries no panic payload (joiner-only). Tests in tests/link.rs cover the propagate/trap/normal/dead-pid/unlink cases.
This commit is contained in:
+223
@@ -0,0 +1,223 @@
|
||||
//! 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.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.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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user