diff --git a/src/lib.rs b/src/lib.rs
index f5073aa..66d9082 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -72,13 +72,13 @@ pub use introspect::{StackInfo,
#[cfg(feature = "observer")]
pub use observer::{ObserverReply, ObserverRequest};
pub use link::{link, trap_exit, unlink, ExitSignal};
-pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
+pub use monitor::{demonitor, monitor, terminal_reason, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard};
pub use pid::{Addressable, Erased, Name, Pid, RawPid};
pub use pg::{dispatch, join, leave, members, members_as, pick, pick_as, Incarnation, Member, NodeId};
pub use registry::{
- install, lookup_as, register, send, send_dyn, send_to, unregister, whereis, RegisterError,
- SendError,
+ install, lookup_as, register, resolve_name, send, send_dyn, send_to, unregister, whereis,
+ NameResolution, RegisterError, SendError,
};
pub use runtime::{init, Config, Runtime};
pub use scheduler::{
diff --git a/src/monitor.rs b/src/monitor.rs
index 4e7ecd1..9a4c856 100644
--- a/src/monitor.rs
+++ b/src/monitor.rs
@@ -177,6 +177,32 @@ pub fn monitor(target: Pid) -> Monitor {
Monitor { id, target, rx }
}
+/// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy
+/// is the *most recent* death of its slot: finalize stamps the slot with
+/// `(generation, reason)`, and the record survives reclaim and the next
+/// tenant's install, until that next tenant itself dies. `None` means the pid
+/// never lived, is still alive, or its record was overwritten by a later
+/// tenancy's death — callers fall back to `NoProc` semantics.
+///
+/// This exists for watch-installers that raced their target's death (bridge
+/// soak signature 4): a `NoProc` observed at install time can be upgraded to
+/// the real reason while the record still matches, which is exactly what an
+/// install that had won the race would have delivered. It does NOT change
+/// [`monitor`]'s own semantics — monitoring a stale pid still queues `NoProc`,
+/// the same shape Erlang gives — the upgrade is the caller's deliberate act.
+/// Same context contract as [`monitor`]: must run inside `Runtime::run()`.
+pub fn terminal_reason(target: Pid) -> Option {
+ let target = target.erase();
+ with_runtime(|inner| {
+ let slot = inner.slot_at(target)?;
+ let cold = slot.cold.lock();
+ match cold.terminal {
+ Some((generation, reason)) if generation == target.generation() => Some(reason),
+ _ => None,
+ }
+ })
+}
+
/// Cancel the monitor `m`. Returns `Some(id)` if a live registration was found
/// and removed, so no `Down` will arrive on `m.rx` from here on. Returns
/// `None` if there was nothing left to remove: the target had already gone
diff --git a/src/registry.rs b/src/registry.rs
index 5eab877..8424ec8 100644
--- a/src/registry.rs
+++ b/src/registry.rs
@@ -486,6 +486,47 @@ pub fn whereis(name: &str) -> Option {
})
}
+/// What a name is bound to, three-valued (bridge soak signature 4).
+///
+/// [`Live`](NameResolution::Live) is [`whereis`]'s `Some`.
+/// [`Corpse`](NameResolution::Corpse) carries the *stored* holder pid of a
+/// dead-but-unpruned binding — a state Erlang cannot represent (its name
+/// death unregisters atomically; smarm's prune is lazy), captured here before
+/// the prune that `whereis` performs discards it, so the caller can consult
+/// [`terminal_reason`](crate::monitor::terminal_reason) for the tenancy's
+/// real down reason. [`Unbound`](NameResolution::Unbound) matches Erlang's
+/// unregistered name.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum NameResolution {
+ /// The stored holder is live (generation-checked); the binding stands.
+ Live(Pid),
+ /// The stored holder is dead. The binding was pruned on the way out —
+ /// the name heals exactly as `whereis` heals it; only the evidence is
+ /// returned instead of discarded. A second resolve is `Unbound`.
+ Corpse(Pid),
+ /// No binding stored (never registered, or already pruned by any reader).
+ Unbound,
+}
+
+/// Resolve `name` like [`whereis`], but keep the corpse: the dead-holder arm
+/// returns the stored pid it pruned instead of a bare `None`. Same lock
+/// discipline and pruning behavior as `whereis`; same `Runtime::run()`
+/// context contract.
+pub fn resolve_name(name: &str) -> NameResolution {
+ with_runtime(|inner| {
+ let mut reg = inner.registry.lock();
+ let Some(&pid) = reg.by_name.get(name) else {
+ return NameResolution::Unbound;
+ };
+ if live(inner, pid) {
+ NameResolution::Live(pid)
+ } else {
+ reg.prune_holder(pid);
+ NameResolution::Corpse(pid)
+ }
+ })
+}
+
/// Like [`whereis`], but returns a *typed* [`Pid`] instead of a bare
/// [`Pid`], so a follow-up [`send_to`] is compile-checked instead of needing
/// the untyped [`send_dyn`] escape hatch. `None` if the name is unbound or its
diff --git a/src/runtime.rs b/src/runtime.rs
index f13b5bf..883a316 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -472,6 +472,14 @@ pub(crate) struct SlotCold {
/// epoch-matched unpark.
pub(crate) waiters: Vec<(Pid, u32)>,
pub(crate) outcome: Option,
+ /// The slot's most recent *death*: `(generation, reason)`, stamped by
+ /// `finalize_actor` and deliberately never cleared — a new tenant's
+ /// install leaves it standing (it describes the previous tenancy), and
+ /// only the next death overwrites it. Read generation-matched via
+ /// [`terminal_reason`](crate::monitor::terminal_reason), so a watch that
+ /// raced its target's death can recover the real down reason instead of
+ /// a blanket `NoProc` (bridge soak signature 4).
+ pub(crate) terminal: Option<(u32, DownReason)>,
pub(crate) supervisor_channel: Option>,
/// Watchers registered via `monitor()`, each tagged with its
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
@@ -626,6 +634,7 @@ impl Slot {
actor: None,
waiters: Vec::new(),
outcome: None,
+ terminal: None,
supervisor_channel: None,
monitors: Vec::new(),
links: Vec::new(),
@@ -1667,6 +1676,11 @@ fn finalize_actor(inner: &Arc, pid: Pid, outcome: Outcome) {
None => panic!("finalize_actor: actor vanished"),
};
cold.outcome = Some(joiner_outcome);
+ // Terminal record (soak sig 4): stamped before the generation ever
+ // bumps, under the cold lock, so a reader that resolved this pid can
+ // recover the reason after the slot moves on. Overwritten only by the
+ // slot's next death.
+ cold.terminal = Some((pid.generation(), down_reason));
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// Done is published under the cold lock, so join's
// check-Done-or-register-waiter (also under it) can never miss: it
diff --git a/tests/terminal_outcome_after_death.rs b/tests/terminal_outcome_after_death.rs
new file mode 100644
index 0000000..b78da62
--- /dev/null
+++ b/tests/terminal_outcome_after_death.rs
@@ -0,0 +1,216 @@
+//! The terminal-record contract (bridge soak signature 4): a watch installed
+//! *after* its target's death — the async-install race the bridge's proxies
+//! live with — must be able to recover the real down reason instead of a
+//! blanket `NoProc`. Two primitives carry it:
+//!
+//! - `finalize_actor` stamps the slot with `(generation, DownReason)`; the
+//! record survives reclaim, registry pruning, and the next tenant's
+//! install, and is overwritten only by the slot's next death.
+//! [`terminal_reason`] reads it generation-matched.
+//! - [`resolve_name`] is `whereis` with the corpse kept: the dead-holder arm
+//! returns the stored pid it prunes ([`NameResolution::Corpse`]) instead
+//! of discarding the only evidence of *who* died. `Unbound` stays the
+//! Erlang-shaped `noproc` for names that were never (or are no longer)
+//! bound.
+//!
+//! `monitor()` of a stale pid still queues plain `NoProc` — the upgrade is a
+//! caller's deliberate act, not a semantics change.
+
+use smarm::{
+ init, request_stop, resolve_name, terminal_reason, CallError, Config, DownReason, GenServer,
+ GenServerBuilder, GenServerName, NameResolution,
+};
+use std::sync::{Arc, Mutex};
+use std::time::Duration;
+
+const TARGET: GenServerName = GenServerName::new("terminal_target");
+
+/// Named server that panics on cast — the sig-4 death.
+struct Target;
+
+impl GenServer for Target {
+ type Call = ();
+ type Reply = ();
+ type Cast = ();
+ type Info = ();
+ type Timer = ();
+
+ fn handle_call(&mut self, _req: ()) {}
+ fn handle_cast(&mut self, _op: ()) {
+ panic!("terminal_target: induced panic");
+ }
+}
+
+/// Slot filler for the re-tenancy phase (distinct type, held alive).
+struct Filler;
+
+impl GenServer for Filler {
+ type Call = ();
+ type Reply = ();
+ type Cast = ();
+ type Info = ();
+ type Timer = ();
+
+ fn handle_call(&mut self, _req: ()) {}
+ fn handle_cast(&mut self, _op: ()) {}
+}
+
+#[derive(Debug)]
+struct Observed {
+ exit_reason: Option,
+ panic_reason: Option,
+ stopped_reason: Option,
+ live_reason: Option,
+ live_resolution_is_live: bool,
+ unknown_resolution: NameResolution,
+ /// First resolve after the named target's panic — must be Corpse(old pid).
+ corpse_resolution_matches: bool,
+ /// Second resolve — the Corpse arm pruned, so the name has healed.
+ resolution_after_prune: NameResolution,
+ /// Read AFTER the prune above: the record is slot-side, not registry-side.
+ corpse_reason_after_prune: Option,
+ /// Record survives the slot being re-tenanted (new tenant still alive).
+ corpse_reason_after_reuse: Option,
+ /// ... and dies with the next tenancy's death (overwritten).
+ corpse_reason_after_tenant_death: Option,
+ tenant_reason: Option,
+}
+
+#[test]
+fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
+ let out: Arc>> = Arc::new(Mutex::new(None));
+ let out_w = out.clone();
+
+ // Tiny slab: prompt slot recycling for the re-tenancy phase.
+ init(Config::exact(2).max_actors(32)).run(move || {
+ // --- Plain actors: one record per way of dying. -------------------
+ let h = smarm::spawn(|| {});
+ let pid_exit = h.pid();
+ let _ = h.join();
+ let exit_reason = terminal_reason(pid_exit);
+
+ let h = smarm::spawn(|| panic!("induced"));
+ let pid_panic = h.pid();
+ let _ = h.join();
+ let panic_reason = terminal_reason(pid_panic);
+
+ let h = smarm::spawn(|| loop {
+ smarm::sleep(Duration::from_millis(2));
+ });
+ let pid_stop = h.pid();
+ request_stop(pid_stop);
+ let _ = h.join();
+ let stopped_reason = terminal_reason(pid_stop);
+
+ // --- The named target: live readings first. -----------------------
+ let target = GenServerBuilder::new(Target)
+ .named(TARGET)
+ .start()
+ .expect("name free at test start");
+ let old_pid = target.pid();
+ let live_reason = terminal_reason(old_pid);
+ let live_resolution_is_live =
+ resolve_name(TARGET.as_str()) == NameResolution::Live(old_pid.erase());
+ let unknown_resolution = resolve_name("terminal_never_bound");
+
+ // --- Kill it by panic; confirm death via the ref, NEVER the name
+ // (any name reader would take the prune arm and destroy the corpse
+ // precondition — the same trap stale_name_slot_reuse.rs documents).
+ let _ = target.cast(());
+ loop {
+ match target.call(()) {
+ Err(CallError::ServerDown) => break,
+ Ok(()) => smarm::sleep(Duration::from_millis(2)),
+ }
+ }
+
+ let corpse_resolution_matches =
+ resolve_name(TARGET.as_str()) == NameResolution::Corpse(old_pid.erase());
+ let resolution_after_prune = resolve_name(TARGET.as_str());
+ let corpse_reason_after_prune = terminal_reason(old_pid);
+
+ // --- Re-tenant the freed slot; the record must outlive the install
+ // and die only with the next tenancy's death.
+ let mut fillers = Vec::new();
+ let mut tenant = None;
+ for i in 0..24 {
+ let name: &'static str = Box::leak(format!("terminal_filler_{i}").into_boxed_str());
+ let f = GenServerBuilder::new(Filler)
+ .named(GenServerName::::new(name))
+ .start()
+ .expect("filler names are fresh");
+ let fp = f.pid();
+ let landed = fp.index() == old_pid.index();
+ fillers.push(f);
+ if landed {
+ tenant = Some((fillers.len() - 1, fp));
+ break;
+ }
+ }
+ let (tenant_at, tenant_pid) = tenant.expect(
+ "precondition: the freed slot must be re-tenanted within the tiny slab \
+ (slots are recycled; every filler is held alive)",
+ );
+ let corpse_reason_after_reuse = terminal_reason(old_pid);
+
+ request_stop(tenant_pid);
+ loop {
+ match fillers[tenant_at].call(()) {
+ Err(CallError::ServerDown) => break,
+ Ok(()) => smarm::sleep(Duration::from_millis(2)),
+ }
+ }
+ let corpse_reason_after_tenant_death = terminal_reason(old_pid);
+ let tenant_reason = terminal_reason(tenant_pid);
+
+ *out_w.lock().unwrap() = Some(Observed {
+ exit_reason,
+ panic_reason,
+ stopped_reason,
+ live_reason,
+ live_resolution_is_live,
+ unknown_resolution,
+ corpse_resolution_matches,
+ resolution_after_prune,
+ corpse_reason_after_prune,
+ corpse_reason_after_reuse,
+ corpse_reason_after_tenant_death,
+ tenant_reason,
+ });
+ });
+
+ let o = out.lock().unwrap().take().expect("runtime body completed");
+ assert_eq!(o.exit_reason, Some(DownReason::Exit), "{o:?}");
+ assert_eq!(o.panic_reason, Some(DownReason::Panic), "{o:?}");
+ assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}");
+ assert_eq!(
+ o.live_reason, None,
+ "live tenancy must have no record: {o:?}"
+ );
+ assert!(o.live_resolution_is_live, "{o:?}");
+ assert_eq!(o.unknown_resolution, NameResolution::Unbound, "{o:?}");
+ assert!(
+ o.corpse_resolution_matches,
+ "first post-death resolve must carry the corpse: {o:?}"
+ );
+ assert_eq!(
+ o.resolution_after_prune,
+ NameResolution::Unbound,
+ "the Corpse arm prunes — the name heals: {o:?}"
+ );
+ assert_eq!(
+ o.corpse_reason_after_prune,
+ Some(DownReason::Panic),
+ "the record is slot-side; registry pruning must not touch it: {o:?}"
+ );
+ assert_eq!(
+ o.corpse_reason_after_reuse,
+ Some(DownReason::Panic),
+ "a new tenant's install must leave the previous tenancy's record: {o:?}"
+ );
+ assert_eq!(
+ o.corpse_reason_after_tenant_death, None,
+ "the next death overwrites — the old generation no longer matches: {o:?}"
+ );
+ assert_eq!(o.tenant_reason, Some(DownReason::Stopped), "{o:?}");
+}