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:
+164
@@ -0,0 +1,164 @@
|
||||
//! Process links + `trap_exit`.
|
||||
//!
|
||||
//! A *link* is bidirectional and persistent, in contrast to a [`monitor`]
|
||||
//! (unidirectional, one-shot): once two actors are linked, the *abnormal*
|
||||
//! death of either propagates to the other. "Abnormal" means a panic or a
|
||||
//! cooperative [`request_stop`] — a normal return never propagates.
|
||||
//!
|
||||
//! What propagation *does* depends on whether the surviving peer traps exits:
|
||||
//!
|
||||
//! - **not trapping** (the default): the peer is cooperatively
|
||||
//! [`request_stop`]'d, so a crash fate-shares across the whole link set —
|
||||
//! Erlang's "let it crash". Because the peer's own links are walked when
|
||||
//! *it* finalizes, the stop cascades transitively.
|
||||
//! - **trapping** (called [`trap_exit`]): the peer instead receives an
|
||||
//! [`ExitSignal`] *message* on its trap inbox and keeps running, turning
|
||||
//! peer failures into ordinary inbox events (Erlang's
|
||||
//! `process_flag(trap_exit, true)`).
|
||||
//!
|
||||
//! Linking an already-dead pid is not a silent no-op: it behaves exactly like
|
||||
//! an immediate abnormal death of the target with reason [`DownReason::NoProc`]
|
||||
//! — a trapping caller gets a message, a non-trapping caller is stopped.
|
||||
//!
|
||||
//! ## Payload
|
||||
//!
|
||||
//! [`ExitSignal`] reuses [`DownReason`] and, like a monitor, does **not**
|
||||
//! carry the panic payload: a panic's payload has a single owner and is
|
||||
//! delivered to whoever `join()`s the actor. A trap inbox only learns *that*
|
||||
//! (and *how*) a peer died.
|
||||
//!
|
||||
//! ## Inbox
|
||||
//!
|
||||
//! The trap inbox is a dedicated channel, distinct from the monitor [`Down`]
|
||||
//! channel: monitors are documented as one-shot/unidirectional, whereas a trap
|
||||
//! inbox receives one [`ExitSignal`] per linked peer death over the actor's
|
||||
//! lifetime. [`trap_exit`] enables trapping and hands back the inbox in one
|
||||
//! call; calling it again installs a fresh inbox (the previous one closes).
|
||||
//!
|
||||
//! ## Races
|
||||
//!
|
||||
//! [`link`] registration and `finalize_actor` (in `runtime`) both serialize on
|
||||
//! the shared mutex, so a target that is live when the link is registered is
|
||||
//! guaranteed to propagate its eventual death; there is no window in which the
|
||||
//! death slips between the liveness check and the registration. As elsewhere,
|
||||
//! we never `send` or `request_stop` while holding the shared lock — those
|
||||
//! re-enter the runtime — so the act is always performed after the lock is
|
||||
//! released.
|
||||
//!
|
||||
//! [`monitor`]: crate::monitor::monitor
|
||||
//! [`Down`]: crate::monitor::Down
|
||||
//! [`request_stop`]: crate::scheduler::request_stop
|
||||
|
||||
use crate::channel::{channel, Receiver, Sender};
|
||||
use crate::monitor::DownReason;
|
||||
use crate::pid::Pid;
|
||||
use crate::runtime::State;
|
||||
use crate::scheduler::{request_stop, self_pid, with_runtime};
|
||||
|
||||
/// A linked peer's death, delivered to a trapping actor's inbox.
|
||||
///
|
||||
/// `Copy` because it carries no payload — see the module docs for why the
|
||||
/// panic payload is *not* included.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ExitSignal {
|
||||
/// The linked pid that died (or the dead pid that was linked).
|
||||
pub from: Pid,
|
||||
/// How it went down. Only ever `Panic`, `Stopped`, or `NoProc` — a normal
|
||||
/// `Exit` does not propagate, so it never appears here.
|
||||
pub reason: DownReason,
|
||||
}
|
||||
|
||||
/// Trap exits on the current actor and return its exit inbox.
|
||||
///
|
||||
/// After this call, the abnormal death of a linked peer arrives here as an
|
||||
/// [`ExitSignal`] message instead of cooperatively stopping this actor.
|
||||
/// Calling it again installs a fresh inbox; the previously returned receiver
|
||||
/// then closes.
|
||||
pub fn trap_exit() -> Receiver<ExitSignal> {
|
||||
let (tx, rx) = channel::<ExitSignal>();
|
||||
let me = self_pid();
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(actor) = s.slot_mut(me).and_then(|slot| slot.actor.as_mut()) {
|
||||
actor.trap = Some(tx);
|
||||
}
|
||||
})
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
/// Bidirectionally link the current actor to `target`.
|
||||
///
|
||||
/// If `target` is live, the link is recorded on both actors and either's
|
||||
/// abnormal death will propagate to the other. If `target` is already gone,
|
||||
/// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller
|
||||
/// (a message if trapping, otherwise a cooperative stop). Linking yourself, or
|
||||
/// re-linking an existing peer, is a no-op.
|
||||
pub fn link(target: Pid) {
|
||||
let me = self_pid();
|
||||
if target == me {
|
||||
return;
|
||||
}
|
||||
|
||||
// Under the lock: if the target is live, record the link both ways and
|
||||
// return `None`. If it is gone, return the caller's trap sender (if any)
|
||||
// so we can deliver the NoProc signal after releasing the lock.
|
||||
let dead_action: Option<Option<Sender<ExitSignal>>> = with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
let target_live = matches!(
|
||||
s.slot(target),
|
||||
Some(slot) if slot.actor.is_some() && !matches!(slot.state, State::Done)
|
||||
);
|
||||
if target_live {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
if !slot.links.contains(&target) {
|
||||
slot.links.push(target);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
if !slot.links.contains(&me) {
|
||||
slot.links.push(me);
|
||||
}
|
||||
}
|
||||
None
|
||||
} else {
|
||||
// Grab our own trap sender so the NoProc delivery (below)
|
||||
// doesn't need a second lock acquisition.
|
||||
Some(
|
||||
s.slot(me)
|
||||
.and_then(|slot| slot.actor.as_ref())
|
||||
.and_then(|a| a.trap.clone()),
|
||||
)
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
match dead_action {
|
||||
None => {} // linked successfully
|
||||
Some(Some(tx)) => {
|
||||
let _ = tx.send(ExitSignal { from: target, reason: DownReason::NoProc });
|
||||
}
|
||||
Some(None) => request_stop(me),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the link between the current actor and `target`, in both directions.
|
||||
///
|
||||
/// After this, neither actor's death propagates to the other. A no-op if the
|
||||
/// two were not linked.
|
||||
pub fn unlink(target: Pid) {
|
||||
let me = self_pid();
|
||||
if target == me {
|
||||
return;
|
||||
}
|
||||
with_runtime(|inner| {
|
||||
inner.with_shared(|s| {
|
||||
if let Some(slot) = s.slot_mut(me) {
|
||||
slot.links.retain(|p| *p != target);
|
||||
}
|
||||
if let Some(slot) = s.slot_mut(target) {
|
||||
slot.links.retain(|p| *p != me);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user