feat(registry): named pid registry as a bimap

register/whereis/unregister plus the inverse lookup name_of. Erlang
semantics: one name per pid, one pid per name, registering over a live
binding errors; same-binding re-register is an idempotent Ok.

Bimap = two HashMaps held in exact inverse under one Leaf RawMutex in
RuntimeInner; the invariant is debug-asserted on every mutation. The
inverse direction costs one extra String per binding and buys O(1)
pid->name for supervisors/tracing/diagnostics.

Cleanup is lazy: no finalize_actor hook, no Slot field (which would buy
into the reset-in-three-places invariant). Liveness rides the
generation-checked slot word - pids are never reused, so a stale binding
is detectable, never misdirected - and bindings to dead actors behave as
absent, pruned on contact by whereis/name_of/register/unregister.
Liveness checks under the registry lock read only the atomic slot word,
so the leaf rule holds trivially.

send_by_name is deliberately absent: smarm has no per-pid mailbox, so
there is nothing generic to send *to* - the registry yields a Pid,
usable with monitor/link/request_stop and as routing for user channels.

Tests: roundtrip both directions, idempotence, NameTaken on live holder,
one-name-per-pid, NoProc, death-evaporates-binding (via lookup pruning
and via register's own eviction path), unregister, cross-actor lookup
wired into request_stop, and a 4-scheduler name-contention churn test.
This commit is contained in:
smarm
2026-06-09 22:53:43 +00:00
parent 8ff6cf4afd
commit 2c7cf0b811
4 changed files with 397 additions and 0 deletions
+2
View File
@@ -23,6 +23,7 @@ pub mod timer;
pub mod io;
pub mod mutex;
pub mod monitor;
pub mod registry;
pub mod link;
pub mod gen_server;
pub mod runtime;
@@ -50,6 +51,7 @@ pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
pub use mutex::{LockTimeout, Mutex, MutexGuard};
pub use pid::Pid;
pub use registry::{name_of, register, unregister, whereis, RegisterError};
pub use runtime::{init, Config, Runtime};
pub use scheduler::{
block_on_io, request_stop, run, self_pid, sleep, spawn, spawn_under, wait_readable,
+169
View File
@@ -0,0 +1,169 @@
//! Named pid registry.
//!
//! `register(name, pid)` binds a name to a live actor; `whereis(name)` looks
//! it up; `name_of(pid)` is the inverse. Erlang semantics: at most one name
//! per pid, at most one pid per name, registering over a live binding is an
//! error. The registry is a *bimap* (two `HashMap`s kept in exact inverse
//! under one lock) so both lookup directions are O(1) — `name_of` exists so
//! supervisors, tracing, and diagnostics can label a pid without scanning.
//!
//! ## Cleanup is lazy
//!
//! There is no hook in `finalize_actor` and no name field in `Slot` (which
//! would buy into the reset-in-three-places slot invariant). Instead every
//! operation that touches a binding checks the bound pid's liveness via the
//! generation-checked slot word — a `Pid` is `(index, generation)` and a
//! generation is never reused, so a stale binding is *detectable*, never
//! misdirected. Bindings to dead actors behave as absent and are pruned on
//! contact: `whereis`/`name_of` return `None`, `register` treats the name as
//! free. The cost is that a dead binding lingers until something looks at it;
//! the payoff is zero coupling to the actor lifecycle.
//!
//! ## Locking
//!
//! One `RawMutex` (Leaf class) in `RuntimeInner`. Liveness checks under it
//! read only the atomic slot state word — no second lock is ever taken, so
//! the leaf rule holds trivially.
use crate::pid::Pid;
use crate::scheduler::with_runtime;
use std::collections::HashMap;
/// Why a [`register`] call was rejected.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegisterError {
/// The name is bound to a different, still-live actor.
NameTaken { holder: Pid },
/// The pid already has a (live) registered name; one name per pid.
PidAlreadyRegistered { name: String },
/// The pid does not refer to a live actor.
NoProc,
}
impl std::fmt::Display for RegisterError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RegisterError::NameTaken { holder } => {
write!(f, "name is already registered to live actor {holder}")
}
RegisterError::PidAlreadyRegistered { name } => {
write!(f, "pid is already registered as {name:?}")
}
RegisterError::NoProc => write!(f, "pid does not refer to a live actor"),
}
}
}
impl std::error::Error for RegisterError {}
/// The bimap. Invariant (held under the registry lock): `by_name` and
/// `by_pid` are exact inverses of each other at all times.
pub(crate) struct Registry {
by_name: HashMap<String, Pid>,
by_pid: HashMap<Pid, String>,
}
impl Registry {
pub(crate) fn new() -> Self {
Self { by_name: HashMap::new(), by_pid: HashMap::new() }
}
fn remove_binding(&mut self, name: &str, pid: Pid) {
self.by_name.remove(name);
self.by_pid.remove(&pid);
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
}
fn insert_binding(&mut self, name: String, pid: Pid) {
self.by_pid.insert(pid, name.clone());
self.by_name.insert(name, pid);
debug_assert_eq!(self.by_name.len(), self.by_pid.len(), "bimap out of sync");
}
}
/// Is `pid` a live actor right now? Atomic slot-word read; no lock.
fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool {
inner.slot_at(pid).is_some_and(|s| s.is_live_for(pid))
}
/// Bind `name` to the live actor `pid`.
///
/// Fails with [`RegisterError::NoProc`] if `pid` is not live,
/// [`RegisterError::NameTaken`] if the name is bound to a *live* actor
/// (a binding to a dead actor is evicted and the name treated as free), and
/// [`RegisterError::PidAlreadyRegistered`] if `pid` already has a name.
///
/// Panics if called outside `Runtime::run()`.
pub fn register(name: impl Into<String>, pid: Pid) -> Result<(), RegisterError> {
let name = name.into();
with_runtime(|inner| {
let mut reg = inner.registry.lock();
if !live(inner, pid) {
return Err(RegisterError::NoProc);
}
let prior = reg.by_name.get(&name).copied();
if let Some(holder) = prior {
if holder == pid {
return Ok(()); // already exactly this binding: idempotent
}
if live(inner, holder) {
return Err(RegisterError::NameTaken { holder });
}
// Stale binding: the holder died. Evict and treat the name as free.
reg.remove_binding(&name, holder);
}
if let Some(existing) = reg.by_pid.get(&pid) {
// `pid` is live (checked above) and pids are never reused, so this
// binding is necessarily current — one name per pid.
return Err(RegisterError::PidAlreadyRegistered { name: existing.clone() });
}
reg.insert_binding(name, pid);
Ok(())
})
}
/// Look up the pid bound to `name`. `None` if unbound, or if the bound actor
/// is no longer live (the stale binding is pruned on the way out).
pub fn whereis(name: &str) -> Option<Pid> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let pid = *reg.by_name.get(name)?;
if live(inner, pid) {
Some(pid)
} else {
reg.remove_binding(name, pid);
None
}
})
}
/// The inverse lookup: the name `pid` is registered under, if any. `None` if
/// unregistered or no longer live (pruning the stale binding).
pub fn name_of(pid: Pid) -> Option<String> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let name = reg.by_pid.get(&pid)?.clone();
if live(inner, pid) {
Some(name)
} else {
reg.remove_binding(&name, pid);
None
}
})
}
/// Remove the binding for `name`, returning the pid it was bound to if that
/// actor is still live. A binding to a dead actor is pruned and reported as
/// `None`, consistent with [`whereis`].
pub fn unregister(name: &str) -> Option<Pid> {
with_runtime(|inner| {
let mut reg = inner.registry.lock();
let pid = *reg.by_name.get(name)?;
reg.remove_binding(name, pid);
if live(inner, pid) {
Some(pid)
} else {
None
}
})
}
+5
View File
@@ -450,6 +450,10 @@ pub(crate) struct RuntimeInner {
/// Preemption knobs, written into each scheduler thread's locals on startup.
pub(crate) alloc_interval: u32,
pub(crate) timeslice_cycles: u64,
/// The name <-> pid registry (bidirectional). RawMutex Leaf: never held
/// with any other lock; liveness checks under it read only the atomic
/// slot word.
pub(crate) registry: RawMutex<crate::registry::Registry>,
/// Recycled stacks waiting to be reused by the next spawn.
pub(crate) stack_pool: RawMutex<Vec<crate::stack::Stack>>,
/// Maximum number of stacks to retain in the pool.
@@ -482,6 +486,7 @@ impl RuntimeInner {
sleeping: AtomicU32::new(0),
alloc_interval,
timeslice_cycles,
registry: RawMutex::new(crate::registry::Registry::new()),
stack_pool: RawMutex::new(Vec::new()),
stack_pool_cap,
})
+221
View File
@@ -0,0 +1,221 @@
//! Named registry tests. Run under the scheduler: registration requires a
//! live runtime and live actors.
use smarm::{channel, name_of, register, run, spawn, unregister, whereis, RegisterError};
#[test]
fn register_whereis_roundtrip() {
run(|| {
let (tx, rx) = channel::<()>();
let h = spawn(move || {
rx.recv().unwrap();
});
register("worker", h.pid()).unwrap();
assert_eq!(whereis("worker"), Some(h.pid()));
assert_eq!(name_of(h.pid()).as_deref(), Some("worker"));
tx.send(()).unwrap();
h.join().unwrap();
});
}
#[test]
fn register_is_idempotent_for_same_binding() {
run(|| {
let (tx, rx) = channel::<()>();
let h = spawn(move || {
rx.recv().unwrap();
});
register("svc", h.pid()).unwrap();
// Same name, same pid: a no-op Ok, not NameTaken.
register("svc", h.pid()).unwrap();
tx.send(()).unwrap();
h.join().unwrap();
});
}
#[test]
fn duplicate_name_on_live_holder_is_rejected() {
run(|| {
let (tx, rx) = channel::<()>();
let (tx2, rx2) = channel::<()>();
let a = spawn(move || {
rx.recv().unwrap();
});
let b = spawn(move || {
rx2.recv().unwrap();
});
register("svc", a.pid()).unwrap();
assert_eq!(
register("svc", b.pid()),
Err(RegisterError::NameTaken { holder: a.pid() })
);
tx.send(()).unwrap();
tx2.send(()).unwrap();
a.join().unwrap();
b.join().unwrap();
});
}
#[test]
fn one_name_per_pid() {
run(|| {
let (tx, rx) = channel::<()>();
let h = spawn(move || {
rx.recv().unwrap();
});
register("first", h.pid()).unwrap();
assert_eq!(
register("second", h.pid()),
Err(RegisterError::PidAlreadyRegistered { name: "first".into() })
);
tx.send(()).unwrap();
h.join().unwrap();
});
}
#[test]
fn registering_a_dead_pid_is_noproc() {
run(|| {
let h = spawn(|| {});
let pid = h.pid();
h.join().unwrap();
assert_eq!(register("ghost", pid), Err(RegisterError::NoProc));
});
}
#[test]
fn binding_evaporates_on_death_and_name_is_reusable() {
run(|| {
let a = spawn(|| {});
let pid_a = a.pid();
register("svc", pid_a).unwrap();
a.join().unwrap();
// Dead holder: both lookup directions report unbound.
assert_eq!(whereis("svc"), None);
assert_eq!(name_of(pid_a), None);
// And the name is free for a successor.
let (tx, rx) = channel::<()>();
let b = spawn(move || {
rx.recv().unwrap();
});
register("svc", b.pid()).unwrap();
assert_eq!(whereis("svc"), Some(b.pid()));
tx.send(()).unwrap();
b.join().unwrap();
});
}
#[test]
fn register_over_a_dead_holder_succeeds_without_lookup_in_between() {
// The eviction path inside register() itself (not via whereis pruning).
run(|| {
let a = spawn(|| {});
let pid_a = a.pid();
register("svc", pid_a).unwrap();
a.join().unwrap();
let (tx, rx) = channel::<()>();
let b = spawn(move || {
rx.recv().unwrap();
});
register("svc", b.pid()).unwrap();
assert_eq!(whereis("svc"), Some(b.pid()));
assert_eq!(name_of(pid_a), None);
tx.send(()).unwrap();
b.join().unwrap();
});
}
#[test]
fn unregister_frees_both_directions() {
run(|| {
let (tx, rx) = channel::<()>();
let h = spawn(move || {
rx.recv().unwrap();
});
register("svc", h.pid()).unwrap();
assert_eq!(unregister("svc"), Some(h.pid()));
assert_eq!(whereis("svc"), None);
assert_eq!(name_of(h.pid()), None);
assert_eq!(unregister("svc"), None);
// The pid may take a new name afterwards.
register("svc2", h.pid()).unwrap();
assert_eq!(name_of(h.pid()).as_deref(), Some("svc2"));
tx.send(()).unwrap();
h.join().unwrap();
});
}
#[test]
fn whereis_from_another_actor_and_usable_with_runtime_apis() {
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
let stopped = Arc::new(AtomicBool::new(false));
let stopped2 = stopped.clone();
run(move || {
let (tx, rx) = channel::<()>();
let svc = spawn(move || {
// Parks forever; only a request_stop ends it.
let _ = rx.recv();
});
register("stoppable", svc.pid()).unwrap();
let stopped3 = stopped2.clone();
let client = spawn(move || {
let pid = whereis("stoppable").expect("name must resolve cross-actor");
// The registry's pids plug into the rest of the runtime API.
smarm::request_stop(pid);
stopped3.store(true, Ordering::Relaxed);
});
client.join().unwrap();
svc.join().unwrap(); // a stopped actor joins Ok
drop(tx);
});
assert!(stopped.load(Ordering::Relaxed));
}
#[test]
fn registry_under_concurrent_churn_multi_thread() {
// Many actors racing to claim the same names while holders die: the
// bimap invariant (debug-asserted internally) and Erlang semantics must
// hold under real parallelism.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
let wins = Arc::new(AtomicU32::new(0));
let wins2 = wins.clone();
smarm::init(smarm::Config::exact(4)).run(move || {
let mut handles = Vec::new();
for round in 0..8 {
let name = format!("contested-{}", round % 2);
for _ in 0..8 {
let name = name.clone();
let wins = wins2.clone();
handles.push(spawn(move || {
let me = smarm::self_pid();
match register(&name, me) {
Ok(()) => {
wins.fetch_add(1, Ordering::Relaxed);
smarm::yield_now();
// May have been pruned-by-contact never; we are
// alive, so our binding must still resolve to us.
assert_eq!(whereis(&name), Some(me));
assert_eq!(unregister(&name), Some(me));
}
Err(RegisterError::NameTaken { .. }) => {}
Err(e) => panic!("unexpected register error: {e}"),
}
}));
}
}
for h in handles {
h.join().unwrap();
}
});
// At least one registration per name must have succeeded.
assert!(wins.load(Ordering::Relaxed) >= 2);
}