monitor/registry: terminal-outcome record — a raced watch can recover the real down reason (soak sig 4)
A watch installed after its target's death has, until now, only NoProc to report — but the bridge's proxies install their native watch asynchronously after acquire returns, so a link established before a crash (from the BEAM's view) could still lose the panic's translated reason to that blanket NoProc (width-20 soak signature 4: link_test.exs:26, 1/600 full-suite, 3/2000 link-only, all whereis-miss; deterministic repro in the bridge suite). Two primitives, no change to monitor()'s own Erlang-faithful stale-pid semantics — the upgrade is the caller's deliberate act: - finalize_actor stamps the slot with (generation, DownReason) under the same cold-lock block that publishes the outcome. The record survives reclaim, registry pruning, and the next tenant's install; only the slot's next death overwrites it. terminal_reason(pid) reads it generation-matched. - resolve_name(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 — whereis itself prunes on the way out, so a whereis-then-lookup consumer would find the evidence already destroyed. Live/Unbound match whereis's Some/None; the name heals exactly as before. Contract pinned in tests/terminal_outcome_after_death.rs: one record per way of dying (Exit/Panic/Stopped), no record while live, corpse capture + heal on resolve_name, record independence from registry pruning, survival across slot re-tenancy, overwrite at the next tenancy's death.
This commit is contained in:
+3
-3
@@ -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::{
|
||||
|
||||
@@ -177,6 +177,32 @@ pub fn monitor<A>(target: Pid<A>) -> 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<A>(target: Pid<A>) -> Option<DownReason> {
|
||||
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
|
||||
|
||||
@@ -486,6 +486,47 @@ pub fn whereis(name: &str) -> Option<Pid> {
|
||||
})
|
||||
}
|
||||
|
||||
/// 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<A>`] 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
|
||||
|
||||
@@ -472,6 +472,14 @@ pub(crate) struct SlotCold {
|
||||
/// epoch-matched unpark.
|
||||
pub(crate) waiters: Vec<(Pid, u32)>,
|
||||
pub(crate) outcome: Option<Outcome>,
|
||||
/// 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<Sender<Signal>>,
|
||||
/// 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<RuntimeInner>, 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
|
||||
|
||||
Reference in New Issue
Block a user