Skip to main content

luna_core/vm/
inspect.rs

1//! v2.0 Track TL — pure-read inspection accessors over a live `Vm`.
2//!
3//! Consumed by the `luna-tools` CLIs (`luna-heap-dump`,
4//! `luna-trace-inspect`, `luna-profile`). Every accessor here is:
5//!
6//! - **Read-only** — `&Vm`, no `&mut Vm`. Embedders can safely call
7//!   between dispatch ticks or from a hook callback without
8//!   perturbing JIT state.
9//! - **Allocation-discipline** — the per-tick work allocates a
10//!   small fixed-size buffer (one `Vec` for the result), nothing
11//!   else. No allocation inside the heap-walk loop.
12//! - **0 unsafe at embedder surface** — the only unsafe lives one
13//!   layer down in [`crate::runtime::Heap::walk_objects`], whose
14//!   safety contract is documented at the call site.
15//!
16//! These accessors are intentionally narrow. `Vm`'s private fields
17//! (`frames`, `stack`, ...) remain private; the tools take what
18//! these wrappers project, not raw internals. When a tool needs a
19//! new view, add a new wrapper here — don't relax the underlying
20//! field visibility.
21
22use crate::runtime::ObjTag;
23use crate::runtime::function::CallFrame;
24use crate::vm::Vm;
25
26/// Heap snapshot from one [`heap_walk`] invocation.
27#[derive(Debug, Clone)]
28pub struct HeapSnapshot {
29    /// Total live (or not-yet-swept) GC objects. Matches
30    /// [`crate::runtime::Heap::live_objects`].
31    pub total_objects: usize,
32    /// Approximate heap byte count. Matches
33    /// [`crate::runtime::Heap::bytes`] — see that field's
34    /// rustdoc on what is and isn't tracked.
35    pub total_bytes: usize,
36    /// Per-tag breakdown, sorted descending by count for stable
37    /// downstream display.
38    pub buckets: Vec<HeapBucket>,
39}
40
41/// One per-type row in [`HeapSnapshot::buckets`].
42#[derive(Debug, Clone)]
43pub struct HeapBucket {
44    /// Lower-cased name of the [`ObjTag`] discriminant
45    /// (`"str"`, `"table"`, `"proto"`, ...).
46    pub type_name: &'static str,
47    /// Count of live objects with this tag.
48    pub count: usize,
49    /// Per-tag byte estimate. Uses `core::mem::size_of` of the
50    /// payload struct as a lower bound — mirrors `Heap::bytes`'s
51    /// "shells only" accounting; embedders that need exact bytes
52    /// must instrument allocations themselves.
53    pub bytes_approx: usize,
54}
55
56/// Walk the Vm's heap and produce a per-type [`HeapSnapshot`].
57///
58/// The walk runs under a `&Heap` borrow so no concurrent mutation
59/// can occur; safe to call from any host context that has a `&Vm`.
60/// Cost: O(live_objects) reads of the intrusive next-link, plus
61/// one `Vec::push` per *distinct* tag (≤ 8 entries by design).
62pub fn heap_walk(vm: &Vm) -> HeapSnapshot {
63    // Fixed-size table indexed by ObjTag discriminant. Avoids any
64    // alloc in the hot loop.
65    let mut counts = [0usize; 8];
66    let mut byte_estimate = [0usize; 8];
67
68    vm.heap.walk_objects(|tag| {
69        let idx = tag as usize;
70        counts[idx] += 1;
71        byte_estimate[idx] += tag_payload_size(tag);
72    });
73
74    let mut buckets: Vec<HeapBucket> = ALL_TAGS
75        .iter()
76        .copied()
77        .filter_map(|tag| {
78            let idx = tag as usize;
79            if counts[idx] == 0 {
80                return None;
81            }
82            Some(HeapBucket {
83                type_name: tag_name(tag),
84                count: counts[idx],
85                bytes_approx: byte_estimate[idx],
86            })
87        })
88        .collect();
89    buckets.sort_by(|a, b| {
90        b.count
91            .cmp(&a.count)
92            .then_with(|| a.type_name.cmp(b.type_name))
93    });
94
95    HeapSnapshot {
96        total_objects: vm.heap.live_objects(),
97        total_bytes: vm.heap.bytes(),
98        buckets,
99    }
100}
101
102/// Compile-time list of every [`ObjTag`] variant. Used by
103/// [`heap_walk`] to drive bucket ordering and lookup; the array
104/// length is asserted equal to the variant count below so adding
105/// a new variant is a compile error here until the table grows.
106const ALL_TAGS: [ObjTag; 8] = [
107    ObjTag::Str,
108    ObjTag::Table,
109    ObjTag::Proto,
110    ObjTag::Closure,
111    ObjTag::Upvalue,
112    ObjTag::Native,
113    ObjTag::Coro,
114    ObjTag::Userdata,
115];
116
117const _OBJTAG_COVERS_EVERY_VARIANT: () = {
118    // Force a compile error if a new ObjTag variant is added but
119    // the ALL_TAGS table isn't grown alongside it. Rust's
120    // exhaustive match on a non-exhaustive enum-by-list pattern
121    // is the only way to get this check without macros.
122    let _ = |t: ObjTag| match t {
123        ObjTag::Str
124        | ObjTag::Table
125        | ObjTag::Proto
126        | ObjTag::Closure
127        | ObjTag::Upvalue
128        | ObjTag::Native
129        | ObjTag::Coro
130        | ObjTag::Userdata => (),
131    };
132};
133
134fn tag_name(tag: ObjTag) -> &'static str {
135    match tag {
136        ObjTag::Str => "str",
137        ObjTag::Table => "table",
138        ObjTag::Proto => "proto",
139        ObjTag::Closure => "closure",
140        ObjTag::Upvalue => "upvalue",
141        ObjTag::Native => "native",
142        ObjTag::Coro => "coro",
143        ObjTag::Userdata => "userdata",
144    }
145}
146
147/// Approximate per-tag shell size in bytes. Lower bound — matches
148/// the accounting policy of [`crate::runtime::Heap::bytes`] (shell
149/// sizes only; `Vec`/`Box` overflow is uncounted).
150fn tag_payload_size(tag: ObjTag) -> usize {
151    use crate::runtime::function::{LuaClosure, NativeClosure, Proto, Upvalue};
152    use crate::runtime::table::Table;
153    use crate::runtime::userdata::Userdata;
154    use crate::runtime::{Coro, LuaStr};
155
156    match tag {
157        ObjTag::Str => core::mem::size_of::<LuaStr>(),
158        ObjTag::Table => core::mem::size_of::<Table>(),
159        ObjTag::Proto => core::mem::size_of::<Proto>(),
160        ObjTag::Closure => core::mem::size_of::<LuaClosure>(),
161        ObjTag::Upvalue => core::mem::size_of::<Upvalue>(),
162        ObjTag::Native => core::mem::size_of::<NativeClosure>(),
163        ObjTag::Coro => core::mem::size_of::<Coro>(),
164        ObjTag::Userdata => core::mem::size_of::<Userdata>(),
165    }
166}
167
168/// Snapshot of the JIT state at one point in time. Used by
169/// `luna-trace-inspect`; fields are stable across the Vm lifetime.
170#[derive(Debug, Clone)]
171pub struct JitStateSnapshot {
172    /// `JitState::enabled` (master switch).
173    pub enabled: bool,
174    /// `JitState::trace_enabled` (trace-JIT subswitch).
175    pub trace_enabled: bool,
176    /// `Some(head_pc)` if a trace is currently being recorded.
177    pub active_trace_head_pc: Option<u32>,
178    /// `Some(ops_len)` length of the in-flight trace's recorded
179    /// op stream.
180    pub active_trace_len: Option<usize>,
181    /// Cumulative trace-close count
182    /// (`JitCounters::closed`).
183    pub trace_closed_count: u64,
184    /// Cumulative trace-abort count (`JitCounters::aborted`).
185    pub trace_aborted_count: u64,
186    /// Cumulative trace-dispatched count
187    /// (`JitCounters::dispatched`).
188    pub trace_dispatched_count: u64,
189    /// Cumulative trace-compiled count
190    /// (`JitCounters::compiled`).
191    pub trace_compiled_count: u64,
192    /// Cumulative trace-deoptimised count
193    /// (`JitCounters::deopt`).
194    pub trace_deopt_count: u64,
195}
196
197/// Project [`JitStateSnapshot`] from a live `Vm`. Pure-read; no
198/// alloc beyond the returned struct itself.
199pub fn jit_state_snapshot(vm: &Vm) -> JitStateSnapshot {
200    let js = &vm.jit;
201    JitStateSnapshot {
202        enabled: js.enabled,
203        trace_enabled: js.trace_enabled,
204        active_trace_head_pc: js.active_trace.as_ref().map(|t| t.head_pc),
205        active_trace_len: js.active_trace.as_ref().map(|t| t.ops.len()),
206        trace_closed_count: js.counters.closed,
207        trace_aborted_count: js.counters.aborted,
208        trace_dispatched_count: js.counters.dispatched,
209        trace_compiled_count: js.counters.compiled,
210        trace_deopt_count: js.counters.deopt,
211    }
212}
213
214/// One activation record projected from a live `Vm.frames`. Used by
215/// [`frames_for_profile`] to feed the `luna-profile` sampler. Owned
216/// strings so the caller can keep samples across dispatch ticks
217/// without holding a `&Vm` borrow.
218#[derive(Debug, Clone, PartialEq, Eq, Hash)]
219pub struct FrameInfo {
220    /// Lua source / chunk name (`Proto.source`, decoded as UTF-8
221    /// lossy). Mirrors what `debug.getinfo(level, "S").source`
222    /// reports — minus the leading `@`/`=` PUC-style chunk prefix
223    /// is preserved verbatim.
224    pub source: String,
225    /// Line number of the currently-dispatched instruction, derived
226    /// from `Proto.lines[pc - 1]`. `0` when the frame's PC hasn't
227    /// advanced past the entry yet (a freshly pushed frame mid-call
228    /// setup) — extremely rare from a Count hook, but tolerated.
229    pub line: u32,
230    /// `Proto.line_defined` — the line the function's `function`
231    /// keyword was on. Useful to differentiate two closures with
232    /// the same source but different definition lines.
233    pub line_defined: u32,
234}
235
236/// Walk the Vm's current call stack and project a vector of
237/// [`FrameInfo`]s, deepest-frame last (matches PUC `debug.traceback`
238/// ordering). Skips `CallFrame::Cont` (yieldable-native guards) —
239/// those aren't user-visible Lua activations and would noise the
240/// flame-graph.
241///
242/// Cost: O(frame_depth) `Vec::push` + one `String::from_utf8_lossy`
243/// per Lua frame; embedders calling this from a Count hook every N
244/// instructions trade hook overhead against sampling density.
245pub fn frames_for_profile(vm: &Vm) -> Vec<FrameInfo> {
246    let frames = vm.inspect_frames();
247    let mut out = Vec::with_capacity(frames.len());
248    for cf in frames {
249        let CallFrame::Lua(f) = cf else { continue };
250        // `Gc<T>: Deref<Target = T>` so the field access auto-borrows
251        // through the heap pointer; safe by the heap's
252        // single-threaded reachability invariant (see Heap docs).
253        let closure = &*f.closure;
254        let proto = &*closure.proto;
255        // PC has already advanced past the dispatched op; `pc - 1`
256        // is the just-executed instruction. Saturating sub so a
257        // freshly-pushed frame (pc=0) reports line 0.
258        let pc_idx = (f.pc as usize).saturating_sub(1);
259        let line = proto.lines.get(pc_idx).copied().unwrap_or(0);
260        let src_bytes = proto.source.as_bytes();
261        out.push(FrameInfo {
262            source: String::from_utf8_lossy(src_bytes).into_owned(),
263            line,
264            line_defined: proto.line_defined,
265        });
266    }
267    out
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn heap_walk_fresh_vm_has_some_protos_and_strings() {
276        // A fresh Vm preloads stdlib (when constructed via the
277        // `new_with_jit` ergonomic path), so the heap has at
278        // minimum a few protos + many interned strings. We assert
279        // the snapshot can be produced and is internally
280        // consistent; exact counts depend on the stdlib loader
281        // version and are intentionally not pinned.
282        let vm = Vm::new(crate::version::LuaVersion::Lua55);
283        let snap = heap_walk(&vm);
284        assert_eq!(
285            snap.total_objects,
286            snap.buckets.iter().map(|b| b.count).sum::<usize>(),
287            "per-bucket counts must sum to live_objects"
288        );
289        assert!(
290            snap.buckets.iter().all(|b| b.count > 0),
291            "no zero-count rows allowed in the report"
292        );
293    }
294
295    #[test]
296    fn jit_state_snapshot_default_inert() {
297        let vm = Vm::new(crate::version::LuaVersion::Lua55);
298        let snap = jit_state_snapshot(&vm);
299        // A bare Vm::new() has the null backend; counters start
300        // at zero, no trace in flight.
301        assert!(snap.enabled);
302        assert!(snap.active_trace_head_pc.is_none());
303        assert_eq!(snap.trace_closed_count, 0);
304        assert_eq!(snap.trace_aborted_count, 0);
305    }
306
307    #[test]
308    fn frames_for_profile_empty_when_no_call_in_flight() {
309        let vm = Vm::new(crate::version::LuaVersion::Lua55);
310        // Between calls the frame stack is empty — confirm we
311        // don't panic and return an empty Vec.
312        let frames = frames_for_profile(&vm);
313        assert!(
314            frames.is_empty(),
315            "no Lua call in flight, expected empty frame list, got {frames:?}"
316        );
317    }
318}