Files
smarm/src/introspect.rs
T
smarm-agent 48d47c45c9 RFC 016 Chunk 2c: approximate per-actor time-budget (reductions-like)
Accumulate on-CPU cycles per actor as ActorInfo.budget_cycles, behind
the off-by-default budget-accounting feature (D6) — a reductions-style
work metric for relative comparison across runs.

- Approximate by design (per Mark): charge now - slice-start once at the
  yield point, reusing the timestamp reset_timeslice already sets, so
  one RDTSC per resume not two. Wake-slot resumes inherit the slice and
  so slightly over-attribute the chain's time to the woken actor — noise
  that averages out; we trade exactness for half the hot-path cost.
- Field/ActorInfo member are unconditional (keeps the snapshot shape
  stable across the feature flag, D1); only the accumulation is gated,
  so default builds are byte-identical and pay nothing. Reads return 0
  when off. Single-writer Relaxed like the other counters; reset in
  reset_counters (D7).

Matrix: default + feature-on + rq-mpmc + rq-striped + release + trace all
green; loom unaffected (feature off under --cfg loom).
2026-06-19 08:47:58 +00:00

290 lines
12 KiB
Rust

//! 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,
};
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.
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,
/// 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).
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).
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).
pub budget_cycles: u64,
}
/// 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);
// Counters are hot-region atomics, read lock-free (RFC 016 Chunk 2).
let overruns = slot.overruns();
let messages_received = slot.messages_received();
let budget_cycles = slot.budget_cycles();
// 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,
overruns,
messages_received,
budget_cycles,
})
}
// ---------------------------------------------------------------------------
// Chunk 3 — tree view (pure derivation over a Chunk-1 snapshot)
// ---------------------------------------------------------------------------
/// One node in the parentage forest. `children` are the actors whose recorded
/// parent edge points at this node's pid.
#[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).
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`].
#[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.
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.
pub fn tree_from(snap: RuntimeSnapshot) -> RuntimeTree {
let RuntimeSnapshot { format_version, actors } = snap;
let mut index_of: HashMap<Pid, usize> = HashMap::with_capacity(actors.len());
for (i, a) in actors.iter().enumerate() {
index_of.insert(a.pid, i);
}
// Group children under their present parent; everything else is a root.
// Scan order is preserved within each parent's child list.
let mut children_of: HashMap<Pid, Vec<usize>> = HashMap::new();
let mut roots: Vec<usize> = Vec::new();
let mut orphaned = vec![false; actors.len()];
for (i, a) in actors.iter().enumerate() {
let parent = a.supervisor;
if parent != ROOT_PID && index_of.contains_key(&parent) {
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.
orphaned[i] = parent != ROOT_PID;
roots.push(i);
}
}
// `take()` each actor as it is placed, which also guards against a
// (constructionally impossible) parentage cycle re-entering a node.
let mut slots: Vec<Option<ActorInfo>> = actors.into_iter().map(Some).collect();
let root_nodes = roots
.into_iter()
.filter_map(|i| build_node(i, &children_of, &orphaned, &mut slots))
.collect();
RuntimeTree { format_version, roots: root_nodes }
}
fn build_node(
i: usize,
children_of: &HashMap<Pid, Vec<usize>>,
orphaned: &[bool],
slots: &mut [Option<ActorInfo>],
) -> Option<TreeNode> {
let info = slots[i].take()?; // already placed → cycle guard / no double-attach
let children = children_of
.get(&info.pid)
.map(|kids| {
kids.iter()
.filter_map(|&c| build_node(c, children_of, orphaned, slots))
.collect()
})
.unwrap_or_default();
Some(TreeNode { info, orphaned: orphaned[i], children })
}