docs(introspect): user-facing rewrite of runtime introspection

Was the worst offender for external-context references (RFC 016
Chunk 1/4, DECISION D1/D2, RFC 003/011), all removed. Lead with the
practical use cases (debugging, health checks, test assertions,
dashboards) for snapshot()/actor_info()/tree(), and explain the
per-actor-reads-not-a-world-freeze consistency model in plain terms
instead of citing a decision log. Add a compiling doctest.
This commit is contained in:
2026-07-24 08:44:56 +02:00
parent dd845f22fe
commit 41b9d6d056
+181 -83
View File
@@ -1,26 +1,85 @@
//! RFC 016 — runtime introspection (Chunk 1: the read primitive). //! Inspect what is running right now: which actors exist, what state each one
//! is in, and how they are related.
//! //!
//! A synchronous, internal read of the slab that returns *owned* data. This is //! This is the tool for questions like "is my server still alive", "how many
//! the mechanism the whole RFC hangs off: tests, the future observer //! actors are currently parked waiting on something", or "what does the spawn
//! gen_server (Chunk 4), and a later control plane (RFC 003) are all consumers //! tree look like". It is meant for debugging, test assertions, a health check
//! of [`snapshot`] / [`actor_info`], never of the runtime internals directly. //! endpoint, or a monitoring dashboard: anywhere you want to look at the
//! runtime from the outside without stopping it or coupling your code to its
//! internals.
//! //!
//! ## Consistency (DECISION D2 — per-slot tearing, `ps` semantics) //! Three entry points, in order of scope:
//! //!
//! [`snapshot`] is point-in-time and mildly racy *across* actors: each slot's //! - [`snapshot`] returns every actor that currently exists, as a plain
//! scheduling state is a lock-free word load, so an actor reported `Running` //! owned `Vec`, so you can filter, count, or search it however you like.
//! may already be `Parked`, and an actor can die mid-scan. This is the cheap, //! - [`actor_info`] returns a coherent view of exactly one actor, by pid.
//! useful model (a coherent stop-the-world cut is expensive and rarely wanted). //! Cheaper than filtering a whole snapshot down to one entry, and more
//! [`actor_info`] is coherent for the single actor it names. //! precise (see "Consistency" below).
//! - [`tree`] returns the same actors as [`snapshot`], folded into a
//! parent/child forest that mirrors who spawned whom.
//! //!
//! ## Locking //! ```
//! use smarm::{actor_info, channel, run, snapshot, spawn, ActorState};
//! //!
//! The lock order is **Leaf → Channel, at most one of each** (`raw_mutex.rs`); //! run(|| {
//! cold locks, the registry, and the free list are all Leaves, so we may never //! let (ready_tx, ready_rx) = channel::<()>();
//! hold two at once. The read is therefore phased: first a single registry-leaf //! let (gate_tx, gate_rx) = channel::<()>();
//! 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 //! let worker = spawn(move || {
//! per-slot cold Leaf. //! ready_tx.send(()).unwrap();
//! gate_rx.recv().unwrap(); // blocks here until released
//! });
//! ready_rx.recv().unwrap();
//!
//! // `snapshot` sees every actor, including this one and the worker.
//! let snap = snapshot();
//! assert!(snap.actors.len() >= 2);
//!
//! // `actor_info` gives a coherent view of just the worker. It is
//! // blocked on the gate channel, so it must be Parked.
//! let pid = worker.pid();
//! let info = actor_info(pid).expect("worker is still alive");
//! assert_eq!(info.state, ActorState::Parked);
//!
//! gate_tx.send(()).unwrap();
//! worker.join().unwrap();
//!
//! // Once joined, the pid no longer names a live actor.
//! assert!(actor_info(pid).is_none());
//! });
//! ```
//!
//! ## Consistency
//!
//! [`snapshot`] is not a single atomic pause-the-world freeze: it walks every
//! actor's state one after another, so it is a series of independent,
//! cheap, lock-free reads rather than one coherent moment in time. Between
//! reading actor A and actor B, either one can change state, and an actor can
//! even finish and disappear mid-scan. In practice this is exactly what you
//! want: a coherent stop-the-world snapshot would mean pausing every actor in
//! the runtime just to look at it, which is expensive and rarely necessary
//! for a dashboard, a test assertion, or a debugging session.
//!
//! [`actor_info`], in contrast, is coherent for the one actor it names: all of
//! its fields describe the same instant for that actor, because a single
//! actor's data cannot tear the way a scan across many actors can.
//!
//! ## Implementation notes
//!
//! These details matter if you are working on smarm itself; they are not part
//! of the public contract.
//!
//! The read never stops the scheduler and never holds a lock across the whole
//! scan. Each actor's scheduling state is a single lock-free word load
//! (hence the possible tearing described above). Reading the rest of an
//! actor's cold data (its supervisor, monitors, links, and so on) takes a
//! brief per-actor lock, just long enough to copy those fields out; nothing
//! is held across actors. Locking follows the crate-wide rule that at most
//! one "leaf" lock (a per-actor lock, the registry lock, or the free list
//! lock) is held at a time, with no leaf lock held while acquiring another.
//! The read is phased accordingly: first one pass over the registry to
//! collect every actor's registered names and mailbox depth, released before
//! the per-actor scan begins.
use crate::pid::Pid; use crate::pid::Pid;
use crate::registry::MailboxInfo; use crate::registry::MailboxInfo;
@@ -31,15 +90,28 @@ use crate::slot_state::{
}; };
use std::collections::HashMap; use std::collections::HashMap;
/// Snapshot wire-format version (DECISION D1). [`RuntimeSnapshot`] is treated as /// The format version carried by every [`RuntimeSnapshot`] and
/// a stable type from day one: it becomes the observer protocol (Chunk 4) and /// [`RuntimeTree`], as [`RuntimeSnapshot::format_version`] /
/// crosses a version boundary the moment a remote observer attaches (RFC 011), /// [`RuntimeTree::format_version`]. If you serialize a snapshot (for example
/// so the version travels with the data from the start. /// to send it somewhere else, or to compare snapshots taken with different
/// versions of smarm) check this field: a change in its value means the shape
/// of [`ActorInfo`] or its neighbors has changed and old and new snapshots
/// should not be assumed compatible. If you only ever read a snapshot
/// in-process in the same version of smarm that produced it, you can ignore
/// this field.
pub const SNAPSHOT_FORMAT_VERSION: u16 = 1; pub const SNAPSHOT_FORMAT_VERSION: u16 = 1;
/// Fine-grained scheduling state, mapped from the packed slot word with no new /// What an actor is doing right now, from the scheduler's point of view.
/// storage. `RunningNotified` collapses into `Notified` — a wake landed while ///
/// the actor was on-CPU and it will re-queue when it yields. /// - `Queued`: runnable, waiting for a scheduler thread to pick it up.
/// - `Running`: currently executing on a scheduler thread.
/// - `Notified`: was running and got woken up (for example, a message
/// arrived) before it had a chance to yield or park; it will be re-queued
/// as soon as it does.
/// - `Parked`: blocked, waiting on something such as a channel receive, a
/// mutex, a timer, or an IO event.
/// - `Done`: has finished (returned or panicked) but its slot has not been
/// reclaimed for reuse yet, so it is still visible to introspection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActorState { pub enum ActorState {
Queued, Queued,
@@ -49,8 +121,8 @@ pub enum ActorState {
Done, Done,
} }
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan) /// Classify a packed state word. `None` for a Vacant slot (skipped by the
/// — the only state that is not an actor. /// scan): a vacant slot holds no actor at all, live or done.
fn classify(w: u64) -> Option<ActorState> { fn classify(w: u64) -> Option<ActorState> {
Some(match word_state(w) { Some(match word_state(w) {
ST_QUEUED => ActorState::Queued, ST_QUEUED => ActorState::Queued,
@@ -62,61 +134,76 @@ fn classify(w: u64) -> Option<ActorState> {
}) })
} }
/// Owned, point-in-time view of one actor — no borrows of runtime internals, so /// An owned, self-contained view of one actor at (approximately) one moment.
/// it is safe to hand to any consumer. /// It borrows nothing from the runtime, so you can keep it, send it
/// elsewhere, or print it long after the actor it describes has changed
/// state or even exited.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ActorInfo { pub struct ActorInfo {
pub pid: Pid, pub pid: Pid,
/// Registered names, inverted from the registry (usually 0 or 1). /// Names this actor is currently registered under (see the
/// [`registry`](crate::registry) module). Usually empty or one name;
/// an actor can have more if it registered several.
pub names: Vec<&'static str>, pub names: Vec<&'static str>,
pub state: ActorState, pub state: ActorState,
/// Spawn-time parent edge (DECISION D9): `spawn_under` sets it to the /// The actor that spawned this one: whoever called `spawn` or
/// supervisor, plain `spawn` to the spawning actor — so it is parentage, /// `spawn_under` to create it. This is a parentage record, not
/// not necessarily a supervision relationship. `ROOT_PID` for the run's /// necessarily a supervision relationship: `spawn_under` records the
/// root actor and for `Done` tombstones (whose `Actor` is already gone). /// supervisor you asked for, while plain `spawn` records the spawning
/// actor itself, whether or not it supervises anything. It is the
/// runtime's root pid for the run's own root actor, and for a `Done`
/// actor whose bookkeeping has already been cleared.
pub supervisor: Pid, pub supervisor: Pid,
pub trap_exit: bool, pub trap_exit: bool,
pub monitors: u32, pub monitors: u32,
pub links: u32, pub links: u32,
pub joiners: u32, pub joiners: u32,
/// Queued messages summed over the actor's *published* channels (register / /// Messages currently queued and not yet delivered, summed across every
/// install / spawn_addr / gen_server). 0 for an actor that holds only a /// channel this actor has published (via `register`, `install`,
/// private `channel()` receiver — those are invisible to the registry. /// `spawn_addr`, or starting a gen_server). This is 0 for an actor that
/// only holds a private, unpublished `channel()` receiver, since nothing
/// outside the actor can see that channel exists.
pub mailbox_depth: u32, pub mailbox_depth: u32,
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how /// How many times this actor has been preempted for running past its
/// many times the actor was preempted for exceeding its slice. Resets on /// scheduling timeslice. Counts only since the actor's current start (a
/// restart (per-incarnation, D7). /// supervisor restart begins a fresh count).
pub overruns: u64, pub overruns: u64,
/// Messages this actor has received (dequeued) this incarnation (RFC 016 /// How many messages this actor has received (taken off its inbox), since
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its /// its current start. Useful for spotting an actor whose mailbox is
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7). /// filling up faster than it can drain it: compare this against
/// `mailbox_depth` over time.
pub messages_received: u64, pub messages_received: u64,
/// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2) /// Approximate CPU cycles this actor has spent running, since its current
/// — a reductions-like work metric for relative comparison. Always 0 unless /// start. A relative measure for comparing actors against each other, not
/// the `budget-accounting` feature is enabled (it costs an RDTSC per resume, /// an absolute or wall-clock figure. Always 0 unless the crate's
/// D6). Per-incarnation (D7). /// `budget-accounting` feature is enabled, since measuring it costs a
/// timestamp read on every resume.
pub budget_cycles: u64, pub budget_cycles: u64,
} }
/// A whole-runtime snapshot. See the module docs for the D2 tearing model. /// A snapshot of every actor in the runtime at (approximately) one moment.
/// See the module docs' "Consistency" section for what "approximately" means
/// here.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuntimeSnapshot { pub struct RuntimeSnapshot {
pub format_version: u16, pub format_version: u16,
pub actors: Vec<ActorInfo>, pub actors: Vec<ActorInfo>,
} }
/// Snapshot every live (and `Done`-but-not-yet-reclaimed) actor on the slab. /// Every actor that currently exists: running, queued, parked, or finished
/// O(n) over the slot table, running with preemption disabled (like every /// but not yet cleaned up. Cheap and lock-free per actor; see the module
/// runtime primitive) but holding no lock across the scan. Panics outside /// docs for what "approximately one moment" means for the result as a whole.
/// `Runtime::run()`; callable from actor code and the run thread. /// Panics if called outside [`run`](crate::run).
pub fn snapshot() -> RuntimeSnapshot { pub fn snapshot() -> RuntimeSnapshot {
with_runtime(|inner| { with_runtime(|inner| {
// Phase A: one registry-leaf pass for names + mailbox depth, released // First pass: one registry lock to collect names + mailbox depth for
// before any cold leaf (no two Leaves at once). // every actor, released before touching any per-actor lock below.
let mail = inner.registry.lock().introspect_map(); let mail = inner.registry.lock().introspect_map();
// Phase B: lock-free slab scan; per-slot cold leaf only to copy cold // Second pass: walk the actor table. Each actor's scheduling state is
// fields. Tearing across slots is intentional (D2). // a lock-free word load; only copying its other fields takes a brief
// per-actor lock. Tearing across actors is expected here (see the
// module docs' "Consistency" section).
let mut actors = Vec::new(); let mut actors = Vec::new();
for (idx, slot) in inner.slots.iter().enumerate() { for (idx, slot) in inner.slots.iter().enumerate() {
let idx = idx as u32; let idx = idx as u32;
@@ -128,8 +215,11 @@ pub fn snapshot() -> RuntimeSnapshot {
}) })
} }
/// Coherent view of a single actor, or `None` if the pid is stale, out of /// A coherent view of exactly one actor, or `None` if `pid` does not name a
/// range, or names a Vacant slot. /// currently-live entry: it is stale (that actor has already exited and its
/// slot was reused by another), out of range, or was never a real pid at
/// all. Unlike [`snapshot`], every field of the result describes the same
/// instant, since there is only one actor to read.
pub fn actor_info(pid: Pid) -> Option<ActorInfo> { pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
with_runtime(|inner| { with_runtime(|inner| {
let slot = inner.slot_at(pid)?; let slot = inner.slot_at(pid)?;
@@ -141,11 +231,12 @@ pub fn actor_info(pid: Pid) -> Option<ActorInfo> {
}) })
} }
/// Build one `ActorInfo` for slot `idx`, or `None` if Vacant or /// Build one `ActorInfo` for slot `idx`, or `None` if the slot is empty or
/// racing-reclaimed. State is classified from a lock-free word load (the torn /// was reclaimed while this read was in progress. The scheduling state comes
/// read); the cold lock then pins the generation (reclaim bumps it under that /// from a lock-free word load (the source of the tearing described in the
/// same lock) so the cold fields are coherent for this incarnation. `mail` is /// module docs); the per-actor lock then confirms the actor has not since
/// this slot's registry entry, if any. /// exited and been replaced, so the rest of the fields are coherent for this
/// exact actor. `mail` is this slot's registry entry, if any.
fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> { fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorInfo> {
let w = slot.state_word(); let w = slot.state_word();
let state = classify(w)?; let state = classify(w)?;
@@ -153,10 +244,11 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
let pid = Pid::new(idx, gen); let pid = Pid::new(idx, gen);
let cold = slot.cold.lock(); let cold = slot.cold.lock();
// If the generation moved between the lock-free load and acquiring the cold // If the generation moved between the lock-free load and acquiring the
// lock, the slot was reclaimed (and maybe reused) — drop it rather than mix // per-actor lock, this actor exited (and the slot may already hold a new
// one incarnation's state with another's cold data. (ps semantics: a racing // one). Drop it rather than mix one actor's state with another's data; a
// actor may simply be missed mid-scan.) // racing actor may simply be missed by this scan, which is expected (see
// the module docs' "Consistency" section).
if word_gen(slot.state_word()) != gen { if word_gen(slot.state_word()) != gen {
return None; return None;
} }
@@ -172,7 +264,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
let joiners = cold.waiters.len() as u32; let joiners = cold.waiters.len() as u32;
drop(cold); drop(cold);
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2). // Counters are plain atomics, read lock-free.
let overruns = slot.overruns(); let overruns = slot.overruns();
let messages_received = slot.messages_received(); let messages_received = slot.messages_received();
let budget_cycles = slot.budget_cycles(); let budget_cycles = slot.budget_cycles();
@@ -202,39 +294,45 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot) // Tree view: a pure derivation over a snapshot
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// One node in the parentage forest. `children` are the actors whose recorded /// One node in the parentage forest returned by [`tree`]. `children` are the
/// parent edge points at this node's pid. /// actors whose recorded parent (see [`ActorInfo::supervisor`]) points at
/// this node's actor.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TreeNode { pub struct TreeNode {
pub info: ActorInfo, pub info: ActorInfo,
/// The actor's recorded parent was absent from the snapshot (already /// True if this actor's recorded parent was not found in the snapshot
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest /// (it had already exited, or was itself missing), so this node was
/// sentinel rather than dropped — the tree stays total (DECISION D8). /// placed at the top of the forest instead of being dropped. This keeps
/// every actor in the snapshot visible somewhere in the tree, even one
/// whose parent is gone.
pub orphaned: bool, pub orphaned: bool,
pub children: Vec<TreeNode>, pub children: Vec<TreeNode>,
} }
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine /// The parentage forest: every actor from a snapshot, arranged by who spawned
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not /// whom. Roots are actors with no parent in the snapshot (including the
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`]. /// run's own root actor) plus any orphaned actors (see [`TreeNode::orphaned`]).
/// This mirrors spawn parentage, not necessarily a supervision tree; see
/// [`ActorInfo::supervisor`].
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct RuntimeTree { pub struct RuntimeTree {
pub format_version: u16, pub format_version: u16,
pub roots: Vec<TreeNode>, pub roots: Vec<TreeNode>,
} }
/// Take a live [`snapshot`] and fold it into the parentage forest. /// Take a fresh [`snapshot`] and fold it into the parentage forest.
pub fn tree() -> RuntimeTree { pub fn tree() -> RuntimeTree {
tree_from(snapshot()) tree_from(snapshot())
} }
/// Fold an existing snapshot into a forest by grouping each actor under its /// Fold an existing snapshot into a parentage forest by grouping each actor
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a /// under its parent, without taking a new snapshot. Useful if you already
/// consumer that already holds a snapshot (or a synthetic one, in tests) can /// have one (for example, one built in a test, or one you took earlier and
/// derive the tree without a second scan. /// want to inspect again) and want the tree view of it without re-reading
/// the runtime.
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree { pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
let RuntimeSnapshot { format_version, actors } = snap; let RuntimeSnapshot { format_version, actors } = snap;
@@ -254,7 +352,7 @@ pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
children_of.entry(parent).or_default().push(i); children_of.entry(parent).or_default().push(i);
} else { } else {
// Parent is the forest sentinel (genuine root) or absent from the // Parent is the forest sentinel (genuine root) or absent from the
// snapshot (orphan, D8) — either way a root of the forest. // snapshot (orphan): either way, a root of the forest.
orphaned[i] = parent != ROOT_PID; orphaned[i] = parent != ROOT_PID;
roots.push(i); roots.push(i);
} }