277 lines
10 KiB
Rust
277 lines
10 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, mark_watchable, 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>,
|
|
/// Anonymous but export-marked while alive — must stamp (sig 5).
|
|
marked_reason: Option<DownReason>,
|
|
/// Marked only after death — must remain unknowable.
|
|
marked_late_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);
|
|
|
|
// --- 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)
|
|
.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,
|
|
marked_reason,
|
|
marked_late_reason,
|
|
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.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,
|
|
"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:?}");
|
|
}
|