RFC 016 Chunk 1: runtime introspection read primitive

snapshot() / actor_info() return owned ActorInfo over the slab: pid,
names, fine-grained scheduling state, parent edge, trap flag, mailbox
depth, and monitor/link/joiner counts. Pure reads, no hot-path change.

- ActorState maps the packed slot word (no new storage); introspect.rs.
- D1: RuntimeSnapshot carries SNAPSHOT_FORMAT_VERSION from day one.
- D2: per-slot tearing (ps semantics); actor_info coherent per actor.
- D3: mailbox depth included. Registry channels now stored behind an
  ErasedSender trait (downcast for clone_sender + type-erased
  queued_len); depth summed over published channels under the registry
  Leaf (Leaf -> Channel), kept out of the cold-lock pass so no two
  Leaves are ever held at once. Depth covers published channels only.
- Done slots surface as root-less tombstones.
This commit is contained in:
smarm-agent
2026-06-19 06:31:10 +00:00
parent fc014c4e54
commit c66691943d
6 changed files with 460 additions and 1 deletions
+180
View File
@@ -0,0 +1,180 @@
//! RFC 016 — runtime introspection (Chunk 1: the read primitive).
//!
//! A synchronous, internal read of the slab that returns *owned* data. This is
//! the mechanism the whole RFC hangs off: tests, the future observer
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly.
//!
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics)
//!
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's
//! scheduling state is a lock-free word load, so an actor reported `Running`
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap,
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted).
//! [`actor_info`] is coherent for the single actor it names.
//!
//! ## Locking
//!
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`);
//! cold locks, the registry, and the free list are all Leaves, so we may never
//! hold two at once. The read is therefore phased: first a single registry-leaf
//! pass for names and mailbox depth (the per-channel length read is a Channel
//! lock taken under that Leaf — legal), released before the slab scan takes any
//! per-slot cold Leaf.
use crate::pid::Pid;
use crate::registry::MailboxInfo;
use crate::runtime::{Slot, ROOT_PID};
use crate::scheduler::with_runtime;
use crate::slot_state::{
word_gen, word_state, ST_DONE, ST_PARKED, ST_QUEUED, ST_RUNNING, ST_RUNNING_NOTIFIED,
};
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and
/// crosses a version boundary the moment a remote observer attaches (RFC 011),
/// so the version travels with the data from the start.
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
/// Fine-grained scheduling state, mapped from the packed slot word with no new
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while
/// the actor was on-CPU and it will re-queue when it yields.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorState {
Queued,
Running,
Notified,
Parked,
Done,
}
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan)
/// — the only state that is not an actor.
fn classify(w: u64) -> Option<ActorState> {
Some(match word_state(w) {
ST_QUEUED => ActorState::Queued,
ST_RUNNING => ActorState::Running,
ST_RUNNING_NOTIFIED => ActorState::Notified,
ST_PARKED => ActorState::Parked,
ST_DONE => ActorState::Done,
_ => return None, // ST_VACANT
})
}
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so
/// it is safe to hand to any consumer.
#[derive(Debug, Clone)]
pub struct ActorInfo {
pub pid: Pid,
/// Registered names, inverted from the registry (usually 0 or 1).
pub names: Vec<&'static str>,
pub state: ActorState,
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the
/// supervisor, plain `spawn` to the spawning actor — so it is parentage,
/// not necessarily a supervision relationship. `ROOT_PID` for the run's
/// root actor and for `Done` tombstones (whose `Actor` is already gone).
pub supervisor: Pid,
pub trap_exit: bool,
pub monitors: u32,
pub links: u32,
pub joiners: u32,
/// Queued messages summed over the actor's *published* channels (register /
/// install / spawn_addr / gen_server). 0 for an actor that holds only a
/// private `channel()` receiver — those are invisible to the registry.
pub mailbox_depth: u32,
}
/// A whole-runtime snapshot. See the module docs for the D2 tearing model.
#[derive(Debug, Clone)]
pub struct RuntimeSnapshot {
pub format_version: u16,
pub actors: Vec<ActorInfo>,
}
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab.
/// O(n) over the slot table, running with preemption disabled (like every
/// runtime primitive) but holding no lock across the scan. Panics outside
/// `Runtime::run()`; callable from actor code and the run thread.
pub fn snapshot() -> RuntimeSnapshot {
with_runtime(|inner| {
// Phase A: one registry-leaf pass for names + mailbox depth, released
// before any cold leaf (no two Leaves at once).
let mail = inner.registry.lock().introspect_map();
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold
// fields. Tearing across slots is intentional (D2).
let mut actors = Vec::new();
for (idx, slot) in inner.slots.iter().enumerate() {
let idx = idx as u32;
if let Some(info) = read_slot(slot, idx, mail.get(&idx)) {
actors.push(info);
}
}
RuntimeSnapshot { format_version: SNAPSHOT_FORMAT_VERSION, actors }
})
}
/// Coherent view of a single actor, or `None` if the pid is stale, out of
/// range, or names a Vacant slot.
pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
with_runtime(|inner| {
let slot = inner.slot_at(pid)?;
let mail = inner.registry.lock().introspect_one(pid.index());
let info = read_slot(slot, pid.index(), mail.as_ref())?;
// read_slot keys on the slab's *current* generation; reject if that
// isn't the incarnation the caller asked about.
(info.pid.generation() == pid.generation()).then_some(info)
})
}
/// Build one `ActorInfo` for slot `idx`, or `None` if Vacant or
/// racing-reclaimed. State is classified from a lock-free word load (the torn
/// read); the cold lock then pins the generation (reclaim bumps it under that
/// same lock) so the cold fields are coherent for this incarnation. `mail` is
/// this slot's registry entry, if any.
fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> {
let w = slot.state_word();
let state = classify(w)?;
let gen = word_gen(w);
let pid = Pid::new(idx, gen);
let cold = slot.cold.lock();
// If the generation moved between the lock-free load and acquiring the cold
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix
// one incarnation's state with another's cold data. (ps semantics: a racing
// actor may simply be missed mid-scan.)
if word_gen(slot.state_word()) != gen {
return None;
}
let (supervisor, trap_exit) = match cold.actor.as_ref() {
// Live incarnation: parent + trap live on the Actor.
Some(actor) => (actor.supervisor, actor.trap.is_some()),
// Done tombstone: the Actor was taken at finalize and the collections
// cleared, so report it root-less with empty counts.
None => (ROOT_PID, false),
};
let monitors = cold.monitors.len() as u32;
let links = cold.links.len() as u32;
let joiners = cold.waiters.len() as u32;
drop(cold);
// Names + depth belong to this incarnation only if the registry mailbox's
// pid matches the slab generation; a stale registry entry (dead prior
// occupant, not yet pruned) contributes nothing.
let (names, mailbox_depth) = match mail {
Some(mi) if mi.pid.generation() == gen => (mi.names.clone(), mi.depth),
_ => (Vec::new(), 0),
};
Some(ActorInfo {
pid,
names,
state,
supervisor,
trap_exit,
monitors,
links,
joiners,
mailbox_depth,
})
}