From 461fe4b768beb98f320dd026851454f073e8de6f Mon Sep 17 00:00:00 2001 From: smarm-agent Date: Wed, 12 Aug 2026 19:18:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20only=20named=20tenancies=20stam?= =?UTF-8?q?p=20the=20terminal=20record=20=E2=80=94=20anonymous=20churn=20m?= =?UTF-8?q?ust=20not=20evict=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discovered wiring the bridge consult: with an unconditional stamp, the record for the very death being raced was the shortest-lived data in the runtime. Every green thread is a slot tenant, the free list is LIFO — so the slot a named server's death frees is the first one recycled, and the next throwaway exit (monitor holders, chain-runner work, anything) overwrote the record before a raced watch could consult it. Deterministic bridge repro: the corpse resolved fine, terminal_reason read None every time. register_with now flags the tenancy (ever_named, reset at reclaim) before the binding lands — set outside the registry lock, so no successfully registered actor can die unflagged and a failed register's overshoot is harmless — and finalize stamps only flagged tenancies. Watchable identities are exactly the named ones (the bridge's pid-identity path deliberately keeps Erlang's raw :noproc), so nothing consultable is lost. Contract test updated: the three death modes now self-register; a new anonymous control pins that unregistered deaths neither stamp nor evict. --- src/monitor.rs | 12 +++++---- src/registry.rs | 10 +++++++ src/runtime.rs | 33 ++++++++++++++++++----- tests/terminal_outcome_after_death.rs | 39 +++++++++++++++++++++++---- 4 files changed, 77 insertions(+), 17 deletions(-) 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!(