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}