Files
smarm/tests/terminal_outcome_after_death.rs
T
smarm-agent 461fe4b768 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.
2026-08-13 05:56:19 +00:00

246 lines
9.3 KiB
Rust

//! The terminal-record contract (bridge soak signature 4): a watch installed
//! *after* its target's death — the async-install race the bridge's proxies
//! live with — must be able to recover the real down reason instead of a
//! blanket `NoProc`. Two primitives carry it:
//!
//! - `finalize_actor` stamps the slot with `(generation, DownReason)`; the
//! record survives reclaim, registry pruning, and the next tenant's
//! install, and is overwritten only by the slot's next death.
//! [`terminal_reason`] reads it generation-matched.
//! - [`resolve_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. `Unbound` stays the
//! Erlang-shaped `noproc` for names that were never (or are no longer)
//! bound.
//!
//! `monitor()` of a stale pid still queues plain `NoProc` — the upgrade is a
//! caller's deliberate act, not a semantics change.
use smarm::{
init, request_stop, resolve_name, terminal_reason, CallError, Config, DownReason, GenServer,
GenServerBuilder, GenServerName, NameResolution,
};
use std::sync::{Arc, Mutex};
use std::time::Duration;
const TARGET: GenServerName<Target> = GenServerName::new("terminal_target");
/// Named server that panics on cast — the sig-4 death.
struct Target;
impl GenServer for Target {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {
panic!("terminal_target: induced panic");
}
}
/// Slot filler for the re-tenancy phase (distinct type, held alive).
struct Filler;
impl GenServer for Filler {
type Call = ();
type Reply = ();
type Cast = ();
type Info = ();
type Timer = ();
fn handle_call(&mut self, _req: ()) {}
fn handle_cast(&mut self, _op: ()) {}
}
#[derive(Debug)]
struct Observed {
exit_reason: Option<DownReason>,
anon_reason: Option<DownReason>,
panic_reason: Option<DownReason>,
stopped_reason: Option<DownReason>,
live_reason: Option<DownReason>,
live_resolution_is_live: bool,
unknown_resolution: NameResolution,
/// First resolve after the named target's panic — must be Corpse(old pid).
corpse_resolution_matches: bool,
/// Second resolve — the Corpse arm pruned, so the name has healed.
resolution_after_prune: NameResolution,
/// Read AFTER the prune above: the record is slot-side, not registry-side.
corpse_reason_after_prune: Option<DownReason>,
/// Record survives the slot being re-tenanted (new tenant still alive).
corpse_reason_after_reuse: Option<DownReason>,
/// ... and dies with the next tenancy's death (overwritten).
corpse_reason_after_tenant_death: Option<DownReason>,
tenant_reason: Option<DownReason>,
}
#[test]
fn terminal_record_recovers_the_reason_a_raced_watch_lost() {
let out: Arc<Mutex<Option<Observed>>> = Arc::new(Mutex::new(None));
let out_w = out.clone();
// Tiny slab: prompt slot recycling for the re-tenancy phase.
init(Config::exact(2).max_actors(32)).run(move || {
// --- 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(|| {
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(|| {
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)
.start()
.expect("name free at test start");
let old_pid = target.pid();
let live_reason = terminal_reason(old_pid);
let live_resolution_is_live =
resolve_name(TARGET.as_str()) == NameResolution::Live(old_pid.erase());
let unknown_resolution = resolve_name("terminal_never_bound");
// --- Kill it by panic; confirm death via the ref, NEVER the name
// (any name reader would take the prune arm and destroy the corpse
// precondition — the same trap stale_name_slot_reuse.rs documents).
let _ = target.cast(());
loop {
match target.call(()) {
Err(CallError::ServerDown) => break,
Ok(()) => smarm::sleep(Duration::from_millis(2)),
}
}
let corpse_resolution_matches =
resolve_name(TARGET.as_str()) == NameResolution::Corpse(old_pid.erase());
let resolution_after_prune = resolve_name(TARGET.as_str());
let corpse_reason_after_prune = terminal_reason(old_pid);
// --- Re-tenant the freed slot; the record must outlive the install
// and die only with the next tenancy's death.
let mut fillers = Vec::new();
let mut tenant = None;
for i in 0..24 {
let name: &'static str = Box::leak(format!("terminal_filler_{i}").into_boxed_str());
let f = GenServerBuilder::new(Filler)
.named(GenServerName::<Filler>::new(name))
.start()
.expect("filler names are fresh");
let fp = f.pid();
let landed = fp.index() == old_pid.index();
fillers.push(f);
if landed {
tenant = Some((fillers.len() - 1, fp));
break;
}
}
let (tenant_at, tenant_pid) = tenant.expect(
"precondition: the freed slot must be re-tenanted within the tiny slab \
(slots are recycled; every filler is held alive)",
);
let corpse_reason_after_reuse = terminal_reason(old_pid);
request_stop(tenant_pid);
loop {
match fillers[tenant_at].call(()) {
Err(CallError::ServerDown) => break,
Ok(()) => smarm::sleep(Duration::from_millis(2)),
}
}
let corpse_reason_after_tenant_death = terminal_reason(old_pid);
let tenant_reason = terminal_reason(tenant_pid);
*out_w.lock().unwrap() = Some(Observed {
exit_reason,
anon_reason,
panic_reason,
stopped_reason,
live_reason,
live_resolution_is_live,
unknown_resolution,
corpse_resolution_matches,
resolution_after_prune,
corpse_reason_after_prune,
corpse_reason_after_reuse,
corpse_reason_after_tenant_death,
tenant_reason,
});
});
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!(
o.live_reason, None,
"live tenancy must have no record: {o:?}"
);
assert!(o.live_resolution_is_live, "{o:?}");
assert_eq!(o.unknown_resolution, NameResolution::Unbound, "{o:?}");
assert!(
o.corpse_resolution_matches,
"first post-death resolve must carry the corpse: {o:?}"
);
assert_eq!(
o.resolution_after_prune,
NameResolution::Unbound,
"the Corpse arm prunes — the name heals: {o:?}"
);
assert_eq!(
o.corpse_reason_after_prune,
Some(DownReason::Panic),
"the record is slot-side; registry pruning must not touch it: {o:?}"
);
assert_eq!(
o.corpse_reason_after_reuse,
Some(DownReason::Panic),
"a new tenant's install must leave the previous tenancy's record: {o:?}"
);
assert_eq!(
o.corpse_reason_after_tenant_death, None,
"the next death overwrites — the old generation no longer matches: {o:?}"
);
assert_eq!(o.tenant_reason, Some(DownReason::Stopped), "{o:?}");
}