//! Reproducer (soak20 signature 2, refcount_test.exs "watcher crash"): //! `by_name` stores only the slot *index*, so a name whose holder died — never //! unregistered, since no smarm stop path unregisters (prune is lazy) — and //! whose slot was then re-tenanted by an unrelated actor reads as *live-held*: //! //! - `register` of the name fails `NameTaken { holder: }`, //! so the bridge's generated `start()` (a `let _ =`) silently no-ops and //! `start_server/1` reports `:ok` for a server that never came up; //! - a by-name `call` resolves the tenant's mailbox, misses on the message //! `TypeId`, and fails `ServerDown` fast — and does NOT prune (only the //! dead-holder and dangling-name arms prune), so the name never heals //! while the tenant lives. The wedge is self-sustaining. //! //! Wild signature: 110235x fast `{:error, :server_down}` probes over the full //! 5 s await window after a swallowed restart (200-run width-20 soak, run 59). //! //! The test asserts the *contract*: after its holder dies, a name must be //! re-registrable regardless of what happened to the slot. Red pre-fix. use smarm::{ call, init, request_stop, whereis, CallError, Config, GenServer, GenServerBuilder, GenServerName, RegisterError, }; use std::sync::{Arc, Mutex}; use std::time::Duration; const TARGET: GenServerName = GenServerName::new("stale_reuse_target"); /// The named server whose death opens the window. Trivial on purpose. 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: ()) {} } /// The unrelated tenant. A *different* server type, so its mailbox holds a /// different `Envelope` `TypeId` — a same-typed tenant would make the by-name /// `call` *deliver to the wrong server* instead of failing, which is the same /// root hole wearing a worse hat. 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 { old_slot: (u32, u32), tenant_slot: (u32, u32), /// `whereis` of the dead name after re-tenanting — `Some` is the misread. whereis_after_reuse: Option<(u32, u32)>, /// By-name call after re-tenanting — the wild `server_down` fast-fail. call_after_reuse: Result<(), CallError>, /// The contract under test: re-registering the dead name. restart: Result<(), RegisterError>, } #[test] fn dead_name_with_reused_slot_must_be_re_registrable() { let out: Arc>> = Arc::new(Mutex::new(None)); let out_w = out.clone(); // A deliberately tiny slab forces prompt slot recycling: with every filler // held alive, the freed slot is the only *recycled* one, so a filler lands // on it deterministically well before the slab (a loud panic) runs out. init(Config::exact(2).max_actors(32)).run(move || { // 1. Named server up; record its slot. let target = GenServerBuilder::new(Target) .named(TARGET) .start() .expect("name should be free at test start"); let old_pid = target.pid(); // 2. Kill it WITHOUT unregistering (no stop path does). Death is // confirmed via the *ref*, never the name — a by-name resolve of a // dead-but-not-yet-reused holder takes the prune arm and heals the // name, destroying the precondition. request_stop(old_pid); loop { match target.call(()) { Err(CallError::ServerDown) => break, Ok(()) => smarm::sleep(Duration::from_millis(5)), } } drop(target); // 3. Re-tenant the slot: spawn fillers (all kept alive) until one // lands on the old index. let mut fillers = Vec::new(); let mut tenant = None; for i in 0..24 { let name: &'static str = Box::leak(format!("stale_filler_{i}").into_boxed_str()); let f = GenServerBuilder::new(Filler) .named(GenServerName::::new(name)) .start() .expect("filler names are fresh"); let fp = f.pid(); fillers.push(f); if fp.index() == old_pid.index() { tenant = Some(fp); break; } } let tenant = tenant.expect( "precondition: the freed slot must be re-tenanted within the tiny slab \ (slots are recycled; every filler is held alive)", ); // 4. Observe the poisoned state through the same paths the bridge uses. let whereis_after_reuse = whereis(TARGET.as_str()).map(|p| (p.index(), p.generation())); let call_after_reuse = call(TARGET, ()); let restart = GenServerBuilder::new(Target) .named(TARGET) .start() .map(|_fresh_ref| ()); *out_w.lock().unwrap() = Some(Observed { old_slot: (old_pid.index(), old_pid.generation()), tenant_slot: (tenant.index(), tenant.generation()), whereis_after_reuse, call_after_reuse, restart, }); drop(fillers); }); let o = out.lock().unwrap().take().expect("run body completed"); eprintln!("observed: {o:?}"); assert!( o.restart.is_ok(), "re-registering '{}' after its holder died failed with {:?}: the dead name \ reads as held by the live, unrelated tenant {:?} because by_name kept only \ the slot index (old slot {:?}). This is the silent-no-op start_server path \ of soak20 signature 2.", TARGET.as_str(), o.restart, o.tenant_slot, o.old_slot, ); // The healed semantics around the re-register: the stale name reads // *unbound* (never the tenant), and a by-name call fails ServerDown rather // than resolving anything of the tenant's. assert_eq!( o.whereis_after_reuse, None, "whereis of a dead name must prune and report unbound, not the slot's new tenant", ); assert_eq!( o.call_after_reuse, Err(CallError::ServerDown), "a by-name call to a dead name must fail ServerDown", ); }