From f5fbb5b14476dfd0b1928868adb2b9e2ccc63b3b Mon Sep 17 00:00:00 2001 From: smarm-agent Date: Tue, 16 Jun 2026 22:08:47 +0000 Subject: [PATCH] =?UTF-8?q?mailbox:=20Phase=203=20=E2=80=94=20typed=20Pid=20over=20a=20raw=20inner=20identity=20(RFC=20014)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold the made-up Addr back into the pid, where it belonged. The typed handle IS the pid now. - RawPid { index, generation }: today's Pid, renamed — the raw numbers, the key for identity-only plumbing and the heterogeneous monitor/link/pg tables. - Pid: RawPid + phantom actor type. Both an identity and a direct, identity-bound address; when A: Addressable a send delivers A::Msg to exactly this incarnation (next phase). Hand-written impls (eq/hash/fmt on the raw only, no A bounds); fn()->A phantom keeps it Copy+Send+Sync for any A. - Erased: uninhabited, deliberately NOT Addressable, so a typed send to a Pid won't compile — that's what send_dyn (section 4.6) will be for. It is the default type arg, so every plain "Pid" written today still compiles as Pid; that, plus the fact that nothing destructures pid fields, is why an identity change touching 18 files cascaded to zero internal edits. - Addr deleted; Name / the Phase-2 registry unchanged (they key on raw). Per the call to make the consumers take typed pids: the user-facing identity APIs — monitor, link, unlink, request_stop, spawn_under(supervisor), pg::join, pg::leave — are now generic over A and erase at the boundary; their bodies are byte-for-byte unchanged. Pure plumbing (unpark, set_current_pid, register_supervisor_channel) stays erased — it only ever sees internal identity. Phase 1's Addr tests become Pid tests (identity/copy/erase/Debug, Send+Sync). Full suite + order-checker green, warning-free across all targets. --- src/lib.rs | 2 +- src/link.rs | 6 +- src/monitor.rs | 3 +- src/pg.rs | 6 +- src/pid.rs | 250 ++++++++++++++++++++++++++++------------------- src/scheduler.rs | 6 +- 6 files changed, 163 insertions(+), 110 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index bc334e3..6231b25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -54,7 +54,7 @@ pub use gen_server::{CallError, CallTimeoutError, CastError, GenServer, ServerBu 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::{Addr, Addressable, Name, Pid}; +pub use pid::{Addressable, Erased, Name, Pid, RawPid}; pub use pg::{join, leave, members, pick, Incarnation, Member, NodeId}; pub use registry::{register, send, unregister, whereis, RegisterError, SendError}; pub use runtime::{init, Config, Runtime}; diff --git a/src/link.rs b/src/link.rs index c56cc16..7fed90c 100644 --- a/src/link.rs +++ b/src/link.rs @@ -95,7 +95,8 @@ pub fn trap_exit() -> Receiver { /// this delivers an immediate [`DownReason::NoProc`] exit signal to the caller /// (a message if trapping, otherwise a cooperative stop). Linking yourself, or /// re-linking an existing peer, is a no-op. -pub fn link(target: Pid) { +pub fn link(target: Pid) { + let target = target.erase(); let me = self_pid(); if target == me { return; @@ -163,7 +164,8 @@ pub fn link(target: Pid) { /// /// After this, neither actor's death propagates to the other. A no-op if the /// two were not linked. -pub fn unlink(target: Pid) { +pub fn unlink(target: Pid) { + let target = target.erase(); let me = self_pid(); if target == me { return; diff --git a/src/monitor.rs b/src/monitor.rs index b299a61..ce4bdc8 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -106,7 +106,8 @@ pub struct Monitor { /// If `target` is still live, the `Down` arrives when it terminates. If /// `target` is already gone, a [`DownReason::NoProc`] `Down` is queued /// immediately so the caller's `rx.recv()` returns without parking. -pub fn monitor(target: Pid) -> Monitor { +pub fn monitor(target: Pid) -> Monitor { + let target = target.erase(); let (tx, rx) = channel::(); // Register under the target's cold lock. `tx.clone()` takes the channel's diff --git a/src/pg.rs b/src/pg.rs index 7eada12..12d3e48 100644 --- a/src/pg.rs +++ b/src/pg.rs @@ -320,8 +320,9 @@ fn live(inner: &crate::runtime::RuntimeInner, pid: Pid) -> bool { /// down. /// /// Panics if called outside `Runtime::run()`. -pub fn join(group: impl Into, pid: Pid) -> bool { +pub fn join(group: impl Into, pid: Pid) -> bool { let group = group.into(); + let pid = pid.erase(); // Install the monitor BEFORE taking the group lock: monitor() acquires the // target's cold lock (Leaf), and two Leaf locks are never held at once. let mon = monitor(pid); @@ -350,7 +351,8 @@ pub fn join(group: impl Into, pid: Pid) -> bool { /// removed. The membership's monitor is demonitored and dropped. /// /// Panics if called outside `Runtime::run()`. -pub fn leave(group: &str, pid: Pid) -> bool { +pub fn leave(group: &str, pid: Pid) -> bool { + let pid = pid.erase(); let (removed, reaped) = with_runtime(|inner| { let member = member_for(inner, pid); let mut pg = inner.process_groups.lock(); diff --git a/src/pid.rs b/src/pid.rs index 98bce27..c8acfd5 100644 --- a/src/pid.rs +++ b/src/pid.rs @@ -1,129 +1,161 @@ //! Process identifiers. //! -//! A `Pid` is `(index, generation)`. The index is a slot in the scheduler's -//! actor table; the generation increments every time that slot is reused. -//! A stale `Pid` (correct index, wrong generation) is a detectable error, -//! not a silent misdirection — solves the ABA problem without exhausting -//! the PID space. +//! Identity is `(index, generation)`: the index is a slot in the scheduler's +//! actor table, the generation increments every time that slot is reused, so a +//! stale id (right index, wrong generation) is a *detectable* error rather than +//! a silent misdirection — the ABA problem solved without exhausting the id +//! space. Those raw numbers live in [`RawPid`]. +//! +//! The public identity is the *typed* [`Pid`] (RFC 014): `RawPid` plus a +//! phantom actor type, so a pid is simultaneously an identity and a direct, +//! identity-bound address. `Pid` — the default — is the untyped pid +//! used for identity-only plumbing and for actors with no single message type +//! (raw `spawn`, gen_servers). Resolving a name yields the durable, re-resolving +//! [`Name`] instead. +use std::marker::PhantomData; + +/// The raw identity numbers, with no actor type. The key for everything that +/// only cares about *which* actor: slab indexing, generation checks, and the +/// heterogeneous monitor / link / pg tables (which hold actors of every type at +/// once, so they cannot be parameterised by one). #[derive(Copy, Clone, PartialEq, Eq, Hash)] -pub struct Pid { +pub struct RawPid { index: u32, generation: u32, } -impl Pid { +impl RawPid { #[inline] pub const fn new(index: u32, generation: u32) -> Self { Self { index, generation } } - #[inline] - pub const fn index(self) -> u32 { self.index } - + pub const fn index(self) -> u32 { + self.index + } #[inline] - pub const fn generation(self) -> u32 { self.generation } + pub const fn generation(self) -> u32 { + self.generation + } } -impl std::fmt::Debug for Pid { +impl std::fmt::Debug for RawPid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "Pid({}.{})", self.index, self.generation) } } -impl std::fmt::Display for Pid { +impl std::fmt::Display for RawPid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "<{}.{}>", self.index, self.generation) } } -// --------------------------------------------------------------------------- -// Typed addresses (RFC 014 — typed addressable mailboxes). Phase 1: types only. -// --------------------------------------------------------------------------- -// -// Two addressing modes layered over the plain `Pid` identity, distinguished by -// their liveness semantics (RFC 014 §4.2): -// -// - `Addr` — direct, identity-bound. Keyed by the *actor type* `A`; its -// message type is `A::Msg` (see [`Addressable`]). Bound to one specific actor -// incarnation: once that actor dies the send fails (generation mismatch) and -// is *not* redirected. This is the RFC's `Pid`, renamed — a generic -// `Pid` cannot coexist with the non-generic `Pid` identity the rest of the -// runtime is built on. -// - `Name` — durable, re-resolving, location-transparent. A phantom-typed -// key over the registry, re-resolved on *every* send, so it always reaches -// whoever currently holds the name (surviving takeover; later, locating an -// actor on another node). The clustering / BEAM-interop priority mode. -// -// Both are pure value tokens: an identity (or a name) plus a zero-sized phantom -// that re-imposes the message type at the call site. The phantom is -// `fn() -> T`, so a token is unconditionally `Copy + Send + Sync` and borrows -// nothing from `T` — `T` is only ever a compile-time key. (This is also why the -// trait impls below are hand-written: a `#[derive]` would wrongly demand -// `T: Copy`/`Eq`/… on the phantom parameter.) +/// Phantom actor type for a pid that has no single message type: raw `spawn` +/// actors, gen_servers (intrinsically multi-message, addressed via `ServerRef`), +/// and every identity-only context. Deliberately **not** [`Addressable`], so a +/// typed `send` to a `Pid` does not compile; the runtime-checked +/// `send_dyn` escape hatch (RFC 014 §4.6) is the sanctioned bare-pid path. +pub enum Erased {} -use std::marker::PhantomData; - -/// An actor type with a single associated message type, so it can key an -/// [`Addr`]. The raw channel layer has no such trait (actors are closures over -/// channels), and `GenServer` is intrinsically multi-message and addressed via -/// its own `ServerRef`; this is the minimal hook that gives a typed, -/// identity-bound address to the actors that *do* have one message type. -/// (RFC 014 §4.2, decision (a).) -pub trait Addressable: 'static { - /// The message this actor receives. An `Addr` delivers `Self::Msg`. - type Msg: Send + 'static; -} - -/// A direct, identity-bound address to a specific actor of type `A` -/// (RFC 014's `Pid`): the plain [`Pid`] identity plus a phantom `A`, whose -/// message type is `A::Msg`. A send through an `Addr` targets the exact -/// incarnation the pid names; when that actor dies the send fails and is *not* -/// redirected. For a durable, re-resolving address use [`Name`]. -pub struct Addr { - pid: Pid, +/// A process identifier parameterised by the actor's type `A` (default +/// [`Erased`]). Wraps the raw `(index, generation)` plus a zero-sized phantom, +/// so a `Pid` is both an identity and a direct, identity-bound address: when +/// `A: Addressable`, a `send` delivers `A::Msg` to exactly the incarnation this +/// pid names — no redirect (contrast the re-resolving [`Name`]). +/// +/// Equality, hashing, and formatting are the raw identity's; the phantom is +/// `fn() -> A`, so `Pid` is unconditionally `Copy + Send + Sync` and borrows +/// nothing from `A`. The trait impls are hand-written so no `A: Trait` bound +/// leaks in from a `#[derive]`. +pub struct Pid { + raw: RawPid, _marker: PhantomData A>, } -impl Addr { - /// Wrap a plain identity as a typed address. Crate-internal on purpose: a - /// public bare-`Pid` → typed-`Addr` mint is exactly the unchecked step the - /// typed API exists to avoid. The sanctioned bare-pid path is the fallible - /// `send_dyn` escape hatch (RFC 014 §4.6); typed addresses reach users via - /// spawn / resolution. - /// - /// Phase 1 has no non-test constructor (this is a types-only commit); the - /// `#[cfg(test)]` comes off in Phase 2 when the handle table / spawn mints - /// these for real. - #[cfg(test)] +impl Pid { + /// Build an untyped pid from raw numbers. The runtime mints identities + /// here; typing happens at typed-actor boundaries via [`Pid::from_raw`]. #[inline] - pub(crate) const fn new(pid: Pid) -> Self { - Self { pid, _marker: PhantomData } - } - - /// The plain runtime identity this address points at. - #[inline] - pub const fn pid(self) -> Pid { - self.pid + pub const fn new(index: u32, generation: u32) -> Self { + Self { raw: RawPid::new(index, generation), _marker: PhantomData } } } -impl Copy for Addr {} -impl Clone for Addr { - fn clone(&self) -> Self { *self } +impl Pid { + /// Wrap a raw identity as a typed pid. Crate-internal: minting a typed pid + /// from raw numbers asserts an actor's type *unchecked*, which is exactly + /// what the typed API exists to avoid outside the runtime's own spawn / + /// resolution paths. + #[inline] + pub(crate) const fn from_raw(raw: RawPid) -> Self { + Self { raw, _marker: PhantomData } + } + + /// The raw identity, dropping the actor type — the key for identity-only + /// tables and internal plumbing. + #[inline] + pub const fn raw(self) -> RawPid { + self.raw + } + + /// Forget the actor type. + #[inline] + pub const fn erase(self) -> Pid { + Pid::from_raw(self.raw) + } + + /// Slot index in the actor table. + #[inline] + pub const fn index(self) -> u32 { + self.raw.index() + } + + /// Reuse generation of the slot (ABA guard). + #[inline] + pub const fn generation(self) -> u32 { + self.raw.generation() + } } -impl PartialEq for Addr { - fn eq(&self, other: &Self) -> bool { self.pid == other.pid } + +impl Copy for Pid {} +impl Clone for Pid { + fn clone(&self) -> Self { + *self + } } -impl Eq for Addr {} -impl std::hash::Hash for Addr { - fn hash(&self, state: &mut H) { self.pid.hash(state); } +impl PartialEq for Pid { + fn eq(&self, other: &Self) -> bool { + self.raw == other.raw + } } -impl std::fmt::Debug for Addr { +impl Eq for Pid {} +impl std::hash::Hash for Pid { + fn hash(&self, state: &mut H) { + self.raw.hash(state); + } +} +impl std::fmt::Debug for Pid { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "Addr<{}>({})", std::any::type_name::(), self.pid) + std::fmt::Debug::fmt(&self.raw, f) } } +impl std::fmt::Display for Pid { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.raw, f) + } +} + +/// An actor type with a single associated message type, so a [`Pid`] is a +/// typed address. The raw channel layer has no such trait (actors are closures +/// over channels) and `GenServer` is intrinsically multi-message (addressed via +/// its own `ServerRef`); this is the minimal hook that lets the single-message +/// actors carry their message type in their pid. (RFC 014 §4.2.) +pub trait Addressable: 'static { + /// The message this actor receives. A `Pid` delivers `Self::Msg`. + type Msg: Send + 'static; +} /// A durable, re-resolving address: a static name plus a phantom message type /// `M` (RFC 014's `Name`). Declared as a constant and shared freely: @@ -132,7 +164,7 @@ impl std::fmt::Debug for Addr { /// const COUNTER: Name = Name::new("counter"); /// ``` /// -/// Unlike [`Addr`], a `Name` is resolved through the registry on *every* send, +/// Unlike a [`Pid`], a `Name` is resolved through the registry on *every* send, /// so it always reaches whoever currently holds the name. pub struct Name { name: &'static str, @@ -156,14 +188,20 @@ impl Name { impl Copy for Name {} impl Clone for Name { - fn clone(&self) -> Self { *self } + fn clone(&self) -> Self { + *self + } } impl PartialEq for Name { - fn eq(&self, other: &Self) -> bool { self.name == other.name } + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } } impl Eq for Name {} impl std::hash::Hash for Name { - fn hash(&self, state: &mut H) { self.name.hash(state); } + fn hash(&self, state: &mut H) { + self.name.hash(state); + } } impl std::fmt::Debug for Name { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -172,7 +210,7 @@ impl std::fmt::Debug for Name { } #[cfg(test)] -mod typed_addr_tests { +mod typed_pid_tests { use super::*; // A stand-in actor type with one message type, exercising `Addressable`. @@ -187,14 +225,22 @@ mod typed_addr_tests { } #[test] - fn addr_is_a_copyable_identity_token() { - let a = Addr::::new(Pid::new(3, 1)); - let b = a; // Copy, not move - assert_eq!(a.pid(), Pid::new(3, 1)); - assert_eq!(a, b); - // Same index, different generation = different actor incarnation. - assert_ne!(a, Addr::::new(Pid::new(3, 2))); - assert!(format!("{a:?}").starts_with("Addr<")); + fn typed_pid_is_a_copyable_identity() { + let p = Pid::::from_raw(RawPid::new(3, 1)); + let q = p; // Copy, not move + assert_eq!(p.index(), 3); + assert_eq!(p.generation(), 1); + assert_eq!(p, q); + // Same index, different generation = different incarnation. + assert_ne!(p, Pid::::from_raw(RawPid::new(3, 2))); + assert!(format!("{p:?}").starts_with("Pid(")); + } + + #[test] + fn erase_drops_the_type_but_keeps_identity() { + let p = Pid::::from_raw(RawPid::new(7, 4)); + assert_eq!(p.erase(), Pid::new(7, 4)); // Pid + assert_eq!(p.raw(), RawPid::new(7, 4)); } #[test] @@ -212,12 +258,12 @@ mod typed_addr_tests { assert!(msg_type_name::().ends_with("CounterMsg")); } - // Tokens must be usable across threads (they key cross-node addressing - // later): assert Send + Sync regardless of whether the keys are. + // Identity tokens must be usable across threads. #[test] fn tokens_are_send_sync_without_key_bounds() { fn assert_send_sync() {} - assert_send_sync::>(); + assert_send_sync::>(); + assert_send_sync::>(); assert_send_sync::>(); } } diff --git a/src/scheduler.rs b/src/scheduler.rs index 0e4f1f4..bf0ec30 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -175,7 +175,8 @@ pub fn spawn(f: impl FnOnce() + Send + 'static) -> JoinHandle { spawn_under(parent, f) } -pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle { +pub fn spawn_under(supervisor: Pid, f: impl FnOnce() + Send + 'static) -> JoinHandle { + let supervisor = supervisor.erase(); // Stack + closure boxing happen before ANY runtime lock is taken: no // syscall and no allocation ever stalls another scheduler thread. let stack = with_runtime(|inner| inner.stack_pool.lock().pop()) @@ -287,7 +288,8 @@ pub(crate) fn retire_wait() { /// observation point — a tight loop with no `check!()`, no allocation, and no /// blocking op — cannot be stopped, exactly as it cannot be preempted. A no-op /// if `pid` is already gone. -pub fn request_stop(pid: Pid) { +pub fn request_stop(pid: Pid) { + let pid = pid.erase(); let _ = try_with_runtime(|inner| { if let Some(slot) = inner.slot_at(pid) { {