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:
+181
-83
@@ -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
|
||||
//! 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.
|
||||
//! This is the tool for questions like "is my server still alive", "how many
|
||||
//! actors are currently parked waiting on something", or "what does the spawn
|
||||
//! tree look like". It is meant for debugging, test assertions, a health check
|
||||
//! 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
|
||||
//! 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.
|
||||
//! - [`snapshot`] returns every actor that currently exists, as a plain
|
||||
//! owned `Vec`, so you can filter, count, or search it however you like.
|
||||
//! - [`actor_info`] returns a coherent view of exactly one actor, by pid.
|
||||
//! Cheaper than filtering a whole snapshot down to one entry, and more
|
||||
//! 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`);
|
||||
//! 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.
|
||||
//! run(|| {
|
||||
//! let (ready_tx, ready_rx) = channel::<()>();
|
||||
//! let (gate_tx, gate_rx) = channel::<()>();
|
||||
//!
|
||||
//! let worker = spawn(move || {
|
||||
//! 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::registry::MailboxInfo;
|
||||
@@ -31,15 +90,28 @@ use crate::slot_state::{
|
||||
};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 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.
|
||||
/// The format version carried by every [`RuntimeSnapshot`] and
|
||||
/// [`RuntimeTree`], as [`RuntimeSnapshot::format_version`] /
|
||||
/// [`RuntimeTree::format_version`]. If you serialize a snapshot (for example
|
||||
/// 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;
|
||||
|
||||
/// 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.
|
||||
/// What an actor is doing right now, from the scheduler's point of view.
|
||||
///
|
||||
/// - `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)]
|
||||
pub enum ActorState {
|
||||
Queued,
|
||||
@@ -49,8 +121,8 @@ pub enum ActorState {
|
||||
Done,
|
||||
}
|
||||
|
||||
/// Classify a packed state word. `None` for a Vacant slot (skipped by the scan)
|
||||
/// — the only state that is not an actor.
|
||||
/// Classify a packed state word. `None` for a Vacant slot (skipped by the
|
||||
/// scan): a vacant slot holds no actor at all, live or done.
|
||||
fn classify(w: u64) -> Option<ActorState> {
|
||||
Some(match word_state(w) {
|
||||
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
|
||||
/// it is safe to hand to any consumer.
|
||||
/// An owned, self-contained view of one actor at (approximately) one moment.
|
||||
/// 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)]
|
||||
pub struct ActorInfo {
|
||||
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 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).
|
||||
/// The actor that spawned this one: whoever called `spawn` or
|
||||
/// `spawn_under` to create it. This is a parentage record, not
|
||||
/// necessarily a supervision relationship: `spawn_under` records the
|
||||
/// 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 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.
|
||||
/// Messages currently queued and not yet delivered, summed across every
|
||||
/// channel this actor has published (via `register`, `install`,
|
||||
/// `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,
|
||||
/// Timeslice overruns tallied for this incarnation (RFC 016 Chunk 2): how
|
||||
/// many times the actor was preempted for exceeding its slice. Resets on
|
||||
/// restart (per-incarnation, D7).
|
||||
/// How many times this actor has been preempted for running past its
|
||||
/// scheduling timeslice. Counts only since the actor's current start (a
|
||||
/// supervisor restart begins a fresh count).
|
||||
pub overruns: u64,
|
||||
/// Messages this actor has received (dequeued) this incarnation (RFC 016
|
||||
/// Chunk 2) — answers "is this actor a hotspot / draining slower than its
|
||||
/// mailbox fills." Counts received, not sent (D4). Per-incarnation (D7).
|
||||
/// How many messages this actor has received (taken off its inbox), since
|
||||
/// its current start. Useful for spotting an actor whose mailbox is
|
||||
/// filling up faster than it can drain it: compare this against
|
||||
/// `mailbox_depth` over time.
|
||||
pub messages_received: u64,
|
||||
/// Approximate on-CPU cycles this incarnation has consumed (RFC 016 Chunk 2)
|
||||
/// — a reductions-like work metric for relative comparison. Always 0 unless
|
||||
/// the `budget-accounting` feature is enabled (it costs an RDTSC per resume,
|
||||
/// D6). Per-incarnation (D7).
|
||||
/// Approximate CPU cycles this actor has spent running, since its current
|
||||
/// start. A relative measure for comparing actors against each other, not
|
||||
/// an absolute or wall-clock figure. Always 0 unless the crate's
|
||||
/// `budget-accounting` feature is enabled, since measuring it costs a
|
||||
/// timestamp read on every resume.
|
||||
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)]
|
||||
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.
|
||||
/// Every actor that currently exists: running, queued, parked, or finished
|
||||
/// but not yet cleaned up. Cheap and lock-free per actor; see the module
|
||||
/// docs for what "approximately one moment" means for the result as a whole.
|
||||
/// Panics if called outside [`run`](crate::run).
|
||||
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).
|
||||
// First pass: one registry lock to collect names + mailbox depth for
|
||||
// every actor, released before touching any per-actor lock below.
|
||||
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).
|
||||
// Second pass: walk the actor table. Each actor's scheduling state is
|
||||
// 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();
|
||||
for (idx, slot) in inner.slots.iter().enumerate() {
|
||||
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
|
||||
/// range, or names a Vacant slot.
|
||||
/// A coherent view of exactly one actor, or `None` if `pid` does not name a
|
||||
/// 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> {
|
||||
with_runtime(|inner| {
|
||||
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
|
||||
/// 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.
|
||||
/// Build one `ActorInfo` for slot `idx`, or `None` if the slot is empty or
|
||||
/// was reclaimed while this read was in progress. The scheduling state comes
|
||||
/// from a lock-free word load (the source of the tearing described in the
|
||||
/// module docs); the per-actor lock then confirms the actor has not since
|
||||
/// 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> {
|
||||
let w = slot.state_word();
|
||||
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 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 the generation moved between the lock-free load and acquiring the
|
||||
// per-actor lock, this actor exited (and the slot may already hold a new
|
||||
// one). Drop it rather than mix one actor's state with another's data; a
|
||||
// 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 {
|
||||
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;
|
||||
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 messages_received = slot.messages_received();
|
||||
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
|
||||
/// parent edge points at this node's pid.
|
||||
/// One node in the parentage forest returned by [`tree`]. `children` are the
|
||||
/// actors whose recorded parent (see [`ActorInfo::supervisor`]) points at
|
||||
/// this node's actor.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TreeNode {
|
||||
pub info: ActorInfo,
|
||||
/// The actor's recorded parent was absent from the snapshot (already
|
||||
/// Done/Vacant, or itself a tombstone), so it was re-rooted under the forest
|
||||
/// sentinel rather than dropped — the tree stays total (DECISION D8).
|
||||
/// True if this actor's recorded parent was not found in the snapshot
|
||||
/// (it had already exited, or was itself missing), so this node was
|
||||
/// 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 children: Vec<TreeNode>,
|
||||
}
|
||||
|
||||
/// The parentage forest. Roots are actors parented at `ROOT_PID` (genuine
|
||||
/// roots) plus re-rooted orphans. The edge is *spawned-by / parent*, not
|
||||
/// necessarily supervision (DECISION D9) — see [`ActorInfo::supervisor`].
|
||||
/// The parentage forest: every actor from a snapshot, arranged by who spawned
|
||||
/// whom. Roots are actors with no parent in the snapshot (including the
|
||||
/// 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)]
|
||||
pub struct RuntimeTree {
|
||||
pub format_version: u16,
|
||||
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 {
|
||||
tree_from(snapshot())
|
||||
}
|
||||
|
||||
/// Fold an existing snapshot into a forest by grouping each actor under its
|
||||
/// parent pid — a single O(n) pass, no new reads. Exposed separately so a
|
||||
/// consumer that already holds a snapshot (or a synthetic one, in tests) can
|
||||
/// derive the tree without a second scan.
|
||||
/// Fold an existing snapshot into a parentage forest by grouping each actor
|
||||
/// under its parent, without taking a new snapshot. Useful if you already
|
||||
/// have one (for example, one built in a test, or one you took earlier and
|
||||
/// want to inspect again) and want the tree view of it without re-reading
|
||||
/// the runtime.
|
||||
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
|
||||
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);
|
||||
} else {
|
||||
// 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;
|
||||
roots.push(i);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user