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).
170 lines
6.3 KiB
Rust
170 lines
6.3 KiB
Rust
//! 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",
|
|
);
|
|
}
|