From 41b9d6d056953389101a2c42a30a122a85d6cd84 Mon Sep 17 00:00:00 2001 From: Mark Kalsbeek Date: Fri, 24 Jul 2026 08:44:56 +0200 Subject: [PATCH] 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. --- src/introspect.rs | 264 +++++++++++++++++++++++++++++++--------------- 1 file changed, 181 insertions(+), 83 deletions(-) diff --git a/src/introspect.rs b/src/introspect.rs index aea2fb3..eab8e19 100644 --- a/src/introspect.rs +++ b/src/introspect.rs @@ -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 { Some(match word_state(w) { ST_QUEUED => ActorState::Queued, @@ -62,61 +134,76 @@ fn classify(w: u64) -> Option { }) } -/// 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, } -/// 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 { with_runtime(|inner| { let slot = inner.slot_at(pid)?; @@ -141,11 +231,12 @@ pub fn actor_info(pid: Pid) -> Option { }) } -/// 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 { let w = slot.state_word(); let state = classify(w)?; @@ -153,10 +244,11 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option) -> Option) -> Option, } -/// 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, } -/// 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); }