RFC 016 Chunk 3: parentage tree view

tree() / tree_from() fold a Chunk-1 snapshot into a forest by grouping
each actor under its parent pid — one O(n) pass, no new reads. tree_from
is public so a held (or synthetic) snapshot can be folded without a
second scan.

- D8: actors parented at ROOT_PID are genuine roots; an actor whose
  recorded parent is absent from the snapshot is re-rooted under the
  sentinel and flagged orphaned, so the forest stays total. take()-on-
  place doubles as a guard against re-entering a node.
- D9: the edge is parent/spawned-by, documented as not necessarily
  supervision.
This commit is contained in:
smarm-agent
2026-06-19 06:33:21 +00:00
parent c66691943d
commit 7ef915c81e
3 changed files with 168 additions and 3 deletions
+88
View File
@@ -29,6 +29,7 @@ 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
@@ -178,3 +179,90 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
mailbox_depth,
})
}
// ---------------------------------------------------------------------------
// 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 })
}
+2 -1
View File
@@ -56,7 +56,8 @@ pub use gen_server::{
NamedServerBuilder, ServerBuilder, ServerCtx, ServerName, ServerRef, TimerHandle, Watcher,
};
pub use introspect::{
actor_info, snapshot, ActorInfo, ActorState, RuntimeSnapshot, SNAPSHOT_FORMAT_VERSION,
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
TreeNode, SNAPSHOT_FORMAT_VERSION,
};
pub use link::{link, trap_exit, unlink, ExitSignal};
pub use monitor::{demonitor, monitor, Down, DownReason, Monitor, MonitorId};
+78 -2
View File
@@ -4,8 +4,8 @@
//! instead of sleeping-and-hoping.
use smarm::{
actor_info, channel, monitor, register, run, self_pid, send, snapshot, spawn, ActorState, Name,
Pid, SNAPSHOT_FORMAT_VERSION,
actor_info, channel, monitor, register, run, self_pid, send, snapshot, spawn, tree, tree_from,
ActorInfo, ActorState, Name, Pid, RuntimeSnapshot, SNAPSHOT_FORMAT_VERSION,
};
const SVC: Name<u64> = Name::new("svc");
@@ -182,3 +182,79 @@ fn done_actor_is_a_tombstone() {
h.join().unwrap();
});
}
#[test]
fn tree_places_child_under_its_spawner() {
run(|| {
let (ready_tx, ready_rx) = channel::<()>();
let (gate_tx, gate_rx) = channel::<()>();
let h = spawn(move || {
ready_tx.send(()).unwrap();
gate_rx.recv().unwrap();
});
ready_rx.recv().unwrap();
let me = self_pid();
let t = tree();
assert_eq!(t.format_version, SNAPSHOT_FORMAT_VERSION);
// The root is parented at the forest sentinel, so it's a genuine root,
// and the worker it spawned hangs beneath it.
let root = t.roots.iter().find(|n| n.info.pid == me).expect("root in forest");
assert!(!root.orphaned);
assert!(
root.children.iter().any(|c| c.info.pid == h.pid()),
"spawned worker should be a child of its spawner"
);
gate_tx.send(()).unwrap();
h.join().unwrap();
});
}
/// D8 re-rooting and nesting, exercised on a synthetic snapshot via the public
/// `tree_from` — the live lifecycle race (parent reclaimed while child lives)
/// is exactly what's awkward to stage deterministically, which is why the fold
/// is testable in isolation.
#[test]
fn tree_from_nests_children_and_reroots_orphans() {
let root_sentinel = Pid::new(u32::MAX, u32::MAX);
let root_pid = Pid::new(0, 1);
let child = Pid::new(1, 1);
let orphan = Pid::new(2, 1);
let absent_parent = Pid::new(99, 1);
let mk = |pid: Pid, supervisor: Pid| ActorInfo {
pid,
names: Vec::new(),
state: ActorState::Running,
supervisor,
trap_exit: false,
monitors: 0,
links: 0,
joiners: 0,
mailbox_depth: 0,
};
let snap = RuntimeSnapshot {
format_version: SNAPSHOT_FORMAT_VERSION,
actors: vec![
mk(root_pid, root_sentinel),
mk(child, root_pid),
mk(orphan, absent_parent),
],
};
let t = tree_from(snap);
assert_eq!(t.roots.len(), 2);
let root = t.roots.iter().find(|n| n.info.pid == root_pid).expect("root present");
assert!(!root.orphaned);
assert_eq!(root.children.len(), 1);
assert_eq!(root.children[0].info.pid, child);
assert!(!root.children[0].orphaned);
let o = t.roots.iter().find(|n| n.info.pid == orphan).expect("orphan re-rooted");
assert!(o.orphaned, "an actor whose parent is absent must be flagged orphaned");
assert!(o.children.is_empty());
}