Skip to main content

gc/
debugger.rs

1//! Copy-out debugger snapshots (playground debugger design §5).
2//!
3//! Every `debugger;` statement executed by the GC VM materializes one
4//! [`DebuggerHit`]: call frames, named slots, and a bounded projection of the
5//! current heap. A hit holds only strings, integers, and plain vectors — never
6//! a `GcRef` — so recording cannot root objects or change later GC behavior.
7//!
8//! Values are projected by teaching semantics, not by allocation reality
9//! (design §2.4): Integer/Boolean/Null/Builtin are inlined into slots and
10//! parent members with `heap_id: None`; String/Array/Hash/Closure/Class/
11//! Instance/BoundMethod/Error become heap nodes referenced as `ref #id`;
12//! CompiledFunction and VM infrastructure are hidden entirely and are not
13//! counted as omitted.
14
15use std::collections::{HashSet, VecDeque};
16
17use compiler::compiler::DebugInfo;
18use parser::lexer::token::Span;
19use serde::Serialize;
20
21use crate::report::summarize_gc_object;
22use crate::value::{format_hash_key_label, EdgeRelation, HashKey, Value, ValueCell, ValueKind};
23use crate::{Frame, GcHeap, GcId, GcRef};
24
25pub const MAX_DEBUGGER_HITS: usize = 25;
26pub const MAX_DEBUGGER_OBJECTS: usize = 100; // per hit
27pub const MAX_DEBUGGER_EDGES: usize = 250; // per hit
28pub const MAX_DEBUGGER_DISPLAY_CHARS: usize = 64;
29pub const MAX_DEBUGGER_SUMMARY_DEPTH: usize = 2;
30pub const MAX_DEBUGGER_MEMBERS: usize = 8; // per container/object
31
32#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct DebuggerHit {
35    /// 1-based position of this hit in execution order.
36    pub index: usize,
37    /// Source span of the `debugger;` statement itself.
38    pub span: Option<Span>,
39    /// `[0]` is main, the last entry is the currently executing frame.
40    pub frames: Vec<FrameView>,
41    /// Global slots in definition-ledger order, rebindings included.
42    pub globals: Vec<SlotView>,
43    pub heap: HeapView,
44}
45
46#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
47#[serde(rename_all = "camelCase")]
48pub struct FrameView {
49    pub name: String,
50    pub current_span: Option<Span>,
51    /// The value being called, read from `base_pointer - 1`; None for main.
52    pub callee: Option<ValueView>,
53    pub locals: Vec<SlotView>,
54    pub captures: Vec<CaptureView>,
55    /// Operand-stack values above the locals window. Only the current frame
56    /// reports these; a suspended caller's window also covers its callee's
57    /// frame, which would misclassify arguments as temporaries.
58    pub temporaries: Vec<StackSlotView>,
59}
60
61#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
62#[serde(rename_all = "camelCase")]
63pub struct SlotView {
64    pub name: String,
65    pub slot: usize,
66    /// False until the slot's `OpSetLocal`/`OpSetGlobal` ran. The prefilled
67    /// null in an uninitialized slot is VM plumbing, not a user value, so
68    /// `value` is None whenever this is false.
69    pub initialized: bool,
70    pub value: Option<ValueView>,
71}
72
73#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
74#[serde(rename_all = "camelCase")]
75pub struct CaptureView {
76    pub name: String,
77    pub index: usize,
78    pub value: ValueView,
79}
80
81#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
82#[serde(rename_all = "camelCase")]
83pub struct StackSlotView {
84    /// Absolute VM stack slot, so hits stay comparable across frames.
85    pub slot: usize,
86    pub value: ValueView,
87}
88
89#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
90#[serde(rename_all = "camelCase")]
91pub struct ValueView {
92    pub kind: ValueKind,
93    pub display: String,
94    /// Present exactly for heap-node kinds, even when the object itself was
95    /// dropped from `HeapView.objects` by the budget — the UI shows those as
96    /// "not captured" instead of losing the reference.
97    pub heap_id: Option<usize>,
98}
99
100#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
101#[serde(rename_all = "camelCase")]
102pub struct HeapObjectView {
103    pub id: usize,
104    pub kind: ValueKind,
105    pub label: String,
106    /// Inlined scalar elements/fields, in `visit_edges` order.
107    pub members: Vec<HeapMemberView>,
108}
109
110#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct HeapMemberView {
113    pub relation: EdgeRelation,
114    pub display: String,
115}
116
117#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
118#[serde(rename_all = "camelCase")]
119pub struct HeapEdgeView {
120    pub from: usize,
121    pub to: usize,
122    pub relation: EdgeRelation,
123}
124
125#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
126#[serde(rename_all = "camelCase")]
127pub struct HeapView {
128    pub objects: Vec<HeapObjectView>,
129    pub edges: Vec<HeapEdgeView>,
130    /// Heap-node objects dropped by the object budget. Inlined scalars and
131    /// deliberately hidden kinds are presentation policy, not truncation, and
132    /// are never counted here.
133    pub omitted_objects: usize,
134    /// Node-to-node edges not emitted because an endpoint missed the object
135    /// budget or the edge budget ran out.
136    pub omitted_edges: usize,
137}
138
139/// Borrowed view of everything `collect_hit` reads from the VM. The VM keeps
140/// its fields private; this struct is the explicit contract of what a
141/// snapshot depends on.
142pub(crate) struct HitContext<'a> {
143    pub heap: &'a GcHeap,
144    /// Active frames only: `frames[0]` is main, the last is current.
145    pub frames: &'a [Frame],
146    pub stack: &'a [GcRef],
147    pub sp: usize,
148    pub globals: &'a [GcRef],
149    pub global_bindings: &'a [compiler::compiler::BindingDebugInfo],
150    pub globals_initialized: &'a [bool],
151    pub main_debug_info: &'a DebugInfo,
152    pub function_debug_info: &'a std::collections::HashMap<GcRef, DebugInfo>,
153    /// 1-based index this hit will get.
154    pub index: usize,
155}
156
157pub(crate) fn collect_hit(ctx: HitContext) -> DebuggerHit {
158    // User-object roots in the documented stable order: globals, then each
159    // frame's callee/locals/captures (main -> current), then the current
160    // frame's temporaries. Views are built in the same pass so the graph can
161    // never disagree with the slot listing.
162    let mut roots: Vec<GcRef> = Vec::new();
163
164    let mut globals = Vec::with_capacity(ctx.global_bindings.len());
165    for binding in ctx.global_bindings {
166        globals.push(slot_view(
167            ctx.heap,
168            &binding.name,
169            binding.slot,
170            ctx.globals_initialized
171                .get(binding.slot)
172                .copied()
173                .unwrap_or(false),
174            ctx.globals.get(binding.slot).copied(),
175            &mut roots,
176        ));
177    }
178
179    let mut frames = Vec::with_capacity(ctx.frames.len());
180    for (frame_number, frame) in ctx.frames.iter().enumerate() {
181        let is_main = frame_number == 0;
182        let is_current = frame_number == ctx.frames.len() - 1;
183        let debug_info = if is_main {
184            Some(ctx.main_debug_info)
185        } else {
186            ctx.function_debug_info.get(&frame.cl.func)
187        };
188
189        let callee = (!is_main)
190            .then(|| frame.base_pointer.checked_sub(1))
191            .flatten()
192            .and_then(|slot| ctx.stack.get(slot))
193            .map(|reference| rooted_value_view(ctx.heap, *reference, &mut roots));
194
195        let mut locals = Vec::new();
196        if let Some(debug_info) = debug_info {
197            for binding in &debug_info.local_bindings {
198                locals.push(slot_view(
199                    ctx.heap,
200                    &binding.name,
201                    binding.slot,
202                    frame
203                        .initialized
204                        .get(binding.slot)
205                        .copied()
206                        .unwrap_or(false),
207                    ctx.stack.get(frame.base_pointer + binding.slot).copied(),
208                    &mut roots,
209                ));
210            }
211        }
212
213        let free_names = debug_info
214            .map(|info| info.free_names.as_slice())
215            .unwrap_or(&[]);
216        let captures = frame
217            .cl
218            .free
219            .iter()
220            .enumerate()
221            .map(|(index, reference)| CaptureView {
222                name: free_names
223                    .get(index)
224                    .cloned()
225                    .unwrap_or_else(|| format!("<free {}>", index)),
226                index,
227                value: rooted_value_view(ctx.heap, *reference, &mut roots),
228            })
229            .collect();
230
231        frames.push(FrameView {
232            name: frame_name(ctx.heap, frame, is_main),
233            current_span: (frame.ip >= 0)
234                .then_some(frame.ip as usize)
235                .and_then(|pc| debug_info.and_then(|info| info.span_for_pc(pc)))
236                .cloned(),
237            callee,
238            locals,
239            captures,
240            // Filled below for the current frame only, after its locals are
241            // rooted, to keep the documented root order.
242            temporaries: Vec::new(),
243        });
244
245        if is_current {
246            let first_temporary = frame.base_pointer + frame.initialized.len();
247            let temporaries = (first_temporary..ctx.sp)
248                .filter_map(|slot| ctx.stack.get(slot).map(|reference| (slot, *reference)))
249                .map(|(slot, reference)| StackSlotView {
250                    slot,
251                    value: rooted_value_view(ctx.heap, reference, &mut roots),
252                })
253                .collect();
254            frames
255                .last_mut()
256                .expect("current frame was just pushed")
257                .temporaries = temporaries;
258        }
259    }
260
261    let span = frames.last().and_then(|frame| frame.current_span.clone());
262    let heap = project_heap(ctx.heap, &roots);
263
264    DebuggerHit {
265        index: ctx.index,
266        span,
267        frames,
268        globals,
269        heap,
270    }
271}
272
273fn slot_view(
274    heap: &GcHeap,
275    name: &str,
276    slot: usize,
277    initialized: bool,
278    reference: Option<GcRef>,
279    roots: &mut Vec<GcRef>,
280) -> SlotView {
281    let value = match reference {
282        Some(reference) if initialized => Some(rooted_value_view(heap, reference, roots)),
283        _ => None,
284    };
285    SlotView {
286        name: name.to_string(),
287        slot,
288        // A binding whose slot the VM never materialized (defensive: hostile
289        // debug metadata) reads as uninitialized rather than inventing a value.
290        initialized: initialized && reference.is_some(),
291        value,
292    }
293}
294
295fn frame_name(heap: &GcHeap, frame: &Frame, is_main: bool) -> String {
296    if is_main {
297        return "main".to_string();
298    }
299    match try_value(heap, frame.cl.func) {
300        Some(Value::CompiledFunction(function)) if !function.name.is_empty() => {
301            function.name.clone()
302        }
303        _ => "<anonymous>".to_string(),
304    }
305}
306
307fn rooted_value_view(heap: &GcHeap, reference: GcRef, roots: &mut Vec<GcRef>) -> ValueView {
308    let view = value_view(heap, reference);
309    if view.heap_id.is_some() {
310        roots.push(reference);
311    }
312    view
313}
314
315pub(crate) fn value_view(heap: &GcHeap, reference: GcRef) -> ValueView {
316    let kind = try_value(heap, reference)
317        .map(Value::kind)
318        .unwrap_or(ValueKind::Other);
319    ValueView {
320        kind,
321        display: bounded_display(heap, reference),
322        heap_id: is_heap_node(kind).then_some(reference.0),
323    }
324}
325
326/// Kinds that become graph nodes; slots reference them as `ref #id`.
327fn is_heap_node(kind: ValueKind) -> bool {
328    matches!(
329        kind,
330        ValueKind::String
331            | ValueKind::Array
332            | ValueKind::Hash
333            | ValueKind::Closure
334            | ValueKind::Class
335            | ValueKind::Instance
336            | ValueKind::BoundMethod
337            | ValueKind::Error
338    )
339}
340
341/// Kinds inlined into slots and parent members; they never get nodes or edges.
342fn is_inline_scalar(kind: ValueKind) -> bool {
343    matches!(kind, ValueKind::Integer | ValueKind::Boolean | ValueKind::Null | ValueKind::Builtin)
344}
345
346fn try_value(heap: &GcHeap, reference: GcRef) -> Option<&Value> {
347    heap.runtime()
348        .object_downcast::<ValueCell>(reference.0)
349        .map(|cell| &cell.value)
350}
351
352/// Character-budgeted string builder. The budget is enforced while
353/// appending — a huge string or array never materializes in full before
354/// truncation (design §5.3).
355struct BoundedText {
356    out: String,
357    remaining: usize,
358    truncated: bool,
359}
360
361impl BoundedText {
362    fn new(limit: usize) -> Self {
363        BoundedText {
364            out: String::new(),
365            remaining: limit,
366            truncated: false,
367        }
368    }
369
370    fn push(&mut self, text: &str) {
371        if self.truncated {
372            return;
373        }
374        for ch in text.chars() {
375            if self.remaining == 0 {
376                self.truncated = true;
377                return;
378            }
379            self.out.push(ch);
380            self.remaining -= 1;
381        }
382    }
383
384    fn finish(mut self) -> String {
385        if self.truncated {
386            // The ellipsis is part of the advertised character budget.
387            self.out.pop();
388            self.out.push('…');
389        }
390        self.out
391    }
392}
393
394fn bounded_display(heap: &GcHeap, reference: GcRef) -> String {
395    let mut text = BoundedText::new(MAX_DEBUGGER_DISPLAY_CHARS);
396    let mut visiting = HashSet::new();
397    append_reference(heap, reference, MAX_DEBUGGER_SUMMARY_DEPTH, &mut visiting, &mut text);
398    text.finish()
399}
400
401fn append_reference(
402    heap: &GcHeap,
403    reference: GcRef,
404    depth: usize,
405    visiting: &mut HashSet<GcId>,
406    text: &mut BoundedText,
407) {
408    if text.truncated {
409        return;
410    }
411    let Some(value) = try_value(heap, reference) else {
412        text.push("<invalid>");
413        return;
414    };
415    if !visiting.insert(reference.0) {
416        text.push(&format!("[cycle #{}]", reference.0));
417        return;
418    }
419    append_value(heap, value, depth, visiting, text);
420    visiting.remove(&reference.0);
421}
422
423fn append_value(
424    heap: &GcHeap,
425    value: &Value,
426    depth: usize,
427    visiting: &mut HashSet<GcId>,
428    text: &mut BoundedText,
429) {
430    match value {
431        Value::Integer(value) => text.push(&value.to_string()),
432        Value::Boolean(value) => text.push(&value.to_string()),
433        Value::String(value) => text.push(value),
434        Value::Null => text.push("null"),
435        Value::Error(message) => text.push(message),
436        Value::Builtin(_) => text.push("[builtin function]"),
437        Value::CompiledFunction(_) => text.push("[compiled function]"),
438        Value::Closure(_) => text.push("[closure function]"),
439        Value::Class(class) => {
440            text.push("[class ");
441            text.push(&class.name);
442            text.push("]");
443        }
444        Value::Instance(instance) => {
445            text.push("[object ");
446            text.push(&referenced_class_name(heap, instance.class));
447            text.push("]");
448        }
449        Value::BoundMethod(method) => {
450            text.push("[bound method ");
451            text.push(&receiver_class_name(heap, method.receiver));
452            text.push(".");
453            text.push(&method.name);
454            text.push("]");
455        }
456        Value::Array(items) => {
457            if depth == 0 {
458                text.push("[…]");
459                return;
460            }
461            text.push("[");
462            for (position, item) in items.iter().take(MAX_DEBUGGER_MEMBERS).enumerate() {
463                if position > 0 {
464                    text.push(", ");
465                }
466                append_reference(heap, *item, depth - 1, visiting, text);
467            }
468            if items.len() > MAX_DEBUGGER_MEMBERS {
469                text.push(", …");
470            }
471            text.push("]");
472        }
473        Value::Hash(map) => {
474            if depth == 0 {
475                text.push("{…}");
476                return;
477            }
478            let mut entries: Vec<(&HashKey, &GcRef)> = map.iter().collect();
479            entries.sort_by_key(|(key, _)| *key);
480            text.push("{");
481            for (position, (key, value)) in entries.iter().take(MAX_DEBUGGER_MEMBERS).enumerate() {
482                if position > 0 {
483                    text.push(", ");
484                }
485                text.push(&format_hash_key_label(key));
486                text.push(": ");
487                append_reference(heap, **value, depth - 1, visiting, text);
488            }
489            if entries.len() > MAX_DEBUGGER_MEMBERS {
490                text.push(", …");
491            }
492            text.push("}");
493        }
494    }
495}
496
497fn referenced_class_name(heap: &GcHeap, class: GcRef) -> String {
498    match try_value(heap, class) {
499        Some(Value::Class(class)) => class.name.clone(),
500        _ => "<invalid class>".to_string(),
501    }
502}
503
504fn receiver_class_name(heap: &GcHeap, receiver: GcRef) -> String {
505    match try_value(heap, receiver) {
506        Some(Value::Instance(instance)) => referenced_class_name(heap, instance.class),
507        _ => "<invalid receiver>".to_string(),
508    }
509}
510
511/// Deterministic bounded heap projection (design §5.3 steps 5-6): BFS from
512/// the roots in order, then remaining user objects by ascending id, then one
513/// edge pass over the selected set.
514fn project_heap(heap: &GcHeap, roots: &[GcRef]) -> HeapView {
515    let kinds = heap.value_kinds_by_id();
516    let node_ids: Vec<GcId> = kinds
517        .iter()
518        .filter(|(_, kind)| is_heap_node(**kind))
519        .map(|(id, _)| *id)
520        .collect();
521    let node_id_set: HashSet<GcId> = node_ids.iter().copied().collect();
522
523    let mut selected: Vec<GcId> = Vec::new();
524    let mut selected_set: HashSet<GcId> = HashSet::new();
525    let mut queue: VecDeque<GcId> = VecDeque::new();
526    let try_select = |id: GcId,
527                      selected: &mut Vec<GcId>,
528                      selected_set: &mut HashSet<GcId>,
529                      queue: &mut VecDeque<GcId>| {
530        if selected.len() >= MAX_DEBUGGER_OBJECTS
531            || !node_id_set.contains(&id)
532            || !selected_set.insert(id)
533        {
534            return;
535        }
536        selected.push(id);
537        queue.push_back(id);
538    };
539
540    for root in roots {
541        try_select(root.0, &mut selected, &mut selected_set, &mut queue);
542    }
543    while let Some(id) = queue.pop_front() {
544        if let Some(cell) = heap.runtime().object_downcast::<ValueCell>(id) {
545            let mut targets = Vec::new();
546            cell.value.visit_edges(|_, target| targets.push(target));
547            for target in targets {
548                try_select(target.0, &mut selected, &mut selected_set, &mut queue);
549            }
550        }
551    }
552    for id in &node_ids {
553        try_select(*id, &mut selected, &mut selected_set, &mut queue);
554    }
555
556    let mut objects = Vec::with_capacity(selected.len());
557    let mut edges = Vec::new();
558    let mut omitted_edges = 0usize;
559    for &id in &selected {
560        let summary = summarize_gc_object(heap.runtime(), id);
561        let mut members = Vec::new();
562        if let Some(cell) = heap.runtime().object_downcast::<ValueCell>(id) {
563            cell.value.visit_edges(|relation, target| {
564                let target_kind = kinds.get(&target.0).copied().unwrap_or(ValueKind::Other);
565                if is_inline_scalar(target_kind) {
566                    if members.len() < MAX_DEBUGGER_MEMBERS {
567                        members.push(HeapMemberView {
568                            relation,
569                            display: bounded_display(heap, target),
570                        });
571                    }
572                } else if is_heap_node(target_kind) {
573                    if selected_set.contains(&target.0) && edges.len() < MAX_DEBUGGER_EDGES {
574                        edges.push(HeapEdgeView {
575                            from: id,
576                            to: target.0,
577                            relation,
578                        });
579                    } else {
580                        omitted_edges += 1;
581                    }
582                }
583                // Hidden kinds (CompiledFunction, Other): neither member,
584                // edge, nor omission — presentation policy, not truncation.
585            });
586        }
587        objects.push(HeapObjectView {
588            id,
589            kind: summary.kind,
590            // GC report labels carry identity as a suffix (for example
591            // `Array#7`), while debugger nodes already expose `id` separately
592            // and the Playground renders it as a `#7` prefix.
593            label: summary
594                .label
595                .strip_suffix(&format!("#{}", id))
596                .unwrap_or(&summary.label)
597                .to_string(),
598            members,
599        });
600    }
601
602    HeapView {
603        objects,
604        edges,
605        omitted_objects: node_ids.len() - selected.len(),
606        omitted_edges,
607    }
608}