fix(registry): by_name stores the full holder Pid — a dead name heals under slot reuse

Root cause of soak20 signature 2 (refcount_test.exs 'watcher crash',
110235x fast {:error, :server_down} probes over the full await window):
by_name mapped name -> slot *index*, so a name whose holder died (no stop
path unregisters; prune is lazy) and whose slot was then re-tenanted read
as live-held: register failed NameTaken{holder: <unrelated tenant>} (which
the bridge macro's generated start() swallows -> start_server/1 reports :ok
for a server that never came up), while name resolution reached the
tenant's mailbox, missed on the message TypeId and failed fast WITHOUT
pruning — the wedge self-sustained for the tenant's lifetime. Name-
addressed send additionally judged liveness on the slot's *current*
mailbox pid, so a same-typed tenant would have received the message
(misdelivery) and a differently typed one a misleading NoChannel.

Fix: by_name: HashMap<&'static str, Pid> — every reader judges the
*stored* holder with the generation-checked live(), so a recycled slot's
tenant no longer impersonates a dead holder, and every touch (register /
whereis / resolve / send) prunes and heals a stale name. prune(index)
becomes prune_holder(pid): names bound to the holder go; the mailbox goes
only while still the holder's own (a tenant's replacement mailbox is left
untouched). Introspection matches names to mailboxes by full pid, so a
stale name never annotates a slot's new tenant.

Deterministic regression test added first and shown to fail pre-fix
(tests/stale_name_slot_reuse.rs: tiny slab forces re-tenanting; old slot
(1,0) died, tenant (1,1) took the index; register -> NameTaken pre-fix).
Post-fix it asserts the healed contract: whereis -> None (pruned), call ->
ServerDown, re-register -> Ok. Suite 33 ok-binaries, clippy gate clean.

In the wild the window opened at every splice_test teardown:
Splice.terminate -> exit_server('subtree') left the name bound; width 20
raised the re-tenant probability. Downstream (smarm_beam): install_child's
unregister-before-register workaround becomes dead code (removed there);
the #[smarm_server] macro's swallowed register error becomes truthful
idempotency (a NameTaken now really is a live holder).
This commit is contained in:
smarm-agent
2026-07-12 07:23:30 +00:00
parent f6641cd266
commit 1c90a4ef5e
2 changed files with 243 additions and 68 deletions
+169
View File
@@ -0,0 +1,169 @@
//! 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: <unrelated tenant> }`,
//! 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<Target> = 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<Mutex<Option<Observed>>> = 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::<Filler>::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",
);
}