//! 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 = 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, 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, 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, /// Record survives the slot being re-tenanted (new tenant still alive). corpse_reason_after_reuse: Option, /// ... and dies with the next tenancy's death (overwritten). corpse_reason_after_tenant_death: Option, tenant_reason: Option, } #[test] fn terminal_record_recovers_the_reason_a_raced_watch_lost() { let out: Arc>> = 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::::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:?}"); }