diff --git a/src/introspect.rs b/src/introspect.rs index dc34934..df60971 100644 --- a/src/introspect.rs +++ b/src/introspect.rs @@ -179,6 +179,34 @@ pub struct ActorInfo { /// `budget-accounting` feature is enabled, since measuring it costs a /// timestamp read on every resume. 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. @@ -281,6 +309,14 @@ fn read_slot(slot: &Slot, idx: u32, mail: Option<&MailboxInfo>) -> Option) -> Option (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 { self.overruns.load(Ordering::Relaxed) } diff --git a/tests/introspect.rs b/tests/introspect.rs index 960d029..accb4a4 100644 --- a/tests/introspect.rs +++ b/tests/introspect.rs @@ -237,6 +237,13 @@ fn tree_from_nests_children_and_reroots_orphans() { overruns: 0, messages_received: 0, budget_cycles: 0, + stack: smarm::StackInfo { + reserve: 0, + guard: 0, + depth_high_water: 0, + parks_since_shrink: 0, + shrinks: 0, + }, }; let snap = RuntimeSnapshot { @@ -352,3 +359,122 @@ fn budget_cycles_accumulate_when_enabled() { 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(); + }); +}