fix(runtime): only named tenancies stamp the terminal record — anonymous churn must not evict it
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.
This commit is contained in:
+7
-5
@@ -178,11 +178,13 @@ pub fn monitor<A>(target: Pid<A>) -> Monitor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy
|
/// The terminal [`DownReason`] of the tenancy `target` names, if that tenancy
|
||||||
/// is the *most recent* death of its slot: finalize stamps the slot with
|
/// ever registered a name and is the *most recent named* death of its slot:
|
||||||
/// `(generation, reason)`, and the record survives reclaim and the next
|
/// finalize stamps the slot with `(generation, reason)` for once-registered
|
||||||
/// tenant's install, until that next tenant itself dies. `None` means the pid
|
/// tenancies (anonymous green-thread churn does not stamp — nor evict), and
|
||||||
/// never lived, is still alive, or its record was overwritten by a later
|
/// the record survives reclaim and the next tenant's install, until the next
|
||||||
/// tenancy's death — callers fall back to `NoProc` semantics.
|
/// *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
|
/// 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
|
/// soak signature 4): a `NoProc` observed at install time can be upgraded to
|
||||||
|
|||||||
@@ -388,6 +388,16 @@ pub(crate) fn register_with<M: Send + 'static>(
|
|||||||
tx: Sender<M>,
|
tx: Sender<M>,
|
||||||
) -> Result<(), RegisterError> {
|
) -> Result<(), RegisterError> {
|
||||||
with_runtime(|inner| {
|
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();
|
let mut reg = inner.registry.lock();
|
||||||
if !live(inner, me) {
|
if !live(inner, me) {
|
||||||
return Err(RegisterError::NoProc);
|
return Err(RegisterError::NoProc);
|
||||||
|
|||||||
+25
-6
@@ -472,14 +472,24 @@ pub(crate) struct SlotCold {
|
|||||||
/// epoch-matched unpark.
|
/// epoch-matched unpark.
|
||||||
pub(crate) waiters: Vec<(Pid, u32)>,
|
pub(crate) waiters: Vec<(Pid, u32)>,
|
||||||
pub(crate) outcome: Option<Outcome>,
|
pub(crate) outcome: Option<Outcome>,
|
||||||
/// The slot's most recent *death*: `(generation, reason)`, stamped by
|
/// The slot's most recent *named-tenancy* death: `(generation, reason)`,
|
||||||
/// `finalize_actor` and deliberately never cleared — a new tenant's
|
/// stamped by `finalize_actor` — but only for a tenancy that ever
|
||||||
/// install leaves it standing (it describes the previous tenancy), and
|
/// registered a name (`ever_named`) — and deliberately never cleared: a
|
||||||
/// only the next death overwrites it. Read generation-matched via
|
/// 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
|
/// [`terminal_reason`](crate::monitor::terminal_reason), so a watch that
|
||||||
/// raced its target's death can recover the real down reason instead of
|
/// 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 signature 4).
|
||||||
pub(crate) terminal: Option<(u32, DownReason)>,
|
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<Sender<Signal>>,
|
pub(crate) supervisor_channel: Option<Sender<Signal>>,
|
||||||
/// Watchers registered via `monitor()`, each tagged with its
|
/// Watchers registered via `monitor()`, each tagged with its
|
||||||
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
|
/// `MonitorId` so `demonitor` can remove exactly one. Each receives one
|
||||||
@@ -635,6 +645,7 @@ impl Slot {
|
|||||||
waiters: Vec::new(),
|
waiters: Vec::new(),
|
||||||
outcome: None,
|
outcome: None,
|
||||||
terminal: None,
|
terminal: None,
|
||||||
|
ever_named: false,
|
||||||
supervisor_channel: None,
|
supervisor_channel: None,
|
||||||
monitors: Vec::new(),
|
monitors: Vec::new(),
|
||||||
links: Vec::new(),
|
links: Vec::new(),
|
||||||
@@ -1637,6 +1648,7 @@ pub(crate) fn reclaim_slot(inner: &RuntimeInner, pid: Pid) {
|
|||||||
cold.waiters.clear();
|
cold.waiters.clear();
|
||||||
cold.monitors.clear();
|
cold.monitors.clear();
|
||||||
cold.links.clear();
|
cold.links.clear();
|
||||||
|
cold.ever_named = false;
|
||||||
slot.reset_counters();
|
slot.reset_counters();
|
||||||
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
||||||
// The generation bump IS the reclaim: every stale pid is dead from
|
// The generation bump IS the reclaim: every stale pid is dead from
|
||||||
@@ -1678,9 +1690,16 @@ fn finalize_actor(inner: &Arc<RuntimeInner>, pid: Pid, outcome: Outcome) {
|
|||||||
cold.outcome = Some(joiner_outcome);
|
cold.outcome = Some(joiner_outcome);
|
||||||
// Terminal record (soak sig 4): stamped before the generation ever
|
// Terminal record (soak sig 4): stamped before the generation ever
|
||||||
// bumps, under the cold lock, so a reader that resolved this pid can
|
// 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
|
// recover the reason after the slot moves on — but only for a
|
||||||
// slot's next death.
|
// 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));
|
cold.terminal = Some((pid.generation(), down_reason));
|
||||||
|
}
|
||||||
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
slot.stop_ptr.store(std::ptr::null_mut(), Ordering::Release);
|
||||||
// Done is published under the cold lock, so join's
|
// Done is published under the cold lock, so join's
|
||||||
// check-Done-or-register-waiter (also under it) can never miss: it
|
// check-Done-or-register-waiter (also under it) can never miss: it
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ impl GenServer for Filler {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
struct Observed {
|
struct Observed {
|
||||||
exit_reason: Option<DownReason>,
|
exit_reason: Option<DownReason>,
|
||||||
|
anon_reason: Option<DownReason>,
|
||||||
panic_reason: Option<DownReason>,
|
panic_reason: Option<DownReason>,
|
||||||
stopped_reason: Option<DownReason>,
|
stopped_reason: Option<DownReason>,
|
||||||
live_reason: Option<DownReason>,
|
live_reason: Option<DownReason>,
|
||||||
@@ -83,25 +84,48 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
|
|||||||
|
|
||||||
// Tiny slab: prompt slot recycling for the re-tenancy phase.
|
// Tiny slab: prompt slot recycling for the re-tenancy phase.
|
||||||
init(Config::exact(2).max_actors(32)).run(move || {
|
init(Config::exact(2).max_actors(32)).run(move || {
|
||||||
// --- Plain actors: one record per way of dying. -------------------
|
// --- Registered plain actors: one record per way of dying. The
|
||||||
let h = smarm::spawn(|| {});
|
// 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 pid_exit = h.pid();
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
let exit_reason = terminal_reason(pid_exit);
|
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 pid_panic = h.pid();
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
let panic_reason = terminal_reason(pid_panic);
|
let panic_reason = terminal_reason(pid_panic);
|
||||||
|
|
||||||
let h = smarm::spawn(|| loop {
|
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));
|
smarm::sleep(Duration::from_millis(2));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
let pid_stop = h.pid();
|
let pid_stop = h.pid();
|
||||||
request_stop(pid_stop);
|
request_stop(pid_stop);
|
||||||
let _ = h.join();
|
let _ = h.join();
|
||||||
let stopped_reason = terminal_reason(pid_stop);
|
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. -----------------------
|
// --- The named target: live readings first. -----------------------
|
||||||
let target = GenServerBuilder::new(Target)
|
let target = GenServerBuilder::new(Target)
|
||||||
.named(TARGET)
|
.named(TARGET)
|
||||||
@@ -165,6 +189,7 @@ fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
|
|||||||
|
|
||||||
*out_w.lock().unwrap() = Some(Observed {
|
*out_w.lock().unwrap() = Some(Observed {
|
||||||
exit_reason,
|
exit_reason,
|
||||||
|
anon_reason,
|
||||||
panic_reason,
|
panic_reason,
|
||||||
stopped_reason,
|
stopped_reason,
|
||||||
live_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");
|
let o = out.lock().unwrap().take().expect("runtime body completed");
|
||||||
assert_eq!(o.exit_reason, Some(DownReason::Exit), "{o:?}");
|
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.panic_reason, Some(DownReason::Panic), "{o:?}");
|
||||||
assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}");
|
assert_eq!(o.stopped_reason, Some(DownReason::Stopped), "{o:?}");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Reference in New Issue
Block a user