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,
})