diff --git a/src/monitor.rs b/src/monitor.rs
index 9a4c856..78038a2 100644
--- a/src/monitor.rs
+++ b/src/monitor.rs
@@ -178,11 +178,13 @@ pub fn monitor(target: Pid) -> Monitor {
}
/// 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.
+/// ever registered a name and is the *most recent named* death of its slot:
+/// finalize stamps the slot with `(generation, reason)` for once-registered
+/// tenancies (anonymous green-thread churn does not stamp — nor evict), and
+/// the record survives reclaim and the next tenant's install, until the next
+/// *named* tenant of the slot itself dies. `None` means the pid never lived,
+/// is still alive, never held a name, or its record was overwritten by a
+/// later named 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
diff --git a/src/registry.rs b/src/registry.rs
index 8424ec8..435d03b 100644
--- a/src/registry.rs
+++ b/src/registry.rs
@@ -388,6 +388,16 @@ pub(crate) fn register_with(
tx: Sender,
) -> Result<(), RegisterError> {
with_runtime(|inner| {
+ // Stamp-eligibility for the terminal record (soak sig 4): flag the
+ // tenancy BEFORE the binding lands and outside the registry lock (no
+ // nesting), so no successfully-registered actor can die unflagged.
+ // A register that then fails leaves a harmless overshoot; a stale
+ // `me` is screened by the same live() the binding requires below.
+ if live(inner, me) {
+ if let Some(slot) = inner.slot_at(me) {
+ slot.cold.lock().ever_named = true;
+ }
+ }
let mut reg = inner.registry.lock();
if !live(inner, me) {
return Err(RegisterError::NoProc);
diff --git a/src/runtime.rs b/src/runtime.rs
index 883a316..cc55bad 100644
--- a/src/runtime.rs
+++ b/src/runtime.rs
@@ -472,14 +472,24 @@ 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
+ /// The slot's most recent *named-tenancy* death: `(generation, reason)`,
+ /// stamped by `finalize_actor` — but only for a tenancy that ever
+ /// registered a name (`ever_named`) — and deliberately never cleared: a
+ /// new tenant's install leaves it standing (it describes the previous
+ /// tenancy), and only the next *named* death overwrites it. Anonymous
+ /// green-thread churn must not evict it: the free list is LIFO, so the
+ /// just-freed slot is the first recycled, and an unconditional stamp
+ /// made a watchable tenancy's record the shortest-lived data in the
+ /// runtime. 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)>,
+ /// This tenancy registered a name at least once — the stamp-eligibility
+ /// bit for `terminal` above. Set by `register_with` *before* the binding
+ /// lands (so no successfully-registered actor can die unflagged; a
+ /// failed register's overshoot is harmless), reset at reclaim.
+ pub(crate) ever_named: bool,
pub(crate) supervisor_channel: Option>,
/// Watchers registered via `monitor()`, each tagged with its
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
@@ -635,6 +645,7 @@ impl Slot {
waiters: Vec::new(),
outcome: None,
terminal: None,
+ ever_named: false,
supervisor_channel: None,
monitors: Vec::new(),
links: Vec::new(),
@@ -1637,6 +1648,7 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
cold.waiters.clear();
cold.monitors.clear();
cold.links.clear();
+ cold.ever_named = false;
slot.reset_counters();
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
// The generation bump IS the reclaim: every stale pid is dead from
@@ -1678,9 +1690,16 @@ fn finalize_actor(inner: &Arc, pid: Pid, outcome: Outcome) {
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));
+ // recover the reason after the slot moves on — but only for a
+ // tenancy that ever held a name. The free list is LIFO, so the slot
+ // this death frees is the very next one recycled; if every green
+ // thread's exit stamped too, the churn behind any real workload
+ // would evict a watchable tenancy's record in well under the race
+ // window this exists to cover. Overwritten only by the slot's next
+ // *named* death.
+ if cold.ever_named {
+ 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
index b78da62..2f50704 100644
--- a/tests/terminal_outcome_after_death.rs
+++ b/tests/terminal_outcome_after_death.rs
@@ -58,6 +58,7 @@ impl GenServer for Filler {
#[derive(Debug)]
struct Observed {
exit_reason: Option,
+ anon_reason: Option,
panic_reason: Option,
stopped_reason: Option,
live_reason: Option,
@@ -83,25 +84,48 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
// 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(|| {});
+ // --- Registered plain actors: one record per way of dying. The
+ // record is named-tenancy-only, so each actor self-registers a
+ // throwaway channel before dying; the anonymous control below pins
+ // the complement.
+ let h = smarm::spawn(|| {
+ let (tx, _rx) = smarm::channel::<()>();
+ let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_exit"), tx);
+ });
let pid_exit = h.pid();
let _ = h.join();
let exit_reason = terminal_reason(pid_exit);
- let h = smarm::spawn(|| panic!("induced"));
+ let h = smarm::spawn(|| {
+ let (tx, _rx) = smarm::channel::<()>();
+ let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_panic"), tx);
+ 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 h = smarm::spawn(|| {
+ let (tx, _rx) = smarm::channel::<()>();
+ let _ = smarm::register(smarm::Name::<()>::new("terminal_probe_stop"), tx);
+ 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);
+ // --- Anonymous control: an unregistered death must NOT stamp (nor
+ // evict) — the free list is LIFO, so green-thread churn would
+ // otherwise overwrite a watchable record faster than any race
+ // window this exists to cover.
+ let h = smarm::spawn(|| panic!("anonymous"));
+ let pid_anon = h.pid();
+ let _ = h.join();
+ let anon_reason = terminal_reason(pid_anon);
+
// --- The named target: live readings first. -----------------------
let target = GenServerBuilder::new(Target)
.named(TARGET)
@@ -165,6 +189,7 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
*out_w.lock().unwrap() = Some(Observed {
exit_reason,
+ anon_reason,
panic_reason,
stopped_reason,
live_reason,
@@ -181,6 +206,10 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
let o = out.lock().unwrap().take().expect("runtime body completed");
assert_eq!(o.exit_reason, Some(DownReason::Exit), "{o:?}");
+ assert_eq!(
+ o.anon_reason, None,
+ "anonymous deaths must not stamp: {o:?}"
+ );
assert_eq!(o.panic_reason, Some(DownReason::Panic), "{o:?}");
assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}");
assert_eq!(