From 7ef915c81ec1a0f9a97fbbf1d29f5235326bd7c4 Mon Sep 17 00:00:00 2001 From: smarm-agent Date: Fri, 19 Jun 2026 06:33:21 +0000 Subject: [PATCH] RFC 016 Chunk 3: parentage tree view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/introspect.rs | 88 +++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 3 +- tests/introspect.rs | 80 +++++++++++++++++++++++++++++++++++++++-- 3 files changed, 168 insertions(+), 3 deletions(-) diff --git a/src/introspect.rs b/src/introspect.rs index 5d37f0b..c230e14 100644 --- a/src/introspect.rs +++ b/src/introspect.rs @@ -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, +} + +/// 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, +} + +/// 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 = 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> = HashMap::new(); + let mut roots: Vec = 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> = 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>, + orphaned: &[bool], + slots: &mut [Option], +) -> Option { + 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 }) +} diff --git a/src/lib.rs b/src/lib.rs index 45bd3a0..f60056b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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}; diff --git a/tests/introspect.rs b/tests/introspect.rs index 29cf082..6173ec6 100644 --- a/tests/introspect.rs +++ b/tests/introspect.rs @@ -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 = 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()); +}