feat(introspect,runtime): per-actor stack surface on ActorInfo (RFC 019 §8)
- introspect::StackInfo { reserve, guard, depth_high_water,
parks_since_shrink, shrinks } as ActorInfo.stack; re-exported at crate
root beside ActorInfo.
- All reads lock-free: geometry from the c6 diag slot atomics, depth =
top - hwm (the §2 sampled high-water; doc spells out sampled-not-exact
and that 0 means never-descheduled-at-depth), counters straight off the
§3 atomics. Coherence for the incarnation rides read_slot's existing
generation check, same as overruns/messages_received.
- Slot::stack_introspect(): one pub(crate) tuple accessor beside the other
counter accessors.
- Exact RSS deliberately absent per RFC (mincore = debug tooling only,
never a runtime path); stack_shape(pid) untouched (cold-lock exact
variant from c2).
- tests/introspect.rs: defaults surface (64 KiB reserve / 1 MiB guard /
sampled ~32 KiB depth / gate park counted / zero shrinks) + live shrink
counters (spike visible pre-shrink; shrinks>=1, cooldown counter reset,
hwm reset after crossing COOLDOWN) read mid-run -- post-join the slot
reclaim correctly hides the incarnation, which the first draft of the
test learned the hard way.
FLAGGED (Claude-solo calls):
- Nested StackInfo struct over five flat ActorInfo fields (grain break;
the five fields are one concern and ActorInfo is already 12 fields).
- Field names reserve/guard/shrinks (RFC says stack_reserve/stack_guard/
shrink count; the stack_ prefix is redundant inside StackInfo).
This commit is contained in:
@@ -179,6 +179,34 @@ pub struct ActorInfo {
|
|||||||
/// `budget-accounting` feature is enabled, since measuring it costs a
|
/// `budget-accounting` feature is enabled, since measuring it costs a
|
||||||
/// timestamp read on every resume.
|
/// timestamp read on every resume.
|
||||||
pub budget_cycles: u64,
|
pub budget_cycles: u64,
|
||||||
|
/// RFC 019 §8 — this actor's stack, as the runtime sees it. All fields
|
||||||
|
/// are lock-free atomic reads, coherent for this incarnation via the
|
||||||
|
/// same generation check as the counters above. Exact RSS is
|
||||||
|
/// deliberately absent: `mincore` is debug tooling, never a runtime
|
||||||
|
/// path.
|
||||||
|
pub stack: StackInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 019 §8 — per-actor stack introspection. Sizes are page-rounded, as
|
||||||
|
/// [`Stack::new`](crate::stack::Stack::new) rounds them.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct StackInfo {
|
||||||
|
/// Usable stack size ([`SpawnOpts::stack_reserve`]
|
||||||
|
/// (crate::SpawnOpts::stack_reserve) or the Config/default).
|
||||||
|
pub reserve: usize,
|
||||||
|
/// PROT_NONE guard below the usable region.
|
||||||
|
pub guard: usize,
|
||||||
|
/// Sampled high-water depth in bytes: `top − lowest saved sp`. Sampled,
|
||||||
|
/// not exact — the context save at yields/parks/preemptions is the
|
||||||
|
/// sampler (RFC 019 §2), so a spike the actor never yielded inside is
|
||||||
|
/// invisible. 0 depth means "never descheduled at any depth", not
|
||||||
|
/// "never ran".
|
||||||
|
pub depth_high_water: usize,
|
||||||
|
/// Parks on this incarnation since its last shrink (or since install if
|
||||||
|
/// it has never shrunk) — the §3 cooldown counter, live.
|
||||||
|
pub parks_since_shrink: u32,
|
||||||
|
/// §3 shrinks performed on this incarnation.
|
||||||
|
pub shrinks: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A snapshot of every actor in the runtime at (approximately) one moment.
|
/// A snapshot of every actor in the runtime at (approximately) one moment.
|
||||||
@@ -281,6 +309,14 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
|
|||||||
drop(cold);
|
drop(cold);
|
||||||
|
|
||||||
// Counters are plain atomics, read lock-free.
|
// Counters are plain atomics, read lock-free.
|
||||||
|
let (reserve, guard, top, hwm, parks_since_shrink, shrinks) = slot.stack_introspect();
|
||||||
|
let stack = StackInfo {
|
||||||
|
reserve,
|
||||||
|
guard,
|
||||||
|
depth_high_water: top.saturating_sub(hwm),
|
||||||
|
parks_since_shrink,
|
||||||
|
shrinks,
|
||||||
|
};
|
||||||
let overruns = slot.overruns();
|
let overruns = slot.overruns();
|
||||||
let messages_received = slot.messages_received();
|
let messages_received = slot.messages_received();
|
||||||
let budget_cycles = slot.budget_cycles();
|
let budget_cycles = slot.budget_cycles();
|
||||||
@@ -306,6 +342,7 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option<ActorI
|
|||||||
overruns,
|
overruns,
|
||||||
messages_received,
|
messages_received,
|
||||||
budget_cycles,
|
budget_cycles,
|
||||||
|
stack,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -65,7 +65,7 @@ pub use gen_statem::{
|
|||||||
CallError as GenStatemCallError, Cx, Machine, Reply, Resolution, SendError as GenStatemSendError,
|
CallError as GenStatemCallError, Cx, Machine, Reply, Resolution, SendError as GenStatemSendError,
|
||||||
GenStatemRef,
|
GenStatemRef,
|
||||||
};
|
};
|
||||||
pub use introspect::{
|
pub use introspect::{StackInfo,
|
||||||
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
|
actor_info, snapshot, tree, tree_from, ActorInfo, ActorState, RuntimeSnapshot, RuntimeTree,
|
||||||
TreeNode, SNAPSHOT_FORMAT_VERSION,
|
TreeNode, SNAPSHOT_FORMAT_VERSION,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -662,6 +662,23 @@ impl Slot {
|
|||||||
|
|
||||||
/// Read the overrun tally (Relaxed; the snapshot reads cross-thread).
|
/// Read the overrun tally (Relaxed; the snapshot reads cross-thread).
|
||||||
#[inline]
|
#[inline]
|
||||||
|
/// RFC 019 §8 — the stack introspection tuple, all lock-free:
|
||||||
|
/// `(reserve, guard, top, hwm, parks_since_shrink, shrink_count)`.
|
||||||
|
/// Geometry from the c6 diag atomics (install-time, gen-coherent under
|
||||||
|
/// `read_slot`'s gen check exactly like the other counters); `hwm` is the
|
||||||
|
/// §2 sampled high-water (lowest saved sp). All zeros before first
|
||||||
|
/// install.
|
||||||
|
pub(crate) fn stack_introspect(&self) -> (usize, usize, usize, usize, u32, u32) {
|
||||||
|
(
|
||||||
|
self.diag_stack_reserve.load(Ordering::Relaxed),
|
||||||
|
self.diag_stack_guard.load(Ordering::Relaxed),
|
||||||
|
self.diag_stack_top.load(Ordering::Relaxed),
|
||||||
|
self.hwm.load(Ordering::Relaxed),
|
||||||
|
self.parks_since_shrink.load(Ordering::Relaxed),
|
||||||
|
self.shrink_count.load(Ordering::Relaxed),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn overruns(&self) -> u64 {
|
pub(crate) fn overruns(&self) -> u64 {
|
||||||
self.overruns.load(Ordering::Relaxed)
|
self.overruns.load(Ordering::Relaxed)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,13 @@ fn tree_from_nests_children_and_reroots_orphans() {
|
|||||||
overruns: 0,
|
overruns: 0,
|
||||||
messages_received: 0,
|
messages_received: 0,
|
||||||
budget_cycles: 0,
|
budget_cycles: 0,
|
||||||
|
stack: smarm::StackInfo {
|
||||||
|
reserve: 0,
|
||||||
|
guard: 0,
|
||||||
|
depth_high_water: 0,
|
||||||
|
parks_since_shrink: 0,
|
||||||
|
shrinks: 0,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
let snap = RuntimeSnapshot {
|
let snap = RuntimeSnapshot {
|
||||||
@@ -352,3 +359,122 @@ fn budget_cycles_accumulate_when_enabled() {
|
|||||||
h.join().unwrap();
|
h.join().unwrap();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RFC 019 §8 — the stack introspection surface.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Burn ~`frames` × 4 KiB of stack with a yield at max depth, so the context
|
||||||
|
/// save samples the high-water there (RFC 019 §2: hwm is SAMPLED at
|
||||||
|
/// deschedule, not tracked continuously).
|
||||||
|
#[inline(never)]
|
||||||
|
fn burn_stack_yielding(frames: usize) -> u64 {
|
||||||
|
let mut local = [0u8; 4096];
|
||||||
|
local[0] = frames as u8;
|
||||||
|
let below = if frames == 0 {
|
||||||
|
smarm::yield_now();
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
burn_stack_yielding(frames - 1)
|
||||||
|
};
|
||||||
|
std::hint::black_box(&mut local);
|
||||||
|
below.wrapping_add(local[0] as u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stack_info_reports_defaults_and_sampled_depth() {
|
||||||
|
run(|| {
|
||||||
|
let (ready_tx, ready_rx) = channel::<()>();
|
||||||
|
let (gate_tx, gate_rx) = channel::<()>();
|
||||||
|
|
||||||
|
let h = spawn(move || {
|
||||||
|
// ~32 KiB deep with a yield at the bottom: the sample point.
|
||||||
|
std::hint::black_box(burn_stack_yielding(8));
|
||||||
|
ready_tx.send(()).unwrap();
|
||||||
|
gate_rx.recv().unwrap();
|
||||||
|
});
|
||||||
|
ready_rx.recv().unwrap();
|
||||||
|
|
||||||
|
let info = spin_until(h.pid(), |a| a.state == ActorState::Parked);
|
||||||
|
let s = info.stack;
|
||||||
|
assert_eq!(s.reserve, 64 * 1024, "default reserve");
|
||||||
|
assert_eq!(s.guard, 1024 * 1024, "default guard (kernel stack_guard_gap convention)");
|
||||||
|
assert!(
|
||||||
|
s.depth_high_water >= 8 * 4096,
|
||||||
|
"hwm sampled at the deep yield: expected ≥ 32 KiB, got {}",
|
||||||
|
s.depth_high_water
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.depth_high_water < s.reserve,
|
||||||
|
"depth {} cannot exceed the reserve {}",
|
||||||
|
s.depth_high_water,
|
||||||
|
s.reserve
|
||||||
|
);
|
||||||
|
// Parked at the gate right now, never shrunk (64 KiB reserve cannot
|
||||||
|
// cross the shrink threshold).
|
||||||
|
assert!(s.parks_since_shrink >= 1, "the gate park must be counted");
|
||||||
|
assert_eq!(s.shrinks, 0);
|
||||||
|
|
||||||
|
gate_tx.send(()).unwrap();
|
||||||
|
h.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stack_info_shrink_counters_are_live() {
|
||||||
|
use smarm::runtime::{Config, SHRINK_COOLDOWN, SHRINK_THRESHOLD};
|
||||||
|
use smarm::{spawn_with, SpawnOpts};
|
||||||
|
|
||||||
|
let rt = smarm::runtime::init(Config::exact(1));
|
||||||
|
rt.run(|| {
|
||||||
|
let (park_tx, park_rx) = channel::<()>();
|
||||||
|
|
||||||
|
let spike = 768 * 4096;
|
||||||
|
assert!(spike > SHRINK_THRESHOLD);
|
||||||
|
let worker = spawn_with(
|
||||||
|
SpawnOpts { stack_reserve: Some(8 * 1024 * 1024), ..SpawnOpts::default() },
|
||||||
|
move || {
|
||||||
|
std::hint::black_box(burn_stack_yielding(768));
|
||||||
|
for _ in 0..(SHRINK_COOLDOWN + 8) {
|
||||||
|
park_rx.recv().unwrap();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let wpid = worker.pid();
|
||||||
|
// Before any parks complete: the spike depth is visible.
|
||||||
|
let info = spin_until(wpid, |a| a.state == ActorState::Parked);
|
||||||
|
assert!(
|
||||||
|
info.stack.depth_high_water >= spike,
|
||||||
|
"spike should be sampled: {} < {spike}",
|
||||||
|
info.stack.depth_high_water
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cross the cooldown, then read the counters live while the worker
|
||||||
|
// is parked waiting for the remaining rounds (post-join the slot is
|
||||||
|
// reclaimed and the generation check correctly hides it).
|
||||||
|
for _ in 0..(SHRINK_COOLDOWN + 2) {
|
||||||
|
spin_until(wpid, |a| a.state == ActorState::Parked);
|
||||||
|
park_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
let info = spin_until(wpid, |a| a.state == ActorState::Parked && a.stack.shrinks >= 1);
|
||||||
|
let s = info.stack;
|
||||||
|
assert!(s.shrinks >= 1, "cooldown was crossed with a spike above threshold");
|
||||||
|
assert!(
|
||||||
|
s.parks_since_shrink < SHRINK_COOLDOWN,
|
||||||
|
"counter must reset at shrink: {}",
|
||||||
|
s.parks_since_shrink
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
s.depth_high_water < spike,
|
||||||
|
"hwm resets to the shallow park sp at shrink; got {}",
|
||||||
|
s.depth_high_water
|
||||||
|
);
|
||||||
|
|
||||||
|
for _ in 0..6 {
|
||||||
|
spin_until(wpid, |a| a.state == ActorState::Parked);
|
||||||
|
park_tx.send(()).unwrap();
|
||||||
|
}
|
||||||
|
worker.join().unwrap();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user