diff --git a/src/lib.rs b/src/lib.rs index 66d9082..de360b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -72,7 +72,7 @@ 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, terminal_reason, Down, DownReason, Monitor, MonitorId}; +pub use monitor::{demonitor, mark_watchable, 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}; diff --git a/src/monitor.rs b/src/monitor.rs index 78038a2..f82058e 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -177,6 +177,36 @@ pub fn monitor(target: Pid) -> Monitor { Monitor { id, target, rx } } +/// Flag `target`'s tenancy as watchable: its death will stamp the slot's +/// terminal record (see [`terminal_reason`]), exactly as registering a name +/// does. The bridge calls this wherever a smarm pid is *encoded across the +/// boundary* — a contract reply, an introspection listing — because BEAM can +/// only watch pids it holds, and can only hold pids that crossed. Keeping the +/// bit rare is what keeps the record alive: anonymous never-exported churn +/// (holder threads, egress tasks) stays ineligible and cannot evict a +/// watchable tenancy's record from a LIFO-recycled slot. +/// +/// Generation-checked and live-screened: marking a pid whose tenancy already +/// ended is a no-op — its record either exists (it was flagged before dying) +/// or is honestly unknowable. Same `Runtime::run()` context contract as +/// [`monitor`]. +pub fn mark_watchable(target: Pid) { + let target = target.erase(); + with_runtime(|inner| { + if let Some(slot) = inner.slot_at(target) { + // Cold lock FIRST: finalize publishes Done and checks the + // watchable bit under this same lock, so the mark either lands + // before finalize reads it (the death stamps) or observes the + // tenancy already dead (no-op). No lost-stamp window between an + // unlocked liveness read and the flag set. + let mut cold = slot.cold.lock(); + if slot.is_live_for(target) { + cold.watchable = true; + } + } + }); +} + /// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy /// ever registered a name and is the *most recent named* death of its slot: /// finalize stamps the slot with `(generation, reason)` for once-registered diff --git a/src/registry.rs b/src/registry.rs index 435d03b..b104079 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -395,7 +395,7 @@ pub(crate) fn register_with( // `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; + slot.cold.lock().watchable = true; } } let mut reg = inner.registry.lock(); diff --git a/src/runtime.rs b/src/runtime.rs index cc55bad..d6c7b6c 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -472,24 +472,28 @@ pub(crate) struct SlotCold { /// epoch-matched unpark. pub(crate) waiters: Vec<(Pid, u32)>, pub(crate) outcome: Option, - /// 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 + /// The slot's most recent *watchable-tenancy* death: `(generation, + /// reason)`, stamped by `finalize_actor` — but only for a tenancy whose + /// `watchable` bit was set — and deliberately never cleared: a new + /// tenant's install leaves it standing (it describes the previous + /// tenancy), and only the next *watchable* 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). + /// a blanket `NoProc` (bridge soak signatures 4 and 5). 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, + /// Stamp eligibility for `terminal` above: someone could plausibly hold + /// a watch on this tenancy. Two set-sites, both while the tenancy is + /// live: `register_with` *before* the binding lands (no successfully + /// registered actor can die unflagged; a failed register's overshoot is + /// harmless), and [`mark_watchable`](crate::monitor::mark_watchable) — + /// the bridge calls it wherever a pid is encoded across the boundary, + /// because BEAM can only watch pids it holds and can only hold pids + /// that crossed. Reset at reclaim. + pub(crate) watchable: bool, pub(crate) supervisor_channel: Option>, /// Watchers registered via `monitor()`, each tagged with its /// `MonitorId` so `demonitor` can remove exactly one. Each receives one @@ -645,7 +649,7 @@ impl Slot { waiters: Vec::new(), outcome: None, terminal: None, - ever_named: false, + watchable: false, supervisor_channel: None, monitors: Vec::new(), links: Vec::new(), @@ -1648,7 +1652,7 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) { cold.waiters.clear(); cold.monitors.clear(); cold.links.clear(); - cold.ever_named = false; + cold.watchable = 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 @@ -1696,8 +1700,8 @@ fn finalize_actor(inner: &Arc, pid: Pid, outcome: Outcome) { // 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 { + // *watchable* death. + if cold.watchable { cold.terminal = Some((pid.generation(), down_reason)); } slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release); diff --git a/tests/terminal_outcome_after_death.rs b/tests/terminal_outcome_after_death.rs index 2f50704..eaf1f37 100644 --- a/tests/terminal_outcome_after_death.rs +++ b/tests/terminal_outcome_after_death.rs @@ -17,7 +17,7 @@ //! caller's deliberate act, not a semantics change. use smarm::{ - init, request_stop, resolve_name, terminal_reason, CallError, Config, DownReason, GenServer, + init, mark_watchable, request_stop, resolve_name, terminal_reason, CallError, Config, DownReason, GenServer, GenServerBuilder, GenServerName, NameResolution, }; use std::sync::{Arc, Mutex}; @@ -59,6 +59,10 @@ impl GenServer for Filler { struct Observed { exit_reason: Option, anon_reason: Option, + /// Anonymous but export-marked while alive — must stamp (sig 5). + marked_reason: Option, + /// Marked only after death — must remain unknowable. + marked_late_reason: Option, panic_reason: Option, stopped_reason: Option, live_reason: Option, @@ -126,6 +130,22 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() { let _ = h.join(); let anon_reason = terminal_reason(pid_anon); + // --- mark_watchable: the bridge's export-seam eligibility (sig 5). + // An anonymous actor marked while alive stamps like a named one ... + let h = smarm::spawn(|| loop { + smarm::sleep(Duration::from_millis(2)); + }); + let pid_marked = h.pid(); + mark_watchable(pid_marked); + request_stop(pid_marked); + let _ = h.join(); + let marked_reason = terminal_reason(pid_marked); + + // ... while marking a pid whose tenancy already ended is a no-op: + // the history is honestly unknowable, not retroactively invented. + mark_watchable(pid_anon); + let marked_late_reason = terminal_reason(pid_anon); + // --- The named target: live readings first. ----------------------- let target = GenServerBuilder::new(Target) .named(TARGET) @@ -197,6 +217,8 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() { unknown_resolution, corpse_resolution_matches, resolution_after_prune, + marked_reason, + marked_late_reason, corpse_reason_after_prune, corpse_reason_after_reuse, corpse_reason_after_tenant_death, @@ -211,6 +233,15 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() { "anonymous deaths must not stamp: {o:?}" ); assert_eq!(o.panic_reason, Some(DownReason::Panic), "{o:?}"); + assert_eq!( + o.marked_reason, + Some(DownReason::Stopped), + "mark_watchable while alive must make the death stamp: {o:?}" + ); + assert_eq!( + o.marked_late_reason, None, + "marking a dead tenancy must not invent history: {o:?}" + ); assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}"); assert_eq!( o.live_reason, None,