A watch installed after its target's death has, until now, only NoProc to report — but the bridge's proxies install their native watch asynchronously after acquire returns, so a link established before a crash (from the BEAM's view) could still lose the panic's translated reason to that blanket NoProc (width-20 soak signature 4: link_test.exs:26, 1/600 full-suite, 3/2000 link-only, all whereis-miss; deterministic repro in the bridge suite). Two primitives, no change to monitor()'s own Erlang-faithful stale-pid semantics — the upgrade is the caller's deliberate act: - finalize_actor stamps the slot with (generation, DownReason) under the same cold-lock block that publishes the outcome. The record survives reclaim, registry pruning, and the next tenant's install; only the slot's next death overwrites it. terminal_reason(pid) reads it generation-matched. - resolve_name(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 — whereis itself prunes on the way out, so a whereis-then-lookup consumer would find the evidence already destroyed. Live/Unbound match whereis's Some/None; the name heals exactly as before. Contract pinned in tests/terminal_outcome_after_death.rs: one record per way of dying (Exit/Panic/Stopped), no record while live, corpse capture + heal on resolve_name, record independence from registry pruning, survival across slot re-tenancy, overwrite at the next tenancy's death.
217 lines
8.1 KiB
Rust
217 lines
8.1 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>,
|
|
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 || {
|
|
// --- Plain actors: one record per way of dying. -------------------
|
|
let h = smarm::spawn(|| {});
|
|
let pid_exit = h.pid();
|
|
let _ = h.join();
|
|
let exit_reason = terminal_reason(pid_exit);
|
|
|
|
let h = smarm::spawn(|| 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 pid_stop = h.pid();
|
|
request_stop(pid_stop);
|
|
let _ = h.join();
|
|
let stopped_reason = terminal_reason(pid_stop);
|
|
|
|
// --- 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,
|
|
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.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:?}");
|
|
}
|